Skip to content

Commit

Permalink
feat(linux): ✨ add session events
Browse files Browse the repository at this point in the history
- add `session_started` and `session_stopped` events, that track when a user logs in or logs out
  • Loading branch information
joshuar committed Oct 18, 2024
1 parent 2897ecb commit 61b87e6
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 36 deletions.
81 changes: 56 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion internal/agent/controllers_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
173 changes: 165 additions & 8 deletions internal/linux/system/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 61b87e6

Please sign in to comment.