From 52b896ae4c85be2a34b32f506f25d9eb7628f9f5 Mon Sep 17 00:00:00 2001 From: Bernardo Farah Date: Tue, 10 Mar 2026 23:06:23 -0700 Subject: [PATCH] Add per-room daylight mode binary sensor for Home Assistant Expose NaturalLightIsOn as a binary_sensor per daylight-capable room. Sensors attach to existing light devices via shared HA identifiers. State is hydrated from the REST API on startup and polled every 30s, with optimistic updates on NL toggle commands. Co-Authored-By: Claude Opus 4.6 --- bridge.go | 144 +++++++++++++++++++++++++++++++++++++++++++++- bridge_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++-- mqtt.go | 76 +++++++++++++++++++++++++ mqtt_test.go | 56 ++++++++++++++++++ types.go | 17 ++++++ types_test.go | 18 ++++++ 6 files changed, 455 insertions(+), 7 deletions(-) diff --git a/bridge.go b/bridge.go index a0a8835..c16682b 100644 --- a/bridge.go +++ b/bridge.go @@ -29,6 +29,9 @@ type Bridge struct { state map[string]*LightState // keyed by UniqueID mu sync.RWMutex // protects state map + + daylightState map[string]bool // keyed by room slug → NaturalLightIsOn + daylightRooms map[string]string // room slug → RoomName (for polling state names) } // NewBridge creates a new bridge with the given configuration. @@ -54,6 +57,9 @@ func (b *Bridge) Start(ctx context.Context) error { // Non-fatal: we can still start with empty state and get updates from avc WS b.logger.Printf("warning: state hydration failed: %v", err) } + if err := b.hydrateDaylightState(ctx); err != nil { + b.logger.Printf("warning: daylight state hydration failed: %v", err) + } // 3. Connect MQTT (retry until connected or ctx cancelled) b.mqtt = NewMQTTClient(b.cfg.MQTT, b.cfg.HA.DiscoveryPrefix) @@ -77,10 +83,14 @@ func (b *Bridge) Start(ctx context.Context) error { if err := b.mqtt.PublishDiscovery(b.entities); err != nil { return fmt.Errorf("mqtt publish discovery: %w", err) } + if err := b.mqtt.PublishDaylightDiscovery(b.entities); err != nil { + return fmt.Errorf("mqtt publish daylight discovery: %w", err) + } if err := b.mqtt.PublishAvailability(true); err != nil { return fmt.Errorf("mqtt publish availability: %w", err) } b.publishAllStates() + b.publishAllDaylightStates() // 5. Subscribe to MQTT commands if err := b.mqtt.SubscribeCommands(b.handleCommand); err != nil { @@ -107,6 +117,12 @@ func (b *Bridge) Start(ctx context.Context) error { b.reconnectLoop(ctx) }() + wg.Add(1) + go func() { + defer wg.Done() + b.daylightPollLoop(ctx) + }() + // Block until shutdown <-ctx.Done() b.logger.Println("shutting down") @@ -167,16 +183,22 @@ func (b *Bridge) discover(ctx context.Context) error { b.state = make(map[string]*LightState, len(entities)) b.entityIdx = make(map[string]*LightEntity, len(entities)) b.addrIdx = make(map[int]*LightEntity, len(entities)) + b.daylightState = make(map[string]bool) + b.daylightRooms = make(map[string]string) for i := range b.entities { e := &b.entities[i] b.state[e.UniqueID] = &LightState{} b.entityIdx[e.RoomSlug+"/"+e.LoadSlug] = e b.addrIdx[e.HexAddr] = e + + if e.FollowDaylight { + b.daylightRooms[e.RoomSlug] = e.RoomName + } } - b.logger.Printf("discovered %d rooms, %d loads → %d entities", - len(rooms), len(loads), len(entities)) + b.logger.Printf("discovered %d rooms, %d loads → %d entities (%d daylight rooms)", + len(rooms), len(loads), len(entities), len(b.daylightRooms)) return nil } @@ -493,6 +515,15 @@ func (b *Bridge) sendAVCCommand(avc *AVCClient, entity *LightEntity, cmd LightCo if err := avc.SimulateButtonPress(entity.DaylightToggleAddr); err != nil { b.logger.Printf("avc button press error: %v", err) } + // Optimistically mark daylight as active for this room + b.mu.Lock() + b.daylightState[entity.RoomSlug] = true + b.mu.Unlock() + if b.mqtt != nil { + if err := b.mqtt.PublishDaylightState(entity, true); err != nil { + b.logger.Printf("optimistic daylight publish error: %v", err) + } + } } else { // Non-daylight load: set to max value := fmt.Sprintf("%d%%.0", entity.Max) @@ -517,3 +548,112 @@ func (b *Bridge) publishAllStates() { } b.logger.Printf("published state for %d entities", len(b.entities)) } + +// hydrateDaylightState fetches initial NaturalLightIsOn values from the REST API. +func (b *Bridge) hydrateDaylightState(ctx context.Context) error { + if len(b.daylightRooms) == 0 { + return nil + } + + var stateNames []string + for _, roomName := range b.daylightRooms { + stateNames = append(stateNames, roomName+".NaturalLightIsOn") + } + + states, err := b.api.FetchStates(ctx, stateNames) + if err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + + for slug, roomName := range b.daylightRooms { + if val, ok := states[roomName+".NaturalLightIsOn"]; ok { + b.daylightState[slug] = val == "1" + } + } + + b.logger.Printf("hydrated daylight state for %d rooms", len(b.daylightRooms)) + return nil +} + +// publishAllDaylightStates publishes the current daylight state for all daylight entities. +func (b *Bridge) publishAllDaylightStates() { + b.mu.RLock() + defer b.mu.RUnlock() + + for i := range b.entities { + e := &b.entities[i] + if !e.FollowDaylight { + continue + } + active := b.daylightState[e.RoomSlug] + if err := b.mqtt.PublishDaylightState(e, active); err != nil { + b.logger.Printf("publish daylight state error for %s: %v", e.UniqueID, err) + } + } +} + +// daylightPollLoop polls the REST API for daylight state changes every 30 seconds. +func (b *Bridge) daylightPollLoop(ctx context.Context) { + if len(b.daylightRooms) == 0 { + return + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + b.pollDaylightState(ctx) + } + } +} + +// pollDaylightState fetches current daylight state and publishes changes. +func (b *Bridge) pollDaylightState(ctx context.Context) { + var stateNames []string + for _, roomName := range b.daylightRooms { + stateNames = append(stateNames, roomName+".NaturalLightIsOn") + } + + states, err := b.api.FetchStates(ctx, stateNames) + if err != nil { + b.logger.Printf("daylight poll error: %v", err) + return + } + + b.mu.Lock() + defer b.mu.Unlock() + + for slug, roomName := range b.daylightRooms { + val, ok := states[roomName+".NaturalLightIsOn"] + if !ok { + continue + } + active := val == "1" + prev := b.daylightState[slug] + if active == prev { + continue + } + b.daylightState[slug] = active + + // Publish for all daylight entities in this room + if b.mqtt == nil { + continue + } + for i := range b.entities { + e := &b.entities[i] + if !e.FollowDaylight || e.RoomSlug != slug { + continue + } + if err := b.mqtt.PublishDaylightState(e, active); err != nil { + b.logger.Printf("daylight publish error for %s: %v", e.UniqueID, err) + } + } + } +} diff --git a/bridge_test.go b/bridge_test.go index f931643..96e8305 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -406,6 +406,142 @@ func TestHydratePerLoadFallsBackToPerRoom(t *testing.T) { } } +// --- daylight state tests --- + +func TestHydrateDaylightState(t *testing.T) { + stateServer := testStateServer(t, map[string]string{ + "Den.NaturalLightIsOn": "1", + }) + defer stateServer.Close() + + b := newTestBridge() + b.cfg = &Config{} + b.api = &SavantAPI{baseURL: stateServer.URL, client: stateServer.Client()} + b.daylightRooms = map[string]string{"den": "Den"} + b.daylightState = make(map[string]bool) + + if err := b.hydrateDaylightState(context.Background()); err != nil { + t.Fatalf("hydrateDaylightState error: %v", err) + } + + if !b.daylightState["den"] { + t.Error("expected daylight state to be true for den") + } +} + +func TestHydrateDaylightState_Off(t *testing.T) { + stateServer := testStateServer(t, map[string]string{ + "Den.NaturalLightIsOn": "0", + }) + defer stateServer.Close() + + b := newTestBridge() + b.cfg = &Config{} + b.api = &SavantAPI{baseURL: stateServer.URL, client: stateServer.Client()} + b.daylightRooms = map[string]string{"den": "Den"} + b.daylightState = make(map[string]bool) + + if err := b.hydrateDaylightState(context.Background()); err != nil { + t.Fatalf("hydrateDaylightState error: %v", err) + } + + if b.daylightState["den"] { + t.Error("expected daylight state to be false for den") + } +} + +func TestHydrateDaylightState_NoDaylightRooms(t *testing.T) { + b := newTestBridge() + b.daylightRooms = make(map[string]string) + b.daylightState = make(map[string]bool) + + // Should return nil immediately without needing an API + if err := b.hydrateDaylightState(context.Background()); err != nil { + t.Fatalf("hydrateDaylightState error: %v", err) + } +} + +func TestPollDaylightState_PublishesOnChange(t *testing.T) { + // Start with daylight off, poll returns on + stateServer := testStateServer(t, map[string]string{ + "Den.NaturalLightIsOn": "1", + }) + defer stateServer.Close() + + b := newTestBridge() + b.cfg = &Config{} + b.api = &SavantAPI{baseURL: stateServer.URL, client: stateServer.Client()} + b.daylightRooms = map[string]string{"den": "Den"} + b.daylightState = map[string]bool{"den": false} + + // pollDaylightState needs mqtt client — but we just test state change here + // without MQTT by leaving b.mqtt nil and checking internal state + b.pollDaylightState(context.Background()) + + if !b.daylightState["den"] { + t.Error("expected daylight state to change to true") + } +} + +func TestPollDaylightState_NoChangeNoPublish(t *testing.T) { + stateServer := testStateServer(t, map[string]string{ + "Den.NaturalLightIsOn": "0", + }) + defer stateServer.Close() + + b := newTestBridge() + b.cfg = &Config{} + b.api = &SavantAPI{baseURL: stateServer.URL, client: stateServer.Client()} + b.daylightRooms = map[string]string{"den": "Den"} + b.daylightState = map[string]bool{"den": false} + + // Already false → should remain false with no change + b.pollDaylightState(context.Background()) + + if b.daylightState["den"] { + t.Error("expected daylight state to stay false") + } +} + +func TestOptimisticDaylightUpdate(t *testing.T) { + b, received := newTestBridgeWithAVC(t) + b.daylightState = map[string]bool{"den": false} + b.daylightRooms = map[string]string{"den": "Den"} + + on := "ON" + b.handleCommand("den/lights", LightCommand{State: &on}) + + // Should have set daylight state optimistically + if !b.daylightState["den"] { + t.Error("expected optimistic daylight state true after NL toggle") + } + + // Drain avc messages (press + release) + expectAVCMessage(t, received, 2*time.Second) + expectAVCMessage(t, received, 1*time.Second) +} + +func TestDiscoverCollectsDaylightRooms(t *testing.T) { + b := newTestBridge() + // newTestBridge doesn't set daylightRooms — verify the field was populated + // by checking test entities directly + entities := testEntities() + + b.daylightRooms = make(map[string]string) + for _, e := range entities { + if e.FollowDaylight { + b.daylightRooms[e.RoomSlug] = e.RoomName + } + } + + if len(b.daylightRooms) != 1 { + t.Errorf("expected 1 daylight room, got %d", len(b.daylightRooms)) + } + if b.daylightRooms["den"] != "Den" { + t.Errorf("expected den→Den, got %q", b.daylightRooms["den"]) + } +} + // --- test helpers --- // newTestBridge creates a Bridge with two entities (den lights + den closet) @@ -453,11 +589,13 @@ func testEntities() []LightEntity { func newTestBridgeFromEntities(entities []LightEntity) *Bridge { b := &Bridge{ - entities: entities, - state: make(map[string]*LightState, len(entities)), - entityIdx: make(map[string]*LightEntity, len(entities)), - addrIdx: make(map[int]*LightEntity, len(entities)), - logger: testLogger(), + entities: entities, + state: make(map[string]*LightState, len(entities)), + entityIdx: make(map[string]*LightEntity, len(entities)), + addrIdx: make(map[int]*LightEntity, len(entities)), + daylightState: make(map[string]bool), + daylightRooms: make(map[string]string), + logger: testLogger(), } for i := range b.entities { @@ -465,6 +603,9 @@ func newTestBridgeFromEntities(entities []LightEntity) *Bridge { b.state[e.UniqueID] = &LightState{} b.entityIdx[e.RoomSlug+"/"+e.LoadSlug] = e b.addrIdx[e.HexAddr] = e + if e.FollowDaylight { + b.daylightRooms[e.RoomSlug] = e.RoomName + } } return b diff --git a/mqtt.go b/mqtt.go index 613845f..93af3a0 100644 --- a/mqtt.go +++ b/mqtt.go @@ -153,6 +153,46 @@ func (m *MQTTClient) SubscribeCommands(handler func(entityID string, cmd LightCo return nil } +// PublishDaylightDiscovery sends HA MQTT Discovery config for daylight binary +// sensors. Only entities with FollowDaylight=true get a sensor, attached to the +// same HA device as their light entity. +func (m *MQTTClient) PublishDaylightDiscovery(entities []LightEntity) error { + count := 0 + for i := range entities { + entity := &entities[i] + if !entity.FollowDaylight { + continue + } + payload, err := buildDaylightDiscoveryPayload(entity, m.topicPrefix, m.haPrefix) + if err != nil { + return fmt.Errorf("building daylight discovery for %s: %w", entity.UniqueID, err) + } + topic := entity.DaylightDiscoveryTopic(m.haPrefix) + token := m.client.Publish(topic, 1, true, payload) + token.Wait() + if err := token.Error(); err != nil { + return fmt.Errorf("publishing daylight discovery for %s: %w", entity.UniqueID, err) + } + count++ + } + if count > 0 { + m.logger.Printf("Published daylight discovery for %d entities", count) + } + return nil +} + +// PublishDaylightState publishes the daylight mode state for a light entity. +func (m *MQTTClient) PublishDaylightState(entity *LightEntity, active bool) error { + payload := "OFF" + if active { + payload = "ON" + } + topic := entity.DaylightStateTopic(m.topicPrefix) + token := m.client.Publish(topic, 0, true, payload) + token.Wait() + return token.Error() +} + // --- Serialization helpers (exported for testing) --- // discoveryDevice is the "device" block in an HA discovery payload. @@ -209,6 +249,42 @@ func buildDiscoveryPayload(entity *LightEntity, topicPrefix string, haPrefix str return json.Marshal(p) } +// binarySensorDiscoveryPayload is the HA MQTT Discovery config for a binary sensor. +type binarySensorDiscoveryPayload struct { + Name string `json:"name"` + UniqueID string `json:"unique_id"` + ObjectID string `json:"object_id"` + StateTopic string `json:"state_topic"` + AvailabilityTopic string `json:"availability_topic"` + PayloadAvailable string `json:"payload_available"` + PayloadNotAvail string `json:"payload_not_available"` + Icon string `json:"icon"` + Device discoveryDevice `json:"device"` +} + +// buildDaylightDiscoveryPayload generates the HA MQTT Discovery JSON for a +// daylight binary sensor attached to the same device as the light entity. +func buildDaylightDiscoveryPayload(entity *LightEntity, topicPrefix string, haPrefix string) ([]byte, error) { + p := binarySensorDiscoveryPayload{ + Name: "Daylight Mode", + UniqueID: entity.DaylightUniqueID(), + ObjectID: entity.DaylightUniqueID(), + StateTopic: entity.DaylightStateTopic(topicPrefix), + AvailabilityTopic: topicPrefix + "/status", + PayloadAvailable: "online", + PayloadNotAvail: "offline", + Icon: "mdi:weather-sunny", + Device: discoveryDevice{ + Identifiers: []string{entity.UniqueID}, + Name: entity.RoomName + " " + entity.Name, + Manufacturer: "Savant", + Model: entity.DeviceModel, + SuggestedArea: entity.RoomName, + }, + } + return json.Marshal(p) +} + // statePayload is the JSON state message published for a light. type statePayload struct { State string `json:"state"` diff --git a/mqtt_test.go b/mqtt_test.go index 5fb0b85..30195ae 100644 --- a/mqtt_test.go +++ b/mqtt_test.go @@ -97,6 +97,62 @@ func TestBuildDiscoveryPayload_Switch(t *testing.T) { assertStr(t, got, "command_topic", "savant/master_bath/sconces/light/set") } +// --- Daylight discovery payload tests --- + +func TestBuildDaylightDiscoveryPayload(t *testing.T) { + entity := &LightEntity{ + UniqueID: "savant_load_005_0", + Name: "Lights", + RoomName: "Den", + RoomSlug: "den", + LoadSlug: "lights", + DeviceModel: "ECHO Adaptive phase", + } + + data, err := buildDaylightDiscoveryPayload(entity, "savant", "homeassistant") + if err != nil { + t.Fatalf("buildDaylightDiscoveryPayload() error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("JSON unmarshal error: %v", err) + } + + // Check fields + assertStr(t, got, "name", "Daylight Mode") + assertStr(t, got, "unique_id", "savant_daylight_den_lights") + assertStr(t, got, "object_id", "savant_daylight_den_lights") + assertStr(t, got, "state_topic", "savant/den/daylight/state") + assertStr(t, got, "availability_topic", "savant/status") + assertStr(t, got, "payload_available", "online") + assertStr(t, got, "payload_not_available", "offline") + assertStr(t, got, "icon", "mdi:weather-sunny") + + // No schema or command_topic (read-only sensor) + if _, ok := got["schema"]; ok { + t.Error("schema should be absent for binary sensor") + } + if _, ok := got["command_topic"]; ok { + t.Error("command_topic should be absent for binary sensor") + } + + // Check device block — should match the light entity's device + dev, ok := got["device"].(map[string]interface{}) + if !ok { + t.Fatal("expected device block to be an object") + } + assertStr(t, dev, "name", "Den Lights") + assertStr(t, dev, "manufacturer", "Savant") + assertStr(t, dev, "model", "ECHO Adaptive phase") + assertStr(t, dev, "suggested_area", "Den") + + ids, ok := dev["identifiers"].([]interface{}) + if !ok || len(ids) != 1 || ids[0] != "savant_load_005_0" { + t.Errorf("expected identifiers: [savant_load_005_0], got %v", dev["identifiers"]) + } +} + // --- State payload tests --- func TestBuildStatePayload_DimmableOn(t *testing.T) { diff --git a/types.go b/types.go index ca28601..4afc5eb 100644 --- a/types.go +++ b/types.go @@ -96,6 +96,23 @@ func (e *LightEntity) DiscoveryTopic(haPrefix string) string { return fmt.Sprintf("%s/light/%s/config", haPrefix, e.UniqueID) } +// DaylightStateTopic returns the MQTT state topic for the room's daylight sensor. +// All entities in the same room share this topic. +func (e *LightEntity) DaylightStateTopic(prefix string) string { + return fmt.Sprintf("%s/%s/daylight/state", prefix, e.RoomSlug) +} + +// DaylightDiscoveryTopic returns the HA MQTT Discovery config topic for this +// entity's daylight binary sensor. +func (e *LightEntity) DaylightDiscoveryTopic(haPrefix string) string { + return fmt.Sprintf("%s/binary_sensor/%s/config", haPrefix, e.DaylightUniqueID()) +} + +// DaylightUniqueID returns the unique ID for this entity's daylight binary sensor. +func (e *LightEntity) DaylightUniqueID() string { + return fmt.Sprintf("savant_daylight_%s_%s", e.RoomSlug, e.LoadSlug) +} + // LightState represents the current state of a light entity. type LightState struct { On bool diff --git a/types_test.go b/types_test.go index 85698cb..d9fb673 100644 --- a/types_test.go +++ b/types_test.go @@ -104,3 +104,21 @@ func TestLightEntityTopics(t *testing.T) { t.Errorf("DiscoveryTopic = %q, want homeassistant/light/savant_load_005_0/config", got) } } + +func TestLightEntityDaylightTopics(t *testing.T) { + e := LightEntity{ + UniqueID: "savant_load_005_0", + RoomSlug: "den", + LoadSlug: "lights", + } + + if got := e.DaylightUniqueID(); got != "savant_daylight_den_lights" { + t.Errorf("DaylightUniqueID = %q, want savant_daylight_den_lights", got) + } + if got := e.DaylightStateTopic("savant"); got != "savant/den/daylight/state" { + t.Errorf("DaylightStateTopic = %q, want savant/den/daylight/state", got) + } + if got := e.DaylightDiscoveryTopic("homeassistant"); got != "homeassistant/binary_sensor/savant_daylight_den_lights/config" { + t.Errorf("DaylightDiscoveryTopic = %q, want homeassistant/binary_sensor/savant_daylight_den_lights/config", got) + } +}