feat: add trigger_state WebSocket message type to /ws/dashboard feed

Broadcasts { type: 'trigger_state', trigger: { id, name, last_fired, enabled } }
on trigger fire, enable, and disable events. Handled in app.js onmessage
and forwarded to window.Automations.updateTriggerState().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-07 08:41:17 -04:00
parent 8e763b8ec2
commit 5b24192186
3 changed files with 152 additions and 3 deletions

View file

@ -522,15 +522,19 @@
if (!msg.health) return;
const health = msg.health;
console.log('[Spaxel] System health:', health);
// Update system health display in UI
const healthEl = document.getElementById('system-uptime');
if (healthEl) {
const uptimeSec = health.uptime_s || 0;
const hours = Math.floor(uptimeSec / 3600);
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
healthEl.textContent = hours + 'h ' + mins + 'm';
if (days > 0) {
healthEl.textContent = days + 'd ' + hours + 'h ' + mins + 'm';
} else {
healthEl.textContent = hours + 'h ' + mins + 'm';
}
}
const memEl = document.getElementById('system-memory');

View file

@ -33,6 +33,7 @@ type NotificationClient interface {
// WSBroadcaster sends messages to dashboard WebSocket clients.
type WSBroadcaster interface {
BroadcastAlert(alertID string, timestamp time.Time, severity, description string, acknowledged bool)
BroadcastTriggerState(triggerID, name string, lastFired time.Time, enabled bool)
}
// VolumeTriggersHandler manages automation trigger volumes with 3D geometry.
@ -418,6 +419,9 @@ func (h *VolumeTriggersHandler) enableTrigger(w http.ResponseWriter, r *http.Req
return
}
// Broadcast updated trigger state to dashboard
h.broadcastTriggerState(id)
writeJSON(w, map[string]interface{}{"status": "ok"})
}
@ -437,6 +441,9 @@ func (h *VolumeTriggersHandler) disableTrigger(w http.ResponseWriter, r *http.Re
return
}
// Broadcast updated trigger state to dashboard
h.broadcastTriggerState(id)
writeJSON(w, map[string]interface{}{"status": "ok"})
}
@ -547,6 +554,13 @@ func (h *VolumeTriggersHandler) onTriggerFired(event volume.FiredEvent) {
}
// Broadcast trigger state to dashboard
h.mu.RLock()
broadcaster := h.wsBroadcaster
h.mu.RUnlock()
if broadcaster != nil {
broadcaster.BroadcastTriggerState(t.ID, t.Name, event.Timestamp, t.Enabled)
}
log.Printf("[INFO] Trigger fired: %s (%s, %d blob(s))", t.Name, t.Condition, len(event.BlobIDs))
}
@ -739,3 +753,42 @@ func (h *VolumeTriggersHandler) executeNotification(action volume.Action, event
log.Printf("[WARN] Notification failed: %v", err)
}
}
// broadcastTriggerState sends a trigger_state WebSocket message for a trigger by ID.
func (h *VolumeTriggersHandler) broadcastTriggerState(triggerID string) {
t := h.store.GetTrigger(triggerID)
if t == nil {
return
}
h.mu.RLock()
broadcaster := h.wsBroadcaster
h.mu.RUnlock()
if broadcaster != nil {
var lastFired time.Time
if t.LastFired != nil {
lastFired = *t.LastFired
}
broadcaster.BroadcastTriggerState(t.ID, t.Name, lastFired, t.Enabled)
}
}
// GetTriggerStates returns all trigger states for the dashboard snapshot/delta protocol.
// Implements dashboard.TriggerState interface.
func (h *VolumeTriggersHandler) GetTriggerStates() []map[string]interface{} {
triggers := h.store.GetAll()
states := make([]map[string]interface{}, 0, len(triggers))
for _, t := range triggers {
state := map[string]interface{}{
"id": t.ID,
"name": t.Name,
"enabled": t.Enabled,
}
if t.LastFired != nil {
state["last_fired"] = t.LastFired.UnixMilli()
}
states = append(states, state)
}
return states
}

View file

@ -777,3 +777,95 @@ func TestHub_DeltaOmitsTypeField(t *testing.T) {
t.Error("expected at least one delta message (no type field)")
}
}
func TestHub_BroadcastTriggerState(t *testing.T) {
tests := []struct {
name string
triggerID string
triggerName string
lastFired time.Time
enabled bool
}{
{
name: "enabled trigger with last fired",
triggerID: "trigger-1",
triggerName: "Couch Dwell",
lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC),
enabled: true,
},
{
name: "disabled trigger never fired",
triggerID: "trigger-2",
triggerName: "Hallway Motion",
lastFired: time.Time{},
enabled: false,
},
{
name: "enabled trigger never fired",
triggerID: "trigger-3",
triggerName: "Kitchen Entry",
lastFired: time.Time{},
enabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastTriggerState(tc.triggerID, tc.triggerName, tc.lastFired, tc.enabled)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse trigger_state JSON: %v", err)
}
if parsed["type"] != "trigger_state" {
t.Errorf("expected type=trigger_state, got %v", parsed["type"])
}
trigger, ok := parsed["trigger"].(map[string]interface{})
if !ok {
t.Fatal("missing trigger object")
}
if trigger["id"] != tc.triggerID {
t.Errorf("expected id=%s, got %v", tc.triggerID, trigger["id"])
}
if trigger["name"] != tc.triggerName {
t.Errorf("expected name=%s, got %v", tc.triggerName, trigger["name"])
}
if trigger["enabled"] != tc.enabled {
t.Errorf("expected enabled=%v, got %v", tc.enabled, trigger["enabled"])
}
// Verify last_fired
if !tc.lastFired.IsZero() {
tsVal, ok := trigger["last_fired"].(float64)
if !ok {
t.Fatalf("expected last_fired to be numeric, got %T", trigger["last_fired"])
}
expectedTs := float64(tc.lastFired.UnixMilli())
if tsVal != expectedTs {
t.Errorf("expected last_fired=%v, got %v", expectedTs, tsVal)
}
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive trigger_state broadcast")
}
})
}
}