Skip to content

Commit b2eca32

Browse files
committed
Merge branch 'develop'
2 parents 4614ec9 + 793a650 commit b2eca32

10 files changed

Lines changed: 587 additions & 10 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-18
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
## Context
2+
3+
The frontend is a Vue 2 + TypeScript SPA (Vuetify) controlling ~300 home-automation appliances via a Java backend. All state fetching currently uses polling (`setInterval` + `appliancesService.getList()`) at 1–3 second intervals from multiple views independently. On slow clients (kiosk tablets), this causes request pileup and stale-state races, which led to the `EchoGate` workaround.
4+
5+
The backend is adding an SSE endpoint with a subscription-based model (see `ai/spec-sse-draft.md`). This design covers the frontend client that consumes it.
6+
7+
Key constraints:
8+
- Vue 2.6 / TypeScript, no Composition API
9+
- Project is pinned to Node.js v14.15.0 (build tooling only — `EventSource` is a browser API, no Node polyfill needed)
10+
- No authentication in use — Keycloak token getter returns empty string
11+
- Existing singleton pattern (`private static instanceField` + `getInstance()`) used throughout the codebase
12+
- Existing `AxiosUtils` singleton handles all REST calls and resolves the server base URL from the Vuex `rest/config` store
13+
14+
## Goals / Non-Goals
15+
16+
**Goals:**
17+
- Single shared SSE connection across the entire app
18+
- Components subscribe to specific appliance IDs and receive only those updates
19+
- Server-side debounce via `minInterval` so slow clients control their own update rate
20+
- Automatic reconnection with transparent re-registration of all active subscriptions
21+
- Visual connection-state indicator (red border when disconnected)
22+
- Drop-in replacement for the current polling pattern — components switch from `setInterval`/`clearInterval` to `subscribe`/`unsubscribe` in `mounted`/`beforeDestroy`
23+
24+
**Non-Goals:**
25+
- Replacing polling for non-appliance resources (plans, switches, window contacts, weather, waste disposal) — those stay as-is for now
26+
- WebSocket or any bidirectional protocol — commands remain REST `POST /execute`
27+
- Vuex store integration — the SSE client owns its own state (subscription map + cache). Pushing into Vuex would add complexity without benefit since components already receive updates via callbacks.
28+
- Offline/queue support — if the connection drops, we show the red border and wait for reconnect
29+
30+
## Decisions
31+
32+
### 1. New file `src/utils/sseClient.ts` as a singleton service
33+
34+
The SSE client lives alongside the existing service singletons (`appliancesService.ts`, `axiosUtils.ts`) and follows the same `getInstance()` pattern. It is not a Vue component, not a Vuex module, and not a mixin — it's a plain TypeScript class that components import.
35+
36+
**Why not Vuex:** The subscription map and appliance cache are internal bookkeeping. No component needs to read "all active subscriptions" from the store. Each component gets its updates via its own callback. Vuex would add boilerplate (mutations, actions, getters) for no consumer.
37+
38+
**Why not a mixin:** Mixins couple lifecycle hooks to the component. The SSE client's lifecycle (connect, reconnect, re-register) is independent of any single component. A mixin would also require every consuming component to declare the same boilerplate.
39+
40+
### 2. Lazy connection — open on first `subscribe()`, not on app boot
41+
42+
The `EventSource` is created only when the first component calls `subscribe()`. If no component ever subscribes (e.g., a settings-only page), no connection is opened.
43+
44+
**Why:** Avoids holding an idle SSE connection on routes that don't display appliance state. Also avoids racing with the `connected` event before any subscription is ready to register.
45+
46+
### 3. Use `AxiosUtils` for register/deregister REST calls
47+
48+
The SSE client delegates `POST /sse/appliances/register` and `DELETE /sse/appliances/register/{id}` to the existing `AxiosUtils` singleton. This keeps server base-URL resolution and error handling consistent.
49+
50+
**Requires:** Adding the new SSE endpoint paths to `store/rest.ts` config:
51+
```typescript
52+
endpoint: {
53+
// existing...
54+
sseAppliances: '/sse/appliances',
55+
sseAppliancesRegister: '/sse/appliances/register'
56+
}
57+
```
58+
59+
The SSE `EventSource` URL is also built from the same `rest/config` server config, so it resolves correctly in both dev (`localhost:8080`) and prod (`overmindserver.unterrainer.info:443`).
60+
61+
### 4. One server subscription per `subscribe()` call (1:1 mapping)
62+
63+
Each component `subscribe()` call creates exactly one server-side subscription via `POST /register`. The server handles overlap (tightest `minInterval` wins per appliance per connection). The client does not merge or deduplicate subscriptions locally.
64+
65+
**Why:** Keeps the client simple. The server already implements interval merging (spec section 4.3). Client-side merging would require tracking which local handles share which server subscription, and re-splitting when one unsubscribes — complex for negligible savings (register/deregister are infrequent REST calls, not hot-path).
66+
67+
**Trade-off:** Slightly more register calls on mount (one per component rather than one merged call). Acceptable since mount happens once, not in a loop.
68+
69+
### 5. Parse `state` and `config` strings in the client, not in callbacks
70+
71+
The SSE client calls `overmindUtils.parseState(entry)` and `overmindUtils.parseConfig(entry)` on each incoming entry before routing to callbacks. Components receive ready-to-use objects, identical to what `getAppliances()` gives them today.
72+
73+
**Why:** Every consuming component currently does this parsing. Centralizing it in the client avoids duplication and ensures consistency.
74+
75+
### 6. Connection indicator via CSS class on `#main` / `.v-app`
76+
77+
The `App.vue` template wraps everything in `<div id="main"><v-app>`. The connection indicator adds a CSS class (`sse-disconnected`) to `<v-app>` when the SSE connection is down. The styling:
78+
79+
```css
80+
.v-app.sse-disconnected {
81+
border: 3px solid red;
82+
}
83+
```
84+
85+
The existing `.v-app` style already has `border: 1px solid rgba(#000, 0.12)`. The disconnected state overrides this with a red border.
86+
87+
**Implementation:** `App.vue` reads `SseClient.getInstance().connected` in a computed property and binds the class. Since `connected` is not a Vue reactive property (it's a plain boolean on a singleton), `App.vue` sets up a small `setInterval` (every 2 seconds) to check and update a local `sseConnected` data property. This is the only remaining polling — and it's purely cosmetic, not data-fetching.
88+
89+
**Alternative considered:** Making `SseClient` extend `Vue` or use `Vue.observable()` for reactivity. Rejected because it would couple the client to Vue and make it harder to test or reuse.
90+
91+
### 7. `subscribe()` is synchronous, registration is fire-and-forget
92+
93+
`subscribe()` returns the handle immediately. The REST registration call (`POST /register`) happens asynchronously in the background. The component doesn't need to `await` it because:
94+
- The server pushes initial state over SSE as soon as registration succeeds
95+
- The callback receives it like any other update
96+
- If registration fails (e.g., connection not ready yet), the client queues it and retries after the `connected` event
97+
98+
**Why:** Components call `subscribe()` in `mounted()`. Making it async would require `async mounted()` and error handling in every component. The synchronous API is simpler and matches the existing `setInterval` pattern (set-and-forget in mounted, clean up in beforeDestroy).
99+
100+
### 8. Reconnection: re-register all, don't persist subscriptions
101+
102+
On `EventSource` reconnect (new `connected` event with new `connectionId`):
103+
1. Update stored `connectionId`
104+
2. Iterate all entries in the local `subscriptions` map
105+
3. Call `POST /register` for each with the new `connectionId`
106+
4. Update each subscription's `serverSubscriptionId` with the new response
107+
108+
The server's stale-subscription cleanup (spec section 4.5) guarantees the old connection's subscriptions are already gone. The client re-registers from scratch.
109+
110+
**Why not persist to localStorage:** Subscriptions are tied to mounted components. If the app refreshes, components remount and re-subscribe naturally. There's nothing to persist.
111+
112+
### 9. Cache survives reconnect
113+
114+
The internal `cache: Map<number, Appliance>` is not cleared on reconnect. Stale entries are overwritten when fresh data arrives from re-registration. This means `getLatest()` returns slightly-stale data during the brief reconnect window rather than `null`.
115+
116+
**Why:** Components that call `getLatest()` for synchronous initialization shouldn't break just because the SSE connection hiccupped for 3 seconds.
117+
118+
## Risks / Trade-offs
119+
120+
**[Subscribe before connected]** — If a component calls `subscribe()` before the SSE connection has delivered its `connected` event (no `connectionId` yet), the register REST call can't be made.
121+
Mitigation: The client queues pending subscriptions and processes them when `connected` fires. This is the natural path since the connection is lazy (opened on first subscribe).
122+
123+
**[Rapid mount/unmount]** — A component that mounts, subscribes, then immediately unmounts (e.g., fast route navigation) fires a register + deregister in quick succession. The server may push initial state after the client has already unsubscribed locally.
124+
Mitigation: The client ignores updates for handles that no longer exist in the subscription map. Stray updates are harmless (just extra data that nobody reads).
125+
126+
**[EventSource on HTTP (not HTTPS)]** — In dev mode the server runs on `http://localhost:8080`. Some browsers limit the number of concurrent HTTP/1.1 connections per host (6 in Chrome). The SSE connection consumes one permanently.
127+
Mitigation: Acceptable for dev. Production uses HTTPS. If it becomes a problem in dev, the backend can add HTTP/2 support.
128+
129+
**[2-second polling for connection indicator]**`App.vue` polls `sseClient.connected` every 2 seconds, which is technically polling. But it's a single boolean read, no network call, and the worst case is a 2-second delay before the red border appears/disappears.
130+
Mitigation: Acceptable. Can be replaced with a callback or `Vue.observable` wrapper later if the delay matters.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Why
2+
3+
The frontend polls `GET /setup/appliances` every 2–3 seconds from multiple views independently. With ~300 appliances each potentially changing state every second, slow clients (kiosk tablets on weak WiFi) suffer request pileup, stale-state races, and UI flicker. An `EchoGate` mechanism was built to paper over these timing bugs. Replacing polling with a subscription-based SSE stream eliminates the root cause: the client receives only the appliances it needs, at a rate it can handle, pushed by the server.
4+
5+
## What Changes
6+
7+
- **New `SseClient` singleton service** — manages a single `EventSource` connection to the backend, handles subscription registration/deregistration via REST, and routes incoming appliance updates to subscribing components by appliance ID.
8+
- **Replace `setInterval` polling in all views** — Appliances, Floorplan, Kiosk panels, and all components that currently call `appliancesService.getList()` on a timer will switch to `sseClient.subscribe(applianceIds, callback, minInterval)` in `mounted` and `sseClient.unsubscribe(handle)` in `beforeDestroy`.
9+
- **Remove `EchoGate` usage** — the echo-gate pattern (timeout-based guess for matching server echoes) is replaced by a simple "is-dragging" flag in debounced controls (brightness slider, color picker, white picker). The SSE stream delivers the confirmed state directly.
10+
- **Connection status indicator** — a thin red border around the viewport when the SSE connection is down, no border when connected. Visible in kiosk mode without cluttering the UI.
11+
- **Remove `Debouncer` from poll loops** — the `Debouncer` class remains (still used for user-input debouncing on controls) but is no longer wrapped around polling calls since polling is gone.
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- `sse-client`: The singleton SSE client service — connection lifecycle, subscription management (subscribe/unsubscribe), event routing by appliance ID, internal cache, and automatic reconnect with re-registration.
18+
- `sse-connection-indicator`: Visual connection-state indicator — red border on the viewport when disconnected, no border when connected.
19+
20+
### Modified Capabilities
21+
22+
_(none — no existing specs are affected at the requirement level)_
23+
24+
## Impact
25+
26+
- **Views affected:** `Appliances.vue`, `Floorplan.vue`, `Plans.vue`, `Switches.vue`, `WindowContacts.vue`, `KioskCamera.vue`, and all Kiosk panel components that poll appliance state.
27+
- **Components affected:** `DebouncedBrightnessSlider.vue`, `DebouncedRgbwPicker.vue`, `DebouncedBwPicker.vue`, `DebouncedOnOffButton.vue` — EchoGate removal.
28+
- **Utils affected:** `echoGate.ts` can be deleted once migration is complete. `debouncer.ts` stays but its usage shrinks.
29+
- **New dependency on backend SSE endpoint:** `GET /sse/appliances` + `POST /sse/appliances/register` + `DELETE /sse/appliances/register/{id}` (see `ai/spec-sse-draft.md`).
30+
- **No breaking changes to REST:** `POST /execute` and all other REST endpoints remain unchanged.
31+
- **App.vue or layout component:** will mount the connection-status border.

0 commit comments

Comments
 (0)