From 61b87e6b876e2c72c0b13b683e6b953890cc9b5e Mon Sep 17 00:00:00 2001 From: Joshua Rich Date: Fri, 18 Oct 2024 16:29:40 +1000 Subject: [PATCH] feat(linux): :sparkles: add session events - add `session_started` and `session_stopped` events, that track when a user logs in or logs out --- README.md | 81 +++++++++---- internal/agent/controllers_linux.go | 4 +- internal/linux/system/users.go | 173 ++++++++++++++++++++++++++-- internal/linux/worker.go | 8 +- 4 files changed, 230 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 96dde684d..c29ee41e3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ - [🌟 About the Project](#-about-the-project) - [đŸŽ¯ Features](#-features) - [🤔 Use-cases](#-use-cases) - - [📈/🕹ī¸ List of Sensors/Controls (by Operating System)](#ī¸-list-of-sensorscontrols-by-operating-system) + - [📈/🕹ī¸ List of Sensors/Controls/Events (by Operating System)](#ī¸-list-of-sensorscontrolsevents-by-operating-system) - [🐧 Linux](#-linux) - [All Operating Systems](#all-operating-systems) - [🗒ī¸ Versioning](#ī¸-versioning) @@ -108,19 +108,20 @@ ## 🌟 About the Project -Go Hass Agent is an application to expose sensors and controls from a device to -Home Assistant. You can think of it as something similar to the [Home Assistant -companion app](https://companion.home-assistant.io/) for mobile devices, but for -your desktop, server, Raspberry Pi, Arduino, toaster, whatever. If it can run Go -and Linux, it can run Go Hass Agent! +Go Hass Agent is an application to expose sensors, controls and events from a +device to Home Assistant. You can think of it as something similar to the [Home +Assistant companion app](https://companion.home-assistant.io/) for mobile +devices, but for your desktop, server, Raspberry Pi, Arduino, toaster, whatever. +If it can run Go and Linux, it can run Go Hass Agent! Out of the box, Go Hass Agent will report lots of details about the system it is running on. You can extend it with additional sensors and controls by hooking it up to MQTT. You can extend it **even further** with your own custom sensors and controls with scripts/programs. -You can then use these sensors/controls in any automations and dashboards, just -like the companion app or any other "thing" you've added into Home Assistant. +You can then use these sensors, controls or events in any automations and +dashboards, just like the companion app or any other "thing" you've added into +Home Assistant. ### đŸŽ¯ Features @@ -135,6 +136,9 @@ connected to MQTT, Go Hass Agent can add some additional sensors/controls for various system features. A selection of device controls are provided by default, and you can configure additional controls to execute D-Bus commands or scripts/executables. See [Control via MQTT](#-mqtt-sensors-and-controls). +- **Events:** Go Hass Agent will send a few events when certain things happen on + the device running the agent (for example, user logins/logouts). You can + listen for these events and react on them in Home Assistant automations. [âŦ†ī¸ Back to Top](#-table-of-contents) @@ -147,6 +151,7 @@ this app: - What active/running apps are on your laptop/desktop. For example, you could set your lights dim or activate a scene when you are gaming. - Whether your screen is locked or the device is shutdown/suspended. +- Set up automations to run when you log in or out of your machine. - With your laptop plugged into a smart plug that is also controlled by Home Assistant, turn the smart plug on/off based on the battery charge. This can force a full charge/discharge cycle of the battery, extending its life over @@ -162,7 +167,7 @@ this app: [âŦ†ī¸ Back to Top](#-table-of-contents) -### 📈/🕹ī¸ List of Sensors/Controls (by Operating System) +### 📈/🕹ī¸ List of Sensors/Controls/Events (by Operating System) > [!NOTE] > The following list shows all **potential** sensors the agent can @@ -171,6 +176,8 @@ this app: #### 🐧 Linux +**Sensors:** + - App Details: - **Active App** (currently active (focused) application) and **Running Apps** (count of all running applications). Updated when active app or number of apps @@ -183,13 +190,9 @@ this app: detected). Updated when theme or colour changes. - Via D-Bus (requires [XDG Desktop Portal Support](https://flatpak.github.io/xdg-desktop-portal/docs/) support). -- Media Controls (when [configured with MQTT](#-mqtt-sensors-and-controls)): - - **Volume Control** Adjust the volume on the default audio output device. - - **Volume Mute** Mute/Unmute the default audio output device. +- Media: - **MPRIS Player State** Show the current state of any MPRIS compatible player. - Requires a player with MPRIS support. - - **Webcam Control** Start/stop a webcam and view the video in Home Assistant. - - Requires a webcam that is exposed via V4L2 (VideoForLinux2). - Connected Battery Details: - **Battery Type** (the type of battery, e.g., UPS, line power). Updated on battery add/remove. - **Battery Temp** (battery temperature). Updated when the temperature changes. @@ -258,17 +261,6 @@ this app: - **Power State** (power state of device, e.g., suspended, powered on/off). Updated when power state changes. - Via D-Bus. Requires `systemd-logind`. -- Power Controls (when [configured with MQTT](#-mqtt-sensors-and-controls)): - - **Lock/Unlock Screen/Screensaver** Locks/unlocks the session for the user - running Go Hass Agent. - - **Suspend** Will (instantly) suspend (the system state is saved to RAM and - the CPU is turned off) the device running Go Hass Agent. - - **Hibernate** Will (instantly) hibernate (the system state is saved to disk - and the machine is powered down) the device running Go Hass Agent. - - **Power Off** Will (instantly) power off the device running Go Hass Agent. - - **Reboot** Will (instantly) reboot the device running Go Hass Agent. - - Power controls require a system configured with `systemd-logind` (and D-Bus) - support. - Various System Details: - **Boot Time** (date/Time of last system boot). Via ProcFS. - **Uptime*. Updated ~every 15 minutes. Via ProcFS. @@ -299,8 +291,47 @@ this app: **alarms**. Updated ~every 1 minute. - Extracted from the `/sys/class/hwmon` file system. +**Controls (when [configured with MQTT](#-mqtt-sensors-and-controls))** + +- Media Controls: + - **Volume Control** Adjust the volume on the default audio output device. + - **Volume Mute** Mute/Unmute the default audio output device. + - **Webcam Control** Start/stop a webcam and view the video in Home Assistant. + - Requires a webcam that is exposed via V4L2 (VideoForLinux2). +- Power Controls: + - **Lock/Unlock Screen/Screensaver** Locks/unlocks the session for the user + running Go Hass Agent. + - **Suspend** Will (instantly) suspend (the system state is saved to RAM and + the CPU is turned off) the device running Go Hass Agent. + - **Hibernate** Will (instantly) hibernate (the system state is saved to disk + and the machine is powered down) the device running Go Hass Agent. + - **Power Off** Will (instantly) power off the device running Go Hass Agent. + - **Reboot** Will (instantly) reboot the device running Go Hass Agent. + - Power controls require a system configured with `systemd-logind` (and D-Bus) + support. + +**Events:** + +- User sessions (login/logout) events. + - Requires a system configured with `systemd-logind` (and D-Bus). + - Event structures: + + ```yaml + event_type: session_started # or session_stopped + data: + desktop: "" # blank or a desktop name, like KDE. + remote: true # true if remote (i.e., ssh) login. + remote_host: "::1" # remote host or blank. + remote_user: "" # remote user or blank. + service: "" # blank or the service that handled the action (e.g., ssh). + type: "tty" # blank or type of session. + user: myuser # username. + ``` + #### All Operating Systems +**Sensors:** + - **Go Hass Agent Version**. Updated on agent start. - **External IP Addresses**. All external IP addresses (IPv4/6) of the device running the agent. diff --git a/internal/agent/controllers_linux.go b/internal/agent/controllers_linux.go index 70e17e7eb..798033bfd 100644 --- a/internal/agent/controllers_linux.go +++ b/internal/agent/controllers_linux.go @@ -63,7 +63,9 @@ var sensorLaptopWorkers = []func(ctx context.Context) (*linux.EventSensorWorker, power.NewLaptopWorker, location.NewLocationWorker, } -var eventWorkers = []func(ctx context.Context) (*linux.EventWorker, error){} +var eventWorkers = []func(ctx context.Context) (*linux.EventWorker, error){ + system.NewUserSessionEventsWorker, +} const ( linuxSensorControllerID = "linux_sensors_controller" diff --git a/internal/linux/system/users.go b/internal/linux/system/users.go index 08fb6babe..541acc328 100644 --- a/internal/linux/system/users.go +++ b/internal/linux/system/users.go @@ -10,7 +10,12 @@ import ( "context" "fmt" "log/slog" + "strings" + "sync" + "github.com/godbus/dbus/v5" + + "github.com/joshuar/go-hass-agent/internal/hass/event" "github.com/joshuar/go-hass-agent/internal/hass/sensor" "github.com/joshuar/go-hass-agent/internal/hass/sensor/types" "github.com/joshuar/go-hass-agent/internal/linux" @@ -28,7 +33,11 @@ const ( sensorUnits = "users" sensorIcon = "mdi:account" - usersWorkerID = "users_sensors" + userSessionSensorWorkerID = "user_session_sensor_worker" + userSessionEventWorkerID = "user_session_event_worker" + + sessionStartedEventName = "session_started" + sessionStoppedEventName = "session_stopped" ) func newUsersSensor(users []string) sensor.Entity { @@ -48,19 +57,19 @@ func newUsersSensor(users []string) sensor.Entity { } } -type Worker struct { +type UserSessionSensorWorker struct { getUsers func() ([]string, error) triggerCh chan dbusx.Trigger linux.EventSensorWorker } -func (w *Worker) Events(ctx context.Context) (chan sensor.Entity, error) { +func (w *UserSessionSensorWorker) Events(ctx context.Context) (chan sensor.Entity, error) { sensorCh := make(chan sensor.Entity) sendUpdate := func() { users, err := w.getUsers() if err != nil { - slog.With(slog.String("worker", usersWorkerID)).Debug("Failed to get list of user sessions.", slog.Any("error", err)) + slog.With(slog.String("worker", userSessionSensorWorkerID)).Debug("Failed to get list of user sessions.", slog.Any("error", err)) } else { sensorCh <- newUsersSensor(users) } @@ -85,15 +94,15 @@ func (w *Worker) Events(ctx context.Context) (chan sensor.Entity, error) { return sensorCh, nil } -func (w *Worker) Sensors(_ context.Context) ([]sensor.Entity, error) { +func (w *UserSessionSensorWorker) Sensors(_ context.Context) ([]sensor.Entity, error) { users, err := w.getUsers() return []sensor.Entity{newUsersSensor(users)}, err } -func NewUserWorker(ctx context.Context) (*Worker, error) { - worker := &Worker{} - worker.WorkerID = usersWorkerID +func NewUserSessionSensorWorker(ctx context.Context) (*UserSessionSensorWorker, error) { + worker := &UserSessionSensorWorker{} + worker.WorkerID = userSessionSensorWorkerID bus, ok := linux.CtxGetSystemBus(ctx) if !ok { @@ -130,3 +139,151 @@ func NewUserWorker(ctx context.Context) (*Worker, error) { return worker, nil } + +type UserSessionEventsWorker struct { + triggerCh chan dbusx.Trigger + tracker sessionTracker + linux.EventWorker +} + +type sessionTracker struct { + getSessionProp func(path, prop string) (dbus.Variant, error) + sessions map[string]map[string]any + mu sync.Mutex +} + +func (t *sessionTracker) addSession(path string) { + t.mu.Lock() + defer t.mu.Unlock() + t.sessions[path] = t.getSessionDetails(path) +} + +func (t *sessionTracker) removeSession(path string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.sessions, path) +} + +func (t *sessionTracker) getSessionDetails(path string) map[string]any { + sessionDetails := make(map[string]any) + + sessionDetails["user"] = sessionProp[string](t.getSessionProp, path, "Name") + sessionDetails["remote"] = sessionProp[bool](t.getSessionProp, path, "Remote") + + if sessionDetails["remote"].(bool) { + sessionDetails["remote_host"] = sessionProp[string](t.getSessionProp, path, "RemoteHost") + sessionDetails["remote_user"] = sessionProp[string](t.getSessionProp, path, "RemoteUser") + } + + sessionDetails["desktop"] = sessionProp[string](t.getSessionProp, path, "Desktop") + sessionDetails["service"] = sessionProp[string](t.getSessionProp, path, "Service") + sessionDetails["type"] = sessionProp[string](t.getSessionProp, path, "Type") + + return sessionDetails +} + +func (w *UserSessionEventsWorker) Events(ctx context.Context) (<-chan event.Event, error) { + eventCh := make(chan event.Event) + + go func() { + defer close(eventCh) + + for { + select { + case <-ctx.Done(): + return + case trigger := <-w.triggerCh: + // If the trigger does not contain a session path, ignore. + path, ok := trigger.Content[1].(dbus.ObjectPath) + if !ok { + continue + } + // Send the appropriate event type. + switch { + case strings.Contains(trigger.Signal, sessionAddedSignal): + w.tracker.addSession(string(path)) + eventCh <- event.Event{ + EventType: sessionStartedEventName, + EventData: w.tracker.sessions[string(path)], + } + case strings.Contains(trigger.Signal, sessionRemovedSignal): + eventCh <- event.Event{ + EventType: sessionStoppedEventName, + EventData: w.tracker.sessions[string(path)], + } + w.tracker.removeSession(string(path)) + } + } + } + }() + + return eventCh, nil +} + +func NewUserSessionEventsWorker(ctx context.Context) (*linux.EventWorker, error) { + worker := linux.NewEventWorker(userSessionEventWorkerID) + + bus, ok := linux.CtxGetSystemBus(ctx) + if !ok { + return worker, linux.ErrNoSystemBus + } + + eventWorker := &UserSessionEventsWorker{ + tracker: sessionTracker{ + sessions: make(map[string]map[string]any), + getSessionProp: func(path, prop string) (dbus.Variant, error) { + value, err := dbusx.NewProperty[dbus.Variant](bus, + path, + loginBaseInterface, + loginBaseInterface+".Session."+prop).Get() + if err != nil { + return dbus.MakeVariant(sensor.StateUnknown), + fmt.Errorf("could not retrieve session property %s (session %s): %w", prop, path, err) + } + + return value, nil + }, + }, + } + + currentSessions, err := dbusx.GetData[[][]any](bus, loginBasePath, loginBaseInterface, listSessionsMethod) + if err != nil { + return nil, fmt.Errorf("could not retrieve sessions from D-Bus: %w", err) + } + + for _, session := range currentSessions { + eventWorker.tracker.addSession(string(session[4].(dbus.ObjectPath))) + } + + triggerCh, err := dbusx.NewWatch( + dbusx.MatchPath(loginBasePath), + dbusx.MatchInterface(managerInterface), + dbusx.MatchMembers(sessionAddedSignal, sessionRemovedSignal), + ).Start(ctx, bus) + if err != nil { + return nil, fmt.Errorf("unable to set-up D-Bus watch for user sessions: %w", err) + } + + eventWorker.triggerCh = triggerCh + + worker.EventType = eventWorker + + return worker, nil +} + +//nolint:errcheck +func sessionProp[T any](getFunc func(string, string) (dbus.Variant, error), path, prop string) T { + var ( + err error + value T + variant dbus.Variant + ) + + if variant, err = getFunc(path, prop); err != nil { + return value + } + + value, _ = dbusx.VariantToValue[T](variant) + + return value +} diff --git a/internal/linux/worker.go b/internal/linux/worker.go index c4f9d8d2f..679c9d31d 100644 --- a/internal/linux/worker.go +++ b/internal/linux/worker.go @@ -62,9 +62,7 @@ func (w *Worker) ID() string { // Stop will stop any processing of sensors controlled by this worker. func (w *Worker) Stop() error { - slog.Debug("Stopping worker", slog.String("worker", w.ID())) w.cancelFunc() - return nil } @@ -160,6 +158,12 @@ func (w *EventWorker) Start(ctx context.Context) (<-chan event.Event, error) { return handleEvents(updatesCtx, w.EventType), nil } +func NewEventWorker(id string) *EventWorker { + return &EventWorker{ + Worker: Worker{WorkerID: id}, + } +} + // handleSensorPolling: create an updater function to run the worker's Sensors // function and pass this to the PollSensors helper, using the interval // and jitter the worker has requested.