From 124175f82705878e0309f748dcc3bcd3d25a7a52 Mon Sep 17 00:00:00 2001 From: Joshua Rich Date: Sat, 26 Oct 2024 10:23:29 +1000 Subject: [PATCH] refactor(hass): :truck: restructure hass package - create separate api package for sending requests to hass api - create new request types to send registration and event/sensor requests to hass - validation back in api package --- internal/agent/agent.go | 4 +- internal/agent/ui/fyneUI/fyneUI.go | 4 +- internal/hass/api/api.go | 155 ++++++++++++++++++ .../{response_test.go => api/api_test.go} | 12 +- internal/hass/client.go | 97 ++++------- internal/hass/config.go | 15 +- internal/hass/discovery.go | 6 +- internal/hass/event/event.go | 30 +++- internal/hass/{ => event}/validation.go | 8 +- internal/hass/registration.go | 28 +--- internal/hass/request.go | 149 ----------------- internal/hass/request_test.go | 71 -------- internal/hass/response.go | 35 +--- internal/hass/sensor/entities.go | 82 ++++++++- internal/hass/sensor/tracker.go | 6 +- internal/hass/sensor/tracker_test.go | 6 +- internal/hass/sensor/validation.go | 40 +++++ internal/hass/websocket.go | 9 +- internal/linux/location/location.go | 3 +- 19 files changed, 370 insertions(+), 390 deletions(-) create mode 100644 internal/hass/api/api.go rename internal/hass/{response_test.go => api/api_test.go} (77%) rename internal/hass/{ => event}/validation.go (81%) delete mode 100644 internal/hass/request.go delete mode 100644 internal/hass/request_test.go create mode 100644 internal/hass/sensor/validation.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 717e8b1fe..a5bc7ebbf 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -142,15 +142,13 @@ func Run(ctx context.Context) error { return } - client, err := hass.NewClient(ctx) + client, err := hass.NewClient(ctx, prefs.RestAPIURL()) if err != nil { logging.FromContext(ctx).Error("Cannot connect to Home Assistant.", slog.Any("error", err)) return } - client.Endpoint(prefs.RestAPIURL(), hass.DefaultTimeout) - // Initialize and gather OS sensor and event workers. sensorWorkers, eventWorkers := setupOSWorkers(runCtx) // Initialize and add connection latency sensor worker. diff --git a/internal/agent/ui/fyneUI/fyneUI.go b/internal/agent/ui/fyneUI/fyneUI.go index eb37994ac..8c44270dd 100644 --- a/internal/agent/ui/fyneUI/fyneUI.go +++ b/internal/agent/ui/fyneUI/fyneUI.go @@ -198,14 +198,12 @@ func (i *FyneUI) aboutWindow(ctx context.Context) fyne.Window { return nil } - hassclient, err := hass.NewClient(ctx) + hassclient, err := hass.NewClient(ctx, prefs.RestAPIURL()) if err != nil { logging.FromContext(ctx).Debug("Cannot create Home Assistant client.", slog.Any("error", err)) return nil } - hassclient.Endpoint(prefs.RestAPIURL(), hass.DefaultTimeout) - icon := canvas.NewImageFromResource(&ui.TrayIcon{}) icon.FillMode = canvas.ImageFillOriginal diff --git a/internal/hass/api/api.go b/internal/hass/api/api.go new file mode 100644 index 000000000..c9f00436c --- /dev/null +++ b/internal/hass/api/api.go @@ -0,0 +1,155 @@ +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT + +//go:generate go run github.com/matryer/moq -out api_mocks_test.go . PostRequest +package api + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/joshuar/go-hass-agent/internal/logging" +) + +const ( + defaultTimeout = 30 * time.Second +) + +var ( + client *resty.Client + + defaultRetryFunc = func(r *resty.Response, _ error) bool { + return r.StatusCode() == http.StatusTooManyRequests + } +) + +func init() { + client = resty.New(). + SetTimeout(defaultTimeout). + AddRetryCondition(defaultRetryFunc) +} + +type RawRequest interface { + RequestBody() any +} + +// Request is a HTTP POST request with the request body provided by Body(). +type Request interface { + RequestType() string + RequestData() any +} + +// Authenticated represents a request that requires passing an authentication +// header with the value returned by Auth(). +type Authenticated interface { + Auth() string +} + +// Encrypted represents a request that should be encrypted with the secret +// provided by Secret(). +type Encrypted interface { + Secret() string +} + +type Validator interface { + Validate() error +} + +type requestBody struct { + Data any `json:"data"` + RequestType string `json:"type"` +} + +type ResponseError struct { + Code any `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func (e *ResponseError) Error() string { + var msg []string + if e.Code != nil { + msg = append(msg, fmt.Sprintf("code %v", e.Code)) + } + + if e.Message != "" { + msg = append(msg, e.Message) + } + + if len(msg) == 0 { + msg = append(msg, "unknown error") + } + + return strings.Join(msg, ": ") +} + +func Send[T any](ctx context.Context, url string, details any) (T, error) { + var ( + response T + responseErr ResponseError + responseObj *resty.Response + ) + + requestClient := client.R().SetContext(ctx) + requestClient = requestClient.SetError(&responseErr) + requestClient = requestClient.SetResult(&response) + + // If the request is authenticated, set the auth header with the token. + if a, ok := details.(Authenticated); ok { + requestClient = requestClient.SetAuthToken(a.Auth()) + } + + // If the request can be validated, validate it. + if v, ok := details.(Validator); ok { + if err := v.Validate(); err != nil { + return response, fmt.Errorf("invalid request: %w", err) + } + } + + switch request := details.(type) { + case Request: + body := &requestBody{ + RequestType: request.RequestType(), + Data: request.RequestData(), + } + logging.FromContext(ctx). + LogAttrs(ctx, logging.LevelTrace, + "Sending request.", + slog.String("method", "POST"), + slog.String("url", url), + slog.Any("body", body), + slog.Time("sent_at", time.Now())) + + responseObj, _ = requestClient.SetBody(body).Post(url) //nolint:errcheck // error is checked with responseObj.IsError() + case RawRequest: + logging.FromContext(ctx). + LogAttrs(ctx, logging.LevelTrace, + "Sending request.", + slog.String("method", "POST"), + slog.String("url", url), + slog.Any("body", request), + slog.Time("sent_at", time.Now())) + + responseObj, _ = requestClient.SetBody(request).Post(url) //nolint:errcheck // error is checked with responseObj.IsError() + } + + logging.FromContext(ctx). + LogAttrs(ctx, logging.LevelTrace, + "Received response.", + slog.Int("statuscode", responseObj.StatusCode()), + slog.String("status", responseObj.Status()), + slog.String("protocol", responseObj.Proto()), + slog.Duration("time", responseObj.Time()), + slog.String("body", string(responseObj.Body()))) + + if responseObj.IsError() { + return response, &ResponseError{Code: responseObj.StatusCode(), Message: responseObj.Status()} + } + + return response, nil +} diff --git a/internal/hass/response_test.go b/internal/hass/api/api_test.go similarity index 77% rename from internal/hass/response_test.go rename to internal/hass/api/api_test.go index 8af7e98a2..0ee40fb55 100644 --- a/internal/hass/response_test.go +++ b/internal/hass/api/api_test.go @@ -1,13 +1,11 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT -package hass +package api import "testing" -func Test_apiError_Error(t *testing.T) { +func Test_ResponseError_Error(t *testing.T) { type fields struct { Code any Message string @@ -39,7 +37,7 @@ func Test_apiError_Error(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - e := &apiError{ + e := &ResponseError{ Code: tt.fields.Code, Message: tt.fields.Message, } diff --git a/internal/hass/client.go b/internal/hass/client.go index f81ffdd3a..b63f6e172 100644 --- a/internal/hass/client.go +++ b/internal/hass/client.go @@ -1,7 +1,5 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package hass @@ -10,11 +8,9 @@ import ( "errors" "fmt" "log/slog" - "net/http" "time" - "github.com/go-resty/resty/v2" - + "github.com/joshuar/go-hass-agent/internal/hass/api" "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/registry" @@ -48,10 +44,6 @@ var ( ErrUnknown = errors.New("unknown error occurred") ErrInvalidSensor = errors.New("invalid sensor") - - defaultRetry = func(r *resty.Response, _ error) bool { - return r.StatusCode() == http.StatusTooManyRequests - } ) type Registry interface { @@ -62,10 +54,10 @@ type Registry interface { } type Client struct { - endpoint *resty.Client + url string } -func NewClient(ctx context.Context) (*Client, error) { +func NewClient(ctx context.Context, url string) (*Client, error) { var err error sensorTracker = sensor.NewTracker() @@ -75,22 +67,11 @@ func NewClient(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("could not start registry: %w", err) } - return &Client{}, nil -} - -func (c *Client) Endpoint(url string, timeout time.Duration) { - if timeout == 0 { - timeout = DefaultTimeout - } - - c.endpoint = resty.New(). - SetTimeout(timeout). - AddRetryCondition(defaultRetry). - SetBaseURL(url) + return &Client{url: url}, nil } func (c *Client) HassVersion(ctx context.Context) string { - config, err := send[Config](ctx, c, &configRequest{}) + config, err := api.Send[Config](ctx, c.url, &configRequest{}) if err != nil { logging.FromContext(ctx). Debug("Could not fetch Home Assistant config.", @@ -103,13 +84,7 @@ func (c *Client) HassVersion(ctx context.Context) string { } func (c *Client) ProcessEvent(ctx context.Context, details event.Event) error { - req := &request{Data: details, RequestType: requestTypeEvent} - - if err := req.Validate(); err != nil { - return fmt.Errorf("invalid event request: %w", err) - } - - resp, err := send[response](ctx, c, req) + resp, err := api.Send[response](ctx, c.url, details) if err != nil { return fmt.Errorf("failed to send event request: %w", err) } @@ -122,12 +97,21 @@ func (c *Client) ProcessEvent(ctx context.Context, details event.Event) error { } func (c *Client) ProcessSensor(ctx context.Context, details sensor.Entity) error { - req := &request{} + // Location request. + if req, ok := details.Value.(*sensor.Location); ok { + resp, err := api.Send[response](ctx, c.url, req) + if err != nil { + return fmt.Errorf("failed to send location update: %w", err) + } - if _, ok := details.Value.(*LocationRequest); ok { - req = &request{Data: details.Value, RequestType: requestTypeLocation} + if _, err := resp.Status(); err != nil { + return err + } + + return nil } + // Sensor update. if sensorRegistry.IsRegistered(details.ID) { // For sensor updates, if the sensor is disabled, don't continue. if c.isDisabled(ctx, details) { @@ -138,41 +122,24 @@ func (c *Client) ProcessSensor(ctx context.Context, details sensor.Entity) error return nil } - req = &request{Data: details.State, RequestType: requestTypeUpdate} - } else { - req = &request{Data: details, RequestType: requestTypeRegister} - } - - if err := req.Validate(); err != nil { - return fmt.Errorf("invalid sensor request: %w", err) - } - - switch req.RequestType { - case requestTypeLocation: - resp, err := send[response](ctx, c, req) - if err != nil { - return fmt.Errorf("failed to send location update: %w", err) - } - - if _, err := resp.Status(); err != nil { - return err - } - case requestTypeUpdate: - resp, err := send[bulkSensorUpdateResponse](ctx, c, req) + resp, err := api.Send[bulkSensorUpdateResponse](ctx, c.url, details.State) if err != nil { - return fmt.Errorf("failed to send location update: %w", err) + return fmt.Errorf("failed to send sensor update: %w", err) } go resp.Process(ctx, details) - case requestTypeRegister: - resp, err := send[registrationResponse](ctx, c, req) - if err != nil { - return fmt.Errorf("failed to send location update: %w", err) - } - go resp.Process(ctx, details) + return nil } + // Sensor registration. + resp, err := api.Send[registrationResponse](ctx, c.url, details) + if err != nil { + return fmt.Errorf("failed to send sensor registration: %w", err) + } + + go resp.Process(ctx, details) + return nil } @@ -219,7 +186,7 @@ func (c *Client) isDisabledInReg(id string) bool { // isDisabledInHA returns the disabled state of the sensor from Home Assistant. func (c *Client) isDisabledInHA(ctx context.Context, details sensor.Entity) bool { - config, err := send[Config](ctx, c, &configRequest{}) + config, err := api.Send[Config](ctx, c.url, &configRequest{}) if err != nil { logging.FromContext(ctx). Debug("Could not fetch Home Assistant config. Assuming sensor is still disabled.", diff --git a/internal/hass/config.go b/internal/hass/config.go index 6a4f3c490..a8e6e5265 100644 --- a/internal/hass/config.go +++ b/internal/hass/config.go @@ -1,13 +1,10 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT //revive:disable:unused-receiver package hass import ( - "encoding/json" "errors" ) @@ -56,6 +53,10 @@ func (c *Config) IsEntityDisabled(entity string) (bool, error) { type configRequest struct{} -func (c *configRequest) RequestBody() json.RawMessage { - return json.RawMessage(`{ "type": "get_config" }`) +func (c *configRequest) RequestType() string { + return "get_config" +} + +func (c *configRequest) RequestData() any { + return nil } diff --git a/internal/hass/discovery.go b/internal/hass/discovery.go index 339c7e6f0..b22f51c4f 100644 --- a/internal/hass/discovery.go +++ b/internal/hass/discovery.go @@ -1,7 +1,5 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package hass diff --git a/internal/hass/event/event.go b/internal/hass/event/event.go index fd2d76e91..7742267f6 100644 --- a/internal/hass/event/event.go +++ b/internal/hass/event/event.go @@ -1,11 +1,33 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT +//revive:disable:unused-receiver package event +import "fmt" + +const ( + requestTypeEvent = "fire_event" +) + type Event struct { EventData any `json:"event_data" validate:"required"` EventType string `json:"event_type" validate:"required"` } + +func (e *Event) Validate() error { + err := validate.Struct(e) + if err != nil { + return fmt.Errorf("event is invalid: %s", parseValidationErrors(err)) + } + + return nil +} + +func (e *Event) RequestType() string { + return requestTypeEvent +} + +func (e *Event) RequestData() any { + return e +} diff --git a/internal/hass/validation.go b/internal/hass/event/validation.go similarity index 81% rename from internal/hass/validation.go rename to internal/hass/event/validation.go index dd1932c50..0ac72430c 100644 --- a/internal/hass/validation.go +++ b/internal/hass/event/validation.go @@ -1,9 +1,7 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT -package hass +package event import ( "strings" diff --git a/internal/hass/registration.go b/internal/hass/registration.go index 3bfa106b3..9131b3db7 100644 --- a/internal/hass/registration.go +++ b/internal/hass/registration.go @@ -1,17 +1,14 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package hass import ( "context" - "encoding/json" "errors" "fmt" - "time" + "github.com/joshuar/go-hass-agent/internal/hass/api" "github.com/joshuar/go-hass-agent/internal/preferences" ) @@ -30,13 +27,8 @@ func (r *registrationRequest) Auth() string { return r.Token } -func (r *registrationRequest) RequestBody() json.RawMessage { - data, err := json.Marshal(r) - if err != nil { - return nil - } - - return data +func (r *registrationRequest) RequestBody() any { + return r } func newRegistrationRequest(device *preferences.Device, token string) *registrationRequest { @@ -52,16 +44,10 @@ func RegisterDevice(ctx context.Context, device *preferences.Device, registratio return nil, fmt.Errorf("could not register device: %w", err) } - // Create a new client connection to Home Assistant at the registration path. - client, err := NewClient(ctx) - if err != nil { - return nil, fmt.Errorf("could not start hass client: %w", err) - } - - client.Endpoint(registration.Server+RegistrationPath, time.Minute) + registrationURL := registration.Server + RegistrationPath // Register the device against the registration endpoint. - registrationStatus, err := send[preferences.Hass](ctx, client, newRegistrationRequest(device, registration.Token)) + registrationStatus, err := api.Send[preferences.Hass](ctx, registrationURL, newRegistrationRequest(device, registration.Token)) if err != nil { return nil, fmt.Errorf("could not register device: %w", err) } diff --git a/internal/hass/request.go b/internal/hass/request.go deleted file mode 100644 index 59613250c..000000000 --- a/internal/hass/request.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -//go:generate go run github.com/matryer/moq -out request_mocks_test.go . PostRequest -package hass - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/go-resty/resty/v2" - - "github.com/joshuar/go-hass-agent/internal/logging" -) - -const ( - requestTypeRegister = "register_sensor" - requestTypeUpdate = "update_sensor_states" - requestTypeLocation = "update_location" - requestTypeEvent = "fire_event" -) - -var ( - ErrNotLocation = errors.New("sensor details do not represent a location update") - ErrUnknownDetails = errors.New("unknown sensor details") -) - -// GetRequest is a HTTP GET request. -type GetRequest any - -// PostRequest is a HTTP POST request with the request body provided by Body(). -type PostRequest interface { - RequestBody() json.RawMessage -} - -// Authenticated represents a request that requires passing an authentication -// header with the value returned by Auth(). -type Authenticated interface { - Auth() string -} - -// Encrypted represents a request that should be encrypted with the secret -// provided by Secret(). -type Encrypted interface { - Secret() string -} - -type Validator interface { - Validate() error -} - -// LocationRequest represents the location information that can be sent to HA to -// update the location of the agent. This is exposed so that device code can -// create location requests directly, as Home Assistant handles these -// differently from other sensors. -type LocationRequest struct { - Gps []float64 `json:"gps" validate:"required"` - GpsAccuracy int `json:"gps_accuracy,omitempty"` - Battery int `json:"battery,omitempty"` - Speed int `json:"speed,omitempty"` - Altitude int `json:"altitude,omitempty"` - Course int `json:"course,omitempty"` - VerticalAccuracy int `json:"vertical_accuracy,omitempty"` -} - -type request struct { - Data any `json:"data" validate:"required"` - RequestType string `json:"type" validate:"required,oneof=register_sensor update_sensor_states update_location fire_event"` -} - -func (r *request) Validate() error { - err := validate.Struct(r) - if err != nil { - return fmt.Errorf("%T is invalid: %s", r.Data, parseValidationErrors(err)) - } - - return nil -} - -func (r *request) RequestBody() json.RawMessage { - data, err := json.Marshal(r) - if err != nil { - return nil - } - - return json.RawMessage(data) -} - -func send[T any](ctx context.Context, client *Client, requestDetails any) (T, error) { - var ( - response T - responseErr apiError - responseObj *resty.Response - ) - - if client.endpoint == nil { - return response, ErrInvalidClient - } - - requestObj := client.endpoint.R().SetContext(ctx) - requestObj = requestObj.SetError(&responseErr) - requestObj = requestObj.SetResult(&response) - - // If the request is authenticated, set the auth header with the token. - if a, ok := requestDetails.(Authenticated); ok { - requestObj = requestObj.SetAuthToken(a.Auth()) - } - - switch req := requestDetails.(type) { - case PostRequest: - logging.FromContext(ctx). - LogAttrs(ctx, logging.LevelTrace, - "Sending request.", - slog.String("method", "POST"), - slog.String("body", string(req.RequestBody())), - slog.Time("sent_at", time.Now())) - - responseObj, _ = requestObj.SetBody(req.RequestBody()).Post("") //nolint:errcheck // error is checked with responseObj.IsError() - case GetRequest: - logging.FromContext(ctx). - LogAttrs(ctx, logging.LevelTrace, - "Sending request.", - slog.String("method", "GET"), - slog.Time("sent_at", time.Now())) - - responseObj, _ = requestObj.Get("") //nolint:errcheck // error is checked with responseObj.IsError() - } - - logging.FromContext(ctx). - LogAttrs(ctx, logging.LevelTrace, - "Received response.", - slog.Int("statuscode", responseObj.StatusCode()), - slog.String("status", responseObj.Status()), - slog.String("protocol", responseObj.Proto()), - slog.Duration("time", responseObj.Time()), - slog.String("body", string(responseObj.Body()))) - - if responseObj.IsError() { - return response, &apiError{Code: responseObj.StatusCode(), Message: responseObj.Status()} - } - - return response, nil -} diff --git a/internal/hass/request_test.go b/internal/hass/request_test.go deleted file mode 100644 index 7d0111809..000000000 --- a/internal/hass/request_test.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -//revive:disable:max-public-structs -package hass - -import ( - "testing" - - "github.com/joshuar/go-hass-agent/internal/hass/sensor" -) - -func Test_request_Validate(t *testing.T) { - entity := sensor.Entity{ - Name: "Mock Entity", - State: &sensor.State{ - ID: "mock_entity", - Value: "mockState", - }, - } - - invalidEntity := sensor.Entity{ - Name: "Mock Entity", - State: &sensor.State{ - ID: "mock_entity", - }, - } - - type fields struct { - Data any - RequestType string - } - tests := []struct { - name string - fields fields - wantErr bool - }{ - { - name: "valid request", - fields: fields{RequestType: requestTypeUpdate, Data: entity}, - }, - { - name: "invalid request: invalid data", - fields: fields{RequestType: requestTypeUpdate, Data: invalidEntity}, - wantErr: true, - }, - { - name: "invalid request: no data", - fields: fields{RequestType: requestTypeUpdate}, - wantErr: true, - }, - { - name: "invalid request: unknown request type", - fields: fields{Data: entity}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &request{ - Data: tt.fields.Data, - RequestType: tt.fields.RequestType, - } - if err := r.Validate(); (err != nil) != tt.wantErr { - t.Errorf("request.Validate() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/internal/hass/response.go b/internal/hass/response.go index 88282c382..f93ce1cf8 100644 --- a/internal/hass/response.go +++ b/internal/hass/response.go @@ -1,16 +1,13 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package hass import ( "context" - "fmt" "log/slog" - "strings" + "github.com/joshuar/go-hass-agent/internal/hass/api" "github.com/joshuar/go-hass-agent/internal/hass/sensor" "github.com/joshuar/go-hass-agent/internal/logging" ) @@ -28,31 +25,9 @@ type Response interface { Status() (responseStatus, error) } -type apiError struct { - Code any `json:"code,omitempty"` - Message string `json:"message,omitempty"` -} - -func (e *apiError) Error() string { - var msg []string - if e.Code != nil { - msg = append(msg, fmt.Sprintf("code %v", e.Code)) - } - - if e.Message != "" { - msg = append(msg, e.Message) - } - - if len(msg) == 0 { - msg = append(msg, "unknown error") - } - - return strings.Join(msg, ": ") -} - type response struct { - ErrorDetails *apiError `json:"error,omitempty"` - IsSuccess bool `json:"success,omitempty"` + ErrorDetails *api.ResponseError `json:"error,omitempty"` + IsSuccess bool `json:"success,omitempty"` } func (r *response) Status() (responseStatus, error) { diff --git a/internal/hass/sensor/entities.go b/internal/hass/sensor/entities.go index c230a572b..45ffab154 100644 --- a/internal/hass/sensor/entities.go +++ b/internal/hass/sensor/entities.go @@ -1,17 +1,22 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT + +//revive:disable:unused-receiver package sensor import ( "encoding/json" + "fmt" "github.com/joshuar/go-hass-agent/internal/hass/sensor/types" ) const ( StateUnknown = "Unknown" + + requestTypeRegisterSensor = "register_sensor" + requestTypeUpdateSensor = "update_sensor_states" + requestTypeLocation = "update_location" ) type State struct { @@ -22,8 +27,17 @@ type State struct { EntityType types.SensorType `json:"type" validate:"omitempty"` } +func (s *State) Validate() error { + err := validate.Struct(s) + if err != nil { + return fmt.Errorf("sensor state is invalid: %s", parseValidationErrors(err)) + } + + return nil +} + //nolint:wrapcheck -func (s State) MarshalJSON() ([]byte, error) { +func (s *State) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { State any `json:"state" validate:"required"` Attributes map[string]any `json:"attributes,omitempty" validate:"omitempty"` @@ -39,6 +53,14 @@ func (s State) MarshalJSON() ([]byte, error) { }) } +func (s *State) RequestType() string { + return requestTypeUpdateSensor +} + +func (s *State) RequestData() any { + return s +} + type Entity struct { *State Name string `json:"name" validate:"required"` @@ -48,8 +70,17 @@ type Entity struct { Category types.Category `json:"entity_category,omitempty" validate:"omitempty"` } +func (e *Entity) Validate() error { + err := validate.Struct(e) + if err != nil { + return fmt.Errorf("sensor is invalid: %s", parseValidationErrors(err)) + } + + return nil +} + //nolint:wrapcheck -func (e Entity) MarshalJSON() ([]byte, error) { +func (e *Entity) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { State any `json:"state" validate:"required"` Attributes map[string]any `json:"attributes,omitempty" validate:"omitempty"` @@ -74,3 +105,42 @@ func (e Entity) MarshalJSON() ([]byte, error) { Category: e.Category.String(), }) } + +func (e *Entity) RequestType() string { + return requestTypeRegisterSensor +} + +func (e *Entity) RequestData() any { + return e +} + +// Location represents the location information that can be sent to HA to +// update the location of the agent. This is exposed so that device code can +// create location requests directly, as Home Assistant handles these +// differently from other sensors. +type Location struct { + Gps []float64 `json:"gps" validate:"required"` + GpsAccuracy int `json:"gps_accuracy,omitempty"` + Battery int `json:"battery,omitempty"` + Speed int `json:"speed,omitempty"` + Altitude int `json:"altitude,omitempty"` + Course int `json:"course,omitempty"` + VerticalAccuracy int `json:"vertical_accuracy,omitempty"` +} + +func (l *Location) Validate() error { + err := validate.Struct(l) + if err != nil { + return fmt.Errorf("location is invalid: %s", parseValidationErrors(err)) + } + + return nil +} + +func (l *Location) RequestType() string { + return requestTypeLocation +} + +func (l *Location) RequestData() any { + return l +} diff --git a/internal/hass/sensor/tracker.go b/internal/hass/sensor/tracker.go index d589cb986..9f1cc0c2c 100644 --- a/internal/hass/sensor/tracker.go +++ b/internal/hass/sensor/tracker.go @@ -1,7 +1,5 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package sensor diff --git a/internal/hass/sensor/tracker_test.go b/internal/hass/sensor/tracker_test.go index 8e8161692..206909e49 100644 --- a/internal/hass/sensor/tracker_test.go +++ b/internal/hass/sensor/tracker_test.go @@ -1,7 +1,5 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT //nolint:paralleltest package sensor diff --git a/internal/hass/sensor/validation.go b/internal/hass/sensor/validation.go new file mode 100644 index 000000000..7c08bdacd --- /dev/null +++ b/internal/hass/sensor/validation.go @@ -0,0 +1,40 @@ +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT + +package sensor + +import ( + "strings" + + "github.com/go-playground/validator/v10" +) + +var validate *validator.Validate + +func init() { + validate = validator.New(validator.WithRequiredStructEnabled()) +} + +//nolint:errorlint +//revive:disable:unhandled-error +func parseValidationErrors(validation error) string { + validationErrs, ok := validation.(validator.ValidationErrors) + if !ok { + return "internal validation error" + } + + var message strings.Builder + + for _, err := range validationErrs { + switch err.Tag() { + case "required": + message.WriteString("field " + err.Field() + " is required") + default: + message.WriteString("field " + err.Field() + " should match " + err.Tag()) + } + + message.WriteRune(' ') + } + + return message.String() +} diff --git a/internal/hass/websocket.go b/internal/hass/websocket.go index 91337e81f..52e0fe8cb 100644 --- a/internal/hass/websocket.go +++ b/internal/hass/websocket.go @@ -1,7 +1,5 @@ -// Copyright (c) 2024 Joshua Rich -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT +// Copyright 2024 Joshua Rich . +// SPDX-License-Identifier: MIT package hass @@ -17,6 +15,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/lxzan/gws" + "github.com/joshuar/go-hass-agent/internal/hass/api" "github.com/joshuar/go-hass-agent/internal/logging" ) @@ -51,7 +50,7 @@ func (m *webSocketRequest) send(conn *gws.Conn) error { type websocketResponse struct { Result any `json:"result,omitempty"` - Error apiError `json:"error,omitempty"` + Error api.ResponseError `json:"error,omitempty"` Type string `json:"type"` HAVersion string `json:"ha_version,omitempty"` Notification WebsocketNotification `json:"event,omitempty"` diff --git a/internal/linux/location/location.go b/internal/linux/location/location.go index 989c20be1..e0f1f0928 100644 --- a/internal/linux/location/location.go +++ b/internal/linux/location/location.go @@ -12,7 +12,6 @@ import ( "fmt" "log/slog" - "github.com/joshuar/go-hass-agent/internal/hass" "github.com/joshuar/go-hass-agent/internal/hass/sensor" "github.com/joshuar/go-hass-agent/internal/linux" "github.com/joshuar/go-hass-agent/internal/logging" @@ -106,7 +105,7 @@ func (w *locationWorker) newLocation(locationPath string) (sensor.Entity, error) location := sensor.Entity{ State: &sensor.State{ - Value: &hass.LocationRequest{ + Value: &sensor.Location{ Gps: []float64{latitude, longitude}, GpsAccuracy: int(accuracy), Speed: int(speed),