Skip to content

Commit ec4f048

Browse files
committed
Merge branch 'develop'
2 parents 877e6dd + 7673119 commit ec4f048

9 files changed

Lines changed: 679 additions & 80 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-19

openspec/changes/archive/2026-04-19-bug-updates-on-floorplan-not-working/design.md

Lines changed: 143 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## Why
2+
3+
On the Floorplan view, the initial render of appliance state (on/off icon, power reading) is correct, but live updates from SSE transports fail to reach the UI for plain (non-group) appliances. After the user toggles a light, the backend receives the command and physical relays switch as expected, but the Floorplan UI does not reflect the new state or the new power value — with the single exception that the **first** appliance clicked in a session flips its on/off icon (its power reading still stays at `0`). Every subsequent click is a silent UI dead-end, so the operator has no feedback that their action landed.
4+
5+
This regressed recently — initial state is correct because it is applied once during the REST load path, so the breakage is isolated to the live-update path that applies `transport-update` payloads into the per-appliance reactive state consumed by Floorplan. We need to fix it now because the Floorplan is the primary control surface on tablet, mobile, and PC in Kiosk mode, and this bug leaves operators blind after every click.
6+
7+
## What Changes
8+
9+
- Fix the Floorplan's `transport-update` handler so that `(applianceId, path, value)` triples for plain (non-group) appliances reliably update the reactive state that backs the on/off icon and the power reading for **every** click, not just the first one.
10+
- Ensure the update path uses Vue 2 reactive writes (`Vue.set` for each hop on dynamic paths) so that creating a new `relays[i]` slot or a new scalar property on first update does not silently drop reactivity.
11+
- Keep the existing group-routing branch (child→group mirroring + power aggregation) untouched; the fix is scoped to the non-group branch.
12+
- Add spec coverage describing how the Floorplan binds live `transport-update` payloads into per-appliance reactive state, with explicit scenarios for plain appliances on both `relays[*].state` and `relays[*].power` paths, and for repeated toggles of the same appliance within a session.
13+
14+
## Capabilities
15+
16+
### New Capabilities
17+
- `floorplan-live-updates`: How the Floorplan view subscribes to SSE transports and applies `transport-update` payloads into the per-appliance reactive state that drives on/off icons and power readings, covering plain appliances, group children (power aggregation + state mirroring), and repeated-click behavior.
18+
19+
### Modified Capabilities
20+
<!-- None. The `sse-transport-client` capability's contract (deliver triples to the registered callback) is believed correct; this change is scoped to the consumer-side application of those triples on the Floorplan. If root-cause analysis in design.md shows the client is also at fault, this section will be amended. -->
21+
22+
## Impact
23+
24+
- **Code**: `src/components/floorplan/Floorplan.vue` (the `transport-update` handler and its `writePath` helper); possibly `src/utils/overmindUtils.ts` (`setPathValue`) if the reactive-write bug lives there; possibly `src/utils/sseClient.ts` if root cause turns out to be client-side (e.g., a transport handle being overwritten across repeated clicks).
25+
- **Specs**: Introduces `openspec/specs/floorplan-live-updates/spec.md`.
26+
- **Runtime behavior**: No new network traffic or API surface — the fix restores previously-expected UI behavior. No migration required.
27+
- **Views affected**: Floorplan on all form factors (tablet, mobile, PC) including Kiosk mode.
28+
- **Dependencies**: No changes to external dependencies; Vue 2 reactivity semantics are already in play.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Single transport subscription on mount
4+
5+
When the Floorplan view (`src/components/floorplan/Floorplan.vue`) mounts, it SHALL first load appliances via REST (`getAppliances`) and then register exactly one SSE transport through `SseClient.getInstance().registerTransport(...)`. The selection MUST be shaped as `{ perAppliance: [{ applianceId, paths }, ...] }` where each entry's `paths` are derived from `pathsForApplianceType(appliance.type, 'compact')`. Appliances with no compact paths MUST be skipped, and each `applianceId` MUST appear at most once in the selection. The Floorplan MUST store the returned `Handle` for later deregistration.
6+
7+
#### Scenario: Mount with one PLUG and one DIMMER on the floorplan
8+
- **WHEN** the Floorplan mounts with a PLUG (id 42) and a DIMMER (id 77) in `this.areas`
9+
- **THEN** exactly one call to `SseClient.getInstance().registerTransport(...)` is made
10+
- **AND** the selection contains `{ applianceId: 42, paths: ['relays[*].power', 'relays[0].state'] }` and `{ applianceId: 77, paths: ['relays[*].power', 'relays[0].state'] }`
11+
- **AND** `this.sseHandle` is assigned the returned `Handle`
12+
13+
#### Scenario: Duplicate area entries for one appliance
14+
- **WHEN** `this.areas` contains two entries for the same `appId` (e.g., a `RELAY_DUAL` with two relay icons)
15+
- **THEN** the selection includes exactly one `perAppliance` entry for that `applianceId`
16+
17+
#### Scenario: Appliance type with no compact paths
18+
- **WHEN** an area's appliance has a type whose `pathsForApplianceType(type, 'compact')` returns an empty array
19+
- **THEN** that appliance is not included in the selection
20+
- **AND** if no appliances remain with non-empty paths, no transport is registered and `this.sseHandle` stays `null`
21+
22+
### Requirement: Initial render uses REST-loaded state
23+
24+
The Floorplan SHALL render the initial on/off icon, avatar color, and power reading for each appliance using the state returned by the REST `getAppliances` load, **before** any transport-update arrives. The initial render MUST NOT require or wait for any SSE event.
25+
26+
#### Scenario: Plain appliance with state from REST
27+
- **WHEN** `getAppliances` returns a PLUG with `state.relays[0].state = 'off'` and `state.relays[0].power = 12`
28+
- **THEN** the PLUG's avatar renders in the "off" color
29+
- **AND** the power reading renders as `12 W` (or the formatted equivalent) if `isOn` gates allow it
30+
31+
### Requirement: Live state updates for plain appliances reflect on every transport-update
32+
33+
For every `transport-update` payload delivered by `SseClient` to the Floorplan callback containing a `(applianceId, path, value)` triple where `applianceId` corresponds to a plain (non-group) appliance tracked by the Floorplan, the appliance's reactive state at `app.state.<path>` SHALL be updated **and** every DOM element that reads that state (or derived state such as `onOffState`, avatar color, or power reading) SHALL reflect the new value within the same render flush as the update. This MUST hold for the first, second, third, … N-th update within a single session, without degradation. The UI MUST NOT become unresponsive to updates after the first one.
34+
35+
#### Scenario: First toggle of a PLUG from off to on
36+
- **WHEN** the user clicks a PLUG (id 42) that is off, the REST command succeeds, and the backend emits a transport-update with `(42, 'relays[0].state', 'on')`
37+
- **THEN** the PLUG's avatar switches to the "on" color within the next render flush
38+
- **AND** `app.state.relays[0].state === 'on'` on the reactive appliance object
39+
40+
#### Scenario: Second toggle of the same PLUG from on to off
41+
- **WHEN** the user then clicks the same PLUG to turn it off, and the backend emits a transport-update with `(42, 'relays[0].state', 'off')`
42+
- **THEN** the PLUG's avatar switches back to the "off" color within the next render flush
43+
- **AND** `app.state.relays[0].state === 'off'` on the reactive appliance object
44+
45+
#### Scenario: Toggling a different plain appliance after a first toggle
46+
- **WHEN** after toggling PLUG 42, the user clicks DIMMER 77 to turn it on, and the backend emits a transport-update with `(77, 'relays[0].state', 'on')`
47+
- **THEN** DIMMER 77's avatar switches to the "on" color within the next render flush
48+
- **AND** PLUG 42's displayed state remains whatever its last update set (not reset)
49+
50+
### Requirement: Live power readings for plain appliances reflect on every transport-update
51+
52+
For every transport-update triple where `path` matches `relays[*].power` and `applianceId` corresponds to a plain appliance tracked by the Floorplan, the appliance's reactive `app.state.relays[i].power` SHALL be updated and the rendered power reading SHALL reflect the new value within the same render flush, even if `relays[i].power` was not present in the REST-loaded initial state (i.e., when the key is being added for the first time).
53+
54+
#### Scenario: Power arrives for a PLUG that had no initial power key
55+
- **WHEN** the REST-loaded state for PLUG 42 had `state.relays[0]` with no `power` property, and the backend emits a transport-update with `(42, 'relays[0].power', 18)`
56+
- **THEN** `app.state.relays[0].power === 18` on the reactive appliance object
57+
- **AND** the power reading on the PLUG's avatar renders `18 W` (or the formatted equivalent), assuming the appliance's on/off gates allow it
58+
59+
#### Scenario: Power updates on each subsequent transport-update
60+
- **WHEN** the backend emits successive transport-updates `(42, 'relays[0].power', 25)` then `(42, 'relays[0].power', 30)` for the same PLUG within one session
61+
- **THEN** the rendered power reading transitions to `25 W` and then to `30 W` on each corresponding render flush
62+
63+
### Requirement: Repeated-update reliability across the session
64+
65+
The Floorplan SHALL process every transport-update for the lifetime of `this.sseHandle` (i.e., until `beforeDestroy`). There SHALL be no per-session state (counter, cache, flag, or closure capture) that causes the first update to be handled one way and subsequent updates another way for the same appliance and path. In particular, no update MUST be silently dropped, coalesced, or suppressed by the Floorplan consumer.
66+
67+
#### Scenario: Ten successive toggles of the same plain appliance
68+
- **WHEN** the user toggles the same PLUG ten times in quick succession and the backend emits ten corresponding transport-updates
69+
- **THEN** each of the ten updates is reflected in the UI (visible on/off transitions)
70+
- **AND** the final rendered state matches the tenth update's value
71+
72+
#### Scenario: Mixed updates across many appliances
73+
- **WHEN** transport-updates arrive for five different plain appliances, each toggled twice in a scattered order
74+
- **THEN** every appliance's final rendered state matches its most recent transport-update's value
75+
76+
### Requirement: Non-interference with group routing
77+
78+
Live updates for plain (non-group) appliances SHALL NOT depend on or interact with the Floorplan's group-routing logic (`primaryChildToGroupIds`, `anyChildToGroupIds`, `groupChildPower`). A transport-update for an appliance that is neither a group primary child nor any group's child MUST be applied **only** to that appliance's own state. Conversely, group routing for `GROUP_PARALLEL` / `GROUP_SERIAL` appliances SHALL continue to work exactly as defined today (primary-child state mirrored to non-power paths; relay power summed across group children per relay index).
79+
80+
#### Scenario: Plain appliance not in any group
81+
- **WHEN** a transport-update arrives for a plain PLUG that is not listed in any group's `config.applianceIds`
82+
- **THEN** only `app.state.<path>` for that PLUG is mutated
83+
- **AND** no other appliance's state is mutated as a side effect
84+
85+
#### Scenario: Group primary child receives a non-power update
86+
- **WHEN** a transport-update arrives for a child appliance that is the primary child of one or more groups with a non-power path
87+
- **THEN** the child's own state is updated **and** each containing group's state at the same path is updated to the same value
88+
89+
#### Scenario: Group child receives a power update
90+
- **WHEN** a transport-update arrives for a child appliance (primary or not) with path `relays[i].power`
91+
- **THEN** the child's own `relays[i].power` is updated
92+
- **AND** each containing group's `relays[i].power` is updated to the sum of all its tracked children's `relays[i].power` values
93+
94+
### Requirement: Reactive property creation via Vue.set
95+
96+
Whenever the Floorplan applies a transport-update triple to an appliance's reactive state along a path that requires creating a property (a property of `state`, an element of a `relays` / `temperatures` / `humidities` / `batteries` / `motions` / `closures` array that didn't exist, or a leaf scalar like `power` that was absent from the REST-loaded JSON), it SHALL use `Vue.set` at each hop that creates a new property or array index, so that the newly-created property participates in Vue's reactivity system. Plain assignment (`obj.foo = value`) MUST NOT be used for property creation on appliance state.
97+
98+
#### Scenario: First-ever power value for a relay
99+
- **WHEN** the REST-loaded `state.relays[0]` has no `power` key and the first `(id, 'relays[0].power', 18)` transport-update arrives
100+
- **THEN** `power` is installed via `Vue.set` so subsequent reads track reactively
101+
- **AND** changing `power` later (e.g., to 25) fires reactive dependencies and causes a re-render
102+
103+
#### Scenario: Terminal scalar assignment
104+
- **WHEN** a transport-update writes to an already-existing terminal scalar on appliance state
105+
- **THEN** the write uses `Vue.set` (or equivalent reactive write) — never a plain `=` assignment — so that reactivity cannot regress in the presence of prototype-level properties or accessor overrides
106+
107+
### Requirement: Derived on/off state is reactive
108+
109+
The Floorplan's rendered on/off indicator (avatar color, icon vs. watts choice) SHALL be derived from the appliance's reactive state at read time, such that every transport-update that changes `state.relays[0].state` (or the analogous field for the appliance type) causes the indicator to re-render within the same flush. Any intermediate cache of the on/off state (e.g., an `onOffState` property set on the appliance object by a canvas-redraw step) MUST be written reactively so that template readers re-render when it changes.
110+
111+
#### Scenario: onOffState is set during canvas redraw
112+
- **WHEN** the Floorplan's `redraw` sets `item.onOffState` based on the current `state.relays[0].state`
113+
- **THEN** the write uses `Vue.set(item, 'onOffState', value)` (or an equivalent reactive write) so template expressions that read `app.onOffState` update on the next flush
114+
115+
#### Scenario: Template reflects new on/off without a full canvas redraw
116+
- **WHEN** a transport-update changes `state.relays[0].state` but the canvas redraw path is not executed
117+
- **THEN** the avatar color, icon, and power readout still reflect the new state on the next render flush (via reactive reads of `state` directly, not via stale `onOffState`)
118+
119+
### Requirement: Teardown deregisters the transport
120+
121+
Upon the Floorplan's `beforeDestroy`, if `this.sseHandle` is non-null, the Floorplan SHALL call `SseClient.getInstance().unregisterTransport(this.sseHandle)` and set `this.sseHandle = null`. The Floorplan MUST NOT leak transports across route changes or component destruction.
122+
123+
#### Scenario: Route change away from Floorplan
124+
- **WHEN** the user navigates away from the Floorplan and the component is destroyed
125+
- **THEN** `unregisterTransport` is called with the stored handle
126+
- **AND** `this.sseHandle` becomes `null`
127+
128+
#### Scenario: Destroy before initial transport handle resolves
129+
- **WHEN** the component is destroyed before the `registerTransport` Promise has resolved (i.e., `this.sseHandle` is still `null`)
130+
- **THEN** no `unregisterTransport` call is attempted and no error is thrown
131+
132+
### Requirement: Diagnostic logging for live updates (opt-in)
133+
134+
The Floorplan MUST include a module-local, opt-in debug flag (e.g., `DEBUG_TRANSPORTS`) that, when enabled, logs per-transport-update at `console.debug` level with at least: the incoming batch's triple count, each triple's `(applianceId, path, value, representsGroups)`, whether `appMap.get(applianceId)` resolved to an appliance, and the `sseHandle.id`. The flag MUST default to `false`. When the flag is `false` there MUST be no runtime logging overhead beyond a single boolean check.
135+
136+
#### Scenario: Flag off (default)
137+
- **WHEN** `DEBUG_TRANSPORTS === false` and transport-updates arrive
138+
- **THEN** no `console.debug` lines are emitted
139+
140+
#### Scenario: Flag on during diagnosis
141+
- **WHEN** a developer flips `DEBUG_TRANSPORTS` to `true` and a transport-update arrives
142+
- **THEN** one `console.debug` line is emitted per batch summarizing the payload and handle
143+
- **AND** each triple's resolution (matched appliance vs. unknown `applianceId`) is visible in the log

0 commit comments

Comments
 (0)