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:
parent
8e763b8ec2
commit
5b24192186
3 changed files with 152 additions and 3 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue