diff --git a/app.go b/app.go index 4464cb1..c12ce58 100644 --- a/app.go +++ b/app.go @@ -15,7 +15,8 @@ import ( "saml.dev/gome-assistant/internal" "saml.dev/gome-assistant/internal/http" - "saml.dev/gome-assistant/internal/websocket" + "saml.dev/gome-assistant/message" + "saml.dev/gome-assistant/websocket" ) var ErrInvalidArgs = errors.New("invalid arguments provided") @@ -261,7 +262,14 @@ func (app *App) registerEventListener(evl EventListener) { eventType := eventType app.conn.SubscribeToEventType( eventType, - func(msg websocket.ChanMsg) { + func(msg message.Message) { + // Subscribing, itself, causes the server to send + // a "result" message. We don't want to forward + // that message to the listeners. + if msg.Type != eventType { + return + } + go app.callEventListeners(eventType, msg) }, ) @@ -328,7 +336,7 @@ func (app *App) Start() { // subscribe to state_changed events app.entitySubscription = app.conn.SubscribeToStateChangedEvents( - func(msg websocket.ChanMsg) { + func(msg message.Message) { go app.callEntityListeners(msg.Raw) }, ) @@ -345,13 +353,18 @@ func (app *App) Start() { } etl.runOnStartupCompleted = true - go etl.callback(app.service, app.state, EntityData{ - TriggerEntityID: eid, - FromState: entityState.State, - FromAttributes: entityState.Attributes, - ToState: entityState.State, - ToAttributes: entityState.Attributes, - LastChanged: entityState.LastChanged, + stateChangedState := message.StateChangedState{ + EntityID: eid, + LastChanged: entityState.LastChanged, + State: entityState.State, + Attributes: entityState.Attributes, + LastUpdated: entityState.LastChanged, + } + + go etl.callback(app.service, app.state, message.StateChangedData{ + EntityID: eid, + NewState: stateChangedState, + OldState: stateChangedState, }) } } diff --git a/call.go b/call.go index 7e02817..6224f5e 100644 --- a/call.go +++ b/call.go @@ -1,17 +1,108 @@ package gomeassistant import ( - "saml.dev/gome-assistant/internal/services" - "saml.dev/gome-assistant/internal/websocket" + "context" + "sync" + + "saml.dev/gome-assistant/message" + "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 message.CallServiceData) error { + reqMsg := message.CallServiceRequest{ + BaseMessage: message.BaseMessage{ + Type: "call_service", + }, + CallServiceData: 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 message.CallServiceData, result any, +) error { + // Call works as follows: + // 1. Generate a message ID. + // 2. Subscribe to that ID. + // 3. Send a `CallServiceRequest` containing `req` over the websocket. + // 4. Wait for a single "result" message. + // 5. Unsubscribe from ID. + // 6. Unmarshal the "result" part of the response into `result`. + + reqMsg := message.CallServiceRequest{ + BaseMessage: message.BaseMessage{ + Type: "call_service", + }, + CallServiceData: 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 message.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/checkers_test.go b/checkers_test.go index 8a659d2..c20fb73 100644 --- a/checkers_test.go +++ b/checkers_test.go @@ -6,12 +6,13 @@ import ( "github.com/stretchr/testify/assert" "saml.dev/gome-assistant/internal" + "saml.dev/gome-assistant/message" ) type MockState struct { EqualsReturn bool EqualsError bool - GetReturn EntityState + GetReturn message.EntityState GetError bool } @@ -27,14 +28,14 @@ func (s MockState) AfterSunset(_ ...DurationString) bool { func (s MockState) BeforeSunset(_ ...DurationString) bool { return true } -func (s MockState) Get(eid string) (EntityState, error) { +func (s MockState) Get(eid string) (message.EntityState, error) { if s.GetError { - return EntityState{}, errors.New("some error") + return message.EntityState{}, errors.New("some error") } return s.GetReturn, nil } -func (s MockState) ListEntities() ([]EntityState, error) { - return []EntityState{}, nil +func (s MockState) ListEntities() ([]message.EntityState, error) { + return []message.EntityState{}, nil } func (s MockState) Equals(eid, state string) (bool, error) { if s.EqualsError { diff --git a/cmd/example/example.go b/cmd/example/example.go index 6469668..2bc451b 100644 --- a/cmd/example/example.go +++ b/cmd/example/example.go @@ -10,6 +10,7 @@ import ( "time" "saml.dev/gome-assistant/cmd/example/entities" // Optional import generated entities + "saml.dev/gome-assistant/message" ga "saml.dev/gome-assistant" ) @@ -38,18 +39,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 message.StateChangedData) { + 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,43 +73,60 @@ 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 message.StateChangedData, +) { l := "light.pantry" // l := entities.Light.Pantry // Or use generated entity constant - if sensor.ToState == "on" { - service.HomeAssistant.TurnOn(l) + if sensor.NewState.State == "on" { + 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") + } } } -func onEvent(service *ga.Service, state ga.State, data ga.EventData) { +func onEvent(service *ga.Service, state ga.State, msg message.Message) { // Since the structure of the event changes depending // on the event type, you can Unmarshal the raw json // into a Go type. If a type for your event doesn't // exist, you can write it yourself! PR's welcome to // the eventTypes.go file :) - ev := ga.EventZWaveJSValueNotification{} - json.Unmarshal(data.RawEventJSON, &ev) + ev := message.ZWaveJSValueNotificationEventMessage{} + json.Unmarshal(msg.Raw, &ev) 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 s.State == "off" && time.Since(s.LastChanged.Time()).Minutes() > 30 { + if _, err := service.Light.TurnOff(ctx, entities.Light.MainLights); err != nil { + slog.Warn("couldn't turn off living light") + return + } } } -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..5ca0031 100644 --- a/cmd/example/example_live_test.go +++ b/cmd/example/example_live_test.go @@ -14,6 +14,7 @@ import ( "gopkg.in/yaml.v3" ga "saml.dev/gome-assistant" + "saml.dev/gome-assistant/message" ) type ( @@ -106,11 +107,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) @@ -130,8 +132,13 @@ func (s *MySuite) TestSchedule() { } // Capture event after light entity state has changed -func (s *MySuite) entityCallback(se *ga.Service, st ga.State, e ga.EntityData) { - slog.Info("Entity callback called.", "entity id", e.TriggerEntityID, "from state", e.FromState, "to state", e.ToState) +func (s *MySuite) entityCallback(se *ga.Service, st ga.State, e message.StateChangedData) { + slog.Info( + "Entity callback called", + "entity id", e.EntityID, + "from state", e.OldState.State, + "to state", e.NewState.State, + ) s.suiteCtx.entityCallbackInvoked.Store(true) } diff --git a/entitylistener.go b/entitylistener.go index 17fc43e..98b6e73 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/message" ) type EntityListener struct { @@ -34,39 +35,7 @@ type EntityListener struct { disabledEntities []internal.EnabledDisabledInfo } -type EntityListenerCallback func(*Service, State, EntityData) - -type EntityData struct { - TriggerEntityID string - FromState string - FromAttributes map[string]any - ToState string - ToAttributes map[string]any - LastChanged time.Time -} - -type stateChangedMsg struct { - ID int `json:"id"` - Type string `json:"type"` - Event struct { - Data stateData `json:"data"` - EventType string `json:"event_type"` - Origin string `json:"origin"` - } `json:"event"` -} - -type stateData struct { - EntityID string `json:"entity_id"` - NewState msgState `json:"new_state"` - OldState msgState `json:"old_state"` -} - -type msgState struct { - EntityID string `json:"entity_id"` - LastChanged time.Time `json:"last_changed"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` -} +type EntityListenerCallback func(*Service, State, message.StateChangedData) /* Methods */ @@ -193,7 +162,7 @@ func (b elBuilder3) Build() EntityListener { return b.entityListener } -func (l *EntityListener) maybeCall(app *App, entityData EntityData, data stateData) { +func (l *EntityListener) maybeCall(app *App, data message.StateChangedData) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -226,24 +195,23 @@ func (l *EntityListener) maybeCall(app *App, entityData EntityData, data stateDa if l.delay != 0 { l := l l.delayTimer = time.AfterFunc(l.delay, func() { - go l.callback(app.service, app.state, entityData) + go l.callback(app.service, app.state, data) l.lastRan = carbon.Now() }) return } // run now if no delay set - go l.callback(app.service, app.state, entityData) + go l.callback(app.service, app.state, data) l.lastRan = carbon.Now() } /* Functions */ func (app *App) callEntityListeners(msgBytes []byte) { - msg := stateChangedMsg{} + msg := message.StateChangedEventMessage{} _ = json.Unmarshal(msgBytes, &msg) data := msg.Event.Data - eid := data.EntityID - listeners, ok := app.entityListeners[eid] + listeners, ok := app.entityListeners[data.EntityID] if !ok { // no listeners registered for this id return @@ -253,20 +221,11 @@ func (app *App) callEntityListeners(msgBytes []byte) { // event listener. I noticed this with iOS app location, // every time I refresh the app it triggers a device_tracker // entity listener. - if msg.Event.Data.NewState.State == msg.Event.Data.OldState.State { + if data.NewState.State == data.OldState.State { return } - entityData := EntityData{ - TriggerEntityID: eid, - FromState: data.OldState.State, - FromAttributes: data.OldState.Attributes, - ToState: data.NewState.State, - ToAttributes: data.NewState.Attributes, - LastChanged: data.OldState.LastChanged, - } - for _, l := range listeners { - l.maybeCall(app, entityData, data) + l.maybeCall(app, data) } } diff --git a/eventListener.go b/eventListener.go index 91f4a1f..7455dbd 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/message" ) type EventListener struct { @@ -25,12 +25,7 @@ type EventListener struct { disabledEntities []internal.EnabledDisabledInfo } -type EventListenerCallback func(*Service, State, EventData) - -type EventData struct { - Type string - RawEventJSON []byte -} +type EventListenerCallback func(*Service, State, message.Message) /* Methods */ @@ -132,7 +127,7 @@ func (b eventListenerBuilder3) Build() EventListener { return b.eventListener } -func (l *EventListener) maybeCall(app *App, eventData EventData) { +func (l *EventListener) maybeCall(app *App, eventMsg message.Message) { // Check conditions if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return @@ -153,24 +148,19 @@ func (l *EventListener) maybeCall(app *App, eventData EventData) { return } - go l.callback(app.service, app.state, eventData) + go l.callback(app.service, app.state, eventMsg) l.lastRan = carbon.Now() } /* Functions */ -func (app *App) callEventListeners(eventType string, msg websocket.ChanMsg) { +func (app *App) callEventListeners(eventType string, msg message.Message) { listeners, ok := app.eventListeners[eventType] if !ok { // no listeners registered for this event type return } - eventData := EventData{ - Type: eventType, - RawEventJSON: msg.Raw, - } - for _, l := range listeners { - l.maybeCall(app, eventData) + l.maybeCall(app, msg) } } diff --git a/eventTypes.go b/eventTypes.go deleted file mode 100644 index cefc6dc..0000000 --- a/eventTypes.go +++ /dev/null @@ -1,29 +0,0 @@ -package gomeassistant - -import "time" - -type EventZWaveJSValueNotification struct { - ID int `json:"id"` - Type string `json:"type"` - Event struct { - EventType string `json:"event_type"` - Data struct { - Domain string `json:"domain"` - NodeID int `json:"node_id"` - HomeID int64 `json:"home_id"` - Endpoint int `json:"endpoint"` - DeviceID string `json:"device_id"` - CommandClass int `json:"command_class"` - CommandClassName string `json:"command_class_name"` - Label string `json:"label"` - Property string `json:"property"` - PropertyName string `json:"property_name"` - PropertyKey string `json:"property_key"` - PropertyKeyName string `json:"property_key_name"` - Value string `json:"value"` - ValueRaw int `json:"value_raw"` - } `json:"data"` - Origin string `json:"origin"` - TimeFired time.Time `json:"time_fired"` - } `json:"event"` -} diff --git a/fire_event.go b/fire_event.go index 555c24b..e000785 100644 --- a/fire_event.go +++ b/fire_event.go @@ -1,26 +1,26 @@ package gomeassistant -import "saml.dev/gome-assistant/internal/websocket" +import ( + "saml.dev/gome-assistant/message" + "saml.dev/gome-assistant/websocket" +) -func (app *App) FireEvent(eventType string, eventData map[string]any) error { +// FireEvent implements [services.API.FireEvent]. +func (app *App) FireEvent(eventType string, eventData any) error { return app.conn.Send( func(lc websocket.LockedConn) error { - req := FireEventRequest{ - ID: lc.NextMessageID(), - Type: "fire_event", + req := message.FireEventRequest{ + BaseMessage: message.BaseMessage{ + ID: lc.NextMessageID(), + Type: "fire_event", + }, EventType: eventType, EventData: eventData, } + // FIXME: wait for result to make sure that the event was + // fired successfully. return lc.SendMessage(req) }, ) } - -// Fire an event -type FireEventRequest struct { - ID int64 `json:"id"` - Type string `json:"type"` // always set to "fire_event" - EventType string `json:"event_type"` - EventData map[string]any `json:"event_data,omitempty"` -} diff --git a/internal/services/adaptive_lighting.go b/internal/services/adaptive_lighting.go index f3bc704..e90faf1 100644 --- a/internal/services/adaptive_lighting.go +++ b/internal/services/adaptive_lighting.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type AdaptiveLighting struct { @@ -9,16 +15,23 @@ type AdaptiveLighting struct { /* Public API */ // Set manual control for an adaptive lighting entity. -func (al AdaptiveLighting) SetManualControl(entityID string, enabled bool) error { - req := BaseServiceRequest{ +func (al AdaptiveLighting) SetManualControl( + ctx context.Context, entityID string, enabled bool, +) (any, error) { + req := message.CallServiceData{ Domain: "adaptive_lighting", Service: "set_manual_control", ServiceData: map[string]any{ "entity_id": entityID, "manual_control": enabled, }, - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := al.api.Call(ctx, req, &result); err != nil { + return nil, err } - return al.api.Call(req) + return result, nil } diff --git a/internal/services/alarm_control_panel.go b/internal/services/alarm_control_panel.go index eee8928..2165d14 100644 --- a/internal/services/alarm_control_panel.go +++ b/internal/services/alarm_control_panel.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type AlarmControlPanel struct { @@ -8,114 +14,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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_away", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_arm_away", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_custom_bypass", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_arm_custom_bypass", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_home", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_arm_home", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_night", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_arm_night", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_arm_vacation", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_arm_vacation", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_disarm", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_disarm", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "alarm_control_panel", - Service: "alarm_trigger", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "alarm_control_panel", + Service: "alarm_trigger", + ServiceData: optionalServiceData(serviceData...), + Target: message.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..beccadd 100644 --- a/internal/services/climate.go +++ b/internal/services/climate.go @@ -1,7 +1,9 @@ package services import ( - "saml.dev/gome-assistant/types" + "context" + + "saml.dev/gome-assistant/message" ) /* Structs */ @@ -12,22 +14,38 @@ type Climate struct { /* Public API */ -func (c Climate) SetFanMode(entityID string, fanMode string) error { - req := BaseServiceRequest{ +func (c Climate) SetFanMode( + ctx context.Context, entityID string, fanMode string, +) (any, error) { + req := message.CallServiceData{ Domain: "climate", Service: "set_fan_mode", ServiceData: map[string]any{"fan_mode": fanMode}, - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + + return result, nil } -func (c Climate) SetTemperature(entityID string, serviceData types.SetTemperatureRequest) error { - req := BaseServiceRequest{ +func (c Climate) SetTemperature( + ctx context.Context, entityID string, serviceData message.SetTemperatureData, +) (any, error) { + req := message.CallServiceData{ Domain: "climate", Service: "set_temperature", - ServiceData: serviceData.ToJSON(), - Target: Entity(entityID), + ServiceData: serviceData, + Target: message.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..3097f4c 100644 --- a/internal/services/cover.go +++ b/internal/services/cover.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Cover struct { @@ -9,111 +15,187 @@ type Cover struct { /* Public API */ // Close all or specified cover. Takes an entityID. -func (c Cover) Close(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) Close( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "close_cover", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + + return result, nil } // Close all or specified cover tilt. Takes an entityID. -func (c Cover) CloseTilt(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) CloseTilt( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "close_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + + return result, nil } // Open all or specified cover. Takes an entityID. -func (c Cover) Open(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) Open( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "open_cover", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (c Cover) OpenTilt( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "open_cover_tilt", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "cover", - Service: "set_cover_position", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "cover", + Service: "set_cover_position", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Target: Entity(entityID), - Domain: "cover", - Service: "set_cover_tilt_position", +// 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 := message.CallServiceData{ + Target: message.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 { - req := BaseServiceRequest{ +func (c Cover) Stop( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "stop_cover", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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 tilt. Takes an entityID. -func (c Cover) StopTilt(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) StopTilt( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "stop_cover_tilt", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + + return result, nil } // Toggle a cover open/closed. Takes an entityID. -func (c Cover) Toggle(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) Toggle( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := c.api.Call(ctx, req, &result); err != nil { + return nil, err } - return c.api.Call(req) + + return result, nil } // Toggle a cover tilt open/closed. Takes an entityID. -func (c Cover) ToggleTilt(entityID string) error { - req := BaseServiceRequest{ +func (c Cover) ToggleTilt( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "cover", Service: "toggle_cover_tilt", - Target: Entity(entityID), + Target: message.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/event.go b/internal/services/event.go index 6747199..17dc5c8 100644 --- a/internal/services/event.go +++ b/internal/services/event.go @@ -8,9 +8,6 @@ type Event struct { // Fire an event. Takes an event type and an optional map that is sent // as `event_data`. -func (e Event) Fire(eventType string, eventData ...map[string]any) error { - if len(eventData) == 0 { - return e.api.FireEvent(eventType, nil) - } - return e.api.FireEvent(eventType, eventData[0]) +func (e Event) Fire(eventType string, eventData any) error { + return e.api.FireEvent(eventType, eventData) } diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go index 53dcd2a..59c8534 100644 --- a/internal/services/homeassistant.go +++ b/internal/services/homeassistant.go @@ -1,44 +1,68 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + 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 { - req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "turn_on", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (ha *HomeAssistant) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "homeassistant", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "homeassistant", - Service: "toggle", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (ha *HomeAssistant) Toggle( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "homeassistant", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (ha *HomeAssistant) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "homeassistant", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ha.api.Call(req) + + return result, nil } diff --git a/internal/services/input_boolean.go b/internal/services/input_boolean.go index 90c7397..9733390 100644 --- a/internal/services/input_boolean.go +++ b/internal/services/input_boolean.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type InputBoolean struct { @@ -8,37 +14,67 @@ type InputBoolean struct { /* Public API */ -func (ib InputBoolean) TurnOn(entityID string) error { - req := BaseServiceRequest{ +func (ib InputBoolean) TurnOn( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_boolean", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputBoolean) Toggle(entityID string) error { - req := BaseServiceRequest{ +func (ib InputBoolean) Toggle( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_boolean", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputBoolean) TurnOff(entityID string) error { - req := BaseServiceRequest{ +func (ib InputBoolean) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_boolean", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputBoolean) Reload() error { - req := BaseServiceRequest{ +func (ib InputBoolean) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..2d0be25 100644 --- a/internal/services/input_button.go +++ b/internal/services/input_button.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type InputButton struct { @@ -8,20 +14,33 @@ type InputButton struct { /* Public API */ -func (ib InputButton) Press(entityID string) error { - req := BaseServiceRequest{ +func (ib InputButton) Press( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_button", Service: "press", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputButton) Reload() error { - req := BaseServiceRequest{ +func (ib InputButton) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..6cd821b 100644 --- a/internal/services/input_datetime.go +++ b/internal/services/input_datetime.go @@ -1,8 +1,11 @@ package services import ( + "context" "fmt" "time" + + "saml.dev/gome-assistant/message" ) /* Structs */ @@ -13,22 +16,36 @@ type InputDatetime struct { /* Public API */ -func (ib InputDatetime) Set(entityID string, value time.Time) error { - req := BaseServiceRequest{ +func (ib InputDatetime) Set( + ctx context.Context, entityID string, value time.Time, +) (any, error) { + req := message.CallServiceData{ Domain: "input_datetime", Service: "set_datetime", ServiceData: map[string]any{ "timestamp": fmt.Sprint(value.Unix()), }, - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputDatetime) Reload() error { - req := BaseServiceRequest{ +func (ib InputDatetime) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..9ee2992 100644 --- a/internal/services/input_number.go +++ b/internal/services/input_number.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type InputNumber struct { @@ -8,38 +14,68 @@ type InputNumber struct { /* Public API */ -func (ib InputNumber) Set(entityID string, value float32) error { - req := BaseServiceRequest{ +func (ib InputNumber) Set( + ctx context.Context, entityID string, value float32, +) (any, error) { + req := message.CallServiceData{ Domain: "input_number", Service: "set_value", ServiceData: map[string]any{"value": value}, - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputNumber) Increment(entityID string) error { - req := BaseServiceRequest{ +func (ib InputNumber) Increment( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_number", Service: "increment", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputNumber) Decrement(entityID string) error { - req := BaseServiceRequest{ +func (ib InputNumber) Decrement( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_number", Service: "decrement", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputNumber) Reload() error { - req := BaseServiceRequest{ +func (ib InputNumber) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..f552083 100644 --- a/internal/services/input_text.go +++ b/internal/services/input_text.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type InputText struct { @@ -8,22 +14,36 @@ type InputText struct { /* Public API */ -func (ib InputText) Set(entityID string, value string) error { - req := BaseServiceRequest{ +func (ib InputText) Set( + ctx context.Context, entityID string, value string, +) (any, error) { + req := message.CallServiceData{ Domain: "input_text", Service: "set_value", ServiceData: map[string]any{ "value": value, }, - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := ib.api.Call(ctx, req, &result); err != nil { + return nil, err } - return ib.api.Call(req) + + return result, nil } -func (ib InputText) Reload() error { - req := BaseServiceRequest{ +func (ib InputText) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..0fbf228 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Light struct { @@ -9,38 +15,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 { - req := BaseServiceRequest{ - Domain: "light", - Service: "turn_on", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (l Light) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "light", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "light", - Service: "toggle", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (l Light) Toggle( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "light", + Service: "toggle", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (l Light) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "light", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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/lock.go b/internal/services/lock.go index fb01877..f9dda5e 100644 --- a/internal/services/lock.go +++ b/internal/services/lock.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Lock struct { @@ -8,30 +14,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 { - req := BaseServiceRequest{ - Domain: "lock", - Service: "lock", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "lock", + Service: "lock", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "lock", - Service: "unlock", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (l Lock) Unlock( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "lock", + Service: "unlock", + ServiceData: optionalServiceData(serviceData...), + Target: message.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..e834b8c 100644 --- a/internal/services/media_player.go +++ b/internal/services/media_player.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type MediaPlayer struct { @@ -10,278 +16,433 @@ type MediaPlayer struct { // Send the media player the command to clear players playlist. // Takes an entityID. -func (mp MediaPlayer) ClearPlaylist(entityID string) error { - req := BaseServiceRequest{ +func (mp MediaPlayer) ClearPlaylist( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "clear_playlist", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "join", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "join", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Next( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_next_track", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Pause( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_pause", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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 play. // Takes an entityID. -func (mp MediaPlayer) Play(entityID string) error { - req := BaseServiceRequest{ +func (mp MediaPlayer) Play( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_play", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } // Toggle media player play/pause state. // Takes an entityID. -func (mp MediaPlayer) PlayPause(entityID string) error { - req := BaseServiceRequest{ +func (mp MediaPlayer) PlayPause( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_play_pause", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Previous( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_previous_track", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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 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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "media_seek", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "media_seek", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Stop( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "media_stop", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "play_media", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "play_media", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "repeat_set", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "repeat_set", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_sound_mode", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "select_sound_mode", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "select_source", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "select_source", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "shuffle_set", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "shuffle_set", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Toggle( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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 power off. // Takes an entityID. -func (mp MediaPlayer) TurnOff(entityID string) error { - req := BaseServiceRequest{ +func (mp MediaPlayer) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "turn_off", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) TurnOn( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + 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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) Unjoin( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "unjoin", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) VolumeDown( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "volume_down", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_mute", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "volume_mute", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "media_player", - Service: "volume_set", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "media_player", + Service: "volume_set", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (mp MediaPlayer) VolumeUp( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "media_player", Service: "volume_up", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := mp.api.Call(ctx, req, &result); err != nil { + return nil, err } - return mp.api.Call(req) + + return result, nil } diff --git a/internal/services/notify.go b/internal/services/notify.go index 66e29c9..a84f798 100644 --- a/internal/services/notify.go +++ b/internal/services/notify.go @@ -1,25 +1,29 @@ package services import ( - "saml.dev/gome-assistant/types" + "context" + + "saml.dev/gome-assistant/message" ) type Notify struct { api API } -// Notify sends a notification. Takes a types.NotifyRequest. -func (ha *Notify) Notify(reqData types.NotifyRequest) error { - req := BaseServiceRequest{ - Domain: "notify", - Service: reqData.ServiceName, +// Notify sends a notification. +func (ha *Notify) Notify( + ctx context.Context, serviceName string, reqData message.NotifyData, +) (any, error) { + req := message.CallServiceData{ + Domain: "notify", + Service: serviceName, + ServiceData: reqData, } - serviceData := map[string]any{} - serviceData["message"] = reqData.Message - serviceData["title"] = reqData.Title - if reqData.Data != nil { - serviceData["data"] = reqData.Data + + var result any + if err := ha.api.Call(ctx, req, &result); err != nil { + return nil, err } - req.ServiceData = serviceData - return ha.api.Call(req) + + return result, nil } diff --git a/internal/services/number.go b/internal/services/number.go index 179dc6e..3f31dbd 100644 --- a/internal/services/number.go +++ b/internal/services/number.go @@ -1,21 +1,37 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + type Number struct { api API } -func (ib Number) SetValue(entityID string, value float32) error { - req := BaseServiceRequest{ +func (ib Number) SetValue( + ctx context.Context, entityID string, value float32, +) (any, error) { + req := message.CallServiceData{ Domain: "number", Service: "set_value", ServiceData: map[string]any{"value": value}, - Target: Entity(entityID), + Target: message.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..2aba47a 100644 --- a/internal/services/scene.go +++ b/internal/services/scene.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Scene struct { @@ -8,53 +14,76 @@ 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 { - req := BaseServiceRequest{ - Domain: "scene", - Service: "apply", - Target: Entity(""), +// 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 := message.CallServiceData{ + Domain: "scene", + Service: "apply", + ServiceData: optionalServiceData(serviceData...), } - 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 { - req := BaseServiceRequest{ - Domain: "scene", - Service: "create", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (s Scene) Create( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "scene", + Service: "create", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (s Scene) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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 { - req := BaseServiceRequest{ - Domain: "scene", - Service: "turn_on", - Target: Entity(entityID), +// service_data, which must be serializable to a JSON object. +func (s Scene) TurnOn( + ctx context.Context, entityID string, serviceData ...any, +) (any, error) { + req := message.CallServiceData{ + Domain: "scene", + Service: "turn_on", + ServiceData: optionalServiceData(serviceData...), + Target: message.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..be7dfa7 100644 --- a/internal/services/script.go +++ b/internal/services/script.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Script struct { @@ -9,41 +15,70 @@ type Script struct { /* Public API */ // Reload a script that was created in the HA UI. -func (s Script) Reload(entityID string) error { - req := BaseServiceRequest{ +func (s Script) Reload( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "script", Service: "reload", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + return result, nil } // Toggle a script that was created in the HA UI. -func (s Script) Toggle(entityID string) error { - req := BaseServiceRequest{ +func (s Script) Toggle( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "script", Service: "toggle", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + return result, nil } // TurnOff a script that was created in the HA UI. -func (s Script) TurnOff() error { - req := BaseServiceRequest{ +func (s Script) TurnOff(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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 { - req := BaseServiceRequest{ +func (s Script) TurnOn( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "script", Service: "turn_on", - Target: Entity(entityID), + Target: message.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..1d00d59 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -1,10 +1,29 @@ package services +import ( + "context" + "log/slog" + + "saml.dev/gome-assistant/message" +) + // API is the interface that the individual services use to interact // with HomeAssistant. type API interface { - Call(req BaseServiceRequest) error - FireEvent(eventType string, eventData map[string]any) error + // CallAndForget makes a call to the Home Assistant API but + // doesn't subscribe to or wait for a response. + CallAndForget(req message.CallServiceData) error + + // Call makes a call to the Home Assistant API and waits for a + // response. The result is unmarshaled into invokes `result`. + // `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 message.CallServiceData, result any) error + + FireEvent(eventType string, eventData any) error } func BuildService[ @@ -35,21 +54,16 @@ func BuildService[ return &T{api: api} } -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"` -} - -type Target struct { - EntityID string `json:"entity_id,omitempty"` -} - -func Entity(entityID string) Target { - return 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..1a8fb2a 100644 --- a/internal/services/switch.go +++ b/internal/services/switch.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Switch struct { @@ -8,29 +14,53 @@ type Switch struct { /* Public API */ -func (s Switch) TurnOn(entityID string) error { - req := BaseServiceRequest{ +func (s Switch) TurnOn( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "switch", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := s.api.Call(ctx, req, &result); err != nil { + return nil, err } - return s.api.Call(req) + + return result, nil } -func (s Switch) Toggle(entityID string) error { - req := BaseServiceRequest{ +func (s Switch) Toggle( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "switch", Service: "toggle", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (s Switch) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "switch", Service: "turn_off", - Target: Entity(entityID), + Target: message.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..03c286b 100644 --- a/internal/services/timer.go +++ b/internal/services/timer.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Timer struct { @@ -9,67 +15,108 @@ type Timer struct { /* Public API */ // See https://www.home-assistant.io/integrations/timer/#action-timerstart -func (t Timer) Start(entityID string, duration string) error { - req := BaseServiceRequest{ +func (t Timer) Start( + ctx context.Context, entityID string, duration string, +) (any, error) { + req := message.CallServiceData{ Domain: "timer", Service: "start", ServiceData: map[string]any{ "duration": duration, }, - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (t Timer) Change( + ctx context.Context, entityID string, duration string, +) (any, error) { + req := message.CallServiceData{ Domain: "timer", Service: "change", ServiceData: map[string]any{ "duration": duration, }, - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (t Timer) Pause( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "timer", Service: "pause", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err } - return t.api.Call(req) + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timercancel -func (t Timer) Cancel() error { - req := BaseServiceRequest{ +func (t Timer) Cancel(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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 { - req := BaseServiceRequest{ +func (t Timer) Finish( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "timer", Service: "finish", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := t.api.Call(ctx, req, &result); err != nil { + return nil, err } - return t.api.Call(req) + + return result, nil } // See https://www.home-assistant.io/integrations/timer/#action-timerreload -func (t Timer) Reload() error { - req := BaseServiceRequest{ +func (t Timer) Reload(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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..1ba97a6 100644 --- a/internal/services/tts.go +++ b/internal/services/tts.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type TTS struct { @@ -9,42 +15,58 @@ type TTS struct { /* Public API */ // Remove all text-to-speech cache files and RAM cache. -func (tts TTS) ClearCache() error { - req := BaseServiceRequest{ +func (tts TTS) ClearCache(ctx context.Context) (any, error) { + req := message.CallServiceData{ 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 { - req := BaseServiceRequest{ - Domain: "tts", - Service: "cloud_say", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "tts", + Service: "cloud_say", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "tts", - Service: "google_translate_say", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "tts", + Service: "google_translate_say", + ServiceData: optionalServiceData(serviceData...), + Target: message.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..6f158f1 100644 --- a/internal/services/vacuum.go +++ b/internal/services/vacuum.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type Vacuum struct { @@ -10,129 +16,208 @@ type Vacuum struct { // Tell the vacuum cleaner to do a spot clean-up. // Takes an entityID. -func (v Vacuum) CleanSpot(entityID string) error { - req := BaseServiceRequest{ +func (v Vacuum) CleanSpot( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "clean_spot", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (v Vacuum) Locate( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "locate", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (v Vacuum) Pause( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "pause", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (v Vacuum) ReturnToBase( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "return_to_base", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err } - return v.api.Call(req) + + 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 { - req := BaseServiceRequest{ - Domain: "vacuum", - Service: "send_command", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "vacuum", + Service: "send_command", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ - Domain: "vacuum", - Service: "set_fan_speed", - Target: Entity(entityID), +// 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 := message.CallServiceData{ + Domain: "vacuum", + Service: "set_fan_speed", + ServiceData: optionalServiceData(serviceData...), + Target: message.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 { - req := BaseServiceRequest{ +func (v Vacuum) Start( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "start", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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, pause, or resume the cleaning task. // Takes an entityID. -func (v Vacuum) StartPause(entityID string) error { - req := BaseServiceRequest{ +func (v Vacuum) StartPause( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "start_pause", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err } - return v.api.Call(req) + + return result, nil } // Stop the current cleaning task. // Takes an entityID. -func (v Vacuum) Stop(entityID string) error { - req := BaseServiceRequest{ +func (v Vacuum) Stop( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "stop", - Target: Entity(entityID), + Target: message.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 { - req := BaseServiceRequest{ +func (v Vacuum) TurnOff( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "turn_off", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + + 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 a new cleaning task. // Takes an entityID. -func (v Vacuum) TurnOn(entityID string) error { - req := BaseServiceRequest{ +func (v Vacuum) TurnOn( + ctx context.Context, entityID string, +) (any, error) { + req := message.CallServiceData{ Domain: "vacuum", Service: "turn_on", - Target: Entity(entityID), + Target: message.Entity(entityID), + } + var result any + if err := v.api.Call(ctx, req, &result); err != nil { + return nil, err } - return v.api.Call(req) + + return result, nil } diff --git a/internal/services/zwavejs.go b/internal/services/zwavejs.go index 9d90ff1..1b7a4bf 100644 --- a/internal/services/zwavejs.go +++ b/internal/services/zwavejs.go @@ -1,5 +1,11 @@ package services +import ( + "context" + + "saml.dev/gome-assistant/message" +) + /* Structs */ type ZWaveJS struct { @@ -9,15 +15,23 @@ type ZWaveJS struct { /* Public API */ // ZWaveJS bulk_set_partial_config_parameters service. -func (zw ZWaveJS) BulkSetPartialConfigParam(entityID string, parameter int, value any) error { - req := BaseServiceRequest{ +func (zw ZWaveJS) BulkSetPartialConfigParam( + ctx context.Context, entityID string, parameter int, value any, +) (any, error) { + req := message.CallServiceData{ Domain: "zwave_js", Service: "bulk_set_partial_config_parameters", ServiceData: map[string]any{ "parameter": parameter, "value": value, }, - Target: Entity(entityID), + Target: message.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/message/call_service_request.go b/message/call_service_request.go new file mode 100644 index 0000000..a0920ba --- /dev/null +++ b/message/call_service_request.go @@ -0,0 +1,18 @@ +package message + +// CallServiceRequest represents a message that can be sent to request +// an API call. Its `Type` field must be set to "call_service". +type CallServiceRequest struct { + BaseMessage + CallServiceData +} + +// CallServiceData contains the fields needed to make an HA API call. +// `Target` is not always required. The `ServiceData` field can +// contain arbitrary data needed for a particular call. +type CallServiceData struct { + Domain string `json:"domain"` + Service string `json:"service"` + Target Target `json:"target,omitempty"` + ServiceData any `json:"service_data,omitempty"` +} diff --git a/message/entity_state.go b/message/entity_state.go new file mode 100644 index 0000000..25302d0 --- /dev/null +++ b/message/entity_state.go @@ -0,0 +1,11 @@ +package message + +// EntityState is the state of an entity in Home Assistant, reported +// by the `/api/states` and `/api/states/` REST APIs. +type EntityState struct { + EntityID string `json:"entity_id"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` + LastChanged TimeStamp `json:"last_changed"` + LastUpdated TimeStamp `json:"last_updated"` +} diff --git a/message/event_message.go b/message/event_message.go new file mode 100644 index 0000000..4f639b1 --- /dev/null +++ b/message/event_message.go @@ -0,0 +1,49 @@ +package message + +// EventMessage represents a full event message. The `DataT` type +// parameter specifies the type of the `Event.Data` field. See +// the Home Assistant [docs] for more information. +// +// [docs]: https://developers.home-assistant.io/docs/api/websocket#subscribe-to-events +type EventMessage[DataT any] struct { + BaseMessage + Event Event[DataT] `json:"event"` +} + +// Event represents the `event` field of an event message. The `DataT` +// type parameter specifies the type of the `Data` field. +type Event[DataT any] struct { + EventType string `json:"event_type"` + Data DataT `json:"data"` + Origin EventOrigin `json:"origin"` + TimeFired TimeStamp `json:"time_fired"` + Context EventContext `json:"context"` +} + +// EventOrigin represents the origin of an event. +type EventOrigin string + +type EventContext struct { + ID string `json:"id,omitzero"` + ParentID string `json:"parent_id,omitzero"` + UserID string `json:"user_id,omitzero"` +} + +type ZWaveJSValueNotificationEventMessage EventMessage[ZWaveJSValueNotificationData] + +type ZWaveJSValueNotificationData struct { + Domain string `json:"domain"` + NodeID int `json:"node_id"` + HomeID int64 `json:"home_id"` + Endpoint int `json:"endpoint"` + DeviceID string `json:"device_id"` + CommandClass int `json:"command_class"` + CommandClassName string `json:"command_class_name"` + Label string `json:"label"` + Property string `json:"property"` + PropertyName string `json:"property_name"` + PropertyKey string `json:"property_key"` + PropertyKeyName string `json:"property_key_name"` + Value string `json:"value"` + ValueRaw int `json:"value_raw"` +} diff --git a/message/fire_event_request.go b/message/fire_event_request.go new file mode 100644 index 0000000..6573aa1 --- /dev/null +++ b/message/fire_event_request.go @@ -0,0 +1,8 @@ +package message + +// FireEventRequest requests that Home Assistant fire an event. +type FireEventRequest struct { + BaseMessage + EventType string `json:"event_type"` + EventData any `json:"event_data,omitempty"` +} diff --git a/message/message.go b/message/message.go new file mode 100644 index 0000000..3705062 --- /dev/null +++ b/message/message.go @@ -0,0 +1,18 @@ +package message + +// 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/message/notify_request.go b/message/notify_request.go new file mode 100644 index 0000000..1b28328 --- /dev/null +++ b/message/notify_request.go @@ -0,0 +1,7 @@ +package message + +type NotifyData struct { + Message string `json:"message"` + Title string `json:"title,omitzero"` + Data any `json:"data,omitzero"` +} diff --git a/message/raw_message.go b/message/raw_message.go new file mode 100644 index 0000000..0982a8a --- /dev/null +++ b/message/raw_message.go @@ -0,0 +1,26 @@ +package message + +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/message/result_message.go b/message/result_message.go new file mode 100644 index 0000000..437f507 --- /dev/null +++ b/message/result_message.go @@ -0,0 +1,78 @@ +package message + +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/message/set_temperature_data.go b/message/set_temperature_data.go new file mode 100644 index 0000000..a246a8a --- /dev/null +++ b/message/set_temperature_data.go @@ -0,0 +1,11 @@ +package message + +type SetTemperatureData struct { + // FIXME: 0° is not an impossible temperature, so treating zero as + // "missing" here is a little bit dubious. + + Temperature float32 `json:"temperature,omitempty"` + TargetTempHigh float32 `json:"target_temp_high,omitempty"` + TargetTempLow float32 `json:"target_temp_low,omitempty"` + HvacMode string `json:"hvac_mode,omitempty"` +} diff --git a/message/state_changed_event_message.go b/message/state_changed_event_message.go new file mode 100644 index 0000000..d7ae7c5 --- /dev/null +++ b/message/state_changed_event_message.go @@ -0,0 +1,18 @@ +package message + +type StateChangedEventMessage EventMessage[StateChangedData] + +type StateChangedData struct { + EntityID string `json:"entity_id"` + NewState StateChangedState `json:"new_state"` + OldState StateChangedState `json:"old_state"` +} + +type StateChangedState struct { + EntityID string `json:"entity_id"` + LastChanged TimeStamp `json:"last_changed"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` + LastUpdated TimeStamp `json:"last_updated"` + Context EventContext `json:"context"` +} diff --git a/message/subscribe_events_request.go b/message/subscribe_events_request.go new file mode 100644 index 0000000..b02cbee --- /dev/null +++ b/message/subscribe_events_request.go @@ -0,0 +1,6 @@ +package message + +type SubscribeEventRequest struct { + BaseMessage + EventType string `json:"event_type"` +} diff --git a/message/target.go b/message/target.go new file mode 100644 index 0000000..440bbed --- /dev/null +++ b/message/target.go @@ -0,0 +1,11 @@ +package message + +type Target struct { + EntityID string `json:"entity_id,omitempty"` +} + +func Entity(entityID string) Target { + return Target{ + EntityID: entityID, + } +} diff --git a/message/time_stamp.go b/message/time_stamp.go new file mode 100644 index 0000000..4165004 --- /dev/null +++ b/message/time_stamp.go @@ -0,0 +1,34 @@ +package message + +import ( + "encoding/json" + "fmt" + "math" + "time" +) + +type TimeStamp time.Time + +func (ts TimeStamp) Time() time.Time { + return time.Time(ts) +} + +// UnmarshalJSON unmarshals a timestamp from JSON. HA sometimes +// formats timestamps as RFC 3339 strings, sometimes as fractional +// seconds since the epoch. Handle either one (without recording which +// one it was). +func (ts *TimeStamp) UnmarshalJSON(b []byte) error { + t := (*time.Time)(ts) + if err := t.UnmarshalJSON(b); err == nil { + return nil + } + + var v float64 + if err := json.Unmarshal(b, &v); err == nil { + seconds, fraction := math.Modf(v) + *t = time.Unix(int64(seconds), int64(fraction*1e+9)) + return nil + } + + return fmt.Errorf("unmarshaling timestamp: '%s'", string(b)) +} diff --git a/state.go b/state.go index cd43338..fe60867 100644 --- a/state.go +++ b/state.go @@ -4,11 +4,11 @@ import ( "encoding/json" "fmt" "strings" - "time" "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal/http" + "saml.dev/gome-assistant/message" ) type State interface { @@ -16,8 +16,8 @@ type State interface { BeforeSunrise(...DurationString) bool AfterSunset(...DurationString) bool BeforeSunset(...DurationString) bool - ListEntities() ([]EntityState, error) - Get(entityID string) (EntityState, error) + ListEntities() ([]message.EntityState, error) + Get(entityID string) (message.EntityState, error) Equals(entityID, state string) (bool, error) } @@ -28,13 +28,6 @@ type StateImpl struct { longitude float64 } -type EntityState struct { - EntityID string `json:"entity_id"` - State string `json:"state"` - Attributes map[string]any `json:"attributes"` - LastChanged time.Time `json:"last_changed"` -} - func newState(c *http.HttpClient, homeZoneEntityID string) (*StateImpl, error) { state := &StateImpl{httpClient: c} @@ -69,24 +62,24 @@ func newState(c *http.HttpClient, homeZoneEntityID string) (*StateImpl, error) { return state, nil } -func (s *StateImpl) Get(entityID string) (EntityState, error) { +func (s *StateImpl) Get(entityID string) (message.EntityState, error) { resp, err := s.httpClient.GetState(entityID) if err != nil { - return EntityState{}, err + return message.EntityState{}, err } - es := EntityState{} + es := message.EntityState{} err = json.Unmarshal(resp, &es) return es, err } // ListEntities returns a list of all entities in Home Assistant. // see rest documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions -func (s *StateImpl) ListEntities() ([]EntityState, error) { +func (s *StateImpl) ListEntities() ([]message.EntityState, error) { resp, err := s.httpClient.States() if err != nil { return nil, err } - es := []EntityState{} + es := []message.EntityState{} err = json.Unmarshal(resp, &es) return es, err } diff --git a/types/requestTypes.go b/types/requestTypes.go deleted file mode 100644 index a2f736f..0000000 --- a/types/requestTypes.go +++ /dev/null @@ -1,33 +0,0 @@ -package types - -type NotifyRequest struct { - // Which notify service to call, such as mobile_app_sams_iphone - ServiceName string - Message string - Title string - Data map[string]any -} - -type SetTemperatureRequest struct { - Temperature float32 - TargetTempHigh float32 - TargetTempLow float32 - HvacMode string -} - -func (r *SetTemperatureRequest) ToJSON() map[string]any { - m := map[string]any{} - if r.Temperature != 0 { - m["temperature"] = r.Temperature - } - if r.TargetTempHigh != 0 { - m["target_temp_high"] = r.TargetTempHigh - } - if r.TargetTempLow != 0 { - m["target_temp_low"] = r.TargetTempLow - } - if r.HvacMode != "" { - m["hvac_mode"] = r.HvacMode - } - return m -} 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/reader.go b/websocket/reader.go new file mode 100644 index 0000000..25a3ea6 --- /dev/null +++ b/websocket/reader.go @@ -0,0 +1,46 @@ +package websocket + +import ( + "encoding/json" + "log/slog" + + "saml.dev/gome-assistant/message" +) + +// 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.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/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 84% rename from internal/websocket/subscriptions.go rename to websocket/subscriptions.go index 590d52c..0fda4e3 100644 --- a/internal/websocket/subscriptions.go +++ b/websocket/subscriptions.go @@ -3,6 +3,8 @@ package websocket import ( "fmt" "log/slog" + + "saml.dev/gome-assistant/message" ) // Subscription represents a websocket-level subscription to a @@ -19,10 +21,10 @@ func (sub Subscription) MessageID() int64 { // Subscriber is called synchronously when a message is received that // matches its subscription's message ID. -type Subscriber func(msg ChanMsg) +type Subscriber func(msg message.Message) // NoopSubscriber is a `Subscriber` that does nothing. -func NoopSubscriber(_ ChanMsg) {} +func NoopSubscriber(_ message.Message) {} // getSubscriber returns the subscriber, if any, that is subscribed to // the specified message ID. @@ -34,20 +36,16 @@ func (conn *Conn) getSubscriber(messageID int64) (Subscriber, bool) { return subscriber, ok } -type SubEvent struct { - ID int64 `json:"id"` - Type string `json:"type"` - EventType string `json:"event_type"` -} - func (conn *Conn) SubscribeToEventType(eventType string, subr Subscriber) Subscription { var subn Subscription err := conn.Send( func(lc LockedConn) error { subn = lc.Subscribe(subr) - e := SubEvent{ - ID: subn.messageID, - Type: "subscribe_events", + e := message.SubscribeEventRequest{ + BaseMessage: message.BaseMessage{ + Type: "subscribe_events", + ID: subn.messageID, + }, EventType: eventType, } diff --git a/internal/websocket/websocket.go b/websocket/websocket.go similarity index 81% rename from internal/websocket/websocket.go rename to websocket/websocket.go index 25aa117..70ab4c7 100644 --- a/internal/websocket/websocket.go +++ b/websocket/websocket.go @@ -17,11 +17,6 @@ import ( var ErrInvalidToken = errors.New("invalid authentication token") -type AuthMessage struct { - MsgType string `json:"type"` - AccessToken string `json:"access_token"` -} - type Conn struct { conn *websocket.Conn writeLock sync.Mutex @@ -77,14 +72,14 @@ func NewConn( } // Send auth message - err = conn.sendAuthMessage(ctx, authToken) + err = conn.sendAuthMessage(authToken) if err != nil { slog.Error("Unknown error creating websocket client\n") return nil, err } // Verify auth message was successful - err = conn.verifyAuthResponse(ctx) + err = conn.verifyAuthResponse() if err != nil { slog.Error("Auth token is invalid. Please double check it or create a new token in your Home Assistant profile\n") return nil, err @@ -97,31 +92,36 @@ func (conn *Conn) Close() error { return conn.conn.Close() } -func (conn *Conn) sendAuthMessage(ctx context.Context, token string) error { - err := conn.conn.WriteJSON(AuthMessage{MsgType: "auth", AccessToken: token}) +func (conn *Conn) sendAuthMessage(token string) error { + type authMessage struct { + Type string `json:"type"` + AccessToken string `json:"access_token"` + } + + err := conn.conn.WriteJSON(authMessage{Type: "auth", AccessToken: token}) if err != nil { return err } return nil } -type authResponse struct { - MsgType string `json:"type"` - Message string `json:"message"` -} - -func (conn *Conn) verifyAuthResponse(ctx context.Context) error { +func (conn *Conn) verifyAuthResponse() error { msg, err := conn.readMessage() if err != nil { return err } + type authResponse struct { + Type string `json:"type"` + Message string `json:"message"` + } + var authResp authResponse err = json.Unmarshal(msg, &authResp) if err != nil { return err } - if authResp.MsgType != "auth_ok" { + if authResp.Type != "auth_ok" { return ErrInvalidToken }