Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e59f916
Rename "message" types uniformly to `FooMessage`
mhagger Dec 13, 2025
02d812a
Make the `internal/websocket` package public
mhagger Dec 28, 2025
4a132b8
Remove `Success` field from `BaseMessage`
mhagger Dec 28, 2025
74d3030
stateChangedMessage: embed a `BaseMessage`
mhagger Dec 28, 2025
c7aee79
Rename `ChanMessage` to `ResultMessage`
mhagger Dec 28, 2025
17c87da
Pass `Message`s, rather than `ResultMessage`s, to `Subscriber`s
mhagger Dec 28, 2025
9911945
Make `ResultMessage` more capable
mhagger Dec 28, 2025
f810cb0
BaseServiceRequest.Type: field renamed from `RequestType`
mhagger Dec 28, 2025
501ac3b
CallServiceMessage: new type
mhagger Dec 28, 2025
083976b
App.CallAndForget(): method renamed from `Call()`
mhagger Dec 29, 2025
45de4bf
websocket: improve some documentation comments
mhagger Dec 29, 2025
23f8b45
Conn.Run(): don't discard "result" messages
mhagger Jan 17, 2026
19e88c7
App.Call(): new method
mhagger Dec 29, 2025
3c8dbc7
services: change the type of `serviceData` arguments to `...any`
mhagger Feb 22, 2026
28963af
services: return the result of the service call
mhagger Dec 31, 2025
919780b
message: new package, for holding message-related data types
mhagger Jan 18, 2026
98d3ec0
message.SubscribeEventsRequest: new type
mhagger Jan 18, 2026
2ec0d9a
AuthMessage: make type local to `Conn.sendAuthMessage()`
mhagger Jan 18, 2026
1e0b80b
autoMessage.Type: field renamed from `MsgType`
mhagger Jan 18, 2026
eecce43
authResponse: make type local to `Conn.verifyAuthResponse()`
mhagger Jan 18, 2026
c390f96
autoResponse.Type: field renamed from `MsgType`
mhagger Jan 18, 2026
60d1f9e
sendAuthMessage(), verifyAuthResponse(): remove `ctx` argument
mhagger Jan 18, 2026
6988609
EventData: remove type
mhagger Jan 18, 2026
bbaad7c
message.EventMessage: new type
mhagger Jan 24, 2026
2cf445d
StateChangedMessage: move to `message` package
mhagger Jan 18, 2026
cef4881
StateChangedEventMessage: base type on `EventMessage`
mhagger Jan 18, 2026
0165ef0
EntityData: delete type
mhagger Jan 18, 2026
724e5c3
message.ZWaveJSValueNotificationEventMessage: new type
mhagger Jan 24, 2026
c359e0d
TimeStamp: new type, for unmarshaling times
mhagger Jan 18, 2026
d706aea
message.FireEventRequest: move type to the `messages` package
mhagger Jan 18, 2026
2d784a9
EntityState: move type to the `message` package
mhagger Jan 18, 2026
c555fde
EntityState.LastUpdated: new field
mhagger Jan 18, 2026
412831b
message.Target: type moved from the `internal/services` package
mhagger Jan 18, 2026
29b4e29
message.CallServiceRequest, CallServiceData: new types
mhagger Jan 18, 2026
5b3be84
message.NotifyData: new type
mhagger Jan 18, 2026
94db109
message.SetTemperatureData: new type
mhagger Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
},
)
Expand Down Expand Up @@ -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)
},
)
Expand All @@ -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,
})
}
}
Expand Down
103 changes: 97 additions & 6 deletions call.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 6 additions & 5 deletions checkers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
60 changes: 42 additions & 18 deletions cmd/example/example.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()

Expand All @@ -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")
}
}
13 changes: 10 additions & 3 deletions cmd/example/example_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"gopkg.in/yaml.v3"

ga "saml.dev/gome-assistant"
"saml.dev/gome-assistant/message"
)

type (
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down
Loading