From e59f916d23030cdd416e424ae10d6afdeabff2cc Mon Sep 17 00:00:00 2001 From: Michael Haggerty Date: Sat, 13 Dec 2025 13:02:52 +0100 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 }