From e59f916d23030cdd416e424ae10d6afdeabff2cc Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sat, 13 Dec 2025 13:02:52 +0100 Subject: [PATCH 01/36] Rename "message" types uniformly to `FooMessage` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some message types (e.g., `BaseMessage` and `AuthMessage`) used the full spelled-out word "Message" in their names, while others (like `ChanMsg` and `stateChangedMsg`) used "Msg". For consistency, rename the latter to be more consistent with the former: * `ChanMsg` → `ChanMessage` * `stateChangedMsg` → `stateChangedMessage` --- app.go | 4 ++-- entitylistener.go | 4 ++-- eventListener.go | 2 +- internal/websocket/message.go | 14 ++++++++++++++ internal/websocket/reader.go | 15 +-------------- internal/websocket/subscriptions.go | 4 ++-- 6 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 internal/websocket/message.go diff --git a/app.go b/app.go index 4464cb1..ec0ec8a 100644 --- a/app.go +++ b/app.go @@ -261,7 +261,7 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.ChanMsg) { + func(msg websocket.ChanMessage) { go app.callEventListeners(eventType, msg) }, ) @@ -328,7 +328,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.ChanMsg) { + func(msg websocket.ChanMessage) { go app.callEntityListeners(msg.Raw) }, ) diff --git a/entitylistener.go b/entitylistener.go index 17fc43e..dc7c71f 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -45,7 +45,7 @@ type EntityData struct { LastChanged time.Time } -type stateChangedMsg struct { +type stateChangedMessage struct { ID int `json:"id"` Type string `json:"type"` Event struct { @@ -239,7 +239,7 @@ func (l *EntityListener) maybeCall(app *App, entityData EntityData, data stateDa /* Functions */ func (app *App) callEntityListeners(msgBytes []byte) { - msg := stateChangedMsg{} + msg := stateChangedMessage{} _ = json.Unmarshal(msgBytes, &msg) data := msg.Event.Data eid := data.EntityID diff --git a/eventListener.go b/eventListener.go index 91f4a1f..87a7bda 100644 --- a/eventListener.go +++ b/eventListener.go @@ -158,7 +158,7 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { } /* Functions */ -func (app *App) callEventListeners(eventType string, msg websocket.ChanMsg) { +func (app *App) callEventListeners(eventType string, msg websocket.ChanMessage) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type diff --git a/internal/websocket/message.go b/internal/websocket/message.go new file mode 100644 index 0000000..8c10c66 --- /dev/null +++ b/internal/websocket/message.go @@ -0,0 +1,14 @@ +package websocket + +type BaseMessage struct { + Type string `json:"type"` + ID int64 `json:"id"` + Success bool `json:"success"` +} + +type ChanMessage struct { + Type string + ID int64 + Success bool + Raw []byte +} diff --git a/internal/websocket/reader.go b/internal/websocket/reader.go index 942ca2e..ce08841 100644 --- a/internal/websocket/reader.go +++ b/internal/websocket/reader.go @@ -5,19 +5,6 @@ import ( "log/slog" ) -type BaseMessage struct { - Type string `json:"type"` - ID int64 `json:"id"` - Success bool `json:"success"` -} - -type ChanMsg struct { - Type string - ID int64 - Success bool - Raw []byte -} - // Run processes incoming messages from `Conn`. It reads // JSON-formatted messages from `conn`, partly deserializes them, and // passes them to the subscriber that has subscribed to that message @@ -51,7 +38,7 @@ func (conn *Conn) Run() error { continue } - chanMsg := ChanMsg{ + chanMsg := ChanMessage{ Type: base.Type, ID: base.ID, Success: base.Success, diff --git a/internal/websocket/subscriptions.go b/internal/websocket/subscriptions.go index 590d52c..9ace262 100644 --- a/internal/websocket/subscriptions.go +++ b/internal/websocket/subscriptions.go @@ -19,10 +19,10 @@ func (sub Subscription) MessageID() int64 { // Subscriber is called synchronously when a message is received that // matches its subscription's message ID. -type Subscriber func(msg ChanMsg) +type Subscriber func(msg ChanMessage) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ ChanMsg) {} +func NoopSubscriber(_ ChanMessage) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. From 02d812aff18e87c1526fbafd8ef7b0a0851bd873 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 15:14:59 +0100 Subject: [PATCH 02/36] Make the `internal/websocket` package public Some users of this library will need to subscribe to messages and/or send and receive individual websocket messages. So make those interfaces public. --- app.go | 2 +- call.go | 3 ++- eventListener.go | 2 +- fire_event.go | 3 ++- {internal/websocket => websocket}/locked_conn.go | 0 {internal/websocket => websocket}/message.go | 0 {internal/websocket => websocket}/reader.go | 0 {internal/websocket => websocket}/send.go | 0 {internal/websocket => websocket}/subscriptions.go | 0 {internal/websocket => websocket}/websocket.go | 0 10 files changed, 6 insertions(+), 4 deletions(-) rename {internal/websocket => websocket}/locked_conn.go (100%) rename {internal/websocket => websocket}/message.go (100%) rename {internal/websocket => websocket}/reader.go (100%) rename {internal/websocket => websocket}/send.go (100%) rename {internal/websocket => websocket}/subscriptions.go (100%) rename {internal/websocket => websocket}/websocket.go (100%) diff --git a/app.go b/app.go index ec0ec8a..a4234d7 100644 --- a/app.go +++ b/app.go @@ -15,7 +15,7 @@ import ( "saml.dev/gome-assistant/internal" "saml.dev/gome-assistant/internal/http" - "saml.dev/gome-assistant/internal/websocket" + "saml.dev/gome-assistant/websocket" ) var ErrInvalidArgs = errors.New("invalid arguments provided") diff --git a/call.go b/call.go index 7e02817..58281f5 100644 --- a/call.go +++ b/call.go @@ -2,9 +2,10 @@ package gomeassistant import ( "saml.dev/gome-assistant/internal/services" - "saml.dev/gome-assistant/internal/websocket" + "saml.dev/gome-assistant/websocket" ) +// Call implements [services.API.Call]. func (app *App) Call(req services.BaseServiceRequest) error { req.RequestType = "call_service" diff --git a/eventListener.go b/eventListener.go index 87a7bda..77efee9 100644 --- a/eventListener.go +++ b/eventListener.go @@ -7,7 +7,7 @@ import ( "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal" - "saml.dev/gome-assistant/internal/websocket" + "saml.dev/gome-assistant/websocket" ) type EventListener struct { diff --git a/fire_event.go b/fire_event.go index 555c24b..e7dffaa 100644 --- a/fire_event.go +++ b/fire_event.go @@ -1,7 +1,8 @@ package gomeassistant -import "saml.dev/gome-assistant/internal/websocket" +import "saml.dev/gome-assistant/websocket" +// FireEvent implements [services.API.FireEvent]. func (app *App) FireEvent(eventType string, eventData map[string]any) error { return app.conn.Send( func(lc websocket.LockedConn) error { diff --git a/internal/websocket/locked_conn.go b/websocket/locked_conn.go similarity index 100% rename from internal/websocket/locked_conn.go rename to websocket/locked_conn.go diff --git a/internal/websocket/message.go b/websocket/message.go similarity index 100% rename from internal/websocket/message.go rename to websocket/message.go diff --git a/internal/websocket/reader.go b/websocket/reader.go similarity index 100% rename from internal/websocket/reader.go rename to websocket/reader.go diff --git a/internal/websocket/send.go b/websocket/send.go similarity index 100% rename from internal/websocket/send.go rename to websocket/send.go diff --git a/internal/websocket/subscriptions.go b/websocket/subscriptions.go similarity index 100% rename from internal/websocket/subscriptions.go rename to websocket/subscriptions.go diff --git a/internal/websocket/websocket.go b/websocket/websocket.go similarity index 100% rename from internal/websocket/websocket.go rename to websocket/websocket.go From 4a132b8514db82d84262a9c19fccdeea107f7998 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 15:27:30 +0100 Subject: [PATCH 03/36] Remove `Success` field from `BaseMessage` All messages have a "type" and "id", but not all messages have a "success" field. So remove the `Success` field from `BaseMessage`. Add a `BaseResultMessage`, which consists of a `BaseMessage` plus a `Success` field, to use in those cases when the message does have a "success" field. --- websocket/message.go | 7 ++++--- websocket/reader.go | 2 +- websocket/result_message.go | 8 ++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 websocket/result_message.go diff --git a/websocket/message.go b/websocket/message.go index 8c10c66..57f6d20 100644 --- a/websocket/message.go +++ b/websocket/message.go @@ -1,9 +1,10 @@ package websocket +// BaseMessage implements the required part of any websocket message. +// This type can be embedded in other message types. type BaseMessage struct { - Type string `json:"type"` - ID int64 `json:"id"` - Success bool `json:"success"` + Type string `json:"type"` + ID int64 `json:"id"` } type ChanMessage struct { diff --git a/websocket/reader.go b/websocket/reader.go index ce08841..0fe197d 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -22,7 +22,7 @@ func (conn *Conn) Run() error { return err } - base := BaseMessage{ + base := BaseResultMessage{ // default to true for messages that don't include "success" at all Success: true, } diff --git a/websocket/result_message.go b/websocket/result_message.go new file mode 100644 index 0000000..d2bbe0e --- /dev/null +++ b/websocket/result_message.go @@ -0,0 +1,8 @@ +package websocket + +// BaseResultMessage represents the header of a websocket message that +// holds the result of an operation. +type BaseResultMessage struct { + BaseMessage + Success bool `json:"success"` +} From 74d303061f7a1ac7f06ef6f6eece0ded4ea8b5af Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 15:48:56 +0100 Subject: [PATCH 04/36] stateChangedMessage: embed a `BaseMessage` Now that `BaseMessage` doesn't include a `Success` field, it is a building block that we can use within `stateChangedMessage`. --- entitylistener.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/entitylistener.go b/entitylistener.go index dc7c71f..3329d40 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -8,6 +8,7 @@ import ( "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal" + "saml.dev/gome-assistant/websocket" ) type EntityListener struct { @@ -46,8 +47,7 @@ type EntityData struct { } type stateChangedMessage struct { - ID int `json:"id"` - Type string `json:"type"` + websocket.BaseMessage Event struct { Data stateData `json:"data"` EventType string `json:"event_type"` From c7aee79b1447fbf7c4e9f03745ef31cf4fc34ef7 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 15:45:20 +0100 Subject: [PATCH 05/36] Rename `ChanMessage` to `ResultMessage` Since we're pretending that these messages have "success" fields, it is more appropriate to call them `ResultMessage`s. Soon, that assumption will be relaxed. --- app.go | 4 ++-- eventListener.go | 2 +- websocket/message.go | 7 ------- websocket/reader.go | 20 ++++++++++---------- websocket/result_message.go | 7 +++++++ websocket/subscriptions.go | 4 ++-- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app.go b/app.go index a4234d7..b8af7ef 100644 --- a/app.go +++ b/app.go @@ -261,7 +261,7 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.ChanMessage) { + func(msg websocket.ResultMessage) { go app.callEventListeners(eventType, msg) }, ) @@ -328,7 +328,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.ChanMessage) { + func(msg websocket.ResultMessage) { go app.callEntityListeners(msg.Raw) }, ) diff --git a/eventListener.go b/eventListener.go index 77efee9..98e33b4 100644 --- a/eventListener.go +++ b/eventListener.go @@ -158,7 +158,7 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { } /* Functions */ -func (app *App) callEventListeners(eventType string, msg websocket.ChanMessage) { +func (app *App) callEventListeners(eventType string, msg websocket.ResultMessage) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type diff --git a/websocket/message.go b/websocket/message.go index 57f6d20..e45a82f 100644 --- a/websocket/message.go +++ b/websocket/message.go @@ -6,10 +6,3 @@ type BaseMessage struct { Type string `json:"type"` ID int64 `json:"id"` } - -type ChanMessage struct { - Type string - ID int64 - Success bool - Raw []byte -} diff --git a/websocket/reader.go b/websocket/reader.go index 0fe197d..ff517f2 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -22,10 +22,12 @@ func (conn *Conn) Run() error { return err } - base := BaseResultMessage{ - // default to true for messages that don't include "success" at all - Success: true, - } + var base BaseResultMessage + + // default to true for messages that don't include "success" + // at all: + base.Success = true + _ = json.Unmarshal(bytes, &base) if !base.Success { slog.Warn("Received unsuccessful response", "response", string(bytes)) @@ -38,17 +40,15 @@ func (conn *Conn) Run() error { continue } - chanMsg := ChanMessage{ - Type: base.Type, - ID: base.ID, - Success: base.Success, - Raw: bytes, + resultMsg := ResultMessage{ + BaseResultMessage: base, + Raw: bytes, } // If a subscriber has been registered for this message ID, // then call it, too: if subr, ok := conn.getSubscriber(base.ID); ok { - subr(chanMsg) + subr(resultMsg) } } } diff --git a/websocket/result_message.go b/websocket/result_message.go index d2bbe0e..ec4529b 100644 --- a/websocket/result_message.go +++ b/websocket/result_message.go @@ -6,3 +6,10 @@ type BaseResultMessage struct { BaseMessage Success bool `json:"success"` } + +// ResultMessage represents the full contents of a websocket message +// that holds the result of an operation. +type ResultMessage struct { + BaseResultMessage + Raw []byte +} diff --git a/websocket/subscriptions.go b/websocket/subscriptions.go index 9ace262..10554b2 100644 --- a/websocket/subscriptions.go +++ b/websocket/subscriptions.go @@ -19,10 +19,10 @@ func (sub Subscription) MessageID() int64 { // Subscriber is called synchronously when a message is received that // matches its subscription's message ID. -type Subscriber func(msg ChanMessage) +type Subscriber func(msg ResultMessage) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ ChanMessage) {} +func NoopSubscriber(_ ResultMessage) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. From 17c87da4a8c85bc69ab74c5a3a27b6d99d1e1f71 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 16:26:34 +0100 Subject: [PATCH 06/36] Pass `Message`s, rather than `ResultMessage`s, to `Subscriber`s Not all messages coming from HA will be result messages, so don't try to parse them as such. Instead, only parse the `BaseMessage` part, and pass them to listeners as `Message` objects, which contain the `BaseMessage` part plus the entire raw message as JSON. --- app.go | 4 ++-- eventListener.go | 2 +- websocket/message.go | 10 ++++++++++ websocket/raw_message.go | 26 ++++++++++++++++++++++++++ websocket/reader.go | 31 +++++++++++++------------------ websocket/subscriptions.go | 4 ++-- 6 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 websocket/raw_message.go diff --git a/app.go b/app.go index b8af7ef..3e98aec 100644 --- a/app.go +++ b/app.go @@ -261,7 +261,7 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.ResultMessage) { + func(msg websocket.Message) { go app.callEventListeners(eventType, msg) }, ) @@ -328,7 +328,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.ResultMessage) { + func(msg websocket.Message) { go app.callEntityListeners(msg.Raw) }, ) diff --git a/eventListener.go b/eventListener.go index 98e33b4..53ef747 100644 --- a/eventListener.go +++ b/eventListener.go @@ -158,7 +158,7 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { } /* Functions */ -func (app *App) callEventListeners(eventType string, msg websocket.ResultMessage) { +func (app *App) callEventListeners(eventType string, msg websocket.Message) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type diff --git a/websocket/message.go b/websocket/message.go index e45a82f..b5e5396 100644 --- a/websocket/message.go +++ b/websocket/message.go @@ -6,3 +6,13 @@ type BaseMessage struct { Type string `json:"type"` ID int64 `json:"id"` } + +// Message holds a complete websocket message, only partly parsed. The +// entire, original, unparsed message is available in the `Raw` field. +type Message struct { + BaseMessage + + // Raw contains the original, full, unparsed message (including + // fields `Type` and `ID`, which also appear in `BaseMessage`). + Raw RawMessage `json:"-"` +} diff --git a/websocket/raw_message.go b/websocket/raw_message.go new file mode 100644 index 0000000..4d68817 --- /dev/null +++ b/websocket/raw_message.go @@ -0,0 +1,26 @@ +package websocket + +import ( + "encoding/json" +) + +// RawMessage is like `json.RawMessage`, but with a `String()` method +// that returns the JSON as a string. +type RawMessage json.RawMessage + +func (m RawMessage) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +// UnmarshalJSON delegates to `json.RawMessage`. (The method has a +// pointer receiver, so we have to implement it explicitly.) +func (m *RawMessage) UnmarshalJSON(data []byte) error { + return (*json.RawMessage)(m).UnmarshalJSON(data) +} + +func (m RawMessage) String() string { + return string(m) +} diff --git a/websocket/reader.go b/websocket/reader.go index ff517f2..7394158 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -22,33 +22,28 @@ func (conn *Conn) Run() error { return err } - var base BaseResultMessage - - // default to true for messages that don't include "success" - // at all: - base.Success = true - - _ = json.Unmarshal(bytes, &base) - if !base.Success { - slog.Warn("Received unsuccessful response", "response", string(bytes)) + var msg Message + if err := json.Unmarshal(bytes, &msg.BaseMessage); err != nil { + slog.Warn( + "error unmarshaling websocket message; ignoring message", + "error", err, + "message", string(bytes), + ) + continue } + msg.Raw = bytes // Result messages are sent in response to the initial subscribe request. // As a result, every event listener was being called on startup. This // check prevents that. - if base.Type == "result" { + if msg.Type == "result" { continue } - resultMsg := ResultMessage{ - BaseResultMessage: base, - Raw: bytes, - } - // If a subscriber has been registered for this message ID, - // then call it, too: - if subr, ok := conn.getSubscriber(base.ID); ok { - subr(resultMsg) + // then call it: + if subr, ok := conn.getSubscriber(msg.ID); ok { + subr(msg) } } } diff --git a/websocket/subscriptions.go b/websocket/subscriptions.go index 10554b2..36f6ece 100644 --- a/websocket/subscriptions.go +++ b/websocket/subscriptions.go @@ -19,10 +19,10 @@ func (sub Subscription) MessageID() int64 { // Subscriber is called synchronously when a message is received that // matches its subscription's message ID. -type Subscriber func(msg ResultMessage) +type Subscriber func(msg Message) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ ResultMessage) {} +func NoopSubscriber(_ Message) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. From 99119457023ddae812b2534b521f2bdf0725fe1f Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 16:42:40 +0100 Subject: [PATCH 07/36] Make `ResultMessage` more capable Now that nobody is using `ResultMessage`, we're free to make it more capable: * Add a new `ResultError` type, for holding errors that were included in result messages. * If a message contains an "error" field, parse it and store it in a new `BaseResultMessage.Error` field of type `ResultError`. * Change `ResultMessage` to have a `Result` field rather than a `Raw` field. Store the unparsed "result" field from a result message to `ResultMessage.Result`. * Add a method `Message.GetResult()`, which allows a `Message` to be unmarshaled as a `ResultMessage` and its result unmarshaled into a user-provided variable. If the message includes and error, return that error as a Go error. Soon this new functionality will be used to handle the results of HA API calls. --- websocket/result_message.go | 71 ++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/websocket/result_message.go b/websocket/result_message.go index ec4529b..897000b 100644 --- a/websocket/result_message.go +++ b/websocket/result_message.go @@ -1,15 +1,78 @@ package websocket +import ( + "encoding/json" + "fmt" +) + // BaseResultMessage represents the header of a websocket message that -// holds the result of an operation. +// holds the result of an operation, possibly including an error. type BaseResultMessage struct { BaseMessage - Success bool `json:"success"` + Success bool `json:"success"` + Error *ResultError `json:"error,omitempty"` +} + +type ResultError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (err *ResultError) Error() string { + switch { + case err.Code != "" && err.Message != "": + return fmt.Sprintf("%s: %s", err.Code, err.Message) + case err.Code == "" && err.Message != "": + return fmt.Sprintf("unknown_error: %s", err.Message) + case err.Code != "" && err.Message == "": + return fmt.Sprintf("%s", err.Code) + default: + // This seems not to be an error at all. + return "INVALID (seems not to be an error)" + } } // ResultMessage represents the full contents of a websocket message -// that holds the result of an operation. +// that holds the result of an operation, possibly including an error. type ResultMessage struct { BaseResultMessage - Raw []byte + + // Raw contains the "result" part of the message, unparsed. + Result RawMessage `json:"result"` +} + +// GetResult parses a result out of `msg.result` into `result`, which +// must be unmarshalable as JSON. `msg` must have message type +// "result". If `msg` indicates that an error occurred, return the +// error as a `*ResultError`. As a special case, if `result` is `nil` +// (i.e., a nil interface, not a typed interface whose value is nil), +// errors are checked as usual, but any result that might be present +// in `msg` is ignored. +func (msg Message) GetResult(result any) error { + if msg.Type != "result" { + return fmt.Errorf( + "response message was not of type 'result': %#v", msg, + ) + } + var resultMsg ResultMessage + if err := json.Unmarshal(msg.Raw, &resultMsg); err != nil { + return fmt.Errorf("unmarshaling result message: %w", err) + } + if !resultMsg.Success { + if resultMsg.Error != nil { + return resultMsg.Error + } + + return fmt.Errorf( + "request did not succeed but no error was returned", + ) + } + + if result != nil { + if err := json.Unmarshal(resultMsg.Result, result); err != nil { + return fmt.Errorf("unmarshalling result from %q: %w", resultMsg.Result, err) + } + } + + return nil } From f810cb0d5150f125c07532931e76fe8b04f3e4d7 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 16:59:11 +0100 Subject: [PATCH 08/36] BaseServiceRequest.Type: field renamed from `RequestType` This agrees with `websocket.BaseMessage` and with the JSON field name. --- call.go | 2 +- internal/services/services.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/call.go b/call.go index 58281f5..111a421 100644 --- a/call.go +++ b/call.go @@ -7,7 +7,7 @@ import ( // Call implements [services.API.Call]. func (app *App) Call(req services.BaseServiceRequest) error { - req.RequestType = "call_service" + req.Type = "call_service" return app.conn.Send( func(lc websocket.LockedConn) error { diff --git a/internal/services/services.go b/internal/services/services.go index 75a715f..1c9d12d 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -37,7 +37,7 @@ func BuildService[ type BaseServiceRequest struct { ID int64 `json:"id"` - RequestType string `json:"type"` // must be set to "call_service" + Type string `json:"type"` // must be set to "call_service" Domain string `json:"domain"` Service string `json:"service"` ServiceData map[string]any `json:"service_data,omitempty"` From 501ac3bfa5a8397e4b050f0b5b7be5a31174eef7 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 28 Dec 2025 17:11:38 +0100 Subject: [PATCH 09/36] CallServiceMessage: new type Introduce a new type, `CallServiceMessage`, to contain the entire message required to invoke an HA service. This type embeds an instance of `BaseServiceRequest`. Remove the `ID` and `Type` fields from `BaseServiceRequest`, because they don't need to be set by the caller, but are rather managed within `App.Call()`. Move these fields to `BaseServiceRequest` by embedding a `websocket.BaseMessage` instance the the latter type. --- call.go | 11 ++++++++--- internal/services/services.go | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/call.go b/call.go index 111a421..bd28a3a 100644 --- a/call.go +++ b/call.go @@ -7,12 +7,17 @@ import ( // Call implements [services.API.Call]. func (app *App) Call(req services.BaseServiceRequest) error { - req.Type = "call_service" + reqMsg := services.CallServiceMessage{ + BaseMessage: websocket.BaseMessage{ + Type: "call_service", + }, + BaseServiceRequest: req, + } return app.conn.Send( func(lc websocket.LockedConn) error { - req.ID = lc.NextMessageID() - return lc.SendMessage(req) + reqMsg.ID = lc.NextMessageID() + return lc.SendMessage(reqMsg) }, ) } diff --git a/internal/services/services.go b/internal/services/services.go index 1c9d12d..ac3aa00 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -1,5 +1,7 @@ package services +import "saml.dev/gome-assistant/websocket" + // API is the interface that the individual services use to interact // with HomeAssistant. type API interface { @@ -35,9 +37,17 @@ func BuildService[ return &T{api: api} } +// CallServiceMessage represents a message that can be sent to request +// an API call. Its `Type` field must be set to "call_service". +type CallServiceMessage struct { + websocket.BaseMessage + BaseServiceRequest +} + +// BaseServiceRequest contains the fields needed to make an HA API +// call. `ServiceData` can contain arbitrary data needed for a +// particular call. type BaseServiceRequest struct { - ID int64 `json:"id"` - Type string `json:"type"` // must be set to "call_service" Domain string `json:"domain"` Service string `json:"service"` ServiceData map[string]any `json:"service_data,omitempty"` From 083976ba46c021d4ae395beebb7fa56a49188588 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 29 Dec 2025 11:45:35 +0100 Subject: [PATCH 10/36] App.CallAndForget(): method renamed from `Call()` Rename method `App.Call()` to `App.CallAndForget()`, because this is a "fire-and-forget" style of calling an API function that doesn't wait for a response. In a moment we'll add a new `Call()` method that waits for and returns the response from the server. --- call.go | 4 +-- internal/services/adaptive_lighting.go | 2 +- internal/services/alarm_control_panel.go | 14 ++++---- internal/services/climate.go | 4 +-- internal/services/cover.go | 20 +++++------ internal/services/homeassistant.go | 6 ++-- internal/services/input_boolean.go | 8 ++--- internal/services/input_button.go | 4 +-- internal/services/input_datetime.go | 4 +-- internal/services/input_number.go | 8 ++--- internal/services/input_text.go | 4 +-- internal/services/light.go | 6 ++-- internal/services/lock.go | 4 +-- internal/services/media_player.go | 44 ++++++++++++------------ internal/services/notify.go | 2 +- internal/services/number.go | 2 +- internal/services/scene.go | 8 ++--- internal/services/script.go | 8 ++--- internal/services/services.go | 5 ++- internal/services/switch.go | 6 ++-- internal/services/timer.go | 12 +++---- internal/services/tts.go | 6 ++-- internal/services/vacuum.go | 22 ++++++------ internal/services/zwavejs.go | 2 +- 24 files changed, 104 insertions(+), 101 deletions(-) diff --git a/call.go b/call.go index bd28a3a..333951a 100644 --- a/call.go +++ b/call.go @@ -5,8 +5,8 @@ import ( "saml.dev/gome-assistant/websocket" ) -// Call implements [services.API.Call]. -func (app *App) Call(req services.BaseServiceRequest) error { +// CallAndForget implements [services.API.CallAndForget]. +func (app *App) CallAndForget(req services.BaseServiceRequest) error { reqMsg := services.CallServiceMessage{ BaseMessage: websocket.BaseMessage{ Type: "call_service", diff --git a/internal/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index f3bc704..31ca40f 100644 --- a/internal/services/adaptive_lighting.go +++ b/internal/services/adaptive_lighting.go @@ -20,5 +20,5 @@ func (al AdaptiveLighting) SetManualControl(entityID string, enabled bool) error Target: Entity(entityID), } - return al.api.Call(req) + return al.api.CallAndForget(req) } diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index eee8928..88539b3 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -21,7 +21,7 @@ func (acp AlarmControlPanel) ArmAway(entityID string, serviceData ...map[string] req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for arm away. @@ -37,7 +37,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass(entityID string, serviceData .. req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for arm home. @@ -53,7 +53,7 @@ func (acp AlarmControlPanel) ArmHome(entityID string, serviceData ...map[string] req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for arm night. @@ -69,7 +69,7 @@ func (acp AlarmControlPanel) ArmNight(entityID string, serviceData ...map[string req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for arm vacation. @@ -85,7 +85,7 @@ func (acp AlarmControlPanel) ArmVacation(entityID string, serviceData ...map[str req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for disarm. @@ -101,7 +101,7 @@ func (acp AlarmControlPanel) Disarm(entityID string, serviceData ...map[string]a req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } // Send the alarm the command for trigger. @@ -117,5 +117,5 @@ func (acp AlarmControlPanel) Trigger(entityID string, serviceData ...map[string] req.ServiceData = serviceData[0] } - return acp.api.Call(req) + return acp.api.CallAndForget(req) } diff --git a/internal/services/climate.go b/internal/services/climate.go index b8719d3..971e204 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -19,7 +19,7 @@ func (c Climate) SetFanMode(entityID string, fanMode string) error { ServiceData: map[string]any{"fan_mode": fanMode}, Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } func (c Climate) SetTemperature(entityID string, serviceData types.SetTemperatureRequest) error { @@ -29,5 +29,5 @@ func (c Climate) SetTemperature(entityID string, serviceData types.SetTemperatur ServiceData: serviceData.ToJSON(), Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } diff --git a/internal/services/cover.go b/internal/services/cover.go index b738ead..758a1bf 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -15,7 +15,7 @@ func (c Cover) Close(entityID string) error { Service: "close_cover", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Close all or specified cover tilt. Takes an entityID. @@ -25,7 +25,7 @@ func (c Cover) CloseTilt(entityID string) error { Service: "close_cover_tilt", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Open all or specified cover. Takes an entityID. @@ -35,7 +35,7 @@ func (c Cover) Open(entityID string) error { Service: "open_cover", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Open all or specified cover tilt. Takes an entityID. @@ -45,7 +45,7 @@ func (c Cover) OpenTilt(entityID string) error { Service: "open_cover_tilt", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Move to specific position all or specified cover. Takes an entityID and an optional @@ -60,7 +60,7 @@ func (c Cover) SetPosition(entityID string, serviceData ...map[string]any) error req.ServiceData = serviceData[0] } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Move to specific position all or specified cover tilt. Takes an entityID and an optional @@ -75,7 +75,7 @@ func (c Cover) SetTiltPosition(entityID string, serviceData ...map[string]any) e req.ServiceData = serviceData[0] } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Stop a cover entity. Takes an entityID. @@ -85,7 +85,7 @@ func (c Cover) Stop(entityID string) error { Service: "stop_cover", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Stop a cover entity tilt. Takes an entityID. @@ -95,7 +95,7 @@ func (c Cover) StopTilt(entityID string) error { Service: "stop_cover_tilt", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Toggle a cover open/closed. Takes an entityID. @@ -105,7 +105,7 @@ func (c Cover) Toggle(entityID string) error { Service: "toggle", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } // Toggle a cover tilt open/closed. Takes an entityID. @@ -115,5 +115,5 @@ func (c Cover) ToggleTilt(entityID string) error { Service: "toggle_cover_tilt", Target: Entity(entityID), } - return c.api.Call(req) + return c.api.CallAndForget(req) } diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index 53dcd2a..8410cbc 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -16,7 +16,7 @@ func (ha *HomeAssistant) TurnOn(entityID string, serviceData ...map[string]any) req.ServiceData = serviceData[0] } - return ha.api.Call(req) + return ha.api.CallAndForget(req) } // Toggle a Home Assistant entity. Takes an entityID and an optional @@ -31,7 +31,7 @@ func (ha *HomeAssistant) Toggle(entityID string, serviceData ...map[string]any) req.ServiceData = serviceData[0] } - return ha.api.Call(req) + return ha.api.CallAndForget(req) } func (ha *HomeAssistant) TurnOff(entityID string) error { @@ -40,5 +40,5 @@ func (ha *HomeAssistant) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return ha.api.Call(req) + return ha.api.CallAndForget(req) } diff --git a/internal/services/input_boolean.go b/internal/services/input_boolean.go index 90c7397..92a5802 100644 --- a/internal/services/input_boolean.go +++ b/internal/services/input_boolean.go @@ -14,7 +14,7 @@ func (ib InputBoolean) TurnOn(entityID string) error { Service: "turn_on", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputBoolean) Toggle(entityID string) error { @@ -23,7 +23,7 @@ func (ib InputBoolean) Toggle(entityID string) error { Service: "toggle", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputBoolean) TurnOff(entityID string) error { @@ -32,7 +32,7 @@ func (ib InputBoolean) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputBoolean) Reload() error { @@ -40,5 +40,5 @@ func (ib InputBoolean) Reload() error { Domain: "input_boolean", Service: "reload", } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } diff --git a/internal/services/input_button.go b/internal/services/input_button.go index 4a0a426..74b6298 100644 --- a/internal/services/input_button.go +++ b/internal/services/input_button.go @@ -14,7 +14,7 @@ func (ib InputButton) Press(entityID string) error { Service: "press", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputButton) Reload() error { @@ -23,5 +23,5 @@ func (ib InputButton) Reload() error { Service: "reload", Target: Entity(""), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } diff --git a/internal/services/input_datetime.go b/internal/services/input_datetime.go index 461acf9..753a6a9 100644 --- a/internal/services/input_datetime.go +++ b/internal/services/input_datetime.go @@ -22,7 +22,7 @@ func (ib InputDatetime) Set(entityID string, value time.Time) error { }, Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputDatetime) Reload() error { @@ -30,5 +30,5 @@ func (ib InputDatetime) Reload() error { Domain: "input_datetime", Service: "reload", } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } diff --git a/internal/services/input_number.go b/internal/services/input_number.go index eacd76f..c43170b 100644 --- a/internal/services/input_number.go +++ b/internal/services/input_number.go @@ -15,7 +15,7 @@ func (ib InputNumber) Set(entityID string, value float32) error { ServiceData: map[string]any{"value": value}, Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputNumber) Increment(entityID string) error { @@ -24,7 +24,7 @@ func (ib InputNumber) Increment(entityID string) error { Service: "increment", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputNumber) Decrement(entityID string) error { @@ -33,7 +33,7 @@ func (ib InputNumber) Decrement(entityID string) error { Service: "decrement", Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputNumber) Reload() error { @@ -41,5 +41,5 @@ func (ib InputNumber) Reload() error { Domain: "input_number", Service: "reload", } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } diff --git a/internal/services/input_text.go b/internal/services/input_text.go index e575156..e349a00 100644 --- a/internal/services/input_text.go +++ b/internal/services/input_text.go @@ -17,7 +17,7 @@ func (ib InputText) Set(entityID string, value string) error { }, Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib InputText) Reload() error { @@ -25,5 +25,5 @@ func (ib InputText) Reload() error { Domain: "input_text", Service: "reload", } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } diff --git a/internal/services/light.go b/internal/services/light.go index a655f58..4f8200a 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -19,7 +19,7 @@ func (l Light) TurnOn(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return l.api.Call(req) + return l.api.CallAndForget(req) } // Toggle a light entity. Takes an entityID and an optional @@ -33,7 +33,7 @@ func (l Light) Toggle(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return l.api.Call(req) + return l.api.CallAndForget(req) } func (l Light) TurnOff(entityID string) error { @@ -42,5 +42,5 @@ func (l Light) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return l.api.Call(req) + return l.api.CallAndForget(req) } diff --git a/internal/services/lock.go b/internal/services/lock.go index fb01877..cbb00c0 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -19,7 +19,7 @@ func (l Lock) Lock(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return l.api.Call(req) + return l.api.CallAndForget(req) } // Unlock a lock entity. Takes an entityID and an optional @@ -33,5 +33,5 @@ func (l Lock) Unlock(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return l.api.Call(req) + return l.api.CallAndForget(req) } diff --git a/internal/services/media_player.go b/internal/services/media_player.go index d3ad8ee..1180a9e 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -16,7 +16,7 @@ func (mp MediaPlayer) ClearPlaylist(entityID string) error { Service: "clear_playlist", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Group players together. Only works on platforms with support for player groups. @@ -31,7 +31,7 @@ func (mp MediaPlayer) Join(entityID string, serviceData ...map[string]any) error if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command for next track. @@ -42,7 +42,7 @@ func (mp MediaPlayer) Next(entityID string) error { Service: "media_next_track", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command for pause. @@ -53,7 +53,7 @@ func (mp MediaPlayer) Pause(entityID string) error { Service: "media_pause", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command for play. @@ -64,7 +64,7 @@ func (mp MediaPlayer) Play(entityID string) error { Service: "media_play", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Toggle media player play/pause state. @@ -75,7 +75,7 @@ func (mp MediaPlayer) PlayPause(entityID string) error { Service: "media_play_pause", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command for previous track. @@ -86,7 +86,7 @@ func (mp MediaPlayer) Previous(entityID string) error { Service: "media_previous_track", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command to seek in current playing media. @@ -101,7 +101,7 @@ func (mp MediaPlayer) Seek(entityID string, serviceData ...map[string]any) error if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the stop command. @@ -112,7 +112,7 @@ func (mp MediaPlayer) Stop(entityID string) error { Service: "media_stop", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command for playing media. @@ -127,7 +127,7 @@ func (mp MediaPlayer) PlayMedia(entityID string, serviceData ...map[string]any) if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Set repeat mode. Takes an entityID and an optional @@ -141,7 +141,7 @@ func (mp MediaPlayer) RepeatSet(entityID string, serviceData ...map[string]any) if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command to change sound mode. @@ -156,7 +156,7 @@ func (mp MediaPlayer) SelectSoundMode(entityID string, serviceData ...map[string if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Send the media player the command to change input source. @@ -171,7 +171,7 @@ func (mp MediaPlayer) SelectSource(entityID string, serviceData ...map[string]an if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Set shuffling state. @@ -186,7 +186,7 @@ func (mp MediaPlayer) Shuffle(entityID string, serviceData ...map[string]any) er if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Toggles a media player power state. @@ -197,7 +197,7 @@ func (mp MediaPlayer) Toggle(entityID string) error { Service: "toggle", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Turn a media player power off. @@ -208,7 +208,7 @@ func (mp MediaPlayer) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Turn a media player power on. @@ -219,7 +219,7 @@ func (mp MediaPlayer) TurnOn(entityID string) error { Service: "turn_on", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Unjoin the player from a group. Only works on @@ -231,7 +231,7 @@ func (mp MediaPlayer) Unjoin(entityID string) error { Service: "unjoin", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Turn a media player volume down. @@ -242,7 +242,7 @@ func (mp MediaPlayer) VolumeDown(entityID string) error { Service: "volume_down", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Mute a media player's volume. @@ -257,7 +257,7 @@ func (mp MediaPlayer) VolumeMute(entityID string, serviceData ...map[string]any) if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Set a media player's volume level. @@ -272,7 +272,7 @@ func (mp MediaPlayer) VolumeSet(entityID string, serviceData ...map[string]any) if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } // Turn a media player volume up. @@ -283,5 +283,5 @@ func (mp MediaPlayer) VolumeUp(entityID string) error { Service: "volume_up", Target: Entity(entityID), } - return mp.api.Call(req) + return mp.api.CallAndForget(req) } diff --git a/internal/services/notify.go b/internal/services/notify.go index 66e29c9..69a3242 100644 --- a/internal/services/notify.go +++ b/internal/services/notify.go @@ -21,5 +21,5 @@ func (ha *Notify) Notify(reqData types.NotifyRequest) error { serviceData["data"] = reqData.Data } req.ServiceData = serviceData - return ha.api.Call(req) + return ha.api.CallAndForget(req) } diff --git a/internal/services/number.go b/internal/services/number.go index 179dc6e..e7a9410 100644 --- a/internal/services/number.go +++ b/internal/services/number.go @@ -11,7 +11,7 @@ func (ib Number) SetValue(entityID string, value float32) error { ServiceData: map[string]any{"value": value}, Target: Entity(entityID), } - return ib.api.Call(req) + return ib.api.CallAndForget(req) } func (ib Number) MustSetValue(entityID string, value float32) { diff --git a/internal/services/scene.go b/internal/services/scene.go index 39c10f3..7b0c0c9 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -18,7 +18,7 @@ func (s Scene) Apply(serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return s.api.Call(req) + return s.api.CallAndForget(req) } // Create a scene entity. Takes an entityID and an optional @@ -32,7 +32,7 @@ func (s Scene) Create(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return s.api.Call(req) + return s.api.CallAndForget(req) } // Reload the scenes. @@ -42,7 +42,7 @@ func (s Scene) Reload() error { Service: "reload", Target: Entity(""), } - return s.api.Call(req) + return s.api.CallAndForget(req) } // TurnOn a scene entity. Takes an entityID and an optional @@ -56,5 +56,5 @@ func (s Scene) TurnOn(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return s.api.Call(req) + return s.api.CallAndForget(req) } diff --git a/internal/services/script.go b/internal/services/script.go index 556c46c..42abff2 100644 --- a/internal/services/script.go +++ b/internal/services/script.go @@ -15,7 +15,7 @@ func (s Script) Reload(entityID string) error { Service: "reload", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } // Toggle a script that was created in the HA UI. @@ -25,7 +25,7 @@ func (s Script) Toggle(entityID string) error { Service: "toggle", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } // TurnOff a script that was created in the HA UI. @@ -35,7 +35,7 @@ func (s Script) TurnOff() error { Service: "turn_off", Target: Entity(""), } - return s.api.Call(req) + return s.api.CallAndForget(req) } // TurnOn a script that was created in the HA UI. @@ -45,5 +45,5 @@ func (s Script) TurnOn(entityID string) error { Service: "turn_on", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } diff --git a/internal/services/services.go b/internal/services/services.go index ac3aa00..2ff1037 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -5,7 +5,10 @@ import "saml.dev/gome-assistant/websocket" // API is the interface that the individual services use to interact // with HomeAssistant. type API interface { - Call(req BaseServiceRequest) error + // CallAndForget makes a call to the Home Assistant API but + // doesn't subscribe to or wait for a response. + CallAndForget(req BaseServiceRequest) error + FireEvent(eventType string, eventData map[string]any) error } diff --git a/internal/services/switch.go b/internal/services/switch.go index e38a60a..c77bf8c 100644 --- a/internal/services/switch.go +++ b/internal/services/switch.go @@ -14,7 +14,7 @@ func (s Switch) TurnOn(entityID string) error { Service: "turn_on", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } func (s Switch) Toggle(entityID string) error { @@ -23,7 +23,7 @@ func (s Switch) Toggle(entityID string) error { Service: "toggle", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } func (s Switch) TurnOff(entityID string) error { @@ -32,5 +32,5 @@ func (s Switch) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return s.api.Call(req) + return s.api.CallAndForget(req) } diff --git a/internal/services/timer.go b/internal/services/timer.go index 6432175..3778891 100644 --- a/internal/services/timer.go +++ b/internal/services/timer.go @@ -18,7 +18,7 @@ func (t Timer) Start(entityID string, duration string) error { }, Target: Entity(entityID), } - return t.api.Call(req) + return t.api.CallAndForget(req) } // See https://www.home-assistant.io/integrations/timer/#action-timerstart @@ -31,7 +31,7 @@ func (t Timer) Change(entityID string, duration string) error { }, Target: Entity(entityID), } - return t.api.Call(req) + return t.api.CallAndForget(req) } // See https://www.home-assistant.io/integrations/timer/#action-timerpause @@ -41,7 +41,7 @@ func (t Timer) Pause(entityID string) error { Service: "pause", Target: Entity(entityID), } - return t.api.Call(req) + return t.api.CallAndForget(req) } // See https://www.home-assistant.io/integrations/timer/#action-timercancel @@ -51,7 +51,7 @@ func (t Timer) Cancel() error { Service: "cancel", Target: Entity(""), } - return t.api.Call(req) + return t.api.CallAndForget(req) } // See https://www.home-assistant.io/integrations/timer/#action-timerfinish @@ -61,7 +61,7 @@ func (t Timer) Finish(entityID string) error { Service: "finish", Target: Entity(entityID), } - return t.api.Call(req) + return t.api.CallAndForget(req) } // See https://www.home-assistant.io/integrations/timer/#action-timerreload @@ -71,5 +71,5 @@ func (t Timer) Reload() error { Service: "reload", Target: Entity(""), } - return t.api.Call(req) + return t.api.CallAndForget(req) } diff --git a/internal/services/tts.go b/internal/services/tts.go index 838dd0d..2d89138 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -15,7 +15,7 @@ func (tts TTS) ClearCache() error { Service: "clear_cache", Target: Entity(""), } - return tts.api.Call(req) + return tts.api.CallAndForget(req) } // Say something using text-to-speech on a media player with cloud. @@ -30,7 +30,7 @@ func (tts TTS) CloudSay(entityID string, serviceData ...map[string]any) error { if len(serviceData) != 0 { req.ServiceData = serviceData[0] } - return tts.api.Call(req) + return tts.api.CallAndForget(req) } // Say something using text-to-speech on a media player with google_translate. @@ -46,5 +46,5 @@ func (tts TTS) GoogleTranslateSay(entityID string, serviceData ...map[string]any req.ServiceData = serviceData[0] } - return tts.api.Call(req) + return tts.api.CallAndForget(req) } diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index e181f4a..e33a652 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -16,7 +16,7 @@ func (v Vacuum) CleanSpot(entityID string) error { Service: "clean_spot", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Locate the vacuum cleaner robot. @@ -27,7 +27,7 @@ func (v Vacuum) Locate(entityID string) error { Service: "locate", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Pause the cleaning task. @@ -38,7 +38,7 @@ func (v Vacuum) Pause(entityID string) error { Service: "pause", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Tell the vacuum cleaner to return to its dock. @@ -49,7 +49,7 @@ func (v Vacuum) ReturnToBase(entityID string) error { Service: "return_to_base", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Send a raw command to the vacuum cleaner. Takes an entityID and an optional @@ -64,7 +64,7 @@ func (v Vacuum) SendCommand(entityID string, serviceData ...map[string]any) erro req.ServiceData = serviceData[0] } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Set the fan speed of the vacuum cleaner. Takes an entityID and an optional @@ -79,7 +79,7 @@ func (v Vacuum) SetFanSpeed(entityID string, serviceData ...map[string]any) erro req.ServiceData = serviceData[0] } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Start or resume the cleaning task. @@ -90,7 +90,7 @@ func (v Vacuum) Start(entityID string) error { Service: "start", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Start, pause, or resume the cleaning task. @@ -101,7 +101,7 @@ func (v Vacuum) StartPause(entityID string) error { Service: "start_pause", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Stop the current cleaning task. @@ -112,7 +112,7 @@ func (v Vacuum) Stop(entityID string) error { Service: "stop", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Stop the current cleaning task and return to home. @@ -123,7 +123,7 @@ func (v Vacuum) TurnOff(entityID string) error { Service: "turn_off", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } // Start a new cleaning task. @@ -134,5 +134,5 @@ func (v Vacuum) TurnOn(entityID string) error { Service: "turn_on", Target: Entity(entityID), } - return v.api.Call(req) + return v.api.CallAndForget(req) } diff --git a/internal/services/zwavejs.go b/internal/services/zwavejs.go index 9d90ff1..02a2324 100644 --- a/internal/services/zwavejs.go +++ b/internal/services/zwavejs.go @@ -19,5 +19,5 @@ func (zw ZWaveJS) BulkSetPartialConfigParam(entityID string, parameter int, valu }, Target: Entity(entityID), } - return zw.api.Call(req) + return zw.api.CallAndForget(req) } From 45de4bf1db6888ec9c228073b030ae5428c695bb Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 29 Dec 2025 13:12:00 +0100 Subject: [PATCH 11/36] websocket: improve some documentation comments --- websocket/locked_conn.go | 2 +- websocket/reader.go | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/websocket/locked_conn.go b/websocket/locked_conn.go index 66da9fe..07834c5 100644 --- a/websocket/locked_conn.go +++ b/websocket/locked_conn.go @@ -16,7 +16,7 @@ type LockedConn interface { // called for any incoming messages that have that ID. This // doesn't actually interact with the server. Typically the next // step would be to send a message with its message ID set to - // `Subscription.ID()`. + // `Subscription.MessageID()`. // // The returned `Subscription` must eventually be passed at least // once to `Unsubscribe()`, though `Unsubscribe()` can be called diff --git a/websocket/reader.go b/websocket/reader.go index 7394158..ac1d92e 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -11,10 +11,12 @@ import ( // ID (if any). If there is an error, return the error and stop // listening. // -// Note that the subscribers are invoked synchronously, in the same -// order as the messages arrived, and only one is run at a time. If -// the subscriber wants processing to happen in the background, it -// must spawn a goroutine itself. +// Note that subscribers are invoked synchronously, in the same order +// as the messages arrive, and only one is run at a time. If the +// subscriber wants processing to happen in the background, it must +// spawn a goroutine itself. A subscriber is allowed to unsubscribe +// itself synchronously within the callback, in which case it is +// guaranteed not to be invoked again for subsequent messages. func (conn *Conn) Run() error { for { bytes, err := conn.readMessage() From 23f8b451cd0cd780015ae9b9b2f81d492eccbe31 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sat, 17 Jan 2026 11:44:28 +0100 Subject: [PATCH 12/36] Conn.Run(): don't discard "result" messages At the `Conn` level, we don't want to discard any messages entirely, because for all we know somebody might be interested in them. (In the future, there will indeed be "result" message listeners.) So instead, change `conn.Run()` to pass all messages through, but change the event listener callback to discard any messages that arrive that don't match the desired message type. --- app.go | 7 +++++++ websocket/reader.go | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app.go b/app.go index 3e98aec..678fb86 100644 --- a/app.go +++ b/app.go @@ -262,6 +262,13 @@ func (app *App) registerEventListener(evl EventListener) { app.conn.SubscribeToEventType( eventType, func(msg websocket.Message) { + // Subscribing, itself, causes the server to send + // a "result" message. We don't want to forward + // that message to the listeners. + if msg.Type != eventType { + return + } + go app.callEventListeners(eventType, msg) }, ) diff --git a/websocket/reader.go b/websocket/reader.go index ac1d92e..b315a65 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -35,13 +35,6 @@ func (conn *Conn) Run() error { } msg.Raw = bytes - // Result messages are sent in response to the initial subscribe request. - // As a result, every event listener was being called on startup. This - // check prevents that. - if msg.Type == "result" { - continue - } - // If a subscriber has been registered for this message ID, // then call it: if subr, ok := conn.getSubscriber(msg.ID); ok { From 19e88c7e3ff075d1186127a52d66db3af62f2e2b Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 29 Dec 2025 13:12:26 +0100 Subject: [PATCH 13/36] App.Call(): new method Add a new version of method `App.Call()` that not only invokes a Home Assistant API (like `CallAndForget()`), but also waits for the server to respond and unmarshals the result into a caller-provided variable. If the server returns an error, return that error as a `*websocket.ResultError`. This makes it easy to call APIs that return results, and also makes it easy for the caller to detect when the requested action failed. --- call.go | 85 +++++++++++++++++++++++++++++++++++ internal/services/services.go | 15 ++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/call.go b/call.go index 333951a..e1ab30a 100644 --- a/call.go +++ b/call.go @@ -1,6 +1,9 @@ package gomeassistant import ( + "context" + "sync" + "saml.dev/gome-assistant/internal/services" "saml.dev/gome-assistant/websocket" ) @@ -21,3 +24,85 @@ func (app *App) CallAndForget(req services.BaseServiceRequest) error { }, ) } + +// Call implements [services.API.Call]. +func (app *App) Call( + ctx context.Context, req services.BaseServiceRequest, result any, +) error { + // Call works as follows: + // 1. Generate a message ID. + // 2. Subscribe to that ID. + // 3. Send a `CallServiceMessage` containing `req` over the websocket. + // 4. Wait for a single "result" message. + // 5. Unsubscribe from ID. + // 6. Unmarshal the "result" part of the response into `result`. + + reqMsg := services.CallServiceMessage{ + BaseMessage: websocket.BaseMessage{ + Type: "call_service", + }, + BaseServiceRequest: req, + } + + // once ensures that exactly one of the following occurs: + // * a single response is handled and then the handler + // unsubscribes itself; or + // * (if `ctx` expires) the handler is unsubscribed if and only + // if no response has been handled. + var once sync.Once + + // responseErr is set either to the error in the response message, + // or to `ctx.Err()`. + var responseErr error + + // done is closed once a response has been processed. + done := make(chan struct{}) + + var subscription websocket.Subscription + + unsubscribe := func() { + _ = app.conn.Send(func(lc websocket.LockedConn) error { + lc.Unsubscribe(subscription) + return nil + }) + } + + handleResponse := func(msg websocket.Message) { + once.Do( + func() { + responseErr = msg.GetResult(result) + unsubscribe() + close(done) + }, + ) + } + + err := app.conn.Send( + func(lc websocket.LockedConn) error { + subscription = lc.Subscribe(handleResponse) + reqMsg.ID = subscription.MessageID() + return lc.SendMessage(reqMsg) + }, + ) + if err != nil { + return err + } + + select { + case <-done: + // `handleResponse` has processed a response and set + // `responseErr`. + case <-ctx.Done(): + // The context has expired. Unsubscribe and return + // `ctx.Err()`, but only if `handleResponse` hasn't just + // racily processed a response. + once.Do( + func() { + unsubscribe() + responseErr = ctx.Err() + }, + ) + } + + return responseErr +} diff --git a/internal/services/services.go b/internal/services/services.go index 2ff1037..ac07811 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -1,6 +1,10 @@ package services -import "saml.dev/gome-assistant/websocket" +import ( + "context" + + "saml.dev/gome-assistant/websocket" +) // API is the interface that the individual services use to interact // with HomeAssistant. @@ -9,6 +13,15 @@ type API interface { // doesn't subscribe to or wait for a response. CallAndForget(req BaseServiceRequest) error + // Call makes a call to the Home Assistant API and waits for a + // response. The result is unmarshaled into invokes `result`. + // `result` must be something that `json.Unmarshal()` can + // deserialize into; typically, it is a pointer. If the result + // indicates a failure (success==false), then return that as a + // `*websocket.ResultError`. If another error occurs (e.g., + // sending the request or if `ctx` expires), return that error. + Call(ctx context.Context, req BaseServiceRequest, result any) error + FireEvent(eventType string, eventData map[string]any) error } From 3c8dbc70869ee32018f8dab50d6795941bdcbb6b Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 22 Feb 2026 11:14:43 +0100 Subject: [PATCH 14/36] services: change the type of `serviceData` arguments to `...any` Change the types of `serviceData` arguments from `...map[string]any` to `...any`. This allows the caller to pass a struct in, as long as the struct can be serialized to a JSON object. For example, for a light, one might define ``` type LightServiceData struct { Brightness int `json:"brightness,omitzero"` // ColorTemp is the color temperature in mireds // (micro-reciprocal-degrees-Kelvin). ColorTemp int `json:"color_temp,omitzero"` } ``` and then call `TurnOn()` like ``` _, err := app.Service.Light.TurnOn( ctx, l.target, ServiceData{Brightness: newBrightness}, ) ``` --- internal/services/alarm_control_panel.go | 120 +++++++---------- internal/services/cover.go | 34 +++-- internal/services/homeassistant.go | 28 ++-- internal/services/light.go | 30 ++--- internal/services/lock.go | 32 +++-- internal/services/media_player.go | 164 +++++++++++------------ internal/services/scene.go | 46 +++---- internal/services/services.go | 23 +++- internal/services/tts.go | 35 +++-- internal/services/vacuum.go | 32 ++--- 10 files changed, 254 insertions(+), 290 deletions(-) diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index 88539b3..b352aee 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -8,113 +8,93 @@ type AlarmControlPanel struct { /* Public API */ -// Send the alarm the command for arm away. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) ArmAway(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for arm away. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) ArmAway(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_away", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_arm_away", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for arm away. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) ArmWithCustomBypass(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for arm away. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) ArmWithCustomBypass(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_custom_bypass", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_arm_custom_bypass", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for arm home. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) ArmHome(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for arm home. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) ArmHome(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_home", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_arm_home", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for arm night. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) ArmNight(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for arm night. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) ArmNight(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_night", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_arm_night", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for arm vacation. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) ArmVacation(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for arm vacation. Takes an entityID and +// an optional service_data, which must be serializable to a JSON +// object. +func (acp AlarmControlPanel) ArmVacation(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_vacation", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_arm_vacation", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for disarm. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) Disarm(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for disarm. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) Disarm(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_disarm", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_disarm", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) } -// Send the alarm the command for trigger. -// Takes an entityID and an optional -// map that is translated into service_data. -func (acp AlarmControlPanel) Trigger(entityID string, serviceData ...map[string]any) error { +// Send the alarm the command for trigger. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (acp AlarmControlPanel) Trigger(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_trigger", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "alarm_control_panel", + Service: "alarm_trigger", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return acp.api.CallAndForget(req) diff --git a/internal/services/cover.go b/internal/services/cover.go index 758a1bf..9fb7cf7 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -48,31 +48,29 @@ func (c Cover) OpenTilt(entityID string) error { return c.api.CallAndForget(req) } -// Move to specific position all or specified cover. Takes an entityID and an optional -// map that is translated into service_data. -func (c Cover) SetPosition(entityID string, serviceData ...map[string]any) error { +// Move to specific position all or specified cover. Takes an entityID +// and an optional service_data, which must be serializable to a JSON +// object. +func (c Cover) SetPosition(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "cover", - Service: "set_cover_position", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "cover", + Service: "set_cover_position", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return c.api.CallAndForget(req) } -// Move to specific position all or specified cover tilt. Takes an entityID and an optional -// map that is translated into service_data. -func (c Cover) SetTiltPosition(entityID string, serviceData ...map[string]any) error { +// Move to specific position all or specified cover tilt. Takes an +// entityID and an optional service_data, which must be serializable +// to a JSON object. +func (c Cover) SetTiltPosition(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Target: Entity(entityID), - Domain: "cover", - Service: "set_cover_tilt_position", - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Target: Entity(entityID), + Domain: "cover", + ServiceData: optionalServiceData(serviceData...), + Service: "set_cover_tilt_position", } return c.api.CallAndForget(req) diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index 8410cbc..c628e72 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -5,30 +5,26 @@ type HomeAssistant struct { } // TurnOn a Home Assistant entity. Takes an entityID and an optional -// map that is translated into service_data. -func (ha *HomeAssistant) TurnOn(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (ha *HomeAssistant) TurnOn(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "turn_on", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "homeassistant", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return ha.api.CallAndForget(req) } // Toggle a Home Assistant entity. Takes an entityID and an optional -// map that is translated into service_data. -func (ha *HomeAssistant) Toggle(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (ha *HomeAssistant) Toggle(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "toggle", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "homeassistant", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return ha.api.CallAndForget(req) diff --git a/internal/services/light.go b/internal/services/light.go index 4f8200a..a9abca0 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -9,30 +9,28 @@ type Light struct { /* Public API */ // TurnOn a light entity. Takes an entityID and an optional -// map that is translated into service_data. -func (l Light) TurnOn(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (l Light) TurnOn(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "light", - Service: "turn_on", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "light", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return l.api.CallAndForget(req) } // Toggle a light entity. Takes an entityID and an optional -// map that is translated into service_data. -func (l Light) Toggle(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (l Light) Toggle(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "light", - Service: "toggle", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "light", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return l.api.CallAndForget(req) } diff --git a/internal/services/lock.go b/internal/services/lock.go index cbb00c0..db73ada 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -8,30 +8,28 @@ type Lock struct { /* Public API */ -// Lock a lock entity. Takes an entityID and an optional -// map that is translated into service_data. -func (l Lock) Lock(entityID string, serviceData ...map[string]any) error { +// Lock a lock entity. Takes an entityID and an optional service_data, +// which must be serializable to a JSON object. +func (l Lock) Lock(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "lock", - Service: "lock", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "lock", + Service: "lock", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return l.api.CallAndForget(req) } // Unlock a lock entity. Takes an entityID and an optional -// map that is translated into service_data. -func (l Lock) Unlock(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (l Lock) Unlock(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "lock", - Service: "unlock", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "lock", + Service: "unlock", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return l.api.CallAndForget(req) } diff --git a/internal/services/media_player.go b/internal/services/media_player.go index 1180a9e..d52e47d 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -19,18 +19,17 @@ func (mp MediaPlayer) ClearPlaylist(entityID string) error { return mp.api.CallAndForget(req) } -// Group players together. Only works on platforms with support for player groups. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) Join(entityID string, serviceData ...map[string]any) error { +// Group players together. Only works on platforms with support for +// player groups. Takes an entityID and an optional service_data, +// which must be serializable to a JSON object. +func (mp MediaPlayer) Join(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "join", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "join", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } @@ -90,17 +89,16 @@ func (mp MediaPlayer) Previous(entityID string) error { } // Send the media player the command to seek in current playing media. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) Seek(entityID string, serviceData ...map[string]any) error { +// Takes an entityID and an optional service_data, which must be +// serializable to a JSON object. +func (mp MediaPlayer) Seek(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "media_seek", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "media_seek", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } @@ -115,77 +113,71 @@ func (mp MediaPlayer) Stop(entityID string) error { return mp.api.CallAndForget(req) } -// Send the media player the command for playing media. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) PlayMedia(entityID string, serviceData ...map[string]any) error { +// Send the media player the command for playing media. Takes an +// entityID and an optional service_data, which must be serializable +// to a JSON object. +func (mp MediaPlayer) PlayMedia(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "play_media", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "play_media", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } -// Set repeat mode. Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) RepeatSet(entityID string, serviceData ...map[string]any) error { +// Set repeat mode. Takes an entityID and an optional service_data, +// which must be serializable to a JSON object. +func (mp MediaPlayer) RepeatSet(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "repeat_set", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "repeat_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } -// Send the media player the command to change sound mode. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) SelectSoundMode(entityID string, serviceData ...map[string]any) error { +// Send the media player the command to change sound mode. Takes an +// entityID and an optional service_data, which must be serializable +// to a JSON object. +func (mp MediaPlayer) SelectSoundMode(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_sound_mode", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "select_sound_mode", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } -// Send the media player the command to change input source. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) SelectSource(entityID string, serviceData ...map[string]any) error { +// Send the media player the command to change input source. Takes an +// entityID and an optional service_data, which must be serializable +// to a JSON object. +func (mp MediaPlayer) SelectSource(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_source", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "select_source", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } -// Set shuffling state. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) Shuffle(entityID string, serviceData ...map[string]any) error { +// Set shuffling state. Takes an entityID and an optional +// service_data, which must be serializable to a JSON object. +func (mp MediaPlayer) Shuffle(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "shuffle_set", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "shuffle_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } @@ -245,33 +237,29 @@ func (mp MediaPlayer) VolumeDown(entityID string) error { return mp.api.CallAndForget(req) } -// Mute a media player's volume. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) VolumeMute(entityID string, serviceData ...map[string]any) error { +// Mute a media player's volume. Takes an entityID and an optional +// service_data, which must be serializable to a JSON object. +func (mp MediaPlayer) VolumeMute(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_mute", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "volume_mute", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } -// Set a media player's volume level. -// Takes an entityID and an optional -// map that is translated into service_data. -func (mp MediaPlayer) VolumeSet(entityID string, serviceData ...map[string]any) error { +// Set a media player's volume level. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (mp MediaPlayer) VolumeSet(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_set", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "media_player", + Service: "volume_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return mp.api.CallAndForget(req) } diff --git a/internal/services/scene.go b/internal/services/scene.go index 7b0c0c9..a352617 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -8,30 +8,29 @@ type Scene struct { /* Public API */ -// Apply a scene. Takes map that is translated into service_data. -func (s Scene) Apply(serviceData ...map[string]any) error { +// Apply a scene. Takes an optional service_data, which must be +// serializable to a JSON object. +func (s Scene) Apply(serviceData ...any) error { req := BaseServiceRequest{ - Domain: "scene", - Service: "apply", - Target: Entity(""), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "scene", + Service: "apply", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(""), } + return s.api.CallAndForget(req) } // Create a scene entity. Takes an entityID and an optional -// map that is translated into service_data. -func (s Scene) Create(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (s Scene) Create(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "scene", - Service: "create", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "scene", + Service: "create", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return s.api.CallAndForget(req) } @@ -46,15 +45,14 @@ func (s Scene) Reload() error { } // TurnOn a scene entity. Takes an entityID and an optional -// map that is translated into service_data. -func (s Scene) TurnOn(entityID string, serviceData ...map[string]any) error { +// service_data, which must be serializable to a JSON object. +func (s Scene) TurnOn(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "scene", - Service: "turn_on", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "scene", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return s.api.CallAndForget(req) } diff --git a/internal/services/services.go b/internal/services/services.go index ac07811..e26180f 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -2,6 +2,7 @@ package services import ( "context" + "log/slog" "saml.dev/gome-assistant/websocket" ) @@ -64,10 +65,10 @@ type CallServiceMessage struct { // call. `ServiceData` can contain arbitrary data needed for a // particular call. type BaseServiceRequest struct { - Domain string `json:"domain"` - Service string `json:"service"` - ServiceData map[string]any `json:"service_data,omitempty"` - Target Target `json:"target,omitempty"` + Domain string `json:"domain"` + Service string `json:"service"` + ServiceData any `json:"service_data,omitempty"` + Target Target `json:"target,omitempty"` } type Target struct { @@ -79,3 +80,17 @@ func Entity(entityID string) Target { EntityID: entityID, } } + +func optionalServiceData(serviceData ...any) any { + switch len(serviceData) { + case 0: + return nil + case 1: + return serviceData[0] + default: + slog.Warn( + "multiple arguments passed as service data; only the first used", + ) + return serviceData[0] + } +} diff --git a/internal/services/tts.go b/internal/services/tts.go index 2d89138..1233235 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -19,31 +19,28 @@ func (tts TTS) ClearCache() error { } // Say something using text-to-speech on a media player with cloud. -// Takes an entityID and an optional -// map that is translated into service_data. -func (tts TTS) CloudSay(entityID string, serviceData ...map[string]any) error { +// Takes an entityID and an optional service_data, which must be +// serializable to a JSON object. +func (tts TTS) CloudSay(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "tts", - Service: "cloud_say", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "tts", + Service: "cloud_say", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } + return tts.api.CallAndForget(req) } -// Say something using text-to-speech on a media player with google_translate. -// Takes an entityID and an optional -// map that is translated into service_data. -func (tts TTS) GoogleTranslateSay(entityID string, serviceData ...map[string]any) error { +// Say something using text-to-speech on a media player with +// google_translate. Takes an entityID and an optional service_data, +// which must be serializable to a JSON object. +func (tts TTS) GoogleTranslateSay(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "tts", - Service: "google_translate_say", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "tts", + Service: "google_translate_say", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return tts.api.CallAndForget(req) diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index e33a652..8a4037c 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -52,31 +52,27 @@ func (v Vacuum) ReturnToBase(entityID string) error { return v.api.CallAndForget(req) } -// Send a raw command to the vacuum cleaner. Takes an entityID and an optional -// map that is translated into service_data. -func (v Vacuum) SendCommand(entityID string, serviceData ...map[string]any) error { +// Send a raw command to the vacuum cleaner. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (v Vacuum) SendCommand(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "vacuum", - Service: "send_command", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "vacuum", + Service: "send_command", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return v.api.CallAndForget(req) } -// Set the fan speed of the vacuum cleaner. Takes an entityID and an optional -// map that is translated into service_data. -func (v Vacuum) SetFanSpeed(entityID string, serviceData ...map[string]any) error { +// Set the fan speed of the vacuum cleaner. Takes an entityID and an +// optional service_data, which must be serializable to a JSON object. +func (v Vacuum) SetFanSpeed(entityID string, serviceData ...any) error { req := BaseServiceRequest{ - Domain: "vacuum", - Service: "set_fan_speed", - Target: Entity(entityID), - } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + Domain: "vacuum", + Service: "set_fan_speed", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } return v.api.CallAndForget(req) From 28963affb1eee23aea8aa1f5b73538af947152f3 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Wed, 31 Dec 2025 15:14:54 +0100 Subject: [PATCH 15/36] services: return the result of the service call When calling a service, use `Call()` rather than `CallAndForget()` so that the result of the service call is collected. Return the result to the caller as type `any`. (If people go to the trouble of figuring out the structure of the methods' responses, these return types can be made more specific. This requires a `context.Context` argument to be added to the service call arguments. It can be used, for example, to limit the amount of time to wait for a response from the server. --- cmd/example/example.go | 49 +++-- cmd/example/example_live_test.go | 3 +- internal/services/adaptive_lighting.go | 13 +- internal/services/alarm_control_panel.go | 79 +++++-- internal/services/climate.go | 26 ++- internal/services/cover.go | 120 +++++++++-- internal/services/homeassistant.go | 36 +++- internal/services/input_boolean.go | 48 ++++- internal/services/input_button.go | 24 ++- internal/services/input_datetime.go | 23 +- internal/services/input_number.go | 48 ++++- internal/services/input_text.go | 24 ++- internal/services/light.go | 35 ++- internal/services/lock.go | 24 ++- internal/services/media_player.go | 257 +++++++++++++++++++---- internal/services/notify.go | 14 +- internal/services/number.go | 20 +- internal/services/scene.go | 45 +++- internal/services/script.go | 48 ++++- internal/services/switch.go | 38 +++- internal/services/timer.go | 69 ++++-- internal/services/tts.go | 34 ++- internal/services/vacuum.go | 129 ++++++++++-- internal/services/zwavejs.go | 14 +- 24 files changed, 1008 insertions(+), 212 deletions(-) diff --git a/cmd/example/example.go b/cmd/example/example.go index 6469668..6780341 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -38,18 +38,24 @@ func main() { pantryDoor := ga. NewEntityListener(). EntityIDs(entities.BinarySensor.PantryDoor). // Use generated entity constant - Call(pantryLights). + Call(func(service *ga.Service, state ga.State, sensor ga.EntityData) { + pantryLights(ctx, service, state, sensor) + }). Build() _11pmSched := ga. NewDailySchedule(). - Call(lightsOut). + Call(func(service *ga.Service, state ga.State) { + lightsOut(ctx, service, state) + }). At("23:00"). Build() _30minsBeforeSunrise := ga. NewDailySchedule(). - Call(sunriseSched). + Call(func(service *ga.Service, state ga.State) { + sunriseSched(ctx, service, state) + }). Sunrise("-30m"). Build() @@ -66,13 +72,19 @@ func main() { app.Start() } -func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) { +func pantryLights( + ctx context.Context, service *ga.Service, state ga.State, sensor ga.EntityData, +) { l := "light.pantry" // l := entities.Light.Pantry // Or use generated entity constant if sensor.ToState == "on" { - service.HomeAssistant.TurnOn(l) + if _, err := service.HomeAssistant.TurnOn(ctx, l); err != nil { + slog.Warn("couldn't turn on pantry light") + } } else { - service.HomeAssistant.TurnOff(l) + if _, err := service.HomeAssistant.TurnOff(ctx, l); err != nil { + slog.Warn("couldn't turn off pantry light") + } } } @@ -87,22 +99,33 @@ func onEvent(service *ga.Service, state ga.State, data ga.EventData) { slog.Info("On event invoked", "event", ev) } -func lightsOut(service *ga.Service, state ga.State) { +func lightsOut(ctx context.Context, service *ga.Service, state ga.State) { // always turn off outside lights - service.Light.TurnOff(entities.Light.OutsideLights) + if _, err := service.Light.TurnOff(ctx, entities.Light.OutsideLights); err != nil { + slog.Warn("couldn't turn off living room light, doing nothing") + return + } s, err := state.Get(entities.BinarySensor.LivingRoomMotion) if err != nil { - slog.Warn("couldnt get living room motion state, doing nothing") + slog.Warn("couldn't get living room motion state, doing nothing") return } // if no motion detected in living room for 30mins if s.State == "off" && time.Since(s.LastChanged).Minutes() > 30 { - service.Light.TurnOff(entities.Light.MainLights) + if _, err := service.Light.TurnOff(ctx, entities.Light.MainLights); err != nil { + slog.Warn("couldn't turn off living light") + return + } } } -func sunriseSched(service *ga.Service, state ga.State) { - service.Light.TurnOn(entities.Light.LivingRoomLamps) - service.Light.TurnOff(entities.Light.ChristmasLights) +func sunriseSched(ctx context.Context, service *ga.Service, state ga.State) { + if _, err := service.Light.TurnOn(ctx, entities.Light.LivingRoomLamps); err != nil { + slog.Warn("couldn't turn on living light") + } + + if _, err := service.Light.TurnOff(ctx, entities.Light.ChristmasLights); err != nil { + slog.Warn("couldn't turn off Christmas lights") + } } diff --git a/cmd/example/example_live_test.go b/cmd/example/example_live_test.go index 65086d4..c0af1be 100644 --- a/cmd/example/example_live_test.go +++ b/cmd/example/example_live_test.go @@ -106,11 +106,12 @@ func (s *MySuite) TearDownSuite() { // Basic test of light toggle service and entity listener func (s *MySuite) TestLightService() { + ctx := context.TODO() entityID := s.config.Entities.LightEntityID if entityID != "" { initState := getEntityState(s, entityID) - s.app.GetService().Light.Toggle(entityID) + s.app.GetService().Light.Toggle(ctx, entityID) assert.EventuallyWithT(s.T(), func(c *assert.CollectT) { newState := getEntityState(s, entityID) diff --git a/internal/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index 31ca40f..7c8fe61 100644 --- a/internal/services/adaptive_lighting.go +++ b/internal/services/adaptive_lighting.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type AdaptiveLighting struct { @@ -9,7 +11,9 @@ type AdaptiveLighting struct { /* Public API */ // Set manual control for an adaptive lighting entity. -func (al AdaptiveLighting) SetManualControl(entityID string, enabled bool) error { +func (al AdaptiveLighting) SetManualControl( + ctx context.Context, entityID string, enabled bool, +) (any, error) { req := BaseServiceRequest{ Domain: "adaptive_lighting", Service: "set_manual_control", @@ -20,5 +24,10 @@ func (al AdaptiveLighting) SetManualControl(entityID string, enabled bool) error Target: Entity(entityID), } - return al.api.CallAndForget(req) + var result any + if err := al.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index b352aee..7959ce9 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type AlarmControlPanel struct { @@ -10,7 +12,9 @@ type AlarmControlPanel struct { // Send the alarm the command for arm away. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) ArmAway(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) ArmAway( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_arm_away", @@ -18,12 +22,19 @@ func (acp AlarmControlPanel) ArmAway(entityID string, serviceData ...any) error Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for arm away. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) ArmWithCustomBypass(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) ArmWithCustomBypass( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_arm_custom_bypass", @@ -31,12 +42,19 @@ func (acp AlarmControlPanel) ArmWithCustomBypass(entityID string, serviceData .. Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for arm home. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) ArmHome(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) ArmHome( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_arm_home", @@ -44,12 +62,19 @@ func (acp AlarmControlPanel) ArmHome(entityID string, serviceData ...any) error Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for arm night. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) ArmNight(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) ArmNight( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_arm_night", @@ -57,13 +82,20 @@ func (acp AlarmControlPanel) ArmNight(entityID string, serviceData ...any) error Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for arm vacation. Takes an entityID and // an optional service_data, which must be serializable to a JSON // object. -func (acp AlarmControlPanel) ArmVacation(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) ArmVacation( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_arm_vacation", @@ -71,12 +103,19 @@ func (acp AlarmControlPanel) ArmVacation(entityID string, serviceData ...any) er Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for disarm. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) Disarm(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) Disarm( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_disarm", @@ -84,12 +123,19 @@ func (acp AlarmControlPanel) Disarm(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the alarm the command for trigger. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (acp AlarmControlPanel) Trigger(entityID string, serviceData ...any) error { +func (acp AlarmControlPanel) Trigger( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "alarm_control_panel", Service: "alarm_trigger", @@ -97,5 +143,10 @@ func (acp AlarmControlPanel) Trigger(entityID string, serviceData ...any) error Target: Entity(entityID), } - return acp.api.CallAndForget(req) + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/climate.go b/internal/services/climate.go index 971e204..98e7f58 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -1,6 +1,8 @@ package services import ( + "context" + "saml.dev/gome-assistant/types" ) @@ -12,22 +14,38 @@ type Climate struct { /* Public API */ -func (c Climate) SetFanMode(entityID string, fanMode string) error { +func (c Climate) SetFanMode( + ctx context.Context, entityID string, fanMode string, +) (any, error) { req := BaseServiceRequest{ Domain: "climate", Service: "set_fan_mode", ServiceData: map[string]any{"fan_mode": fanMode}, Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (c Climate) SetTemperature(entityID string, serviceData types.SetTemperatureRequest) error { +func (c Climate) SetTemperature( + ctx context.Context, entityID string, serviceData types.SetTemperatureRequest, +) (any, error) { req := BaseServiceRequest{ Domain: "climate", Service: "set_temperature", ServiceData: serviceData.ToJSON(), Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/cover.go b/internal/services/cover.go index 9fb7cf7..228f8a9 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Cover struct { @@ -9,49 +11,83 @@ type Cover struct { /* Public API */ // Close all or specified cover. Takes an entityID. -func (c Cover) Close(entityID string) error { +func (c Cover) Close( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "close_cover", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Close all or specified cover tilt. Takes an entityID. -func (c Cover) CloseTilt(entityID string) error { +func (c Cover) CloseTilt( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "close_cover_tilt", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Open all or specified cover. Takes an entityID. -func (c Cover) Open(entityID string) error { +func (c Cover) Open( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "open_cover", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Open all or specified cover tilt. Takes an entityID. -func (c Cover) OpenTilt(entityID string) error { +func (c Cover) OpenTilt( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "open_cover_tilt", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Move to specific position all or specified cover. Takes an entityID // and an optional service_data, which must be serializable to a JSON // object. -func (c Cover) SetPosition(entityID string, serviceData ...any) error { +func (c Cover) SetPosition( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "set_cover_position", @@ -59,13 +95,20 @@ func (c Cover) SetPosition(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return c.api.CallAndForget(req) + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Move to specific position all or specified cover tilt. Takes an // entityID and an optional service_data, which must be serializable // to a JSON object. -func (c Cover) SetTiltPosition(entityID string, serviceData ...any) error { +func (c Cover) SetTiltPosition( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Target: Entity(entityID), Domain: "cover", @@ -73,45 +116,82 @@ func (c Cover) SetTiltPosition(entityID string, serviceData ...any) error { Service: "set_cover_tilt_position", } - return c.api.CallAndForget(req) + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Stop a cover entity. Takes an entityID. -func (c Cover) Stop(entityID string) error { +func (c Cover) Stop( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "stop_cover", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Stop a cover entity tilt. Takes an entityID. -func (c Cover) StopTilt(entityID string) error { +func (c Cover) StopTilt( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "stop_cover_tilt", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle a cover open/closed. Takes an entityID. -func (c Cover) Toggle(entityID string) error { +func (c Cover) Toggle( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "toggle", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle a cover tilt open/closed. Takes an entityID. -func (c Cover) ToggleTilt(entityID string) error { +func (c Cover) ToggleTilt( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "cover", Service: "toggle_cover_tilt", Target: Entity(entityID), } - return c.api.CallAndForget(req) + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index c628e72..7fc45f7 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -1,12 +1,16 @@ package services +import "context" + type HomeAssistant struct { api API } // TurnOn a Home Assistant entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (ha *HomeAssistant) TurnOn(entityID string, serviceData ...any) error { +func (ha *HomeAssistant) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "homeassistant", Service: "turn_on", @@ -14,12 +18,19 @@ func (ha *HomeAssistant) TurnOn(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return ha.api.CallAndForget(req) + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle a Home Assistant entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (ha *HomeAssistant) Toggle(entityID string, serviceData ...any) error { +func (ha *HomeAssistant) Toggle( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "homeassistant", Service: "toggle", @@ -27,14 +38,27 @@ func (ha *HomeAssistant) Toggle(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return ha.api.CallAndForget(req) + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ha *HomeAssistant) TurnOff(entityID string) error { +func (ha *HomeAssistant) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "homeassistant", Service: "turn_off", Target: Entity(entityID), } - return ha.api.CallAndForget(req) + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/input_boolean.go b/internal/services/input_boolean.go index 92a5802..36e6264 100644 --- a/internal/services/input_boolean.go +++ b/internal/services/input_boolean.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type InputBoolean struct { @@ -8,37 +10,67 @@ type InputBoolean struct { /* Public API */ -func (ib InputBoolean) TurnOn(entityID string) error { +func (ib InputBoolean) TurnOn( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_boolean", Service: "turn_on", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputBoolean) Toggle(entityID string) error { +func (ib InputBoolean) Toggle( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_boolean", Service: "toggle", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputBoolean) TurnOff(entityID string) error { +func (ib InputBoolean) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_boolean", Service: "turn_off", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputBoolean) Reload() error { +func (ib InputBoolean) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_boolean", Service: "reload", } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/input_button.go b/internal/services/input_button.go index 74b6298..30b73e2 100644 --- a/internal/services/input_button.go +++ b/internal/services/input_button.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type InputButton struct { @@ -8,20 +10,34 @@ type InputButton struct { /* Public API */ -func (ib InputButton) Press(entityID string) error { +func (ib InputButton) Press( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_button", Service: "press", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputButton) Reload() error { +func (ib InputButton) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_button", Service: "reload", Target: Entity(""), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/input_datetime.go b/internal/services/input_datetime.go index 753a6a9..0d3e230 100644 --- a/internal/services/input_datetime.go +++ b/internal/services/input_datetime.go @@ -1,6 +1,7 @@ package services import ( + "context" "fmt" "time" ) @@ -13,7 +14,9 @@ type InputDatetime struct { /* Public API */ -func (ib InputDatetime) Set(entityID string, value time.Time) error { +func (ib InputDatetime) Set( + ctx context.Context, entityID string, value time.Time, +) (any, error) { req := BaseServiceRequest{ Domain: "input_datetime", Service: "set_datetime", @@ -22,13 +25,25 @@ func (ib InputDatetime) Set(entityID string, value time.Time) error { }, Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputDatetime) Reload() error { +func (ib InputDatetime) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_datetime", Service: "reload", } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/input_number.go b/internal/services/input_number.go index c43170b..7f56a56 100644 --- a/internal/services/input_number.go +++ b/internal/services/input_number.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type InputNumber struct { @@ -8,38 +10,68 @@ type InputNumber struct { /* Public API */ -func (ib InputNumber) Set(entityID string, value float32) error { +func (ib InputNumber) Set( + ctx context.Context, entityID string, value float32, +) (any, error) { req := BaseServiceRequest{ Domain: "input_number", Service: "set_value", ServiceData: map[string]any{"value": value}, Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputNumber) Increment(entityID string) error { +func (ib InputNumber) Increment( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_number", Service: "increment", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputNumber) Decrement(entityID string) error { +func (ib InputNumber) Decrement( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_number", Service: "decrement", Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputNumber) Reload() error { +func (ib InputNumber) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_number", Service: "reload", } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/input_text.go b/internal/services/input_text.go index e349a00..f6279c1 100644 --- a/internal/services/input_text.go +++ b/internal/services/input_text.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type InputText struct { @@ -8,7 +10,9 @@ type InputText struct { /* Public API */ -func (ib InputText) Set(entityID string, value string) error { +func (ib InputText) Set( + ctx context.Context, entityID string, value string, +) (any, error) { req := BaseServiceRequest{ Domain: "input_text", Service: "set_value", @@ -17,13 +21,25 @@ func (ib InputText) Set(entityID string, value string) error { }, Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib InputText) Reload() error { +func (ib InputText) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_text", Service: "reload", } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/light.go b/internal/services/light.go index a9abca0..2e12881 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Light struct { @@ -10,7 +12,9 @@ type Light struct { // TurnOn a light entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (l Light) TurnOn(entityID string, serviceData ...any) error { +func (l Light) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "light", Service: "turn_on", @@ -18,12 +22,19 @@ func (l Light) TurnOn(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return l.api.CallAndForget(req) + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle a light entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (l Light) Toggle(entityID string, serviceData ...any) error { +func (l Light) Toggle( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "light", Service: "toggle", @@ -31,14 +42,26 @@ func (l Light) Toggle(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return l.api.CallAndForget(req) + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (l Light) TurnOff(entityID string) error { +func (l Light) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "light", Service: "turn_off", Target: Entity(entityID), } - return l.api.CallAndForget(req) + + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err + } + return result, nil } diff --git a/internal/services/lock.go b/internal/services/lock.go index db73ada..447b51a 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Lock struct { @@ -10,7 +12,9 @@ type Lock struct { // Lock a lock entity. Takes an entityID and an optional service_data, // which must be serializable to a JSON object. -func (l Lock) Lock(entityID string, serviceData ...any) error { +func (l Lock) Lock( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "lock", Service: "lock", @@ -18,12 +22,19 @@ func (l Lock) Lock(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return l.api.CallAndForget(req) + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Unlock a lock entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (l Lock) Unlock(entityID string, serviceData ...any) error { +func (l Lock) Unlock( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "lock", Service: "unlock", @@ -31,5 +42,10 @@ func (l Lock) Unlock(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return l.api.CallAndForget(req) + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/media_player.go b/internal/services/media_player.go index d52e47d..9999442 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type MediaPlayer struct { @@ -10,19 +12,29 @@ type MediaPlayer struct { // Send the media player the command to clear players playlist. // Takes an entityID. -func (mp MediaPlayer) ClearPlaylist(entityID string) error { +func (mp MediaPlayer) ClearPlaylist( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "clear_playlist", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Group players together. Only works on platforms with support for // player groups. Takes an entityID and an optional service_data, // which must be serializable to a JSON object. -func (mp MediaPlayer) Join(entityID string, serviceData ...any) error { +func (mp MediaPlayer) Join( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "join", @@ -30,68 +42,115 @@ func (mp MediaPlayer) Join(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command for next track. // Takes an entityID. -func (mp MediaPlayer) Next(entityID string) error { +func (mp MediaPlayer) Next( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_next_track", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command for pause. // Takes an entityID. -func (mp MediaPlayer) Pause(entityID string) error { +func (mp MediaPlayer) Pause( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_pause", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command for play. // Takes an entityID. -func (mp MediaPlayer) Play(entityID string) error { +func (mp MediaPlayer) Play( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_play", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle media player play/pause state. // Takes an entityID. -func (mp MediaPlayer) PlayPause(entityID string) error { +func (mp MediaPlayer) PlayPause( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_play_pause", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command for previous track. // Takes an entityID. -func (mp MediaPlayer) Previous(entityID string) error { +func (mp MediaPlayer) Previous( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_previous_track", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command to seek in current playing media. // Takes an entityID and an optional service_data, which must be // serializable to a JSON object. -func (mp MediaPlayer) Seek(entityID string, serviceData ...any) error { +func (mp MediaPlayer) Seek( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_seek", @@ -99,24 +158,39 @@ func (mp MediaPlayer) Seek(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the stop command. // Takes an entityID. -func (mp MediaPlayer) Stop(entityID string) error { +func (mp MediaPlayer) Stop( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "media_stop", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command for playing media. Takes an // entityID and an optional service_data, which must be serializable // to a JSON object. -func (mp MediaPlayer) PlayMedia(entityID string, serviceData ...any) error { +func (mp MediaPlayer) PlayMedia( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "play_media", @@ -124,12 +198,19 @@ func (mp MediaPlayer) PlayMedia(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Set repeat mode. Takes an entityID and an optional service_data, // which must be serializable to a JSON object. -func (mp MediaPlayer) RepeatSet(entityID string, serviceData ...any) error { +func (mp MediaPlayer) RepeatSet( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "repeat_set", @@ -137,13 +218,20 @@ func (mp MediaPlayer) RepeatSet(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command to change sound mode. Takes an // entityID and an optional service_data, which must be serializable // to a JSON object. -func (mp MediaPlayer) SelectSoundMode(entityID string, serviceData ...any) error { +func (mp MediaPlayer) SelectSoundMode( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "select_sound_mode", @@ -151,13 +239,20 @@ func (mp MediaPlayer) SelectSoundMode(entityID string, serviceData ...any) error Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send the media player the command to change input source. Takes an // entityID and an optional service_data, which must be serializable // to a JSON object. -func (mp MediaPlayer) SelectSource(entityID string, serviceData ...any) error { +func (mp MediaPlayer) SelectSource( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "select_source", @@ -165,12 +260,19 @@ func (mp MediaPlayer) SelectSource(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Set shuffling state. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (mp MediaPlayer) Shuffle(entityID string, serviceData ...any) error { +func (mp MediaPlayer) Shuffle( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "shuffle_set", @@ -178,68 +280,115 @@ func (mp MediaPlayer) Shuffle(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggles a media player power state. // Takes an entityID. -func (mp MediaPlayer) Toggle(entityID string) error { +func (mp MediaPlayer) Toggle( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "toggle", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Turn a media player power off. // Takes an entityID. -func (mp MediaPlayer) TurnOff(entityID string) error { +func (mp MediaPlayer) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "turn_off", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Turn a media player power on. // Takes an entityID. -func (mp MediaPlayer) TurnOn(entityID string) error { +func (mp MediaPlayer) TurnOn( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "turn_on", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Unjoin the player from a group. Only works on // platforms with support for player groups. // Takes an entityID. -func (mp MediaPlayer) Unjoin(entityID string) error { +func (mp MediaPlayer) Unjoin( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "unjoin", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Turn a media player volume down. // Takes an entityID. -func (mp MediaPlayer) VolumeDown(entityID string) error { +func (mp MediaPlayer) VolumeDown( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "volume_down", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Mute a media player's volume. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (mp MediaPlayer) VolumeMute(entityID string, serviceData ...any) error { +func (mp MediaPlayer) VolumeMute( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "volume_mute", @@ -247,12 +396,19 @@ func (mp MediaPlayer) VolumeMute(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Set a media player's volume level. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (mp MediaPlayer) VolumeSet(entityID string, serviceData ...any) error { +func (mp MediaPlayer) VolumeSet( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "volume_set", @@ -260,16 +416,29 @@ func (mp MediaPlayer) VolumeSet(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return mp.api.CallAndForget(req) + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Turn a media player volume up. // Takes an entityID. -func (mp MediaPlayer) VolumeUp(entityID string) error { +func (mp MediaPlayer) VolumeUp( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "media_player", Service: "volume_up", Target: Entity(entityID), } - return mp.api.CallAndForget(req) + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/notify.go b/internal/services/notify.go index 69a3242..5e12d54 100644 --- a/internal/services/notify.go +++ b/internal/services/notify.go @@ -1,6 +1,8 @@ package services import ( + "context" + "saml.dev/gome-assistant/types" ) @@ -9,7 +11,9 @@ type Notify struct { } // Notify sends a notification. Takes a types.NotifyRequest. -func (ha *Notify) Notify(reqData types.NotifyRequest) error { +func (ha *Notify) Notify( + ctx context.Context, reqData types.NotifyRequest, +) (any, error) { req := BaseServiceRequest{ Domain: "notify", Service: reqData.ServiceName, @@ -21,5 +25,11 @@ func (ha *Notify) Notify(reqData types.NotifyRequest) error { serviceData["data"] = reqData.Data } req.ServiceData = serviceData - return ha.api.CallAndForget(req) + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/number.go b/internal/services/number.go index e7a9410..2d0b7a5 100644 --- a/internal/services/number.go +++ b/internal/services/number.go @@ -1,21 +1,33 @@ package services +import "context" + type Number struct { api API } -func (ib Number) SetValue(entityID string, value float32) error { +func (ib Number) SetValue( + ctx context.Context, entityID string, value float32, +) (any, error) { req := BaseServiceRequest{ Domain: "number", Service: "set_value", ServiceData: map[string]any{"value": value}, Target: Entity(entityID), } - return ib.api.CallAndForget(req) + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (ib Number) MustSetValue(entityID string, value float32) { - if err := ib.SetValue(entityID, value); err != nil { +func (ib Number) MustSetValue( + ctx context.Context, entityID string, value float32, +) { + if _, err := ib.SetValue(ctx, entityID, value); err != nil { panic(err) } } diff --git a/internal/services/scene.go b/internal/services/scene.go index a352617..f2415e2 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Scene struct { @@ -10,7 +12,9 @@ type Scene struct { // Apply a scene. Takes an optional service_data, which must be // serializable to a JSON object. -func (s Scene) Apply(serviceData ...any) error { +func (s Scene) Apply( + ctx context.Context, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "scene", Service: "apply", @@ -18,12 +22,19 @@ func (s Scene) Apply(serviceData ...any) error { Target: Entity(""), } - return s.api.CallAndForget(req) + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Create a scene entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (s Scene) Create(entityID string, serviceData ...any) error { +func (s Scene) Create( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "scene", Service: "create", @@ -31,22 +42,35 @@ func (s Scene) Create(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return s.api.CallAndForget(req) + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Reload the scenes. -func (s Scene) Reload() error { +func (s Scene) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "scene", Service: "reload", Target: Entity(""), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // TurnOn a scene entity. Takes an entityID and an optional // service_data, which must be serializable to a JSON object. -func (s Scene) TurnOn(entityID string, serviceData ...any) error { +func (s Scene) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "scene", Service: "turn_on", @@ -54,5 +78,10 @@ func (s Scene) TurnOn(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return s.api.CallAndForget(req) + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/script.go b/internal/services/script.go index 42abff2..849b840 100644 --- a/internal/services/script.go +++ b/internal/services/script.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Script struct { @@ -9,41 +11,71 @@ type Script struct { /* Public API */ // Reload a script that was created in the HA UI. -func (s Script) Reload(entityID string) error { +func (s Script) Reload( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "script", Service: "reload", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Toggle a script that was created in the HA UI. -func (s Script) Toggle(entityID string) error { +func (s Script) Toggle( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "script", Service: "toggle", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // TurnOff a script that was created in the HA UI. -func (s Script) TurnOff() error { +func (s Script) TurnOff(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "script", Service: "turn_off", Target: Entity(""), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // TurnOn a script that was created in the HA UI. -func (s Script) TurnOn(entityID string) error { +func (s Script) TurnOn( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "script", Service: "turn_on", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/switch.go b/internal/services/switch.go index c77bf8c..bbb2d92 100644 --- a/internal/services/switch.go +++ b/internal/services/switch.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Switch struct { @@ -8,29 +10,53 @@ type Switch struct { /* Public API */ -func (s Switch) TurnOn(entityID string) error { +func (s Switch) TurnOn( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "switch", Service: "turn_on", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (s Switch) Toggle(entityID string) error { +func (s Switch) Toggle( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "switch", Service: "toggle", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } -func (s Switch) TurnOff(entityID string) error { +func (s Switch) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "switch", Service: "turn_off", Target: Entity(entityID), } - return s.api.CallAndForget(req) + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/timer.go b/internal/services/timer.go index 3778891..eb005be 100644 --- a/internal/services/timer.go +++ b/internal/services/timer.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Timer struct { @@ -9,7 +11,9 @@ type Timer struct { /* Public API */ // See https://www.home-assistant.io/integrations/timer/#action-timerstart -func (t Timer) Start(entityID string, duration string) error { +func (t Timer) Start( + ctx context.Context, entityID string, duration string, +) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "start", @@ -18,11 +22,19 @@ func (t Timer) Start(entityID string, duration string) error { }, Target: Entity(entityID), } - return t.api.CallAndForget(req) + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timerstart -func (t Timer) Change(entityID string, duration string) error { +func (t Timer) Change( + ctx context.Context, entityID string, duration string, +) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "change", @@ -31,45 +43,78 @@ func (t Timer) Change(entityID string, duration string) error { }, Target: Entity(entityID), } - return t.api.CallAndForget(req) + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timerpause -func (t Timer) Pause(entityID string) error { +func (t Timer) Pause( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "pause", Target: Entity(entityID), } - return t.api.CallAndForget(req) + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timercancel -func (t Timer) Cancel() error { +func (t Timer) Cancel(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "cancel", Target: Entity(""), } - return t.api.CallAndForget(req) + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timerfinish -func (t Timer) Finish(entityID string) error { +func (t Timer) Finish( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "finish", Target: Entity(entityID), } - return t.api.CallAndForget(req) + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timerreload -func (t Timer) Reload() error { +func (t Timer) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "reload", Target: Entity(""), } - return t.api.CallAndForget(req) + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/tts.go b/internal/services/tts.go index 1233235..f60ab96 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type TTS struct { @@ -9,19 +11,27 @@ type TTS struct { /* Public API */ // Remove all text-to-speech cache files and RAM cache. -func (tts TTS) ClearCache() error { +func (tts TTS) ClearCache(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "tts", Service: "clear_cache", Target: Entity(""), } - return tts.api.CallAndForget(req) + + var result any + if err := tts.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Say something using text-to-speech on a media player with cloud. // Takes an entityID and an optional service_data, which must be // serializable to a JSON object. -func (tts TTS) CloudSay(entityID string, serviceData ...any) error { +func (tts TTS) CloudSay( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "tts", Service: "cloud_say", @@ -29,13 +39,20 @@ func (tts TTS) CloudSay(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return tts.api.CallAndForget(req) + var result any + if err := tts.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Say something using text-to-speech on a media player with // google_translate. Takes an entityID and an optional service_data, // which must be serializable to a JSON object. -func (tts TTS) GoogleTranslateSay(entityID string, serviceData ...any) error { +func (tts TTS) GoogleTranslateSay( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "tts", Service: "google_translate_say", @@ -43,5 +60,10 @@ func (tts TTS) GoogleTranslateSay(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return tts.api.CallAndForget(req) + var result any + if err := tts.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index 8a4037c..b6f9957 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Vacuum struct { @@ -10,51 +12,84 @@ type Vacuum struct { // Tell the vacuum cleaner to do a spot clean-up. // Takes an entityID. -func (v Vacuum) CleanSpot(entityID string) error { +func (v Vacuum) CleanSpot( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "clean_spot", Target: Entity(entityID), } - return v.api.CallAndForget(req) + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Locate the vacuum cleaner robot. // Takes an entityID. -func (v Vacuum) Locate(entityID string) error { +func (v Vacuum) Locate( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "locate", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Pause the cleaning task. // Takes an entityID. -func (v Vacuum) Pause(entityID string) error { +func (v Vacuum) Pause( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "pause", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Tell the vacuum cleaner to return to its dock. // Takes an entityID. -func (v Vacuum) ReturnToBase(entityID string) error { +func (v Vacuum) ReturnToBase( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "return_to_base", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Send a raw command to the vacuum cleaner. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (v Vacuum) SendCommand(entityID string, serviceData ...any) error { +func (v Vacuum) SendCommand( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "send_command", @@ -62,12 +97,19 @@ func (v Vacuum) SendCommand(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return v.api.CallAndForget(req) + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Set the fan speed of the vacuum cleaner. Takes an entityID and an // optional service_data, which must be serializable to a JSON object. -func (v Vacuum) SetFanSpeed(entityID string, serviceData ...any) error { +func (v Vacuum) SetFanSpeed( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "set_fan_speed", @@ -75,60 +117,103 @@ func (v Vacuum) SetFanSpeed(entityID string, serviceData ...any) error { Target: Entity(entityID), } - return v.api.CallAndForget(req) + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Start or resume the cleaning task. // Takes an entityID. -func (v Vacuum) Start(entityID string) error { +func (v Vacuum) Start( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "start", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Start, pause, or resume the cleaning task. // Takes an entityID. -func (v Vacuum) StartPause(entityID string) error { +func (v Vacuum) StartPause( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "start_pause", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Stop the current cleaning task. // Takes an entityID. -func (v Vacuum) Stop(entityID string) error { +func (v Vacuum) Stop( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "stop", Target: Entity(entityID), } - return v.api.CallAndForget(req) + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Stop the current cleaning task and return to home. // Takes an entityID. -func (v Vacuum) TurnOff(entityID string) error { +func (v Vacuum) TurnOff( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "turn_off", Target: Entity(entityID), } - return v.api.CallAndForget(req) + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } // Start a new cleaning task. // Takes an entityID. -func (v Vacuum) TurnOn(entityID string) error { +func (v Vacuum) TurnOn( + ctx context.Context, entityID string, +) (any, error) { req := BaseServiceRequest{ Domain: "vacuum", Service: "turn_on", Target: Entity(entityID), } - return v.api.CallAndForget(req) + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/services/zwavejs.go b/internal/services/zwavejs.go index 02a2324..baa4503 100644 --- a/internal/services/zwavejs.go +++ b/internal/services/zwavejs.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type ZWaveJS struct { @@ -9,7 +11,9 @@ type ZWaveJS struct { /* Public API */ // ZWaveJS bulk_set_partial_config_parameters service. -func (zw ZWaveJS) BulkSetPartialConfigParam(entityID string, parameter int, value any) error { +func (zw ZWaveJS) BulkSetPartialConfigParam( + ctx context.Context, entityID string, parameter int, value any, +) (any, error) { req := BaseServiceRequest{ Domain: "zwave_js", Service: "bulk_set_partial_config_parameters", @@ -19,5 +23,11 @@ func (zw ZWaveJS) BulkSetPartialConfigParam(entityID string, parameter int, valu }, Target: Entity(entityID), } - return zw.api.CallAndForget(req) + + var result any + if err := zw.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } From 919780beab6dcd5691097b1dcf7806599ba6b5c9 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 16:49:07 +0100 Subject: [PATCH 16/36] message: new package, for holding message-related data types Currently, message types are declared in multiple packages: `websocket`, `gomeassistant`, `types`, `internal/services`, and maybe somewhere else that I forgot. Partly this is historical, but partly it is to avoid import cycles. There are also inconsistencies about how messages are named and whether they embed `BaseMessage`. Let's move all of the message types to a new `message` package. This package won't have dependencies on any other packages, so it can be imported from anywhere. Start by moving the following types to the new package, and adjusting the users: * `BaseMessage` * `Message` * `RawMessage` * `BaseResultMessage` * `ResultError` * `ResultMessage` --- app.go | 5 +++-- call.go | 7 ++++--- entitylistener.go | 4 ++-- eventListener.go | 4 ++-- internal/services/services.go | 4 ++-- {websocket => message}/message.go | 2 +- {websocket => message}/raw_message.go | 2 +- {websocket => message}/result_message.go | 2 +- websocket/reader.go | 4 +++- websocket/subscriptions.go | 6 ++++-- 10 files changed, 23 insertions(+), 17 deletions(-) rename {websocket => message}/message.go (96%) rename {websocket => message}/raw_message.go (97%) rename {websocket => message}/result_message.go (99%) diff --git a/app.go b/app.go index 678fb86..7d38446 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "saml.dev/gome-assistant/internal" "saml.dev/gome-assistant/internal/http" + "saml.dev/gome-assistant/message" "saml.dev/gome-assistant/websocket" ) @@ -261,7 +262,7 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.Message) { + func(msg message.Message) { // Subscribing, itself, causes the server to send // a "result" message. We don't want to forward // that message to the listeners. @@ -335,7 +336,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.Message) { + func(msg message.Message) { go app.callEntityListeners(msg.Raw) }, ) diff --git a/call.go b/call.go index e1ab30a..757b6e0 100644 --- a/call.go +++ b/call.go @@ -5,13 +5,14 @@ import ( "sync" "saml.dev/gome-assistant/internal/services" + "saml.dev/gome-assistant/message" "saml.dev/gome-assistant/websocket" ) // CallAndForget implements [services.API.CallAndForget]. func (app *App) CallAndForget(req services.BaseServiceRequest) error { reqMsg := services.CallServiceMessage{ - BaseMessage: websocket.BaseMessage{ + BaseMessage: message.BaseMessage{ Type: "call_service", }, BaseServiceRequest: req, @@ -38,7 +39,7 @@ func (app *App) Call( // 6. Unmarshal the "result" part of the response into `result`. reqMsg := services.CallServiceMessage{ - BaseMessage: websocket.BaseMessage{ + BaseMessage: message.BaseMessage{ Type: "call_service", }, BaseServiceRequest: req, @@ -67,7 +68,7 @@ func (app *App) Call( }) } - handleResponse := func(msg websocket.Message) { + handleResponse := func(msg message.Message) { once.Do( func() { responseErr = msg.GetResult(result) diff --git a/entitylistener.go b/entitylistener.go index 3329d40..791da8f 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -8,7 +8,7 @@ import ( "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal" - "saml.dev/gome-assistant/websocket" + "saml.dev/gome-assistant/message" ) type EntityListener struct { @@ -47,7 +47,7 @@ type EntityData struct { } type stateChangedMessage struct { - websocket.BaseMessage + message.BaseMessage Event struct { Data stateData `json:"data"` EventType string `json:"event_type"` diff --git a/eventListener.go b/eventListener.go index 53ef747..7f70429 100644 --- a/eventListener.go +++ b/eventListener.go @@ -7,7 +7,7 @@ import ( "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal" - "saml.dev/gome-assistant/websocket" + "saml.dev/gome-assistant/message" ) type EventListener struct { @@ -158,7 +158,7 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { } /* Functions */ -func (app *App) callEventListeners(eventType string, msg websocket.Message) { +func (app *App) callEventListeners(eventType string, msg message.Message) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type diff --git a/internal/services/services.go b/internal/services/services.go index e26180f..ab761ab 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -4,7 +4,7 @@ import ( "context" "log/slog" - "saml.dev/gome-assistant/websocket" + "saml.dev/gome-assistant/message" ) // API is the interface that the individual services use to interact @@ -57,7 +57,7 @@ func BuildService[ // CallServiceMessage represents a message that can be sent to request // an API call. Its `Type` field must be set to "call_service". type CallServiceMessage struct { - websocket.BaseMessage + message.BaseMessage BaseServiceRequest } diff --git a/websocket/message.go b/message/message.go similarity index 96% rename from websocket/message.go rename to message/message.go index b5e5396..3705062 100644 --- a/websocket/message.go +++ b/message/message.go @@ -1,4 +1,4 @@ -package websocket +package message // BaseMessage implements the required part of any websocket message. // This type can be embedded in other message types. diff --git a/websocket/raw_message.go b/message/raw_message.go similarity index 97% rename from websocket/raw_message.go rename to message/raw_message.go index 4d68817..0982a8a 100644 --- a/websocket/raw_message.go +++ b/message/raw_message.go @@ -1,4 +1,4 @@ -package websocket +package message import ( "encoding/json" diff --git a/websocket/result_message.go b/message/result_message.go similarity index 99% rename from websocket/result_message.go rename to message/result_message.go index 897000b..437f507 100644 --- a/websocket/result_message.go +++ b/message/result_message.go @@ -1,4 +1,4 @@ -package websocket +package message import ( "encoding/json" diff --git a/websocket/reader.go b/websocket/reader.go index b315a65..25a3ea6 100644 --- a/websocket/reader.go +++ b/websocket/reader.go @@ -3,6 +3,8 @@ package websocket import ( "encoding/json" "log/slog" + + "saml.dev/gome-assistant/message" ) // Run processes incoming messages from `Conn`. It reads @@ -24,7 +26,7 @@ func (conn *Conn) Run() error { return err } - var msg Message + var msg message.Message if err := json.Unmarshal(bytes, &msg.BaseMessage); err != nil { slog.Warn( "error unmarshaling websocket message; ignoring message", diff --git a/websocket/subscriptions.go b/websocket/subscriptions.go index 36f6ece..48c2f47 100644 --- a/websocket/subscriptions.go +++ b/websocket/subscriptions.go @@ -3,6 +3,8 @@ package websocket import ( "fmt" "log/slog" + + "saml.dev/gome-assistant/message" ) // Subscription represents a websocket-level subscription to a @@ -19,10 +21,10 @@ func (sub Subscription) MessageID() int64 { // Subscriber is called synchronously when a message is received that // matches its subscription's message ID. -type Subscriber func(msg Message) +type Subscriber func(msg message.Message) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ Message) {} +func NoopSubscriber(_ message.Message) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. From 98d3ec0d3dc1184194de50e940f858ad3355e8c3 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:02:40 +0100 Subject: [PATCH 17/36] message.SubscribeEventsRequest: new type Move type `websocket.SubEvent` to the `message` package, rename it, and embed a `BaseMessage`. --- message/subscribe_events_request.go | 6 ++++++ websocket/subscriptions.go | 14 +++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 message/subscribe_events_request.go diff --git a/message/subscribe_events_request.go b/message/subscribe_events_request.go new file mode 100644 index 0000000..b02cbee --- /dev/null +++ b/message/subscribe_events_request.go @@ -0,0 +1,6 @@ +package message + +type SubscribeEventRequest struct { + BaseMessage + EventType string `json:"event_type"` +} diff --git a/websocket/subscriptions.go b/websocket/subscriptions.go index 48c2f47..0fda4e3 100644 --- a/websocket/subscriptions.go +++ b/websocket/subscriptions.go @@ -36,20 +36,16 @@ func (conn *Conn) getSubscriber(messageID int64) (Subscriber, bool) { return subscriber, ok } -type SubEvent struct { - ID int64 `json:"id"` - Type string `json:"type"` - EventType string `json:"event_type"` -} - func (conn *Conn) SubscribeToEventType(eventType string, subr Subscriber) Subscription { var subn Subscription err := conn.Send( func(lc LockedConn) error { subn = lc.Subscribe(subr) - e := SubEvent{ - ID: subn.messageID, - Type: "subscribe_events", + e := message.SubscribeEventRequest{ + BaseMessage: message.BaseMessage{ + Type: "subscribe_events", + ID: subn.messageID, + }, EventType: eventType, } From 2ec0d9a58a51438c6a9827ecaa5b11f06d421a8e Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:10:56 +0100 Subject: [PATCH 18/36] AuthMessage: make type local to `Conn.sendAuthMessage()` This type is not used elsewhere, and it doesn't conform to the standard message layout, so make it local to the function where it is used. --- websocket/websocket.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/websocket/websocket.go b/websocket/websocket.go index 25aa117..0b260fb 100644 --- a/websocket/websocket.go +++ b/websocket/websocket.go @@ -17,11 +17,6 @@ import ( var ErrInvalidToken = errors.New("invalid authentication token") -type AuthMessage struct { - MsgType string `json:"type"` - AccessToken string `json:"access_token"` -} - type Conn struct { conn *websocket.Conn writeLock sync.Mutex @@ -98,7 +93,12 @@ func (conn *Conn) Close() error { } func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { - err := conn.conn.WriteJSON(AuthMessage{MsgType: "auth", AccessToken: token}) + type authMessage struct { + MsgType string `json:"type"` + AccessToken string `json:"access_token"` + } + + err := conn.conn.WriteJSON(authMessage{MsgType: "auth", AccessToken: token}) if err != nil { return err } From 1e0b80bffd6668efa9f6f623018ae8c5f70fb4d7 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:14:05 +0100 Subject: [PATCH 19/36] autoMessage.Type: field renamed from `MsgType` This is more consistent with the other message definitions, not to mention with the JSON field that it corresponds to. --- websocket/websocket.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websocket/websocket.go b/websocket/websocket.go index 0b260fb..543e1ef 100644 --- a/websocket/websocket.go +++ b/websocket/websocket.go @@ -94,11 +94,11 @@ func (conn *Conn) Close() error { func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { type authMessage struct { - MsgType string `json:"type"` + Type string `json:"type"` AccessToken string `json:"access_token"` } - err := conn.conn.WriteJSON(authMessage{MsgType: "auth", AccessToken: token}) + err := conn.conn.WriteJSON(authMessage{Type: "auth", AccessToken: token}) if err != nil { return err } From eecce430f49b066de31cf6e433e54d2104be1cb6 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:16:12 +0100 Subject: [PATCH 20/36] authResponse: make type local to `Conn.verifyAuthResponse()` This type is not used elsewhere, and it doesn't conform to the standard message layout, so make it local to the function where it is used. --- websocket/websocket.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/websocket/websocket.go b/websocket/websocket.go index 543e1ef..f443ee2 100644 --- a/websocket/websocket.go +++ b/websocket/websocket.go @@ -105,17 +105,17 @@ func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { return nil } -type authResponse struct { - MsgType string `json:"type"` - Message string `json:"message"` -} - func (conn *Conn) verifyAuthResponse(ctx context.Context) error { msg, err := conn.readMessage() if err != nil { return err } + type authResponse struct { + MsgType string `json:"type"` + Message string `json:"message"` + } + var authResp authResponse err = json.Unmarshal(msg, &authResp) if err != nil { From c390f967f04a2aaf4150857e02eebcdcc8cda3af Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:16:54 +0100 Subject: [PATCH 21/36] autoResponse.Type: field renamed from `MsgType` This is more consistent with the other message definitions, not to mention with the JSON field that it corresponds to. --- websocket/websocket.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websocket/websocket.go b/websocket/websocket.go index f443ee2..30a36da 100644 --- a/websocket/websocket.go +++ b/websocket/websocket.go @@ -112,7 +112,7 @@ func (conn *Conn) verifyAuthResponse(ctx context.Context) error { } type authResponse struct { - MsgType string `json:"type"` + Type string `json:"type"` Message string `json:"message"` } @@ -121,7 +121,7 @@ func (conn *Conn) verifyAuthResponse(ctx context.Context) error { if err != nil { return err } - if authResp.MsgType != "auth_ok" { + if authResp.Type != "auth_ok" { return ErrInvalidToken } From 60d1f9ea3fdf502c2402e67a090b9d5879e921df Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:17:45 +0100 Subject: [PATCH 22/36] sendAuthMessage(), verifyAuthResponse(): remove `ctx` argument We should really be using a `ctx` to allow a timeout. But that's not implemented now, so for now, remove the `ctx` arguments to these two methods. --- websocket/websocket.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/websocket/websocket.go b/websocket/websocket.go index 30a36da..70ab4c7 100644 --- a/websocket/websocket.go +++ b/websocket/websocket.go @@ -72,14 +72,14 @@ func NewConn( } // Send auth message - err = conn.sendAuthMessage(ctx, authToken) + err = conn.sendAuthMessage(authToken) if err != nil { slog.Error("Unknown error creating websocket client\n") return nil, err } // Verify auth message was successful - err = conn.verifyAuthResponse(ctx) + err = conn.verifyAuthResponse() if err != nil { slog.Error("Auth token is invalid. Please double check it or create a new token in your Home Assistant profile\n") return nil, err @@ -92,7 +92,7 @@ func (conn *Conn) Close() error { return conn.conn.Close() } -func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { +func (conn *Conn) sendAuthMessage(token string) error { type authMessage struct { Type string `json:"type"` AccessToken string `json:"access_token"` @@ -105,7 +105,7 @@ func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { return nil } -func (conn *Conn) verifyAuthResponse(ctx context.Context) error { +func (conn *Conn) verifyAuthResponse() error { msg, err := conn.readMessage() if err != nil { return err From 69886096a9060a76784be405c6b4934c43bae8c4 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:57:47 +0100 Subject: [PATCH 23/36] EventData: remove type It's not doing anything for us that `message.Message` can't already do. --- cmd/example/example.go | 5 +++-- eventListener.go | 18 ++++-------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/cmd/example/example.go b/cmd/example/example.go index 6780341..2f4fee9 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -10,6 +10,7 @@ import ( "time" "saml.dev/gome-assistant/cmd/example/entities" // Optional import generated entities + "saml.dev/gome-assistant/message" ga "saml.dev/gome-assistant" ) @@ -88,14 +89,14 @@ func pantryLights( } } -func onEvent(service *ga.Service, state ga.State, data ga.EventData) { +func onEvent(service *ga.Service, state ga.State, msg message.Message) { // Since the structure of the event changes depending // on the event type, you can Unmarshal the raw json // into a Go type. If a type for your event doesn't // exist, you can write it yourself! PR's welcome to // the eventTypes.go file :) ev := ga.EventZWaveJSValueNotification{} - json.Unmarshal(data.RawEventJSON, &ev) + json.Unmarshal(msg.Raw, &ev) slog.Info("On event invoked", "event", ev) } diff --git a/eventListener.go b/eventListener.go index 7f70429..7455dbd 100644 --- a/eventListener.go +++ b/eventListener.go @@ -25,12 +25,7 @@ type EventListener struct { disabledEntities []internal.EnabledDisabledInfo } -type EventListenerCallback func(*Service, State, EventData) - -type EventData struct { - Type string - RawEventJSON []byte -} +type EventListenerCallback func(*Service, State, message.Message) /* Methods */ @@ -132,7 +127,7 @@ func (b eventListenerBuilder3) Build() EventListener { return b.eventListener } -func (l *EventListener) maybeCall(app *App, eventData EventData) { +func (l *EventListener) maybeCall(app *App, eventMsg message.Message) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -153,7 +148,7 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { return } - go l.callback(app.service, app.state, eventData) + go l.callback(app.service, app.state, eventMsg) l.lastRan = carbon.Now() } @@ -165,12 +160,7 @@ func (app *App) callEventListeners(eventType string, msg message.Message) { return } - eventData := EventData{ - Type: eventType, - RawEventJSON: msg.Raw, - } - for _, l := range listeners { - l.maybeCall(app, eventData) + l.maybeCall(app, msg) } } From bbaad7c324ce057bcece1837c45bd0f73cffa780 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sat, 24 Jan 2026 19:23:41 +0100 Subject: [PATCH 24/36] message.EventMessage: new type Add an `EventMessage` type to represent an event message, parameterized by the type of the event's "data" field. Also add supporting types `Event`, `EventOrigin`, and `EventContext` types to represent components of the message. The new types are not yet used. --- message/event_message.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 message/event_message.go diff --git a/message/event_message.go b/message/event_message.go new file mode 100644 index 0000000..9b9681b --- /dev/null +++ b/message/event_message.go @@ -0,0 +1,32 @@ +package message + +import "time" + +// EventMessage represents a full event message. The `DataT` type +// parameter specifies the type of the `Event.Data` field. See +// the Home Assistant [docs] for more information. +// +// [docs]: https://developers.home-assistant.io/docs/api/websocket#subscribe-to-events +type EventMessage[DataT any] struct { + BaseMessage + Event Event[DataT] `json:"event"` +} + +// Event represents the `event` field of an event message. The `DataT` +// type parameter specifies the type of the `Data` field. +type Event[DataT any] struct { + EventType string `json:"event_type"` + Data DataT `json:"data"` + Origin EventOrigin `json:"origin"` + TimeFired time.Time `json:"time_fired"` + Context EventContext `json:"context"` +} + +// EventOrigin represents the origin of an event. +type EventOrigin string + +type EventContext struct { + ID string `json:"id,omitzero"` + ParentID string `json:"parent_id,omitzero"` + UserID string `json:"user_id,omitzero"` +} From 2cf445d58192ab1bf2803bb1e2b1b83de8f4103f Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 17:25:30 +0100 Subject: [PATCH 25/36] StateChangedMessage: move to `message` package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move type `StateChangedMessage`, along with its related types `StateData` and `MessageState`, from the top-level package to `message`, make them public, and tweak the name `msgState` → `MessageState`. --- entitylistener.go | 26 ++------------------------ message/state_changed_message.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 message/state_changed_message.go diff --git a/entitylistener.go b/entitylistener.go index 791da8f..057df5f 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -46,28 +46,6 @@ type EntityData struct { LastChanged time.Time } -type stateChangedMessage struct { - message.BaseMessage - Event struct { - Data stateData `json:"data"` - EventType string `json:"event_type"` - Origin string `json:"origin"` - } `json:"event"` -} - -type stateData struct { - EntityID string `json:"entity_id"` - NewState msgState `json:"new_state"` - OldState msgState `json:"old_state"` -} - -type msgState struct { - EntityID string `json:"entity_id"` - LastChanged time.Time `json:"last_changed"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` -} - /* Methods */ func NewEntityListener() elBuilder1 { @@ -193,7 +171,7 @@ func (b elBuilder3) Build() EntityListener { return b.entityListener } -func (l *EntityListener) maybeCall(app *App, entityData EntityData, data stateData) { +func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message.StateData) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -239,7 +217,7 @@ func (l *EntityListener) maybeCall(app *App, entityData EntityData, data stateDa /* Functions */ func (app *App) callEntityListeners(msgBytes []byte) { - msg := stateChangedMessage{} + msg := message.StateChangedMessage{} _ = json.Unmarshal(msgBytes, &msg) data := msg.Event.Data eid := data.EntityID diff --git a/message/state_changed_message.go b/message/state_changed_message.go new file mode 100644 index 0000000..6d5948a --- /dev/null +++ b/message/state_changed_message.go @@ -0,0 +1,25 @@ +package message + +import "time" + +type StateChangedMessage struct { + BaseMessage + Event struct { + Data StateData `json:"data"` + EventType string `json:"event_type"` + Origin string `json:"origin"` + } `json:"event"` +} + +type StateData struct { + EntityID string `json:"entity_id"` + NewState MessageState `json:"new_state"` + OldState MessageState `json:"old_state"` +} + +type MessageState struct { + EntityID string `json:"entity_id"` + LastChanged time.Time `json:"last_changed"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` +} From cef48811f15c86dec423b17ed9da7fb6c4615137 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 22:47:27 +0100 Subject: [PATCH 26/36] StateChangedEventMessage: base type on `EventMessage` `StateChangedMessage` is also an `EventMessage`, just with a different data payload. So pull it into the system: * Rename `StateChangedMessage` to `StateChangedEventMessage`. * Change the definition of `StateChangedEventMessage` to `EventMessage[StateChangedData]`. This adds some fields that were omitted before. * Rename `StateData` to `StateChangedData`. * Rename `MessageState` to `StateChangedState` and add some fields that were omitted before. --- entitylistener.go | 9 ++++----- message/state_changed_event_message.go | 20 ++++++++++++++++++++ message/state_changed_message.go | 25 ------------------------- 3 files changed, 24 insertions(+), 30 deletions(-) create mode 100644 message/state_changed_event_message.go delete mode 100644 message/state_changed_message.go diff --git a/entitylistener.go b/entitylistener.go index 057df5f..a9444ec 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -171,7 +171,7 @@ func (b elBuilder3) Build() EntityListener { return b.entityListener } -func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message.StateData) { +func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message.StateChangedData) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -217,11 +217,10 @@ func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message /* Functions */ func (app *App) callEntityListeners(msgBytes []byte) { - msg := message.StateChangedMessage{} + msg := message.StateChangedEventMessage{} _ = json.Unmarshal(msgBytes, &msg) data := msg.Event.Data - eid := data.EntityID - listeners, ok := app.entityListeners[eid] + listeners, ok := app.entityListeners[data.EntityID] if !ok { // no listeners registered for this id return @@ -236,7 +235,7 @@ func (app *App) callEntityListeners(msgBytes []byte) { } entityData := EntityData{ - TriggerEntityID: eid, + TriggerEntityID: data.EntityID, FromState: data.OldState.State, FromAttributes: data.OldState.Attributes, ToState: data.NewState.State, diff --git a/message/state_changed_event_message.go b/message/state_changed_event_message.go new file mode 100644 index 0000000..ee6def5 --- /dev/null +++ b/message/state_changed_event_message.go @@ -0,0 +1,20 @@ +package message + +import "time" + +type StateChangedEventMessage EventMessage[StateChangedData] + +type StateChangedData struct { + EntityID string `json:"entity_id"` + NewState StateChangedState `json:"new_state"` + OldState StateChangedState `json:"old_state"` +} + +type StateChangedState struct { + EntityID string `json:"entity_id"` + LastChanged time.Time `json:"last_changed"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` + LastUpdated time.Time `json:"last_updated"` + Context EventContext `json:"context"` +} diff --git a/message/state_changed_message.go b/message/state_changed_message.go deleted file mode 100644 index 6d5948a..0000000 --- a/message/state_changed_message.go +++ /dev/null @@ -1,25 +0,0 @@ -package message - -import "time" - -type StateChangedMessage struct { - BaseMessage - Event struct { - Data StateData `json:"data"` - EventType string `json:"event_type"` - Origin string `json:"origin"` - } `json:"event"` -} - -type StateData struct { - EntityID string `json:"entity_id"` - NewState MessageState `json:"new_state"` - OldState MessageState `json:"old_state"` -} - -type MessageState struct { - EntityID string `json:"entity_id"` - LastChanged time.Time `json:"last_changed"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` -} From 0165ef09fad2dedd6ab58efb85dcf74ec8c00dce Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 23:15:51 +0100 Subject: [PATCH 27/36] EntityData: delete type Instead of extracting the entity data from an `EntityChangeEventMessage`, putting it in an `EntityData`, and passing the `EntityData` to the `EntityListener`s, just pass the `StateChangedData` structure to the listeners. This requires one less type, gives the listeners more information to work with, and makes the information that listeners get agree with the Home Assistant documentation about change events. This requires listeners' callbacks to have a different signature, but the change is pretty straightforward. --- app.go | 19 ++++++++++++------- cmd/example/example.go | 6 +++--- cmd/example/example_live_test.go | 10 ++++++++-- entitylistener.go | 30 ++++++------------------------ 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/app.go b/app.go index 7d38446..c12ce58 100644 --- a/app.go +++ b/app.go @@ -353,13 +353,18 @@ func (app *App) Start() { } etl.runOnStartupCompleted = true - go etl.callback(app.service, app.state, EntityData{ - TriggerEntityID: eid, - FromState: entityState.State, - FromAttributes: entityState.Attributes, - ToState: entityState.State, - ToAttributes: entityState.Attributes, - LastChanged: entityState.LastChanged, + stateChangedState := message.StateChangedState{ + EntityID: eid, + LastChanged: entityState.LastChanged, + State: entityState.State, + Attributes: entityState.Attributes, + LastUpdated: entityState.LastChanged, + } + + go etl.callback(app.service, app.state, message.StateChangedData{ + EntityID: eid, + NewState: stateChangedState, + OldState: stateChangedState, }) } } diff --git a/cmd/example/example.go b/cmd/example/example.go index 2f4fee9..9bb22d3 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -39,7 +39,7 @@ func main() { pantryDoor := ga. NewEntityListener(). EntityIDs(entities.BinarySensor.PantryDoor). // Use generated entity constant - Call(func(service *ga.Service, state ga.State, sensor ga.EntityData) { + Call(func(service *ga.Service, state ga.State, sensor message.StateChangedData) { pantryLights(ctx, service, state, sensor) }). Build() @@ -74,11 +74,11 @@ func main() { } func pantryLights( - ctx context.Context, service *ga.Service, state ga.State, sensor ga.EntityData, + ctx context.Context, service *ga.Service, state ga.State, sensor message.StateChangedData, ) { l := "light.pantry" // l := entities.Light.Pantry // Or use generated entity constant - if sensor.ToState == "on" { + if sensor.NewState.State == "on" { if _, err := service.HomeAssistant.TurnOn(ctx, l); err != nil { slog.Warn("couldn't turn on pantry light") } diff --git a/cmd/example/example_live_test.go b/cmd/example/example_live_test.go index c0af1be..5ca0031 100644 --- a/cmd/example/example_live_test.go +++ b/cmd/example/example_live_test.go @@ -14,6 +14,7 @@ import ( "gopkg.in/yaml.v3" ga "saml.dev/gome-assistant" + "saml.dev/gome-assistant/message" ) type ( @@ -131,8 +132,13 @@ func (s *MySuite) TestSchedule() { } // Capture event after light entity state has changed -func (s *MySuite) entityCallback(se *ga.Service, st ga.State, e ga.EntityData) { - slog.Info("Entity callback called.", "entity id", e.TriggerEntityID, "from state", e.FromState, "to state", e.ToState) +func (s *MySuite) entityCallback(se *ga.Service, st ga.State, e message.StateChangedData) { + slog.Info( + "Entity callback called", + "entity id", e.EntityID, + "from state", e.OldState.State, + "to state", e.NewState.State, + ) s.suiteCtx.entityCallbackInvoked.Store(true) } diff --git a/entitylistener.go b/entitylistener.go index a9444ec..98b6e73 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -35,16 +35,7 @@ type EntityListener struct { disabledEntities []internal.EnabledDisabledInfo } -type EntityListenerCallback func(*Service, State, EntityData) - -type EntityData struct { - TriggerEntityID string - FromState string - FromAttributes map[string]any - ToState string - ToAttributes map[string]any - LastChanged time.Time -} +type EntityListenerCallback func(*Service, State, message.StateChangedData) /* Methods */ @@ -171,7 +162,7 @@ func (b elBuilder3) Build() EntityListener { return b.entityListener } -func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message.StateChangedData) { +func (l *EntityListener) maybeCall(app *App, data message.StateChangedData) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -204,14 +195,14 @@ func (l *EntityListener) maybeCall(app *App, entityData EntityData, data message if l.delay != 0 { l := l l.delayTimer = time.AfterFunc(l.delay, func() { - go l.callback(app.service, app.state, entityData) + go l.callback(app.service, app.state, data) l.lastRan = carbon.Now() }) return } // run now if no delay set - go l.callback(app.service, app.state, entityData) + go l.callback(app.service, app.state, data) l.lastRan = carbon.Now() } @@ -230,20 +221,11 @@ func (app *App) callEntityListeners(msgBytes []byte) { // event listener. I noticed this with iOS app location, // every time I refresh the app it triggers a device_tracker // entity listener. - if msg.Event.Data.NewState.State == msg.Event.Data.OldState.State { + if data.NewState.State == data.OldState.State { return } - entityData := EntityData{ - TriggerEntityID: data.EntityID, - FromState: data.OldState.State, - FromAttributes: data.OldState.Attributes, - ToState: data.NewState.State, - ToAttributes: data.NewState.Attributes, - LastChanged: data.OldState.LastChanged, - } - for _, l := range listeners { - l.maybeCall(app, entityData, data) + l.maybeCall(app, data) } } From 724e5c390f06cc84c9bae441b4c02b25328ae1ae Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sat, 24 Jan 2026 19:29:28 +0100 Subject: [PATCH 28/36] message.ZWaveJSValueNotificationEventMessage: new type Move type `EventZWaveJSValueNotification` from the top-level package to the `message` package and rename it to `ZWaveJSValueNotificationEventMessage` for consistency with the other message types. Re-implement `ZWaveJSValueNotificationEventMessage` in terms of `EventMessage` and a new `ZWaveJSValueNotificationData` type that represents its data. --- cmd/example/example.go | 2 +- eventTypes.go | 29 ----------------------------- message/event_message.go | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 30 deletions(-) delete mode 100644 eventTypes.go diff --git a/cmd/example/example.go b/cmd/example/example.go index 9bb22d3..c7fc623 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -95,7 +95,7 @@ func onEvent(service *ga.Service, state ga.State, msg message.Message) { // into a Go type. If a type for your event doesn't // exist, you can write it yourself! PR's welcome to // the eventTypes.go file :) - ev := ga.EventZWaveJSValueNotification{} + ev := message.ZWaveJSValueNotificationEventMessage{} json.Unmarshal(msg.Raw, &ev) slog.Info("On event invoked", "event", ev) } diff --git a/eventTypes.go b/eventTypes.go deleted file mode 100644 index cefc6dc..0000000 --- a/eventTypes.go +++ /dev/null @@ -1,29 +0,0 @@ -package gomeassistant - -import "time" - -type EventZWaveJSValueNotification struct { - ID int `json:"id"` - Type string `json:"type"` - Event struct { - EventType string `json:"event_type"` - Data struct { - Domain string `json:"domain"` - NodeID int `json:"node_id"` - HomeID int64 `json:"home_id"` - Endpoint int `json:"endpoint"` - DeviceID string `json:"device_id"` - CommandClass int `json:"command_class"` - CommandClassName string `json:"command_class_name"` - Label string `json:"label"` - Property string `json:"property"` - PropertyName string `json:"property_name"` - PropertyKey string `json:"property_key"` - PropertyKeyName string `json:"property_key_name"` - Value string `json:"value"` - ValueRaw int `json:"value_raw"` - } `json:"data"` - Origin string `json:"origin"` - TimeFired time.Time `json:"time_fired"` - } `json:"event"` -} diff --git a/message/event_message.go b/message/event_message.go index 9b9681b..e7bd0d6 100644 --- a/message/event_message.go +++ b/message/event_message.go @@ -30,3 +30,22 @@ type EventContext struct { ParentID string `json:"parent_id,omitzero"` UserID string `json:"user_id,omitzero"` } + +type ZWaveJSValueNotificationEventMessage EventMessage[ZWaveJSValueNotificationData] + +type ZWaveJSValueNotificationData struct { + Domain string `json:"domain"` + NodeID int `json:"node_id"` + HomeID int64 `json:"home_id"` + Endpoint int `json:"endpoint"` + DeviceID string `json:"device_id"` + CommandClass int `json:"command_class"` + CommandClassName string `json:"command_class_name"` + Label string `json:"label"` + Property string `json:"property"` + PropertyName string `json:"property_name"` + PropertyKey string `json:"property_key"` + PropertyKeyName string `json:"property_key_name"` + Value string `json:"value"` + ValueRaw int `json:"value_raw"` +} From c359e0d6ca0c68f02435f581f7e0fbc8f3d30854 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 23:33:01 +0100 Subject: [PATCH 29/36] TimeStamp: new type, for unmarshaling times I've seen HA sometimes formatting timestamps as RFC 3339 strings, sometimes as fractional seconds since the epoch. Add a type `message.TimeStamp` that can handle either one. --- cmd/example/example.go | 2 +- message/event_message.go | 4 +-- message/state_changed_event_message.go | 6 ++--- message/time_stamp.go | 34 ++++++++++++++++++++++++++ state.go | 10 ++++---- 5 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 message/time_stamp.go diff --git a/cmd/example/example.go b/cmd/example/example.go index c7fc623..2bc451b 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -113,7 +113,7 @@ func lightsOut(ctx context.Context, service *ga.Service, state ga.State) { } // if no motion detected in living room for 30mins - if s.State == "off" && time.Since(s.LastChanged).Minutes() > 30 { + if s.State == "off" && time.Since(s.LastChanged.Time()).Minutes() > 30 { if _, err := service.Light.TurnOff(ctx, entities.Light.MainLights); err != nil { slog.Warn("couldn't turn off living light") return diff --git a/message/event_message.go b/message/event_message.go index e7bd0d6..4f639b1 100644 --- a/message/event_message.go +++ b/message/event_message.go @@ -1,7 +1,5 @@ package message -import "time" - // EventMessage represents a full event message. The `DataT` type // parameter specifies the type of the `Event.Data` field. See // the Home Assistant [docs] for more information. @@ -18,7 +16,7 @@ type Event[DataT any] struct { EventType string `json:"event_type"` Data DataT `json:"data"` Origin EventOrigin `json:"origin"` - TimeFired time.Time `json:"time_fired"` + TimeFired TimeStamp `json:"time_fired"` Context EventContext `json:"context"` } diff --git a/message/state_changed_event_message.go b/message/state_changed_event_message.go index ee6def5..d7ae7c5 100644 --- a/message/state_changed_event_message.go +++ b/message/state_changed_event_message.go @@ -1,7 +1,5 @@ package message -import "time" - type StateChangedEventMessage EventMessage[StateChangedData] type StateChangedData struct { @@ -12,9 +10,9 @@ type StateChangedData struct { type StateChangedState struct { EntityID string `json:"entity_id"` - LastChanged time.Time `json:"last_changed"` + LastChanged TimeStamp `json:"last_changed"` State string `json:"state"` Attributes map[string]any `json:"attributes"` - LastUpdated time.Time `json:"last_updated"` + LastUpdated TimeStamp `json:"last_updated"` Context EventContext `json:"context"` } diff --git a/message/time_stamp.go b/message/time_stamp.go new file mode 100644 index 0000000..4165004 --- /dev/null +++ b/message/time_stamp.go @@ -0,0 +1,34 @@ +package message + +import ( + "encoding/json" + "fmt" + "math" + "time" +) + +type TimeStamp time.Time + +func (ts TimeStamp) Time() time.Time { + return time.Time(ts) +} + +// UnmarshalJSON unmarshals a timestamp from JSON. HA sometimes +// formats timestamps as RFC 3339 strings, sometimes as fractional +// seconds since the epoch. Handle either one (without recording which +// one it was). +func (ts *TimeStamp) UnmarshalJSON(b []byte) error { + t := (*time.Time)(ts) + if err := t.UnmarshalJSON(b); err == nil { + return nil + } + + var v float64 + if err := json.Unmarshal(b, &v); err == nil { + seconds, fraction := math.Modf(v) + *t = time.Unix(int64(seconds), int64(fraction*1e+9)) + return nil + } + + return fmt.Errorf("unmarshaling timestamp: '%s'", string(b)) +} diff --git a/state.go b/state.go index cd43338..bf5b998 100644 --- a/state.go +++ b/state.go @@ -4,11 +4,11 @@ import ( "encoding/json" "fmt" "strings" - "time" "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal/http" + "saml.dev/gome-assistant/message" ) type State interface { @@ -29,10 +29,10 @@ type StateImpl struct { } type EntityState struct { - EntityID string `json:"entity_id"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` - LastChanged time.Time `json:"last_changed"` + EntityID string `json:"entity_id"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` + LastChanged message.TimeStamp `json:"last_changed"` } func newState(c *http.HttpClient, homeZoneEntityID string) (*StateImpl, error) { From d706aead58fd40940fcf6e1b15b79a21e5c14a8d Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sun, 18 Jan 2026 23:56:47 +0100 Subject: [PATCH 30/36] message.FireEventRequest: move type to the `messages` package Move `FireEventRequest` to the `messages` package and make it look more like other messages. --- fire_event.go | 25 ++++++++++++------------- internal/services/event.go | 7 ++----- internal/services/services.go | 2 +- message/fire_event_request.go | 8 ++++++++ 4 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 message/fire_event_request.go diff --git a/fire_event.go b/fire_event.go index e7dffaa..e000785 100644 --- a/fire_event.go +++ b/fire_event.go @@ -1,27 +1,26 @@ package gomeassistant -import "saml.dev/gome-assistant/websocket" +import ( + "saml.dev/gome-assistant/message" + "saml.dev/gome-assistant/websocket" +) // FireEvent implements [services.API.FireEvent]. -func (app *App) FireEvent(eventType string, eventData map[string]any) error { +func (app *App) FireEvent(eventType string, eventData any) error { return app.conn.Send( func(lc websocket.LockedConn) error { - req := FireEventRequest{ - ID: lc.NextMessageID(), - Type: "fire_event", + req := message.FireEventRequest{ + BaseMessage: message.BaseMessage{ + ID: lc.NextMessageID(), + Type: "fire_event", + }, EventType: eventType, EventData: eventData, } + // FIXME: wait for result to make sure that the event was + // fired successfully. return lc.SendMessage(req) }, ) } - -// Fire an event -type FireEventRequest struct { - ID int64 `json:"id"` - Type string `json:"type"` // always set to "fire_event" - EventType string `json:"event_type"` - EventData map[string]any `json:"event_data,omitempty"` -} diff --git a/internal/services/event.go b/internal/services/event.go index 6747199..17dc5c8 100644 --- a/internal/services/event.go +++ b/internal/services/event.go @@ -8,9 +8,6 @@ type Event struct { // Fire an event. Takes an event type and an optional map that is sent // as `event_data`. -func (e Event) Fire(eventType string, eventData ...map[string]any) error { - if len(eventData) == 0 { - return e.api.FireEvent(eventType, nil) - } - return e.api.FireEvent(eventType, eventData[0]) +func (e Event) Fire(eventType string, eventData any) error { + return e.api.FireEvent(eventType, eventData) } diff --git a/internal/services/services.go b/internal/services/services.go index ab761ab..2d18c61 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -23,7 +23,7 @@ type API interface { // sending the request or if `ctx` expires), return that error. Call(ctx context.Context, req BaseServiceRequest, result any) error - FireEvent(eventType string, eventData map[string]any) error + FireEvent(eventType string, eventData any) error } func BuildService[ diff --git a/message/fire_event_request.go b/message/fire_event_request.go new file mode 100644 index 0000000..6573aa1 --- /dev/null +++ b/message/fire_event_request.go @@ -0,0 +1,8 @@ +package message + +// FireEventRequest requests that Home Assistant fire an event. +type FireEventRequest struct { + BaseMessage + EventType string `json:"event_type"` + EventData any `json:"event_data,omitempty"` +} From 2d784a9082eba18cb98f75d504f5453e4abe2fa3 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 00:06:25 +0100 Subject: [PATCH 31/36] EntityState: move type to the `message` package --- checkers_test.go | 11 ++++++----- message/entity_state.go | 10 ++++++++++ state.go | 21 +++++++-------------- 3 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 message/entity_state.go diff --git a/checkers_test.go b/checkers_test.go index 8a659d2..c20fb73 100644 --- a/checkers_test.go +++ b/checkers_test.go @@ -6,12 +6,13 @@ import ( "github.com/stretchr/testify/assert" "saml.dev/gome-assistant/internal" + "saml.dev/gome-assistant/message" ) type MockState struct { EqualsReturn bool EqualsError bool - GetReturn EntityState + GetReturn message.EntityState GetError bool } @@ -27,14 +28,14 @@ func (s MockState) AfterSunset(_ ...DurationString) bool { func (s MockState) BeforeSunset(_ ...DurationString) bool { return true } -func (s MockState) Get(eid string) (EntityState, error) { +func (s MockState) Get(eid string) (message.EntityState, error) { if s.GetError { - return EntityState{}, errors.New("some error") + return message.EntityState{}, errors.New("some error") } return s.GetReturn, nil } -func (s MockState) ListEntities() ([]EntityState, error) { - return []EntityState{}, nil +func (s MockState) ListEntities() ([]message.EntityState, error) { + return []message.EntityState{}, nil } func (s MockState) Equals(eid, state string) (bool, error) { if s.EqualsError { diff --git a/message/entity_state.go b/message/entity_state.go new file mode 100644 index 0000000..827c8db --- /dev/null +++ b/message/entity_state.go @@ -0,0 +1,10 @@ +package message + +// EntityState is the state of an entity in Home Assistant, reported +// by the `/api/states` and `/api/states/` REST APIs. +type EntityState struct { + EntityID string `json:"entity_id"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` + LastChanged TimeStamp `json:"last_changed"` +} diff --git a/state.go b/state.go index bf5b998..fe60867 100644 --- a/state.go +++ b/state.go @@ -16,8 +16,8 @@ type State interface { BeforeSunrise(...DurationString) bool AfterSunset(...DurationString) bool BeforeSunset(...DurationString) bool - ListEntities() ([]EntityState, error) - Get(entityID string) (EntityState, error) + ListEntities() ([]message.EntityState, error) + Get(entityID string) (message.EntityState, error) Equals(entityID, state string) (bool, error) } @@ -28,13 +28,6 @@ type StateImpl struct { longitude float64 } -type EntityState struct { - EntityID string `json:"entity_id"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` - LastChanged message.TimeStamp `json:"last_changed"` -} - func newState(c *http.HttpClient, homeZoneEntityID string) (*StateImpl, error) { state := &StateImpl{httpClient: c} @@ -69,24 +62,24 @@ func newState(c *http.HttpClient, homeZoneEntityID string) (*StateImpl, error) { return state, nil } -func (s *StateImpl) Get(entityID string) (EntityState, error) { +func (s *StateImpl) Get(entityID string) (message.EntityState, error) { resp, err := s.httpClient.GetState(entityID) if err != nil { - return EntityState{}, err + return message.EntityState{}, err } - es := EntityState{} + es := message.EntityState{} err = json.Unmarshal(resp, &es) return es, err } // ListEntities returns a list of all entities in Home Assistant. // see rest documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions -func (s *StateImpl) ListEntities() ([]EntityState, error) { +func (s *StateImpl) ListEntities() ([]message.EntityState, error) { resp, err := s.httpClient.States() if err != nil { return nil, err } - es := []EntityState{} + es := []message.EntityState{} err = json.Unmarshal(resp, &es) return es, err } From c555fdec5f0b20525dacea43e1a739ebb56b4b8d Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 00:07:06 +0100 Subject: [PATCH 32/36] EntityState.LastUpdated: new field It's documented by Home Assistant. --- message/entity_state.go | 1 + 1 file changed, 1 insertion(+) diff --git a/message/entity_state.go b/message/entity_state.go index 827c8db..25302d0 100644 --- a/message/entity_state.go +++ b/message/entity_state.go @@ -7,4 +7,5 @@ type EntityState struct { State string `json:"state"` Attributes map[string]any `json:"attributes"` LastChanged TimeStamp `json:"last_changed"` + LastUpdated TimeStamp `json:"last_updated"` } From 412831bc0beea61cfb77aeb32df5bc77b2d0c2c6 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 00:17:51 +0100 Subject: [PATCH 33/36] message.Target: type moved from the `internal/services` package Move the `services.Target` type to the `message` package, so that it can be used more broadly. --- internal/services/adaptive_lighting.go | 8 +++- internal/services/alarm_control_panel.go | 20 ++++++---- internal/services/climate.go | 5 ++- internal/services/cover.go | 26 ++++++------ internal/services/homeassistant.go | 12 ++++-- internal/services/input_boolean.go | 12 ++++-- internal/services/input_button.go | 9 +++-- internal/services/input_datetime.go | 4 +- internal/services/input_number.go | 12 ++++-- internal/services/input_text.go | 8 +++- internal/services/light.go | 12 ++++-- internal/services/lock.go | 10 +++-- internal/services/media_player.go | 50 +++++++++++++----------- internal/services/number.go | 8 +++- internal/services/scene.go | 12 +++--- internal/services/script.go | 13 +++--- internal/services/services.go | 18 ++------- internal/services/switch.go | 12 ++++-- internal/services/timer.go | 16 ++++---- internal/services/tts.go | 11 ++++-- internal/services/vacuum.go | 28 +++++++------ internal/services/zwavejs.go | 8 +++- message/target.go | 11 ++++++ 23 files changed, 199 insertions(+), 126 deletions(-) create mode 100644 message/target.go diff --git a/internal/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index 7c8fe61..c31890b 100644 --- a/internal/services/adaptive_lighting.go +++ b/internal/services/adaptive_lighting.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -21,7 +25,7 @@ func (al AdaptiveLighting) SetManualControl( "entity_id": entityID, "manual_control": enabled, }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index 7959ce9..1fcf770 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -19,7 +23,7 @@ func (acp AlarmControlPanel) ArmAway( Domain: "alarm_control_panel", Service: "alarm_arm_away", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -39,7 +43,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass( Domain: "alarm_control_panel", Service: "alarm_arm_custom_bypass", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -59,7 +63,7 @@ func (acp AlarmControlPanel) ArmHome( Domain: "alarm_control_panel", Service: "alarm_arm_home", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -79,7 +83,7 @@ func (acp AlarmControlPanel) ArmNight( Domain: "alarm_control_panel", Service: "alarm_arm_night", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -100,7 +104,7 @@ func (acp AlarmControlPanel) ArmVacation( Domain: "alarm_control_panel", Service: "alarm_arm_vacation", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -120,7 +124,7 @@ func (acp AlarmControlPanel) Disarm( Domain: "alarm_control_panel", Service: "alarm_disarm", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -140,7 +144,7 @@ func (acp AlarmControlPanel) Trigger( Domain: "alarm_control_panel", Service: "alarm_trigger", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/climate.go b/internal/services/climate.go index 98e7f58..fedf68f 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -3,6 +3,7 @@ package services import ( "context" + "saml.dev/gome-assistant/message" "saml.dev/gome-assistant/types" ) @@ -21,7 +22,7 @@ func (c Climate) SetFanMode( Domain: "climate", Service: "set_fan_mode", ServiceData: map[string]any{"fan_mode": fanMode}, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -39,7 +40,7 @@ func (c Climate) SetTemperature( Domain: "climate", Service: "set_temperature", ServiceData: serviceData.ToJSON(), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/cover.go b/internal/services/cover.go index 228f8a9..d7fd0d8 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -17,7 +21,7 @@ func (c Cover) Close( req := BaseServiceRequest{ Domain: "cover", Service: "close_cover", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -35,7 +39,7 @@ func (c Cover) CloseTilt( req := BaseServiceRequest{ Domain: "cover", Service: "close_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -53,7 +57,7 @@ func (c Cover) Open( req := BaseServiceRequest{ Domain: "cover", Service: "open_cover", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -71,7 +75,7 @@ func (c Cover) OpenTilt( req := BaseServiceRequest{ Domain: "cover", Service: "open_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -92,7 +96,7 @@ func (c Cover) SetPosition( Domain: "cover", Service: "set_cover_position", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -110,7 +114,7 @@ func (c Cover) SetTiltPosition( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { req := BaseServiceRequest{ - Target: Entity(entityID), + Target: message.Entity(entityID), Domain: "cover", ServiceData: optionalServiceData(serviceData...), Service: "set_cover_tilt_position", @@ -131,7 +135,7 @@ func (c Cover) Stop( req := BaseServiceRequest{ Domain: "cover", Service: "stop_cover", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -149,7 +153,7 @@ func (c Cover) StopTilt( req := BaseServiceRequest{ Domain: "cover", Service: "stop_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -167,7 +171,7 @@ func (c Cover) Toggle( req := BaseServiceRequest{ Domain: "cover", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -185,7 +189,7 @@ func (c Cover) ToggleTilt( req := BaseServiceRequest{ Domain: "cover", Service: "toggle_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index 7fc45f7..44bad3e 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) type HomeAssistant struct { api API @@ -15,7 +19,7 @@ func (ha *HomeAssistant) TurnOn( Domain: "homeassistant", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -35,7 +39,7 @@ func (ha *HomeAssistant) Toggle( Domain: "homeassistant", Service: "toggle", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -52,7 +56,7 @@ func (ha *HomeAssistant) TurnOff( req := BaseServiceRequest{ Domain: "homeassistant", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/input_boolean.go b/internal/services/input_boolean.go index 36e6264..ae4a869 100644 --- a/internal/services/input_boolean.go +++ b/internal/services/input_boolean.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -16,7 +20,7 @@ func (ib InputBoolean) TurnOn( req := BaseServiceRequest{ Domain: "input_boolean", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -33,7 +37,7 @@ func (ib InputBoolean) Toggle( req := BaseServiceRequest{ Domain: "input_boolean", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -50,7 +54,7 @@ func (ib InputBoolean) TurnOff( req := BaseServiceRequest{ Domain: "input_boolean", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/input_button.go b/internal/services/input_button.go index 30b73e2..559fb00 100644 --- a/internal/services/input_button.go +++ b/internal/services/input_button.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -16,7 +20,7 @@ func (ib InputButton) Press( req := BaseServiceRequest{ Domain: "input_button", Service: "press", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -31,7 +35,6 @@ func (ib InputButton) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "input_button", Service: "reload", - Target: Entity(""), } var result any diff --git a/internal/services/input_datetime.go b/internal/services/input_datetime.go index 0d3e230..54356d2 100644 --- a/internal/services/input_datetime.go +++ b/internal/services/input_datetime.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "time" + + "saml.dev/gome-assistant/message" ) /* Structs */ @@ -23,7 +25,7 @@ func (ib InputDatetime) Set( ServiceData: map[string]any{ "timestamp": fmt.Sprint(value.Unix()), }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/input_number.go b/internal/services/input_number.go index 7f56a56..9f6f198 100644 --- a/internal/services/input_number.go +++ b/internal/services/input_number.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -17,7 +21,7 @@ func (ib InputNumber) Set( Domain: "input_number", Service: "set_value", ServiceData: map[string]any{"value": value}, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -34,7 +38,7 @@ func (ib InputNumber) Increment( req := BaseServiceRequest{ Domain: "input_number", Service: "increment", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -51,7 +55,7 @@ func (ib InputNumber) Decrement( req := BaseServiceRequest{ Domain: "input_number", Service: "decrement", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/input_text.go b/internal/services/input_text.go index f6279c1..3baa831 100644 --- a/internal/services/input_text.go +++ b/internal/services/input_text.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -19,7 +23,7 @@ func (ib InputText) Set( ServiceData: map[string]any{ "value": value, }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/light.go b/internal/services/light.go index 2e12881..59e875b 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -19,7 +23,7 @@ func (l Light) TurnOn( Domain: "light", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -39,7 +43,7 @@ func (l Light) Toggle( Domain: "light", Service: "toggle", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -56,7 +60,7 @@ func (l Light) TurnOff( req := BaseServiceRequest{ Domain: "light", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/lock.go b/internal/services/lock.go index 447b51a..f19e526 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -19,7 +23,7 @@ func (l Lock) Lock( Domain: "lock", Service: "lock", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -39,7 +43,7 @@ func (l Lock) Unlock( Domain: "lock", Service: "unlock", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/media_player.go b/internal/services/media_player.go index 9999442..e87fe09 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -18,7 +22,7 @@ func (mp MediaPlayer) ClearPlaylist( req := BaseServiceRequest{ Domain: "media_player", Service: "clear_playlist", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -39,7 +43,7 @@ func (mp MediaPlayer) Join( Domain: "media_player", Service: "join", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -58,7 +62,7 @@ func (mp MediaPlayer) Next( req := BaseServiceRequest{ Domain: "media_player", Service: "media_next_track", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -77,7 +81,7 @@ func (mp MediaPlayer) Pause( req := BaseServiceRequest{ Domain: "media_player", Service: "media_pause", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -96,7 +100,7 @@ func (mp MediaPlayer) Play( req := BaseServiceRequest{ Domain: "media_player", Service: "media_play", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -115,7 +119,7 @@ func (mp MediaPlayer) PlayPause( req := BaseServiceRequest{ Domain: "media_player", Service: "media_play_pause", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -134,7 +138,7 @@ func (mp MediaPlayer) Previous( req := BaseServiceRequest{ Domain: "media_player", Service: "media_previous_track", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -155,7 +159,7 @@ func (mp MediaPlayer) Seek( Domain: "media_player", Service: "media_seek", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -174,7 +178,7 @@ func (mp MediaPlayer) Stop( req := BaseServiceRequest{ Domain: "media_player", Service: "media_stop", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -195,7 +199,7 @@ func (mp MediaPlayer) PlayMedia( Domain: "media_player", Service: "play_media", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -215,7 +219,7 @@ func (mp MediaPlayer) RepeatSet( Domain: "media_player", Service: "repeat_set", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -236,7 +240,7 @@ func (mp MediaPlayer) SelectSoundMode( Domain: "media_player", Service: "select_sound_mode", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -257,7 +261,7 @@ func (mp MediaPlayer) SelectSource( Domain: "media_player", Service: "select_source", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -277,7 +281,7 @@ func (mp MediaPlayer) Shuffle( Domain: "media_player", Service: "shuffle_set", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -296,7 +300,7 @@ func (mp MediaPlayer) Toggle( req := BaseServiceRequest{ Domain: "media_player", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -315,7 +319,7 @@ func (mp MediaPlayer) TurnOff( req := BaseServiceRequest{ Domain: "media_player", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -334,7 +338,7 @@ func (mp MediaPlayer) TurnOn( req := BaseServiceRequest{ Domain: "media_player", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -354,7 +358,7 @@ func (mp MediaPlayer) Unjoin( req := BaseServiceRequest{ Domain: "media_player", Service: "unjoin", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -373,7 +377,7 @@ func (mp MediaPlayer) VolumeDown( req := BaseServiceRequest{ Domain: "media_player", Service: "volume_down", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -393,7 +397,7 @@ func (mp MediaPlayer) VolumeMute( Domain: "media_player", Service: "volume_mute", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -413,7 +417,7 @@ func (mp MediaPlayer) VolumeSet( Domain: "media_player", Service: "volume_set", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -432,7 +436,7 @@ func (mp MediaPlayer) VolumeUp( req := BaseServiceRequest{ Domain: "media_player", Service: "volume_up", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/number.go b/internal/services/number.go index 2d0b7a5..d440c6b 100644 --- a/internal/services/number.go +++ b/internal/services/number.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) type Number struct { api API @@ -13,7 +17,7 @@ func (ib Number) SetValue( Domain: "number", Service: "set_value", ServiceData: map[string]any{"value": value}, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/scene.go b/internal/services/scene.go index f2415e2..c2d14fb 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -19,7 +23,6 @@ func (s Scene) Apply( Domain: "scene", Service: "apply", ServiceData: optionalServiceData(serviceData...), - Target: Entity(""), } var result any @@ -39,7 +42,7 @@ func (s Scene) Create( Domain: "scene", Service: "create", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -55,7 +58,6 @@ func (s Scene) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "scene", Service: "reload", - Target: Entity(""), } var result any @@ -75,7 +77,7 @@ func (s Scene) TurnOn( Domain: "scene", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/script.go b/internal/services/script.go index 849b840..249ebf3 100644 --- a/internal/services/script.go +++ b/internal/services/script.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -17,7 +21,7 @@ func (s Script) Reload( req := BaseServiceRequest{ Domain: "script", Service: "reload", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -35,7 +39,7 @@ func (s Script) Toggle( req := BaseServiceRequest{ Domain: "script", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -51,7 +55,6 @@ func (s Script) TurnOff(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "script", Service: "turn_off", - Target: Entity(""), } var result any @@ -69,7 +72,7 @@ func (s Script) TurnOn( req := BaseServiceRequest{ Domain: "script", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/services.go b/internal/services/services.go index 2d18c61..4efda2f 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -65,20 +65,10 @@ type CallServiceMessage struct { // call. `ServiceData` can contain arbitrary data needed for a // particular call. type BaseServiceRequest struct { - Domain string `json:"domain"` - Service string `json:"service"` - ServiceData any `json:"service_data,omitempty"` - Target Target `json:"target,omitempty"` -} - -type Target struct { - EntityID string `json:"entity_id,omitempty"` -} - -func Entity(entityID string) Target { - return Target{ - EntityID: entityID, - } + Domain string `json:"domain"` + Service string `json:"service"` + ServiceData any `json:"service_data,omitempty"` + Target message.Target `json:"target,omitempty"` } func optionalServiceData(serviceData ...any) any { diff --git a/internal/services/switch.go b/internal/services/switch.go index bbb2d92..b50e99e 100644 --- a/internal/services/switch.go +++ b/internal/services/switch.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -16,7 +20,7 @@ func (s Switch) TurnOn( req := BaseServiceRequest{ Domain: "switch", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -33,7 +37,7 @@ func (s Switch) Toggle( req := BaseServiceRequest{ Domain: "switch", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -50,7 +54,7 @@ func (s Switch) TurnOff( req := BaseServiceRequest{ Domain: "switch", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/timer.go b/internal/services/timer.go index eb005be..5dc5dda 100644 --- a/internal/services/timer.go +++ b/internal/services/timer.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -20,7 +24,7 @@ func (t Timer) Start( ServiceData: map[string]any{ "duration": duration, }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -41,7 +45,7 @@ func (t Timer) Change( ServiceData: map[string]any{ "duration": duration, }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -59,7 +63,7 @@ func (t Timer) Pause( req := BaseServiceRequest{ Domain: "timer", Service: "pause", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -75,7 +79,6 @@ func (t Timer) Cancel(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "cancel", - Target: Entity(""), } var result any if err := t.api.Call(ctx, req, &result); err != nil { @@ -92,7 +95,7 @@ func (t Timer) Finish( req := BaseServiceRequest{ Domain: "timer", Service: "finish", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -108,7 +111,6 @@ func (t Timer) Reload(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "timer", Service: "reload", - Target: Entity(""), } var result any diff --git a/internal/services/tts.go b/internal/services/tts.go index f60ab96..60b7156 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -15,7 +19,6 @@ func (tts TTS) ClearCache(ctx context.Context) (any, error) { req := BaseServiceRequest{ Domain: "tts", Service: "clear_cache", - Target: Entity(""), } var result any @@ -36,7 +39,7 @@ func (tts TTS) CloudSay( Domain: "tts", Service: "cloud_say", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -57,7 +60,7 @@ func (tts TTS) GoogleTranslateSay( Domain: "tts", Service: "google_translate_say", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index b6f9957..07ccf0f 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -18,7 +22,7 @@ func (v Vacuum) CleanSpot( req := BaseServiceRequest{ Domain: "vacuum", Service: "clean_spot", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any if err := v.api.Call(ctx, req, &result); err != nil { @@ -36,7 +40,7 @@ func (v Vacuum) Locate( req := BaseServiceRequest{ Domain: "vacuum", Service: "locate", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -55,7 +59,7 @@ func (v Vacuum) Pause( req := BaseServiceRequest{ Domain: "vacuum", Service: "pause", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -74,7 +78,7 @@ func (v Vacuum) ReturnToBase( req := BaseServiceRequest{ Domain: "vacuum", Service: "return_to_base", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -94,7 +98,7 @@ func (v Vacuum) SendCommand( Domain: "vacuum", Service: "send_command", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -114,7 +118,7 @@ func (v Vacuum) SetFanSpeed( Domain: "vacuum", Service: "set_fan_speed", ServiceData: optionalServiceData(serviceData...), - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -133,7 +137,7 @@ func (v Vacuum) Start( req := BaseServiceRequest{ Domain: "vacuum", Service: "start", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -152,7 +156,7 @@ func (v Vacuum) StartPause( req := BaseServiceRequest{ Domain: "vacuum", Service: "start_pause", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -171,7 +175,7 @@ func (v Vacuum) Stop( req := BaseServiceRequest{ Domain: "vacuum", Service: "stop", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any if err := v.api.Call(ctx, req, &result); err != nil { @@ -189,7 +193,7 @@ func (v Vacuum) TurnOff( req := BaseServiceRequest{ Domain: "vacuum", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any @@ -208,7 +212,7 @@ func (v Vacuum) TurnOn( req := BaseServiceRequest{ Domain: "vacuum", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any if err := v.api.Call(ctx, req, &result); err != nil { diff --git a/internal/services/zwavejs.go b/internal/services/zwavejs.go index baa4503..f5130d9 100644 --- a/internal/services/zwavejs.go +++ b/internal/services/zwavejs.go @@ -1,6 +1,10 @@ package services -import "context" +import ( + "context" + + "saml.dev/gome-assistant/message" +) /* Structs */ @@ -21,7 +25,7 @@ func (zw ZWaveJS) BulkSetPartialConfigParam( "parameter": parameter, "value": value, }, - Target: Entity(entityID), + Target: message.Entity(entityID), } var result any diff --git a/message/target.go b/message/target.go new file mode 100644 index 0000000..440bbed --- /dev/null +++ b/message/target.go @@ -0,0 +1,11 @@ +package message + +type Target struct { + EntityID string `json:"entity_id,omitempty"` +} + +func Entity(entityID string) Target { + return Target{ + EntityID: entityID, + } +} From 29b4e294033c1c3db70bf1298938e25f165f8a72 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 00:29:01 +0100 Subject: [PATCH 34/36] message.CallServiceRequest, CallServiceData: new types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move types related to calling services from the `internal/services` package to the `message` package: * `services.CallServiceMessage` → `message.CallServiceRequest` * `services.BaseServiceRequest` → `message.CallServiceData` --- call.go | 15 ++++---- internal/services/adaptive_lighting.go | 2 +- internal/services/alarm_control_panel.go | 14 ++++---- internal/services/climate.go | 4 +-- internal/services/cover.go | 20 +++++------ internal/services/homeassistant.go | 6 ++-- internal/services/input_boolean.go | 8 ++--- internal/services/input_button.go | 4 +-- internal/services/input_datetime.go | 4 +-- internal/services/input_number.go | 8 ++--- internal/services/input_text.go | 4 +-- internal/services/light.go | 6 ++-- internal/services/lock.go | 4 +-- internal/services/media_player.go | 44 ++++++++++++------------ internal/services/notify.go | 3 +- internal/services/number.go | 2 +- internal/services/scene.go | 8 ++--- internal/services/script.go | 8 ++--- internal/services/services.go | 21 ++--------- internal/services/switch.go | 6 ++-- internal/services/timer.go | 12 +++---- internal/services/tts.go | 6 ++-- internal/services/vacuum.go | 22 ++++++------ internal/services/zwavejs.go | 2 +- message/call_service_request.go | 18 ++++++++++ 25 files changed, 126 insertions(+), 125 deletions(-) create mode 100644 message/call_service_request.go diff --git a/call.go b/call.go index 757b6e0..6224f5e 100644 --- a/call.go +++ b/call.go @@ -4,18 +4,17 @@ import ( "context" "sync" - "saml.dev/gome-assistant/internal/services" "saml.dev/gome-assistant/message" "saml.dev/gome-assistant/websocket" ) // CallAndForget implements [services.API.CallAndForget]. -func (app *App) CallAndForget(req services.BaseServiceRequest) error { - reqMsg := services.CallServiceMessage{ +func (app *App) CallAndForget(req message.CallServiceData) error { + reqMsg := message.CallServiceRequest{ BaseMessage: message.BaseMessage{ Type: "call_service", }, - BaseServiceRequest: req, + CallServiceData: req, } return app.conn.Send( @@ -28,21 +27,21 @@ func (app *App) CallAndForget(req services.BaseServiceRequest) error { // Call implements [services.API.Call]. func (app *App) Call( - ctx context.Context, req services.BaseServiceRequest, result any, + ctx context.Context, req message.CallServiceData, result any, ) error { // Call works as follows: // 1. Generate a message ID. // 2. Subscribe to that ID. - // 3. Send a `CallServiceMessage` containing `req` over the websocket. + // 3. Send a `CallServiceRequest` containing `req` over the websocket. // 4. Wait for a single "result" message. // 5. Unsubscribe from ID. // 6. Unmarshal the "result" part of the response into `result`. - reqMsg := services.CallServiceMessage{ + reqMsg := message.CallServiceRequest{ BaseMessage: message.BaseMessage{ Type: "call_service", }, - BaseServiceRequest: req, + CallServiceData: req, } // once ensures that exactly one of the following occurs: diff --git a/internal/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index c31890b..e90faf1 100644 --- a/internal/services/adaptive_lighting.go +++ b/internal/services/adaptive_lighting.go @@ -18,7 +18,7 @@ type AdaptiveLighting struct { func (al AdaptiveLighting) SetManualControl( ctx context.Context, entityID string, enabled bool, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "adaptive_lighting", Service: "set_manual_control", ServiceData: map[string]any{ diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index 1fcf770..2165d14 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -19,7 +19,7 @@ type AlarmControlPanel struct { func (acp AlarmControlPanel) ArmAway( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_arm_away", ServiceData: optionalServiceData(serviceData...), @@ -39,7 +39,7 @@ func (acp AlarmControlPanel) ArmAway( func (acp AlarmControlPanel) ArmWithCustomBypass( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_arm_custom_bypass", ServiceData: optionalServiceData(serviceData...), @@ -59,7 +59,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass( func (acp AlarmControlPanel) ArmHome( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_arm_home", ServiceData: optionalServiceData(serviceData...), @@ -79,7 +79,7 @@ func (acp AlarmControlPanel) ArmHome( func (acp AlarmControlPanel) ArmNight( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_arm_night", ServiceData: optionalServiceData(serviceData...), @@ -100,7 +100,7 @@ func (acp AlarmControlPanel) ArmNight( func (acp AlarmControlPanel) ArmVacation( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_arm_vacation", ServiceData: optionalServiceData(serviceData...), @@ -120,7 +120,7 @@ func (acp AlarmControlPanel) ArmVacation( func (acp AlarmControlPanel) Disarm( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_disarm", ServiceData: optionalServiceData(serviceData...), @@ -140,7 +140,7 @@ func (acp AlarmControlPanel) Disarm( func (acp AlarmControlPanel) Trigger( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "alarm_control_panel", Service: "alarm_trigger", ServiceData: optionalServiceData(serviceData...), diff --git a/internal/services/climate.go b/internal/services/climate.go index fedf68f..03b4b63 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -18,7 +18,7 @@ type Climate struct { func (c Climate) SetFanMode( ctx context.Context, entityID string, fanMode string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "climate", Service: "set_fan_mode", ServiceData: map[string]any{"fan_mode": fanMode}, @@ -36,7 +36,7 @@ func (c Climate) SetFanMode( func (c Climate) SetTemperature( ctx context.Context, entityID string, serviceData types.SetTemperatureRequest, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "climate", Service: "set_temperature", ServiceData: serviceData.ToJSON(), diff --git a/internal/services/cover.go b/internal/services/cover.go index d7fd0d8..3097f4c 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -18,7 +18,7 @@ type Cover struct { func (c Cover) Close( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "close_cover", Target: message.Entity(entityID), @@ -36,7 +36,7 @@ func (c Cover) Close( func (c Cover) CloseTilt( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "close_cover_tilt", Target: message.Entity(entityID), @@ -54,7 +54,7 @@ func (c Cover) CloseTilt( func (c Cover) Open( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "open_cover", Target: message.Entity(entityID), @@ -72,7 +72,7 @@ func (c Cover) Open( func (c Cover) OpenTilt( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "open_cover_tilt", Target: message.Entity(entityID), @@ -92,7 +92,7 @@ func (c Cover) OpenTilt( func (c Cover) SetPosition( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "set_cover_position", ServiceData: optionalServiceData(serviceData...), @@ -113,7 +113,7 @@ func (c Cover) SetPosition( func (c Cover) SetTiltPosition( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Target: message.Entity(entityID), Domain: "cover", ServiceData: optionalServiceData(serviceData...), @@ -132,7 +132,7 @@ func (c Cover) SetTiltPosition( func (c Cover) Stop( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "stop_cover", Target: message.Entity(entityID), @@ -150,7 +150,7 @@ func (c Cover) Stop( func (c Cover) StopTilt( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "stop_cover_tilt", Target: message.Entity(entityID), @@ -168,7 +168,7 @@ func (c Cover) StopTilt( func (c Cover) Toggle( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "toggle", Target: message.Entity(entityID), @@ -186,7 +186,7 @@ func (c Cover) Toggle( func (c Cover) ToggleTilt( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "cover", Service: "toggle_cover_tilt", Target: message.Entity(entityID), diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index 44bad3e..59c8534 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -15,7 +15,7 @@ type HomeAssistant struct { func (ha *HomeAssistant) TurnOn( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "homeassistant", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), @@ -35,7 +35,7 @@ func (ha *HomeAssistant) TurnOn( func (ha *HomeAssistant) Toggle( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "homeassistant", Service: "toggle", ServiceData: optionalServiceData(serviceData...), @@ -53,7 +53,7 @@ func (ha *HomeAssistant) Toggle( func (ha *HomeAssistant) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "homeassistant", Service: "turn_off", Target: message.Entity(entityID), diff --git a/internal/services/input_boolean.go b/internal/services/input_boolean.go index ae4a869..9733390 100644 --- a/internal/services/input_boolean.go +++ b/internal/services/input_boolean.go @@ -17,7 +17,7 @@ type InputBoolean struct { func (ib InputBoolean) TurnOn( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_boolean", Service: "turn_on", Target: message.Entity(entityID), @@ -34,7 +34,7 @@ func (ib InputBoolean) TurnOn( func (ib InputBoolean) Toggle( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_boolean", Service: "toggle", Target: message.Entity(entityID), @@ -51,7 +51,7 @@ func (ib InputBoolean) Toggle( func (ib InputBoolean) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_boolean", Service: "turn_off", Target: message.Entity(entityID), @@ -66,7 +66,7 @@ func (ib InputBoolean) TurnOff( } func (ib InputBoolean) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_boolean", Service: "reload", } diff --git a/internal/services/input_button.go b/internal/services/input_button.go index 559fb00..2d0be25 100644 --- a/internal/services/input_button.go +++ b/internal/services/input_button.go @@ -17,7 +17,7 @@ type InputButton struct { func (ib InputButton) Press( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_button", Service: "press", Target: message.Entity(entityID), @@ -32,7 +32,7 @@ func (ib InputButton) Press( } func (ib InputButton) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_button", Service: "reload", } diff --git a/internal/services/input_datetime.go b/internal/services/input_datetime.go index 54356d2..6cd821b 100644 --- a/internal/services/input_datetime.go +++ b/internal/services/input_datetime.go @@ -19,7 +19,7 @@ type InputDatetime struct { func (ib InputDatetime) Set( ctx context.Context, entityID string, value time.Time, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_datetime", Service: "set_datetime", ServiceData: map[string]any{ @@ -37,7 +37,7 @@ func (ib InputDatetime) Set( } func (ib InputDatetime) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_datetime", Service: "reload", } diff --git a/internal/services/input_number.go b/internal/services/input_number.go index 9f6f198..9ee2992 100644 --- a/internal/services/input_number.go +++ b/internal/services/input_number.go @@ -17,7 +17,7 @@ type InputNumber struct { func (ib InputNumber) Set( ctx context.Context, entityID string, value float32, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_number", Service: "set_value", ServiceData: map[string]any{"value": value}, @@ -35,7 +35,7 @@ func (ib InputNumber) Set( func (ib InputNumber) Increment( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_number", Service: "increment", Target: message.Entity(entityID), @@ -52,7 +52,7 @@ func (ib InputNumber) Increment( func (ib InputNumber) Decrement( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_number", Service: "decrement", Target: message.Entity(entityID), @@ -67,7 +67,7 @@ func (ib InputNumber) Decrement( } func (ib InputNumber) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_number", Service: "reload", } diff --git a/internal/services/input_text.go b/internal/services/input_text.go index 3baa831..f552083 100644 --- a/internal/services/input_text.go +++ b/internal/services/input_text.go @@ -17,7 +17,7 @@ type InputText struct { func (ib InputText) Set( ctx context.Context, entityID string, value string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_text", Service: "set_value", ServiceData: map[string]any{ @@ -35,7 +35,7 @@ func (ib InputText) Set( } func (ib InputText) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "input_text", Service: "reload", } diff --git a/internal/services/light.go b/internal/services/light.go index 59e875b..0fbf228 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -19,7 +19,7 @@ type Light struct { func (l Light) TurnOn( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "light", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), @@ -39,7 +39,7 @@ func (l Light) TurnOn( func (l Light) Toggle( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "light", Service: "toggle", ServiceData: optionalServiceData(serviceData...), @@ -57,7 +57,7 @@ func (l Light) Toggle( func (l Light) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "light", Service: "turn_off", Target: message.Entity(entityID), diff --git a/internal/services/lock.go b/internal/services/lock.go index f19e526..f9dda5e 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -19,7 +19,7 @@ type Lock struct { func (l Lock) Lock( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "lock", Service: "lock", ServiceData: optionalServiceData(serviceData...), @@ -39,7 +39,7 @@ func (l Lock) Lock( func (l Lock) Unlock( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "lock", Service: "unlock", ServiceData: optionalServiceData(serviceData...), diff --git a/internal/services/media_player.go b/internal/services/media_player.go index e87fe09..e834b8c 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -19,7 +19,7 @@ type MediaPlayer struct { func (mp MediaPlayer) ClearPlaylist( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "clear_playlist", Target: message.Entity(entityID), @@ -39,7 +39,7 @@ func (mp MediaPlayer) ClearPlaylist( func (mp MediaPlayer) Join( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "join", ServiceData: optionalServiceData(serviceData...), @@ -59,7 +59,7 @@ func (mp MediaPlayer) Join( func (mp MediaPlayer) Next( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_next_track", Target: message.Entity(entityID), @@ -78,7 +78,7 @@ func (mp MediaPlayer) Next( func (mp MediaPlayer) Pause( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_pause", Target: message.Entity(entityID), @@ -97,7 +97,7 @@ func (mp MediaPlayer) Pause( func (mp MediaPlayer) Play( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_play", Target: message.Entity(entityID), @@ -116,7 +116,7 @@ func (mp MediaPlayer) Play( func (mp MediaPlayer) PlayPause( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_play_pause", Target: message.Entity(entityID), @@ -135,7 +135,7 @@ func (mp MediaPlayer) PlayPause( func (mp MediaPlayer) Previous( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_previous_track", Target: message.Entity(entityID), @@ -155,7 +155,7 @@ func (mp MediaPlayer) Previous( func (mp MediaPlayer) Seek( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_seek", ServiceData: optionalServiceData(serviceData...), @@ -175,7 +175,7 @@ func (mp MediaPlayer) Seek( func (mp MediaPlayer) Stop( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "media_stop", Target: message.Entity(entityID), @@ -195,7 +195,7 @@ func (mp MediaPlayer) Stop( func (mp MediaPlayer) PlayMedia( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "play_media", ServiceData: optionalServiceData(serviceData...), @@ -215,7 +215,7 @@ func (mp MediaPlayer) PlayMedia( func (mp MediaPlayer) RepeatSet( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "repeat_set", ServiceData: optionalServiceData(serviceData...), @@ -236,7 +236,7 @@ func (mp MediaPlayer) RepeatSet( func (mp MediaPlayer) SelectSoundMode( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "select_sound_mode", ServiceData: optionalServiceData(serviceData...), @@ -257,7 +257,7 @@ func (mp MediaPlayer) SelectSoundMode( func (mp MediaPlayer) SelectSource( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "select_source", ServiceData: optionalServiceData(serviceData...), @@ -277,7 +277,7 @@ func (mp MediaPlayer) SelectSource( func (mp MediaPlayer) Shuffle( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "shuffle_set", ServiceData: optionalServiceData(serviceData...), @@ -297,7 +297,7 @@ func (mp MediaPlayer) Shuffle( func (mp MediaPlayer) Toggle( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "toggle", Target: message.Entity(entityID), @@ -316,7 +316,7 @@ func (mp MediaPlayer) Toggle( func (mp MediaPlayer) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "turn_off", Target: message.Entity(entityID), @@ -335,7 +335,7 @@ func (mp MediaPlayer) TurnOff( func (mp MediaPlayer) TurnOn( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "turn_on", Target: message.Entity(entityID), @@ -355,7 +355,7 @@ func (mp MediaPlayer) TurnOn( func (mp MediaPlayer) Unjoin( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "unjoin", Target: message.Entity(entityID), @@ -374,7 +374,7 @@ func (mp MediaPlayer) Unjoin( func (mp MediaPlayer) VolumeDown( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "volume_down", Target: message.Entity(entityID), @@ -393,7 +393,7 @@ func (mp MediaPlayer) VolumeDown( func (mp MediaPlayer) VolumeMute( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "volume_mute", ServiceData: optionalServiceData(serviceData...), @@ -413,7 +413,7 @@ func (mp MediaPlayer) VolumeMute( func (mp MediaPlayer) VolumeSet( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "volume_set", ServiceData: optionalServiceData(serviceData...), @@ -433,7 +433,7 @@ func (mp MediaPlayer) VolumeSet( func (mp MediaPlayer) VolumeUp( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "media_player", Service: "volume_up", Target: message.Entity(entityID), diff --git a/internal/services/notify.go b/internal/services/notify.go index 5e12d54..5e97700 100644 --- a/internal/services/notify.go +++ b/internal/services/notify.go @@ -3,6 +3,7 @@ package services import ( "context" + "saml.dev/gome-assistant/message" "saml.dev/gome-assistant/types" ) @@ -14,7 +15,7 @@ type Notify struct { func (ha *Notify) Notify( ctx context.Context, reqData types.NotifyRequest, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "notify", Service: reqData.ServiceName, } diff --git a/internal/services/number.go b/internal/services/number.go index d440c6b..3f31dbd 100644 --- a/internal/services/number.go +++ b/internal/services/number.go @@ -13,7 +13,7 @@ type Number struct { func (ib Number) SetValue( ctx context.Context, entityID string, value float32, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "number", Service: "set_value", ServiceData: map[string]any{"value": value}, diff --git a/internal/services/scene.go b/internal/services/scene.go index c2d14fb..2aba47a 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -19,7 +19,7 @@ type Scene struct { func (s Scene) Apply( ctx context.Context, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "scene", Service: "apply", ServiceData: optionalServiceData(serviceData...), @@ -38,7 +38,7 @@ func (s Scene) Apply( func (s Scene) Create( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "scene", Service: "create", ServiceData: optionalServiceData(serviceData...), @@ -55,7 +55,7 @@ func (s Scene) Create( // Reload the scenes. func (s Scene) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "scene", Service: "reload", } @@ -73,7 +73,7 @@ func (s Scene) Reload(ctx context.Context) (any, error) { func (s Scene) TurnOn( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "scene", Service: "turn_on", ServiceData: optionalServiceData(serviceData...), diff --git a/internal/services/script.go b/internal/services/script.go index 249ebf3..be7dfa7 100644 --- a/internal/services/script.go +++ b/internal/services/script.go @@ -18,7 +18,7 @@ type Script struct { func (s Script) Reload( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "script", Service: "reload", Target: message.Entity(entityID), @@ -36,7 +36,7 @@ func (s Script) Reload( func (s Script) Toggle( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "script", Service: "toggle", Target: message.Entity(entityID), @@ -52,7 +52,7 @@ func (s Script) Toggle( // TurnOff a script that was created in the HA UI. func (s Script) TurnOff(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "script", Service: "turn_off", } @@ -69,7 +69,7 @@ func (s Script) TurnOff(ctx context.Context) (any, error) { func (s Script) TurnOn( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "script", Service: "turn_on", Target: message.Entity(entityID), diff --git a/internal/services/services.go b/internal/services/services.go index 4efda2f..1d00d59 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -12,7 +12,7 @@ import ( type API interface { // CallAndForget makes a call to the Home Assistant API but // doesn't subscribe to or wait for a response. - CallAndForget(req BaseServiceRequest) error + CallAndForget(req message.CallServiceData) error // Call makes a call to the Home Assistant API and waits for a // response. The result is unmarshaled into invokes `result`. @@ -21,7 +21,7 @@ type API interface { // indicates a failure (success==false), then return that as a // `*websocket.ResultError`. If another error occurs (e.g., // sending the request or if `ctx` expires), return that error. - Call(ctx context.Context, req BaseServiceRequest, result any) error + Call(ctx context.Context, req message.CallServiceData, result any) error FireEvent(eventType string, eventData any) error } @@ -54,23 +54,6 @@ func BuildService[ return &T{api: api} } -// CallServiceMessage represents a message that can be sent to request -// an API call. Its `Type` field must be set to "call_service". -type CallServiceMessage struct { - message.BaseMessage - BaseServiceRequest -} - -// BaseServiceRequest contains the fields needed to make an HA API -// call. `ServiceData` can contain arbitrary data needed for a -// particular call. -type BaseServiceRequest struct { - Domain string `json:"domain"` - Service string `json:"service"` - ServiceData any `json:"service_data,omitempty"` - Target message.Target `json:"target,omitempty"` -} - func optionalServiceData(serviceData ...any) any { switch len(serviceData) { case 0: diff --git a/internal/services/switch.go b/internal/services/switch.go index b50e99e..1a8fb2a 100644 --- a/internal/services/switch.go +++ b/internal/services/switch.go @@ -17,7 +17,7 @@ type Switch struct { func (s Switch) TurnOn( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "switch", Service: "turn_on", Target: message.Entity(entityID), @@ -34,7 +34,7 @@ func (s Switch) TurnOn( func (s Switch) Toggle( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "switch", Service: "toggle", Target: message.Entity(entityID), @@ -51,7 +51,7 @@ func (s Switch) Toggle( func (s Switch) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "switch", Service: "turn_off", Target: message.Entity(entityID), diff --git a/internal/services/timer.go b/internal/services/timer.go index 5dc5dda..03c286b 100644 --- a/internal/services/timer.go +++ b/internal/services/timer.go @@ -18,7 +18,7 @@ type Timer struct { func (t Timer) Start( ctx context.Context, entityID string, duration string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "start", ServiceData: map[string]any{ @@ -39,7 +39,7 @@ func (t Timer) Start( func (t Timer) Change( ctx context.Context, entityID string, duration string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "change", ServiceData: map[string]any{ @@ -60,7 +60,7 @@ func (t Timer) Change( func (t Timer) Pause( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "pause", Target: message.Entity(entityID), @@ -76,7 +76,7 @@ func (t Timer) Pause( // See https://www.home-assistant.io/integrations/timer/#action-timercancel func (t Timer) Cancel(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "cancel", } @@ -92,7 +92,7 @@ func (t Timer) Cancel(ctx context.Context) (any, error) { func (t Timer) Finish( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "finish", Target: message.Entity(entityID), @@ -108,7 +108,7 @@ func (t Timer) Finish( // See https://www.home-assistant.io/integrations/timer/#action-timerreload func (t Timer) Reload(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "timer", Service: "reload", } diff --git a/internal/services/tts.go b/internal/services/tts.go index 60b7156..1ba97a6 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -16,7 +16,7 @@ type TTS struct { // Remove all text-to-speech cache files and RAM cache. func (tts TTS) ClearCache(ctx context.Context) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "tts", Service: "clear_cache", } @@ -35,7 +35,7 @@ func (tts TTS) ClearCache(ctx context.Context) (any, error) { func (tts TTS) CloudSay( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "tts", Service: "cloud_say", ServiceData: optionalServiceData(serviceData...), @@ -56,7 +56,7 @@ func (tts TTS) CloudSay( func (tts TTS) GoogleTranslateSay( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "tts", Service: "google_translate_say", ServiceData: optionalServiceData(serviceData...), diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index 07ccf0f..6f158f1 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -19,7 +19,7 @@ type Vacuum struct { func (v Vacuum) CleanSpot( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "clean_spot", Target: message.Entity(entityID), @@ -37,7 +37,7 @@ func (v Vacuum) CleanSpot( func (v Vacuum) Locate( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "locate", Target: message.Entity(entityID), @@ -56,7 +56,7 @@ func (v Vacuum) Locate( func (v Vacuum) Pause( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "pause", Target: message.Entity(entityID), @@ -75,7 +75,7 @@ func (v Vacuum) Pause( func (v Vacuum) ReturnToBase( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "return_to_base", Target: message.Entity(entityID), @@ -94,7 +94,7 @@ func (v Vacuum) ReturnToBase( func (v Vacuum) SendCommand( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "send_command", ServiceData: optionalServiceData(serviceData...), @@ -114,7 +114,7 @@ func (v Vacuum) SendCommand( func (v Vacuum) SetFanSpeed( ctx context.Context, entityID string, serviceData ...any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "set_fan_speed", ServiceData: optionalServiceData(serviceData...), @@ -134,7 +134,7 @@ func (v Vacuum) SetFanSpeed( func (v Vacuum) Start( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "start", Target: message.Entity(entityID), @@ -153,7 +153,7 @@ func (v Vacuum) Start( func (v Vacuum) StartPause( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "start_pause", Target: message.Entity(entityID), @@ -172,7 +172,7 @@ func (v Vacuum) StartPause( func (v Vacuum) Stop( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "stop", Target: message.Entity(entityID), @@ -190,7 +190,7 @@ func (v Vacuum) Stop( func (v Vacuum) TurnOff( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "turn_off", Target: message.Entity(entityID), @@ -209,7 +209,7 @@ func (v Vacuum) TurnOff( func (v Vacuum) TurnOn( ctx context.Context, entityID string, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "vacuum", Service: "turn_on", Target: message.Entity(entityID), diff --git a/internal/services/zwavejs.go b/internal/services/zwavejs.go index f5130d9..1b7a4bf 100644 --- a/internal/services/zwavejs.go +++ b/internal/services/zwavejs.go @@ -18,7 +18,7 @@ type ZWaveJS struct { func (zw ZWaveJS) BulkSetPartialConfigParam( ctx context.Context, entityID string, parameter int, value any, ) (any, error) { - req := BaseServiceRequest{ + req := message.CallServiceData{ Domain: "zwave_js", Service: "bulk_set_partial_config_parameters", ServiceData: map[string]any{ diff --git a/message/call_service_request.go b/message/call_service_request.go new file mode 100644 index 0000000..a0920ba --- /dev/null +++ b/message/call_service_request.go @@ -0,0 +1,18 @@ +package message + +// CallServiceRequest represents a message that can be sent to request +// an API call. Its `Type` field must be set to "call_service". +type CallServiceRequest struct { + BaseMessage + CallServiceData +} + +// CallServiceData contains the fields needed to make an HA API call. +// `Target` is not always required. The `ServiceData` field can +// contain arbitrary data needed for a particular call. +type CallServiceData struct { + Domain string `json:"domain"` + Service string `json:"service"` + Target Target `json:"target,omitempty"` + ServiceData any `json:"service_data,omitempty"` +} From 5b3be846480f24300194139fe8d620df7efcfb05 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 00:58:16 +0100 Subject: [PATCH 35/36] message.NotifyData: new type Replace `type.NotifyRequest` with a new `message.NotifyData` type. This type doesn't include the service name; instead, it should be passed separately to the `Notify.Notify()` method. --- internal/services/notify.go | 17 +++++------------ message/notify_request.go | 7 +++++++ types/requestTypes.go | 8 -------- 3 files changed, 12 insertions(+), 20 deletions(-) create mode 100644 message/notify_request.go diff --git a/internal/services/notify.go b/internal/services/notify.go index 5e97700..a84f798 100644 --- a/internal/services/notify.go +++ b/internal/services/notify.go @@ -4,28 +4,21 @@ import ( "context" "saml.dev/gome-assistant/message" - "saml.dev/gome-assistant/types" ) type Notify struct { api API } -// Notify sends a notification. Takes a types.NotifyRequest. +// Notify sends a notification. func (ha *Notify) Notify( - ctx context.Context, reqData types.NotifyRequest, + ctx context.Context, serviceName string, reqData message.NotifyData, ) (any, error) { req := message.CallServiceData{ - Domain: "notify", - Service: reqData.ServiceName, + Domain: "notify", + Service: serviceName, + ServiceData: reqData, } - serviceData := map[string]any{} - serviceData["message"] = reqData.Message - serviceData["title"] = reqData.Title - if reqData.Data != nil { - serviceData["data"] = reqData.Data - } - req.ServiceData = serviceData var result any if err := ha.api.Call(ctx, req, &result); err != nil { diff --git a/message/notify_request.go b/message/notify_request.go new file mode 100644 index 0000000..1b28328 --- /dev/null +++ b/message/notify_request.go @@ -0,0 +1,7 @@ +package message + +type NotifyData struct { + Message string `json:"message"` + Title string `json:"title,omitzero"` + Data any `json:"data,omitzero"` +} diff --git a/types/requestTypes.go b/types/requestTypes.go index a2f736f..5278572 100644 --- a/types/requestTypes.go +++ b/types/requestTypes.go @@ -1,13 +1,5 @@ package types -type NotifyRequest struct { - // Which notify service to call, such as mobile_app_sams_iphone - ServiceName string - Message string - Title string - Data map[string]any -} - type SetTemperatureRequest struct { Temperature float32 TargetTempHigh float32 From 94db109217b691e6e5b45e8b94ec596d0f9f0bb7 Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Mon, 19 Jan 2026 01:09:55 +0100 Subject: [PATCH 36/36] message.SetTemperatureData: new type Replace `type.SetTemperatureRequest` with a new type, `message.SetTemperatureData`. Given the JSON annotations, there is no need for this type to have an explicit `ToJSON()` method. --- internal/services/climate.go | 5 ++--- message/set_temperature_data.go | 11 +++++++++++ types/requestTypes.go | 25 ------------------------- 3 files changed, 13 insertions(+), 28 deletions(-) create mode 100644 message/set_temperature_data.go delete mode 100644 types/requestTypes.go diff --git a/internal/services/climate.go b/internal/services/climate.go index 03b4b63..beccadd 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -4,7 +4,6 @@ import ( "context" "saml.dev/gome-assistant/message" - "saml.dev/gome-assistant/types" ) /* Structs */ @@ -34,12 +33,12 @@ func (c Climate) SetFanMode( } func (c Climate) SetTemperature( - ctx context.Context, entityID string, serviceData types.SetTemperatureRequest, + ctx context.Context, entityID string, serviceData message.SetTemperatureData, ) (any, error) { req := message.CallServiceData{ Domain: "climate", Service: "set_temperature", - ServiceData: serviceData.ToJSON(), + ServiceData: serviceData, Target: message.Entity(entityID), } diff --git a/message/set_temperature_data.go b/message/set_temperature_data.go new file mode 100644 index 0000000..a246a8a --- /dev/null +++ b/message/set_temperature_data.go @@ -0,0 +1,11 @@ +package message + +type SetTemperatureData struct { + // FIXME: 0° is not an impossible temperature, so treating zero as + // "missing" here is a little bit dubious. + + Temperature float32 `json:"temperature,omitempty"` + TargetTempHigh float32 `json:"target_temp_high,omitempty"` + TargetTempLow float32 `json:"target_temp_low,omitempty"` + HvacMode string `json:"hvac_mode,omitempty"` +} diff --git a/types/requestTypes.go b/types/requestTypes.go deleted file mode 100644 index 5278572..0000000 --- a/types/requestTypes.go +++ /dev/null @@ -1,25 +0,0 @@ -package types - -type SetTemperatureRequest struct { - Temperature float32 - TargetTempHigh float32 - TargetTempLow float32 - HvacMode string -} - -func (r *SetTemperatureRequest) ToJSON() map[string]any { - m := map[string]any{} - if r.Temperature != 0 { - m["temperature"] = r.Temperature - } - if r.TargetTempHigh != 0 { - m["target_temp_high"] = r.TargetTempHigh - } - if r.TargetTempLow != 0 { - m["target_temp_low"] = r.TargetTempLow - } - if r.HvacMode != "" { - m["hvac_mode"] = r.HvacMode - } - return m -}