Skip to content

Commit

Permalink
feat(hass,linux): ✨ use options pattern to create sensors
Browse files Browse the repository at this point in the history
- introduce an options pattern to create sensors. Workers can use `sensor.NewSensor` to create a sensor with whatever options are required.
- use new options pattern sensor creation in Linux sensor workers
  • Loading branch information
joshuar committed Dec 22, 2024
1 parent d820611 commit b614ec3
Show file tree
Hide file tree
Showing 36 changed files with 896 additions and 745 deletions.
43 changes: 19 additions & 24 deletions internal/agent/agentsensor/connection_latency.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,25 @@ const (
var ErrEmptyResponse = errors.New("empty response")

func newConnectionLatencySensor(info resty.TraceInfo) sensor.Entity {
connectionLatency := sensor.Entity{
Name: "Connection Latency",
Units: connectionLatencyUnits,
DeviceClass: types.SensorDeviceClassDuration,
StateClass: types.StateClassMeasurement,
Category: types.CategoryDiagnostic,
State: &sensor.State{
ID: "connection_latency",
Icon: "mdi:connection",
EntityType: types.Sensor,
Value: info.TotalTime.Milliseconds(),
Attributes: map[string]any{
"DNS Lookup Time": info.DNSLookup.Milliseconds(),
"Connection Time": info.ConnTime.Milliseconds(),
"TCP Connection Time": info.TCPConnTime.Milliseconds(),
"TLS Handshake Time": info.TLSHandshake.Milliseconds(),
"Server Time": info.ServerTime.Milliseconds(),
"Response Time": info.ResponseTime.Milliseconds(),
"native_unit_of_measurement": connectionLatencyUnits,
},
},
}

return connectionLatency
return sensor.NewSensor(
sensor.WithName("Connection Latency"),
sensor.WithUnits(connectionLatencyUnits),
sensor.WithDeviceClass(types.SensorDeviceClassDuration),
sensor.WithStateClass(types.StateClassMeasurement),
sensor.AsDiagnostic(),
sensor.WithState(
sensor.WithID("connection_latency"),
sensor.WithIcon("mdi:connection"),
sensor.WithValue(info.TotalTime.Milliseconds()),
sensor.WithAttribute("DNS Lookup Time", info.DNSLookup.Milliseconds()),
sensor.WithAttribute("Connection Time", info.ConnTime.Milliseconds()),
sensor.WithAttribute("TCP Connection Time", info.TCPConnTime.Milliseconds()),
sensor.WithAttribute("TLS Handshake Time", info.TLSHandshake.Milliseconds()),
sensor.WithAttribute("Server Time", info.ServerTime.Milliseconds()),
sensor.WithAttribute("Response Time", info.ResponseTime.Milliseconds()),
sensor.WithAttribute("native_unit_of_measurement", connectionLatencyUnits),
),
)
}

type ConnectionLatencySensorWorker struct {
Expand Down
24 changes: 10 additions & 14 deletions internal/agent/agentsensor/external_ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (

"github.com/joshuar/go-hass-agent/internal/device/helpers"
"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/logging"
"github.com/joshuar/go-hass-agent/internal/preferences"
)
Expand Down Expand Up @@ -54,19 +53,16 @@ func newExternalIPSensor(addr net.IP) sensor.Entity {
icon = "mdi:numeric-6-box-outline"
}

return sensor.Entity{
Name: name,
Category: types.CategoryDiagnostic,
State: &sensor.State{
ID: id,
Icon: icon,
EntityType: types.Sensor,
Value: addr.String(),
Attributes: map[string]any{
"last_updated": time.Now().Format(time.RFC3339),
},
},
}
return sensor.NewSensor(
sensor.WithName(name),
sensor.AsDiagnostic(),
sensor.WithState(
sensor.WithID(id),
sensor.WithIcon(icon),
sensor.WithValue(addr.String()),
sensor.WithAttribute("last_updated", time.Now().Format(time.RFC3339)),
),
)
}

type ExternalIPWorker struct {
Expand Down
20 changes: 9 additions & 11 deletions internal/agent/agentsensor/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"context"

"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/preferences"
)

Expand All @@ -19,16 +18,15 @@ const (
)

func newVersionSensor() sensor.Entity {
return sensor.Entity{
Name: "Go Hass Agent Version",
Category: types.CategoryDiagnostic,
State: &sensor.State{
ID: "agent_version",
Icon: "mdi:face-agent",
EntityType: types.Sensor,
Value: preferences.AppVersion,
},
}
return sensor.NewSensor(
sensor.WithName("Go Hass Agent Version"),
sensor.AsDiagnostic(),
sensor.WithState(
sensor.WithID("agent_version"),
sensor.WithIcon("mdi:face-agent"),
sensor.WithValue(preferences.AppVersion),
),
)
}

type VersionWorker struct{}
Expand Down
21 changes: 12 additions & 9 deletions internal/hass/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ func init() {
AddRetryCondition(defaultRetryFunc)
}

// Request is an API request to Home Assistant. It has a request body (typically
// JSON) and a boolean to indicate whether the request should be retried (with a
// default exponential backoff).
type Request interface {
RequestBody() any
Retry() bool
Expand All @@ -57,10 +60,12 @@ type Encrypted interface {
Secret() string
}

// Validator represents a request that should be validated before being sent.
type Validator interface {
Validate() error
}

// ResponseError contains Home Assistant API error response details.
type ResponseError struct {
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Expand All @@ -83,6 +88,11 @@ func (e *ResponseError) Error() string {
return strings.Join(msg, ": ")
}

// Send will send the given request to the specified URL. It will handle
// marshaling the request and unmarshaling the response. It can optionally set
// an Auth token for requests that require it and validate the request before
// sending. It will also handle retrying the request with an exponential backoff
// if requested.
func Send[T any](ctx context.Context, url string, details Request) (T, error) {
var response T

Expand All @@ -101,18 +111,11 @@ func Send[T any](ctx context.Context, url string, details Request) (T, error) {
}
}

// If request needs to be retried, retry the request on any error.
if details.Retry() {
// If request needs to be retried, retry the request on any error.
logging.FromContext(ctx).Debug("Will retry requests.", slog.Any("body", details))

requestClient = requestClient.AddRetryCondition(
func(_ *resty.Response, err error) bool {
if err != nil {
logging.FromContext(ctx).Debug("Retrying request.", slog.Any("body", details))
return true
}

return false
return err != nil
},
)
}
Expand Down
187 changes: 187 additions & 0 deletions internal/hass/sensor/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package sensor
import (
"encoding/json"
"fmt"
"maps"

"github.com/joshuar/go-hass-agent/internal/hass/sensor/types"
"github.com/joshuar/go-hass-agent/internal/validation"
Expand All @@ -20,6 +21,8 @@ const (
requestTypeLocation = "update_location"
)

type Option[T any] func(T) T

type Request struct {
Data any `json:"data"`
RequestType string `json:"type"`
Expand All @@ -38,6 +41,105 @@ type State struct {
RequestMetadata
}

// WithValue assigns a value to the sensor.
func WithValue(value any) Option[State] {
return func(s State) State {
s.Value = value
return s
}
}

// WithAttributes sets the additional attributes for the sensor.
func WithAttributes(attributes map[string]any) Option[State] {
return func(s State) State {

Check failure on line 54 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

parameter name 's' is too short for the scope of its usage (varnamelen)
if s.Attributes != nil {
maps.Copy(s.Attributes, attributes)
} else {
s.Attributes = attributes
}
return s

Check failure on line 60 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

return statements should not be cuddled if block has more than two lines (wsl)
}
}

// WithAttribute sets the given additional attribute to the given value.
func WithAttribute(name string, value any) Option[State] {
return func(s State) State {

Check failure on line 66 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

parameter name 's' is too short for the scope of its usage (varnamelen)
if s.Attributes == nil {
s.Attributes = make(map[string]any)
}

s.Attributes[name] = value

return s
}
}

// WithDataSourceAttribute will set the "data_source" additional attribute to
// the given value.
func WithDataSourceAttribute(source string) Option[State] {
return func(s State) State {

Check failure on line 80 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

parameter name 's' is too short for the scope of its usage (varnamelen)
if s.Attributes == nil {
s.Attributes = make(map[string]any)
}

s.Attributes["data_source"] = source

return s
}
}

// WithIcon sets the sensor icon.
func WithIcon(icon string) Option[State] {
return func(s State) State {
s.Icon = icon
return s
}
}

// WithID sets the entity ID of the sensor.
func WithID(id string) Option[State] {
return func(s State) State {
s.ID = id
return s
}
}

// AsTypeSensor ensures the sensor is treated as a Sensor Entity.
// https://developers.home-assistant.io/docs/core/entity/sensor/
func AsTypeSensor() Option[State] {
return func(s State) State {
s.EntityType = types.Sensor
return s
}
}

// AsTypeBinarySensor ensures the sensor is treated as a Binary Sensor Entity.
// https://developers.home-assistant.io/docs/core/entity/binary-sensor
func AsTypeBinarySensor() Option[State] {
return func(s State) State {
s.EntityType = types.BinarySensor
return s
}
}

// UpdateValue will update the sensor state with the given value.
func (e *State) UpdateValue(value any) {
e.Value = value
}

// UpdateIcon will update the sensor icon with the given value.
func (e *State) UpdateIcon(icon string) {
e.Icon = icon
}

// UpdateAttribute will set the given attribute to the given value.
func (e *State) UpdateAttribute(key string, value any) {
if e.Attributes == nil {
e.Attributes = make(map[string]any)
}
e.Attributes[key] = value
}

func (s *State) Validate() error {
err := validation.Validate.Struct(s)
if err != nil {
Expand Down Expand Up @@ -84,6 +186,91 @@ type Entity struct {
Category types.Category `json:"entity_category,omitempty" validate:"omitempty"`
}

// WithState sets the sensor state options. This is useful on entity
// creation to set an intial state.

Check failure on line 190 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

`intial` is a misspelling of `initial` (misspell)
func WithState(options ...Option[State]) Option[Entity] {
return func(e Entity) Entity {

Check failure on line 192 in internal/hass/sensor/entities.go

View workflow job for this annotation

GitHub Actions / golangci

parameter name 'e' is too short for the scope of its usage (varnamelen)
state := State{}

for _, option := range options {
state = option(state)
}

e.State = &state

return e
}
}

// WithName sets the friendly name for the sensor entity.
func WithName(name string) Option[Entity] {
return func(e Entity) Entity {
e.Name = name
return e
}
}

// WithUnits defines the native unit of measurement of the sensor entity.
func WithUnits(units string) Option[Entity] {
return func(e Entity) Entity {
e.Units = units
return e
}
}

// WithDeviceClass sets the device class of the sensor entity.
//
// For type Sensor: https://developers.home-assistant.io/docs/core/entity/sensor#available-device-classes
//
// For type Binary Sensor: https://developers.home-assistant.io/docs/core/entity/binary-sensor#available-device-classes
func WithDeviceClass(class types.DeviceClass) Option[Entity] {
return func(e Entity) Entity {
e.DeviceClass = class
return e
}
}

// WithStateClass sets the state class of the sensor entity.
// https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes
func WithStateClass(class types.StateClass) Option[Entity] {
return func(e Entity) Entity {
e.StateClass = class
return e
}
}

// AsDiagnostic sets the sensor entity as a diagnostic. This will ensure it will
// be grouped under a diagnostic header in the Home Assistant UI.
func AsDiagnostic() Option[Entity] {
return func(e Entity) Entity {
e.Category = types.CategoryDiagnostic
return e
}
}

// NewSensor provides a way to build a sensor entity with the given options.
func NewSensor(options ...Option[Entity]) Entity {
sensor := Entity{}

for _, option := range options {
sensor = option(sensor)
}

return sensor
}

// UpdateState will set the state of the entity as per the given options. This can
// be used on an existing Entity to "update" the state. Note that any existing
// state will be reset and only the new options will be applied.
func (e *Entity) UpdateState(options ...Option[State]) {
state := State{}
for _, option := range options {
state = option(state)
}

e.State = &state
}

func (e *Entity) Validate() error {
err := validation.Validate.Struct(e)
if err != nil {
Expand Down
Loading

0 comments on commit b614ec3

Please sign in to comment.