joshuar-go-hass-agent/pkg/linux/dbusx/dbus.go

210 lines
5.6 KiB
Go

// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
//revive:disable:max-public-structs
//go:generate stringer -type=dbusType -output busType_strings.go
package dbusx
import (
"context"
"errors"
"fmt"
"log/slog"
"os/user"
"github.com/godbus/dbus/v5"
"github.com/joshuar/go-hass-agent/internal/logging"
)
const (
SessionBus dbusType = iota // session
SystemBus // system
PropInterface = "org.freedesktop.DBus.Properties"
PropChangedSignal = PropInterface + ".PropertiesChanged"
loginBasePath = "/org/freedesktop/login1"
loginBaseInterface = "org.freedesktop.login1"
loginManagerInterface = loginBaseInterface + ".Manager"
listSessionsMethod = loginManagerInterface + ".ListSessions"
)
var (
ErrNoBus = errors.New("no D-Bus connection")
ErrNoBusCtx = errors.New("no D-Bus connection in context")
ErrNotPropChanged = errors.New("signal contents do not appear to represent changed properties")
ErrParseInterface = errors.New("could not parse interface name")
ErrParseNewProps = errors.New("could not parse changed properties")
ErrParseOldProps = errors.New("could not parse invalidated properties")
ErrNotValChanged = errors.New("signal contents do not appear to represent a value change")
ErrParseNewVal = errors.New("could not parse new value")
ErrParseOldVal = errors.New("could not parse old value")
ErrNoSessionPath = errors.New("could not determine session path")
ErrInvalidPath = errors.New("invalid D-Bus path")
ErrUnknownBus = errors.New("unknown bus")
)
var DbusTypeMap = map[string]dbusType{
"session": 0,
"system": 1,
}
type dbusType int
// Values represents a property value that changed. It contains the new and old
// values.
type Values[T any] struct {
New T
Old T
}
// Bus represents a particular D-Bus connection to either the system or session
// bus.
type Bus struct {
conn *dbus.Conn
traceLog func(msg string, args ...any)
busType dbusType
}
func (b *Bus) getObject(intr, path string) dbus.BusObject {
return b.conn.Object(intr, dbus.ObjectPath(path))
}
// NewBus creates a D-Bus connection to the requested bus. If a connection
// cannot be established, an error is returned.
//
//nolint:sloglint
func NewBus(ctx context.Context, busType dbusType) (*Bus, error) {
var (
conn *dbus.Conn
err error
)
// Connect to the requested bus.
switch busType {
case SessionBus:
conn, err = dbus.ConnectSessionBus(dbus.WithContext(ctx))
case SystemBus:
conn, err = dbus.ConnectSystemBus(dbus.WithContext(ctx))
default:
return nil, ErrUnknownBus
}
// If the connection fails, we bails.
if err != nil {
return nil, fmt.Errorf("could not connect to bus: %w", err)
}
// Set up our bus object.
bus := &Bus{
conn: conn,
busType: busType,
traceLog: func(msg string, args ...any) {
slog.With(slog.String("subsystem", "dbus"), slog.String("bus", busType.String())).
Log(ctx, logging.LevelTrace, msg, args...)
},
}
// Start a goroutine to close the connection when the context is canceled
// (i.e. agent shutdown).
go func() {
defer conn.Close()
<-ctx.Done()
}()
return bus, nil
}
// GetData fetches data using the given method from D-Bus, as the provided type.
// If there is an error or the result cannot be stored in the given type, it
// will return an non-nil error. To execute a method, see Call. To get the value
// of a property, see GetProp.
func GetData[D any](bus *Bus, path, dest, method string, args ...any) (D, error) {
var (
data D
err error
)
bus.traceLog("Getting data.", slog.String("path", path), slog.String("dest", dest), slog.String("method", method))
obj := bus.getObject(dest, path)
if args != nil {
err = obj.Call(method, 0, args...).Store(&data)
} else {
err = obj.Call(method, 0).Store(&data)
}
if err != nil {
return data, fmt.Errorf("%s: unable to get data %s from %s: %w", bus.busType.String(), method, dest, err)
}
return data, nil
}
func (b *Bus) GetSessionPath() (string, error) {
usr, err := user.Current()
if err != nil {
return "", fmt.Errorf("unable to determine user: %w", err)
}
sessions, err := GetData[[][]any](b, loginBasePath, loginBaseInterface, listSessionsMethod)
if err != nil {
return "", fmt.Errorf("unable to retrieve session path: %w", err)
}
for _, s := range sessions {
if thisUser, ok := s[2].(string); ok && thisUser == usr.Username {
if p, ok := s[4].(dbus.ObjectPath); ok {
return string(p), nil
}
}
}
return "", ErrNoSessionPath
}
// ParseValueChange treats the given signal body as matching a value change of a
// property from an old value to a new value. It will parse the signal body into
// a Value object with old/new values of the given type. If there was a problem
// parsing the signal body, an error will be returned with details of the
// problem.
//
//nolint:mnd
func ParseValueChange[T any](valueChanged []any) (*Values[T], error) {
values := &Values[T]{}
var ok bool
if len(valueChanged) != 2 {
return nil, ErrNotValChanged
}
values.New, ok = valueChanged[0].(T)
if !ok {
return nil, ErrParseNewVal
}
values.Old, ok = valueChanged[1].(T)
if !ok {
return nil, ErrParseOldVal
}
return values, nil
}
// VariantToValue converts a dbus.Variant value into the specified Go type. If
// the value is nil or it cannot be converted, then the return value will be the
// default value of the specified type.
func VariantToValue[S any](variant dbus.Variant) (S, error) {
var value S
err := variant.Store(&value)
if err != nil {
return value, fmt.Errorf("unable to convert D-Bus variant %v to type %T: %w", variant, value, err)
}
return value, nil
}