joshuar-go-hass-agent/internal/linux/net/connection.go

382 lines
10 KiB
Go

// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package net
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"github.com/godbus/dbus/v5"
"github.com/joshuar/go-hass-agent/internal/hass/sensor"
"github.com/joshuar/go-hass-agent/pkg/linux/dbusx"
)
const (
connectionIDProp = dbusNMActiveConnIntr + ".Id"
connectionTypeProp = dbusNMActiveConnIntr + ".Type"
connectionDevicesProp = dbusNMActiveConnIntr + ".Devices"
connectionStateProp = dbusNMActiveConnIntr + "." + statePropName
connectionIPv4ConfigProp = dbusNMActiveConnIntr + "." + ipv4ConfigPropName
connectionIPv6ConfigProp = dbusNMActiveConnIntr + "." + ipv6ConfigPropName
wirelessDeviceInterface = dBusNMObj + ".Device.Wireless"
activeAPPropName = "ActiveAccessPoint"
)
var (
ErrUnknownProp = errors.New("unknown or invalid property")
ErrUnsupportedValue = errors.New("unsupported state value")
)
type connection struct {
devicesProp *dbusx.Property[[]dbus.ObjectPath]
logger *slog.Logger
doneCh chan struct{}
name string
connType string
path dbus.ObjectPath
}
// newConnection sets up an object that tracks connection state. It both static
// (stored in the object) and dynamic (fetched from D-Bus as needed) properties
// of a connection.
func newConnection(bus *dbusx.Bus, path dbus.ObjectPath) (*connection, error) {
// Get the connection name.
name, err := dbusx.NewProperty[string](bus, string(path), dBusNMObj, connectionIDProp).Get()
if err != nil {
return nil, fmt.Errorf("could not determine connection name: %w", err)
}
connType, err := dbusx.NewProperty[string](bus, string(path), dBusNMObj, connectionTypeProp).Get()
if err != nil {
return nil, fmt.Errorf("could not determine connection type: %w", err)
}
conn := &connection{
path: path,
name: name,
connType: connType,
devicesProp: dbusx.NewProperty[[]dbus.ObjectPath](bus, string(path), dBusNMObj, connectionDevicesProp),
logger: slog.With(slog.String("connection", name)),
doneCh: make(chan struct{}),
}
return conn, nil
}
// monitor will set up a D-Bus watch on the connection path for
// connection property changes and send those back through the returned channel
// as sensors.
func (c *connection) monitor(ctx context.Context, bus *dbusx.Bus) <-chan sensor.Entity {
sensorCh := make(chan sensor.Entity)
// Monitor connection properties.
go func() {
c.logger.Debug("Monitoring connection.")
for sensor := range c.monitorConnection(ctx, bus) {
sensorCh <- sensor
}
c.logger.Debug("Unmonitoring connection.")
}()
// If the connection is a wifi connection, monitor wifi properties.
if c.connType == "802-11-wireless" {
go func() {
c.logger.Debug("Monitoring WiFi.")
for sensor := range c.monitorWifi(ctx, bus) {
sensorCh <- sensor
}
c.logger.Debug("Unmonitoring WiFi.")
}()
}
return sensorCh
}
// monitorConnection sets up the D-Bus watch for connection property changes.
//
//nolint:gocognit,gocyclo,cyclop
//revive:disable:function-length
func (c *connection) monitorConnection(ctx context.Context, bus *dbusx.Bus) <-chan sensor.Entity {
sensorCh := make(chan sensor.Entity)
monitorCtx, monitorCancel := context.WithCancel(ctx)
// Create sensors for monitored properties.
stateSensor := newConnectionStateSensor(bus, string(c.path), c.name)
if err := stateSensor.updateState(); err != nil {
c.logger.Debug("Could not update sensor.",
slog.String("sensor", stateSensor.Name),
slog.Any("error", err))
}
// Send initial states as sensors
go func() {
sensorCh <- *stateSensor.Entity
}()
triggerCh, err := dbusx.NewWatch(
dbusx.MatchPath(string(c.path)),
dbusx.MatchPropChanged(),
).Start(monitorCtx, bus)
if err != nil {
c.logger.Debug("Could not start D-Bus connection property watch.", slog.Any("error", err))
monitorCancel()
close(sensorCh)
return sensorCh
}
go func() {
defer close(sensorCh)
defer monitorCancel()
defer close(c.doneCh)
c.logger.Debug("Stated watching for connection property updates.")
for {
select {
case <-ctx.Done():
c.logger.Debug("Stopped watching for connection property updates.")
return
case event := <-triggerCh:
props, err := dbusx.ParsePropertiesChanged(event.Content)
if err != nil {
continue
}
// Ignore device statistics.
if props.Interface == "org.freedesktop.NetworkManager.Device.Statistics" {
continue
}
for prop, value := range props.Changed {
switch {
case prop == statePropName && props.Interface == dbusNMActiveConnIntr: // State changed.
if err := stateSensor.setState(value); err != nil {
c.logger.Warn("Could not update connection state sensor.", slog.Any("error", err))
} else {
// Send the connection state as a sensor.
sensorCh <- *stateSensor.Entity
}
default:
c.logger.Debug("Unhandled property changed.",
slog.String("interface", props.Interface),
slog.String("property", prop),
slog.Any("value", value.Value()))
}
}
}
if stateSensor.Entity.Value == connOffline.String() {
break
}
}
}()
return sensorCh
}
// monitorWifi will monitor wifi connection properties.
//
//nolint:gocognit
func (c *connection) monitorWifi(ctx context.Context, bus *dbusx.Bus) <-chan sensor.Entity {
triggerCh := make(chan dbusx.Trigger)
sensorCh := make(chan sensor.Entity)
monitorCtx, monitorCancel := context.WithCancel(ctx)
// Get and send initial values for wifi props.
go func() {
for _, ap := range c.getWifiAPs(bus) {
for _, wifiSensor := range getWifiSensors(bus, string(ap)) {
sensorCh <- wifiSensor
}
}
}()
go func() {
c.watchAccessPointProps(monitorCtx, bus, triggerCh)
}()
go func() {
defer close(sensorCh)
defer monitorCancel()
c.logger.Debug("Started monitoring wifi properties.")
for {
select {
case <-c.doneCh: // Connection offline.
return
case <-ctx.Done(): // Agent shutting down.
return
case event := <-triggerCh: // Wifi property changed.
props, err := dbusx.ParsePropertiesChanged(event.Content)
if err != nil {
continue
}
for prop, value := range props.Changed {
if slices.Contains(apPropList, prop) { // Wifi property changed.
sensorCh <- newWifiSensor(prop, value.Value())
}
}
}
}
}()
return sensorCh
}
// watchAccessPointProps sets up the watches for changes to access points and
// their properties.
func (c *connection) watchAccessPointProps(ctx context.Context, bus *dbusx.Bus, triggerCh chan dbusx.Trigger) {
apCh := make(chan dbus.ObjectPath)
defer close(apCh)
apWatchCtx, apWatchCancel := context.WithCancel(ctx)
defer apWatchCancel()
devices, err := c.devicesProp.Get()
if err != nil {
c.logger.Debug("Could not retrieve wireless devices for connection from D-Bus", slog.Any("error", err))
return
}
go func() {
// Monitor access point changes on devices.
for _, devicePath := range devices {
c.monitorDeviceAccessPoint(apWatchCtx, bus, string(devicePath), apCh)
}
// Send the current active access points.
for _, ap := range c.getWifiAPs(bus) {
apCh <- ap
}
}()
var apPropCancel context.CancelFunc
for {
select {
case <-c.doneCh:
return
case <-ctx.Done():
return
case accessPoint := <-apCh:
// If there was a previous ap watch, cancel it.
if apPropCancel != nil {
apPropCancel()
}
// Watch this ap for prop changes.
apPropCancel = c.watchAP(ctx, bus, string(accessPoint), triggerCh)
}
}
}
// monitorDeviceAccessPoint starts a D-Bus watch for changes to the active
// access point for a device.
func (c *connection) monitorDeviceAccessPoint(ctx context.Context, bus *dbusx.Bus, devicePath string, outCh chan dbus.ObjectPath) {
monitorCtx, monitorCancel := context.WithCancel(ctx)
// Monitor the active access point property.
triggerCh, err := dbusx.NewWatch(
dbusx.MatchPath(devicePath),
dbusx.MatchInterface(wirelessDeviceInterface),
dbusx.MatchMembers(activeAPPropName),
).Start(monitorCtx, bus)
if err != nil {
c.logger.Debug("Could not monitor device access point.", slog.Any("error", err))
monitorCancel()
return
}
go func() {
defer monitorCancel()
for {
select {
case <-c.doneCh:
return
case <-ctx.Done():
return
case event := <-triggerCh:
values, err := dbusx.ParseValueChange[dbus.ObjectPath](event.Content)
if err != nil {
c.logger.Debug("Could not parse changed access point prop.", slog.Any("error", err))
continue
}
outCh <- values.New
}
}
}()
}
// getWifiAPs returns a slice of dbus.ObjectPath representing all the active
// access points the connection is using.
func (c *connection) getWifiAPs(bus *dbusx.Bus) []dbus.ObjectPath {
devices, err := c.devicesProp.Get()
if err != nil {
c.logger.Debug("Could not retrieve active access points.", slog.Any("error", err))
return nil
}
aps := make([]dbus.ObjectPath, 0, len(devices))
for _, devicePath := range devices {
apPath, err := dbusx.NewProperty[dbus.ObjectPath](bus, string(devicePath), dBusNMObj, wirelessDeviceInterface+"."+activeAPPropName).Get()
if err != nil {
continue
}
aps = append(aps, apPath)
}
return aps
}
// watchAP will set up a D-Bus watch for a connection on its active wireless
// access point and send any access point property changes to the given trigger
// channel. It returns a context.CancelFunc that can be used to stop the watch.
func (c *connection) watchAP(ctx context.Context, bus *dbusx.Bus, apPath string, outCh chan dbusx.Trigger) context.CancelFunc {
watchCtx, watchCancel := context.WithCancel(ctx)
apPropCh, err := dbusx.NewWatch(
dbusx.MatchPath(apPath),
dbusx.MatchPropChanged(),
).Start(watchCtx, bus)
if err != nil {
c.logger.Debug("Could not start D-Bus access point property watch.", slog.Any("error", err))
return watchCancel
}
go func() {
defer watchCancel()
for {
select {
case <-c.doneCh:
return
case event := <-apPropCh:
outCh <- event
}
}
}()
return watchCancel
}