diff --git a/app.go b/app.go index 4464cb1..678fb86 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") @@ -261,7 +261,14 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.ChanMsg) { + 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) }, ) @@ -328,7 +335,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.ChanMsg) { + func(msg websocket.Message) { go app.callEntityListeners(msg.Raw) }, ) diff --git a/call.go b/call.go index 7e02817..e1ab30a 100644 --- a/call.go +++ b/call.go @@ -1,17 +1,108 @@ package gomeassistant import ( + "context" + "sync" + "saml.dev/gome-assistant/internal/services" - "saml.dev/gome-assistant/internal/websocket" + "saml.dev/gome-assistant/websocket" ) -func (app *App) Call(req services.BaseServiceRequest) error { - req.RequestType = "call_service" +// CallAndForget implements [services.API.CallAndForget]. +func (app *App) CallAndForget(req services.BaseServiceRequest) error { + 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) + }, + ) +} + +// 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/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/entitylistener.go b/entitylistener.go index 17fc43e..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 { @@ -45,9 +46,8 @@ type EntityData struct { LastChanged time.Time } -type stateChangedMsg struct { - ID int `json:"id"` - Type string `json:"type"` +type stateChangedMessage struct { + websocket.BaseMessage Event struct { Data stateData `json:"data"` EventType string `json:"event_type"` @@ -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..53ef747 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 { @@ -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.Message) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type 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/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index f3bc704..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.Call(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 eee8928..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 { @@ -8,114 +10,143 @@ 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_away", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_arm_away", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_custom_bypass", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_arm_custom_bypass", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_home", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_arm_home", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_night", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_arm_night", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_vacation", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_arm_vacation", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_disarm", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_disarm", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_trigger", - Target: Entity(entityID), + Domain: "alarm_control_panel", + Service: "alarm_trigger", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := acp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return acp.api.Call(req) + return result, nil } diff --git a/internal/services/climate.go b/internal/services/climate.go index b8719d3..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.Call(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.Call(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 b738ead..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,111 +11,187 @@ 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.Call(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.Call(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.Call(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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "cover", - Service: "set_cover_position", - Target: Entity(entityID), + Domain: "cover", + Service: "set_cover_position", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Target: Entity(entityID), - Domain: "cover", - Service: "set_cover_tilt_position", + Target: Entity(entityID), + Domain: "cover", + ServiceData: optionalServiceData(serviceData...), + Service: "set_cover_tilt_position", } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + 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.Call(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.Call(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.Call(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.Call(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 53dcd2a..7fc45f7 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -1,44 +1,64 @@ package services +import "context" + type HomeAssistant struct { api API } // 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "turn_on", - Target: Entity(entityID), + Domain: "homeassistant", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ha.api.Call(req) + return result, nil } // 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "toggle", - Target: Entity(entityID), + Domain: "homeassistant", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ha.api.Call(req) + 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.Call(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 90c7397..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.Call(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.Call(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.Call(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.Call(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 4a0a426..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.Call(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.Call(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 461acf9..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.Call(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.Call(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 eacd76f..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.Call(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.Call(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.Call(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.Call(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 e575156..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.Call(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.Call(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 a655f58..2e12881 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Light struct { @@ -9,38 +11,57 @@ 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "light", - Service: "turn_on", - Target: Entity(entityID), + Domain: "light", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err } - return l.api.Call(req) + + return result, nil } // 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "light", - Service: "toggle", - Target: Entity(entityID), + Domain: "light", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err } - return l.api.Call(req) + + 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.Call(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 fb01877..447b51a 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Lock struct { @@ -8,30 +10,42 @@ 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "lock", - Service: "lock", - Target: Entity(entityID), + Domain: "lock", + Service: "lock", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err } - return l.api.Call(req) + + return result, nil } // 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "lock", - Service: "unlock", - Target: Entity(entityID), + Domain: "lock", + Service: "unlock", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := l.api.Call(ctx, req, &result); err != nil { + return nil, err } - return l.api.Call(req) + + return result, nil } diff --git a/internal/services/media_player.go b/internal/services/media_player.go index d3ad8ee..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,278 +12,433 @@ 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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "join", - Target: Entity(entityID), + Domain: "media_player", + Service: "join", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + 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.Call(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.Call(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.Call(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.Call(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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "media_seek", - Target: Entity(entityID), + Domain: "media_player", + Service: "media_seek", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + 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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "play_media", - Target: Entity(entityID), + Domain: "media_player", + Service: "play_media", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "repeat_set", - Target: Entity(entityID), + Domain: "media_player", + Service: "repeat_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_sound_mode", - Target: Entity(entityID), + Domain: "media_player", + Service: "select_sound_mode", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_source", - Target: Entity(entityID), + Domain: "media_player", + Service: "select_source", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "shuffle_set", - Target: Entity(entityID), + Domain: "media_player", + Service: "shuffle_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + 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.Call(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.Call(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.Call(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.Call(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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_mute", - Target: Entity(entityID), + Domain: "media_player", + Service: "volume_mute", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_set", - Target: Entity(entityID), + Domain: "media_player", + Service: "volume_set", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + 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.Call(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 66e29c9..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.Call(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 179dc6e..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.Call(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 39c10f3..f2415e2 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -1,5 +1,7 @@ package services +import "context" + /* Structs */ type Scene struct { @@ -8,53 +10,78 @@ 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( + ctx context.Context, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "scene", - Service: "apply", - Target: Entity(""), + Domain: "scene", + Service: "apply", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(""), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + return result, nil } // 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "scene", - Service: "create", - Target: Entity(entityID), + Domain: "scene", + Service: "create", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + 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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "scene", - Service: "turn_on", - Target: Entity(entityID), + Domain: "scene", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + return result, nil } diff --git a/internal/services/script.go b/internal/services/script.go index 556c46c..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.Call(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.Call(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.Call(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.Call(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/services.go b/internal/services/services.go index 75a715f..e26180f 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -1,9 +1,28 @@ package services +import ( + "context" + "log/slog" + + "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 + + // 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 } @@ -35,13 +54,21 @@ 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"` - RequestType 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"` - 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 { @@ -53,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/switch.go b/internal/services/switch.go index e38a60a..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.Call(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.Call(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.Call(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 6432175..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.Call(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.Call(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.Call(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.Call(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.Call(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.Call(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 838dd0d..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,42 +11,59 @@ 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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "tts", - Service: "cloud_say", - Target: Entity(entityID), + Domain: "tts", + Service: "cloud_say", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := tts.api.Call(ctx, req, &result); err != nil { + return nil, err } - return tts.api.Call(req) + + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "tts", - Service: "google_translate_say", - Target: Entity(entityID), + Domain: "tts", + Service: "google_translate_say", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := tts.api.Call(ctx, req, &result); err != nil { + return nil, err } - return tts.api.Call(req) + return result, nil } diff --git a/internal/services/vacuum.go b/internal/services/vacuum.go index e181f4a..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,129 +12,208 @@ 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.Call(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.Call(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.Call(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.Call(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 -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "vacuum", - Service: "send_command", - Target: Entity(entityID), + Domain: "vacuum", + Service: "send_command", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err } - return v.api.Call(req) + return result, nil } -// 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( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { req := BaseServiceRequest{ - Domain: "vacuum", - Service: "set_fan_speed", - Target: Entity(entityID), + Domain: "vacuum", + Service: "set_fan_speed", + ServiceData: optionalServiceData(serviceData...), + Target: Entity(entityID), } - if len(serviceData) != 0 { - req.ServiceData = serviceData[0] + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err } - return v.api.Call(req) + 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.Call(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.Call(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.Call(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.Call(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.Call(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 9d90ff1..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.Call(req) + + var result any + if err := zw.api.Call(ctx, req, &result); err != nil { + return nil, err + } + + return result, nil } diff --git a/internal/websocket/reader.go b/internal/websocket/reader.go deleted file mode 100644 index 942ca2e..0000000 --- a/internal/websocket/reader.go +++ /dev/null @@ -1,67 +0,0 @@ -package websocket - -import ( - "encoding/json" - "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 -// 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. -func (conn *Conn) Run() error { - for { - bytes, err := conn.readMessage() - if err != nil { - return err - } - - base := BaseMessage{ - // default to true for messages that don't include "success" at all - Success: true, - } - _ = json.Unmarshal(bytes, &base) - if !base.Success { - slog.Warn("Received unsuccessful response", "response", string(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" { - continue - } - - chanMsg := ChanMsg{ - Type: base.Type, - ID: base.ID, - Success: base.Success, - 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) - } - } -} diff --git a/internal/websocket/locked_conn.go b/websocket/locked_conn.go similarity index 98% rename from internal/websocket/locked_conn.go rename to websocket/locked_conn.go index 66da9fe..07834c5 100644 --- a/internal/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/message.go b/websocket/message.go new file mode 100644 index 0000000..b5e5396 --- /dev/null +++ b/websocket/message.go @@ -0,0 +1,18 @@ +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"` +} + +// 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 new file mode 100644 index 0000000..b315a65 --- /dev/null +++ b/websocket/reader.go @@ -0,0 +1,44 @@ +package websocket + +import ( + "encoding/json" + "log/slog" +) + +// 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 +// ID (if any). If there is an error, return the error and stop +// listening. +// +// 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() + if err != nil { + return err + } + + 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 + + // If a subscriber has been registered for this message ID, + // then call it: + if subr, ok := conn.getSubscriber(msg.ID); ok { + subr(msg) + } + } +} diff --git a/websocket/result_message.go b/websocket/result_message.go new file mode 100644 index 0000000..897000b --- /dev/null +++ b/websocket/result_message.go @@ -0,0 +1,78 @@ +package websocket + +import ( + "encoding/json" + "fmt" +) + +// BaseResultMessage represents the header of a websocket message that +// holds the result of an operation, possibly including an error. +type BaseResultMessage struct { + BaseMessage + 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, possibly including an error. +type ResultMessage struct { + BaseResultMessage + + // 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 +} 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 96% rename from internal/websocket/subscriptions.go rename to websocket/subscriptions.go index 590d52c..36f6ece 100644 --- a/internal/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 ChanMsg) +type Subscriber func(msg Message) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ ChanMsg) {} +func NoopSubscriber(_ Message) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. 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