joshuar-go-hass-agent/internal/linux/battery/battery.go

647 lines
16 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:unused-receiver
//go:generate stringer -type=batterySensor -output battery_generated.go -linecomment
package battery
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"github.com/godbus/dbus/v5"
"github.com/iancoleman/strcase"
"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"
"github.com/joshuar/go-hass-agent/internal/logging"
"github.com/joshuar/go-hass-agent/pkg/linux/dbusx"
)
const (
battType batterySensor = iota // Battery Type
battPercentage // Battery Level
battTemp // Battery Temperature
battVoltage // Battery Voltage
battEnergy // Battery Energy
battEnergyRate // Battery Power
battState // Battery State
battNativePath // Battery Path
battLevel // Battery Level
battModel // Battery Model
upowerDBusDest = "org.freedesktop.UPower"
upowerDBusDeviceDest = upowerDBusDest + ".Device"
upowerDBusPath = "/org/freedesktop/UPower"
upowerGetDevicesMethod = "org.freedesktop.UPower.EnumerateDevices"
deviceAddedSignal = "DeviceAdded"
deviceRemovedSignal = "DeviceRemoved"
batteryIcon = "mdi:battery"
workerID = "battery_sensors"
)
type batterySensor int
var ErrInvalidBattery = errors.New("invalid battery")
// dBusSensorToProps is a map of battery sensors to their D-Bus properties.
var dBusSensorToProps = map[batterySensor]string{
battType: upowerDBusDeviceDest + ".Type",
battPercentage: upowerDBusDeviceDest + ".Percentage",
battTemp: upowerDBusDeviceDest + ".Temperature",
battVoltage: upowerDBusDeviceDest + ".Voltage",
battEnergy: upowerDBusDeviceDest + ".Energy",
battEnergyRate: upowerDBusDeviceDest + ".EnergyRate",
battState: upowerDBusDeviceDest + ".State",
battNativePath: upowerDBusDeviceDest + ".NativePath",
battLevel: upowerDBusDeviceDest + ".BatteryLevel",
battModel: upowerDBusDeviceDest + ".Model",
}
// dBusPropToSensor provides a map for to convert D-Bus properties to sensors.
var dBusPropToSensor = map[string]batterySensor{
"Energy": battEnergy,
"EnergyRate": battEnergyRate,
"Voltage": battVoltage,
"Percentage": battPercentage,
"Temperatute": battTemp,
"State": battState,
"BatteryLevel": battLevel,
}
type upowerBattery struct {
logger *slog.Logger
bus *dbusx.Bus
id string
model string
dBusPath dbus.ObjectPath
sensors []batterySensor
battType batteryType
}
// getProp retrieves the property from D-Bus that matches the given battery sensor type.
func (b *upowerBattery) getProp(t batterySensor) (dbus.Variant, error) {
value, err := dbusx.NewProperty[dbus.Variant](b.bus, string(b.dBusPath), upowerDBusDest, dBusSensorToProps[t]).Get()
if err != nil {
return dbus.Variant{}, fmt.Errorf("could not retrieve battery property %s: %w", t.String(), err)
}
return value, nil
}
// getSensors retrieves the sensors passed in for a given battery.
func (b *upowerBattery) getSensors(sensors ...batterySensor) chan sensor.Details {
sensorCh := make(chan sensor.Details, len(sensors))
defer close(sensorCh)
for _, batterySensor := range sensors {
value, err := b.getProp(batterySensor)
if err != nil {
b.logger.Warn("Could not retrieve battery sensor.",
slog.String("sensor", batterySensor.String()),
slog.Any("error", err))
continue
}
sensorCh <- newBatterySensor(b, batterySensor, value)
}
return sensorCh
}
// newBattery creates a battery object that will have a number of properties to
// be treated as sensors in Home Assistant.
func newBattery(bus *dbusx.Bus, logger *slog.Logger, path dbus.ObjectPath) (*upowerBattery, error) {
battery := &upowerBattery{
dBusPath: path,
bus: bus,
}
var (
variant dbus.Variant
err error
)
// Get the battery type. Depending on the value, additional sensors will be added.
variant, err = battery.getProp(battType)
if err != nil {
return nil, fmt.Errorf("could not determine battery type: %w", err)
}
// Store the battery type.
battery.battType, err = dbusx.VariantToValue[batteryType](variant)
if err != nil {
return nil, fmt.Errorf("could not determine battery type: %w", err)
}
// Use the native path D-Bus property for the battery id.
variant, err = battery.getProp(battNativePath)
if err != nil {
return nil, fmt.Errorf("could not retrieve battery path in D-Bus: %w", err)
}
// Store the battery id/name.
battery.id, err = dbusx.VariantToValue[string](variant)
if err != nil {
return nil, fmt.Errorf("could not retrieve battery path in D-Bus: %w", err)
}
// Set up a logger for the battery with some battery-specific default
// attributes.
battery.logger = logger.With(
slog.Group("battery_info",
slog.String("name", battery.id),
slog.String("dbus_path", string(battery.dBusPath)),
),
)
// Get the battery model.
variant, err = battery.getProp(battModel)
if err != nil {
battery.logger.Warn("Could not determine battery model.")
}
// Store the battery model.
battery.model, err = dbusx.VariantToValue[string](variant)
if err != nil {
battery.logger.Warn("Could not determine battery model.")
}
// At a minimum, monitor the battery type and the charging state.
battery.sensors = append(battery.sensors, battState)
if battery.battType == batteryTypeBattery {
// Battery has charge percentage, temp and charging rate sensors
battery.sensors = append(battery.sensors, battPercentage, battTemp, battEnergyRate)
} else {
// Battery has a textual level sensor
battery.sensors = append(battery.sensors, battLevel)
}
return battery, nil
}
type upowerBatterySensor struct {
attributes any
logger *slog.Logger
batteryID string
model string
linux.Sensor
sensorType batterySensor
}
// uPowerBatteryState implements hass.SensorUpdate
func (s *upowerBatterySensor) Name() string {
if s.model == "" {
return s.batteryID + " " + s.sensorType.String()
}
return s.model + " " + s.sensorType.String()
}
func (s *upowerBatterySensor) ID() string {
return s.batteryID + "_" + strings.ToLower(strcase.ToSnake(s.sensorType.String()))
}
//nolint:exhaustive
func (s *upowerBatterySensor) Icon() string {
switch s.sensorType {
case battPercentage:
return batteryPercentIcon(s.Value)
case battEnergyRate:
return batteryChargeIcon(s.Value)
default:
return batteryIcon
}
}
//nolint:exhaustive
func (s *upowerBatterySensor) DeviceClass() types.DeviceClass {
switch s.sensorType {
case battPercentage:
return types.DeviceClassBattery
case battTemp:
return types.DeviceClassTemperature
case battEnergyRate:
return types.DeviceClassPower
default:
return 0
}
}
//nolint:exhaustive
func (s *upowerBatterySensor) StateClass() types.StateClass {
switch s.sensorType {
case battPercentage, battTemp, battEnergyRate:
return types.StateClassMeasurement
default:
return 0
}
}
//nolint:exhaustive
func (s *upowerBatterySensor) State() any {
if s.Value == nil {
return sensor.StateUnknown
}
switch s.sensorType {
case battVoltage, battTemp, battEnergy, battEnergyRate, battPercentage:
if value, ok := s.Value.(float64); !ok {
return sensor.StateUnknown
} else {
return value
}
case battState:
if value, ok := s.Value.(uint32); !ok {
return sensor.StateUnknown
} else {
return battChargeState(value).String()
}
case battLevel:
if value, ok := s.Value.(uint32); !ok {
return sensor.StateUnknown
} else {
return batteryLevel(value).String()
}
default:
if value, ok := s.Value.(string); !ok {
return sensor.StateUnknown
} else {
return value
}
}
}
//nolint:exhaustive
func (s *upowerBatterySensor) Units() string {
switch s.sensorType {
case battPercentage:
return "%"
case battTemp:
return "°C"
case battEnergyRate:
return "W"
default:
return ""
}
}
func (s *upowerBatterySensor) Attributes() map[string]any {
attributes := make(map[string]any)
attributes["extra_attributes"] = s.attributes
return attributes
}
//nolint:exhaustive
func (s *upowerBatterySensor) generateAttributes(battery *upowerBattery) {
switch s.sensorType {
case battEnergyRate:
var (
variant dbus.Variant
err error
voltage, energy float64
)
variant, err = battery.getProp(battVoltage)
if err != nil {
s.logger.Warn("Could not retrieve battery voltage.", slog.Any("error", err))
}
voltage, err = dbusx.VariantToValue[float64](variant)
if err != nil {
s.logger.Warn("Could not retrieve battery voltage.", slog.Any("error", err))
}
variant, err = battery.getProp(battEnergy)
if err != nil {
s.logger.Warn("Could not retrieve battery energy.", slog.Any("error", err))
}
energy, err = dbusx.VariantToValue[float64](variant)
if err != nil {
s.logger.Warn("Could not retrieve battery energy.", slog.Any("error", err))
}
s.attributes = &struct {
DataSource string `json:"data_source"`
Voltage float64 `json:"voltage"`
Energy float64 `json:"energy"`
}{
Voltage: voltage,
Energy: energy,
DataSource: linux.DataSrcDbus,
}
case battPercentage, battLevel:
s.attributes = &struct {
Type string `json:"battery_type"`
DataSource string `json:"data_source"`
}{
Type: battery.battType.String(),
DataSource: linux.DataSrcDbus,
}
}
}
// newBatterySensor creates a new sensor for Home Assistant from a battery
// property.
func newBatterySensor(battery *upowerBattery, sensorType batterySensor, value dbus.Variant) *upowerBatterySensor {
batterySensor := &upowerBatterySensor{
batteryID: battery.id,
model: battery.model,
logger: battery.logger,
sensorType: sensorType,
}
batterySensor.Value = value.Value()
batterySensor.IsDiagnostic = true
batterySensor.generateAttributes(battery)
return batterySensor
}
// monitorBattery will monitor a battery device for any property changes and
// send these as sensors.
func monitorBattery(ctx context.Context, battery *upowerBattery) <-chan sensor.Details {
sensorCh := make(chan sensor.Details)
// Create a DBus signal match to watch for property changes for this
// battery.
events, err := dbusx.NewWatch(
dbusx.MatchPath(string(battery.dBusPath)),
dbusx.MatchPropChanged(),
).Start(ctx, battery.bus)
if err != nil {
battery.logger.Debug("Failed to create D-Bus watch for battery property changes.", slog.Any("error", err))
close(sensorCh)
return sensorCh
}
go func() {
battery.logger.Debug("Monitoring battery.")
defer close(sensorCh)
for {
select {
case <-ctx.Done():
battery.logger.Debug("Stopped monitoring battery.")
return
case event := <-events:
props, err := dbusx.ParsePropertiesChanged(event.Content)
if err != nil {
battery.logger.Warn("Received a battery property change event that could not be understood.", slog.Any("error", err))
continue
}
for prop, value := range props.Changed {
if s, ok := dBusPropToSensor[prop]; ok {
sensorCh <- newBatterySensor(battery, s, value)
}
}
}
}
}()
return sensorCh
}
//nolint:mnd
func batteryPercentIcon(v any) string {
percentage, ok := v.(float64)
if !ok {
return batteryIcon + "-unknown"
}
if percentage >= 95 {
return batteryIcon
}
return fmt.Sprintf("%s-%d", batteryIcon, int(math.Round(percentage/10)*10))
}
func batteryChargeIcon(v any) string {
energyRate, ok := v.(float64)
if !ok {
return batteryIcon
}
if math.Signbit(energyRate) {
return batteryIcon + "-minus"
}
return batteryIcon + "-plus"
}
type sensorWorker struct {
bus *dbusx.Bus
batteryList map[dbus.ObjectPath]context.CancelFunc
logger *slog.Logger
mu sync.Mutex
}
// ?: implement initial battery sensor retrieval.
func (w *sensorWorker) Sensors(_ context.Context) ([]sensor.Details, error) {
return nil, linux.ErrUnimplemented
}
func (w *sensorWorker) Events(ctx context.Context) (chan sensor.Details, error) {
sensorCh := make(chan sensor.Details)
var wg sync.WaitGroup
// Get a list of all current connected batteries and monitor them.
batteries, err := w.getBatteries()
if err != nil {
w.logger.Warn("Could not retrieve any battery details from D-Bus.", slog.Any("error", err))
}
for _, path := range batteries {
wg.Add(1)
go func(path dbus.ObjectPath) {
defer wg.Done()
for batterySensor := range w.track(ctx, path) {
sensorCh <- batterySensor
}
}(path)
}
wg.Add(1)
go func() {
defer wg.Done()
for batterySensor := range w.monitorBatteryChanges(ctx) {
sensorCh <- batterySensor
}
}()
go func() {
defer close(sensorCh)
wg.Wait()
}()
return sensorCh, nil
}
// getBatteries is a helper function to retrieve all of the known batteries
// connected to the system.
func (w *sensorWorker) getBatteries() ([]dbus.ObjectPath, error) {
batteryList, err := dbusx.GetData[[]dbus.ObjectPath](w.bus, upowerDBusPath, upowerDBusDest, upowerGetDevicesMethod)
if err != nil {
return nil, err
}
return batteryList, nil
}
func (w *sensorWorker) track(ctx context.Context, batteryPath dbus.ObjectPath) <-chan sensor.Details {
sensorCh := make(chan sensor.Details)
var wg sync.WaitGroup
battery, err := newBattery(w.bus, w.logger, batteryPath)
if err != nil {
w.logger.Warn("Cannot monitor battery.",
slog.Any("path", batteryPath),
slog.Any("error", err))
return sensorCh
}
battCtx, cancelFunc := context.WithCancel(ctx)
w.mu.Lock()
w.batteryList[batteryPath] = cancelFunc
w.mu.Unlock()
wg.Add(1)
go func() {
defer wg.Done()
for prop := range battery.getSensors(battery.sensors...) {
sensorCh <- prop
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for battery := range monitorBattery(battCtx, battery) {
sensorCh <- battery
}
}()
go func() {
defer close(sensorCh)
wg.Wait()
}()
return sensorCh
}
func (w *sensorWorker) remove(batteryPath dbus.ObjectPath) {
if cancelFunc, ok := w.batteryList[batteryPath]; ok {
cancelFunc()
w.mu.Lock()
delete(w.batteryList, batteryPath)
w.mu.Unlock()
}
}
// monitorBatteryChanges monitors for battery devices being added/removed from
// the system and will start/stop monitory each battery as appropriate.
func (w *sensorWorker) monitorBatteryChanges(ctx context.Context) <-chan sensor.Details {
triggerCh, err := dbusx.NewWatch(
dbusx.MatchPath(upowerDBusPath),
dbusx.MatchInterface(upowerDBusDest),
dbusx.MatchMembers(deviceAddedSignal, deviceRemovedSignal),
).Start(ctx, w.bus)
if err != nil {
w.logger.Debug("Unable to set-up D-Bus watch for battery changes.", slog.Any("error", err))
return nil
}
sensorCh := make(chan sensor.Details)
// events, err := dbusx.NewWatch(
// dbusx.MatchPath(upowerDBusPath),
// dbusx.MatchInterface(upowerDBusDest),
// dbusx.MatchMember(deviceAddedSignal, deviceRemovedSignal),
// ).Start(ctx, w.bus)
// if err != nil {
// w.logger.Debug("Failed to create D-Bus watch for battery additions/removals.", "error", err.Error())
// close(sensorCh)
// return sensorCh
// }
go func() {
w.logger.Debug("Monitoring for battery additions/removals.")
defer close(sensorCh)
for {
select {
case <-ctx.Done():
w.logger.Debug("Stopped monitoring for batteries.")
return
case event := <-triggerCh:
batteryPath, validBatteryPath := event.Content[0].(dbus.ObjectPath)
if !validBatteryPath {
continue
}
switch {
case strings.Contains(event.Signal, deviceAddedSignal):
go func() {
for s := range w.track(ctx, batteryPath) {
sensorCh <- s
}
}()
case strings.Contains(event.Signal, deviceRemovedSignal):
w.remove(batteryPath)
}
}
}
}()
return sensorCh
}
func NewBatteryWorker(ctx context.Context) (*linux.EventSensorWorker, error) {
worker := linux.NewEventWorker(workerID)
bus, ok := linux.CtxGetSystemBus(ctx)
if !ok {
return worker, linux.ErrNoSystemBus
}
worker.EventType = &sensorWorker{
batteryList: make(map[dbus.ObjectPath]context.CancelFunc),
bus: bus,
logger: logging.FromContext(ctx).With(slog.String("worker", workerID)),
}
return worker, nil
}