README: refresh docs; highlight server-owns-state design principle
- Rename "State replay on connect" to "State is server-only; GUIs only reflect it" and expand it with the core design principle: neither GUI client ever maintains its own model of server state — controls update only on server push, never speculatively on user input. Multi-client sync, stale-overwrite safety, and reconnect correctness all follow from this single rule. - Update TC-enable description: HeaterTask now owns the TC's enabled switch via the closed_loop flag; SudTask no longer touches it. - Add "Heater control modes" subsection describing Closed-loop/Open-loop, the enable/disable rules per mode and sud state, the auto-reset on play, and the schedule parameter restore on resume. - Refresh the Browser client section: document the Heater control mode checkbox, header countdown timers, and the always-reflect-server-values slider policy; call out the server-only-state principle explicitly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
This commit is contained in:
@@ -128,32 +128,55 @@ Each component runs inside its own `ATask` at a configurable interval
|
|||||||
keyed WebSocket channel that any connected client can subscribe to and send
|
keyed WebSocket channel that any connected client can subscribe to and send
|
||||||
commands back on (e.g. `{"TempCtrl": {"Soll": {"Temp": 65}}}`).
|
commands back on (e.g. `{"TempCtrl": {"Soll": {"Temp": 65}}}`).
|
||||||
|
|
||||||
### State replay on connect
|
### State is server-only; GUIs only reflect it
|
||||||
|
|
||||||
A fundamental assumption baked into this protocol: a client never knows what
|
**The server is the single source of truth for all state. Neither GUI client
|
||||||
it's connecting to. The GUI might be the first thing to ever talk to a
|
ever maintains its own model of what the server should be doing.**
|
||||||
freshly started server, or it might connect to one that's been running
|
|
||||||
unattended for hours, mid-brew, with a schedule already loaded and paused
|
|
||||||
partway through step 4. A client can also disconnect (network hiccup, the
|
|
||||||
user closing and reopening the GUI) and reconnect later, at any server state,
|
|
||||||
with no special "resume" handshake. In every one of these cases, the client
|
|
||||||
must end up with the *same* full picture of current state as one that's been
|
|
||||||
connected the whole time - not just whatever changes happen after it joins.
|
|
||||||
|
|
||||||
This is what `WsServerMultiUser.global_state` (`ws/server/ws_server_multi_user.py`)
|
When a user moves a slider or clicks a button, the client sends a command
|
||||||
exists for: every message ever broadcast over the dispatcher is merged into
|
and *waits* for the server's reply before updating any display — the reply
|
||||||
it (`ws/user.py`'s `update()`), keyed exactly like the live channels are.
|
is the only authority. Consequences:
|
||||||
Subscribing to a channel (`{"+": "Sud"}`, sent automatically for every
|
|
||||||
channel on connect - see `MessageDispatcherSync(auto_subscribe=True)`)
|
|
||||||
doesn't just start a future stream of changes; the server immediately
|
|
||||||
replays the *entire* currently-known state for that channel in one message,
|
|
||||||
via `User.send()`'s `dpath` search over `global_state`. A reconnecting
|
|
||||||
client gets exactly the same replay a brand-new one would, because the
|
|
||||||
server doesn't distinguish between the two cases at all - there's no
|
|
||||||
"first connect" special-casing anywhere in this path.
|
|
||||||
|
|
||||||
The corollary, easy to miss: this mechanism has no concept of "transient" -
|
- **Multiple clients stay in sync automatically.** Every state change is
|
||||||
anything sent through it is implicitly durable state that will be replayed
|
broadcast to all connected clients simultaneously. A second browser tab
|
||||||
|
and the desktop GUI connected to the same server both see the same update
|
||||||
|
at essentially the same time, with no cross-client coordination needed.
|
||||||
|
- **Controls always reflect the last value the server pushed**, not what the
|
||||||
|
user last touched. `ChangedFloat`/`ChangedInteger` wrappers on the server
|
||||||
|
side mean a broadcast only goes out when a value actually changes — the
|
||||||
|
client never needs to suppress its own echoes, since the server handles that.
|
||||||
|
- **No special "resume" handshake is needed on reconnect**, because the
|
||||||
|
client never diverged from the server in the first place — it only ever
|
||||||
|
holds what the server pushed.
|
||||||
|
|
||||||
|
The Closed-loop checkbox is a concrete example: clicking it sends
|
||||||
|
`{Heater: {ClosedLoop: false}}`, but the checkbox's own display is only
|
||||||
|
updated when the server echoes `{ClosedLoop: false}` back through
|
||||||
|
`onHeaterChanged()`. If the command is lost or refused, the UI stays at
|
||||||
|
whatever the server last reported — no local flag, no "optimistic update."
|
||||||
|
|
||||||
|
**State replay on connect** is how a freshly-connected client gets that
|
||||||
|
full server picture instantly. The GUI might be the first thing to ever
|
||||||
|
talk to a freshly started server, or it might connect to one that's been
|
||||||
|
running unattended for hours, mid-brew, with a schedule already loaded and
|
||||||
|
paused partway through step 4. A client can also disconnect and reconnect
|
||||||
|
later, at any server state. In every case the client must end up with the
|
||||||
|
*same* full picture as one that's been connected the whole time.
|
||||||
|
|
||||||
|
This is what `WsServerMultiUser.global_state`
|
||||||
|
(`ws/server/ws_server_multi_user.py`) exists for: every message ever
|
||||||
|
broadcast over the dispatcher is merged into it (`ws/user.py`'s `update()`),
|
||||||
|
keyed exactly like the live channels are. Subscribing to a channel
|
||||||
|
(`{"+": "Sud"}`, sent automatically for every channel on connect - see
|
||||||
|
`MessageDispatcherSync(auto_subscribe=True)`) doesn't just start a future
|
||||||
|
stream of changes; the server immediately replays the *entire*
|
||||||
|
currently-known state for that channel in one message, via `User.send()`'s
|
||||||
|
`dpath` search over `global_state`. A reconnecting client gets exactly the
|
||||||
|
same replay a brand-new one would — there's no "first connect"
|
||||||
|
special-casing anywhere in this path.
|
||||||
|
|
||||||
|
One corollary, easy to miss: this mechanism has no concept of "transient".
|
||||||
|
Anything sent through it is implicitly durable state that will be replayed
|
||||||
to whoever connects next, even long after the fact. A one-shot *event*
|
to whoever connects next, even long after the fact. A one-shot *event*
|
||||||
(something that happened, rather than something that's currently true) has
|
(something that happened, rather than something that's currently true) has
|
||||||
to be modeled as a value that gets explicitly cleared back to neutral right
|
to be modeled as a value that gets explicitly cleared back to neutral right
|
||||||
@@ -318,12 +341,46 @@ running schedule - refused (with an explicit, one-shot `Error` reply - see
|
|||||||
than the client trying to guess that from its own view of the state.
|
than the client trying to guess that from its own view of the state.
|
||||||
|
|
||||||
The temperature controller has a master enabled switch (off by default -
|
The temperature controller has a master enabled switch (off by default -
|
||||||
see `components/pid/temp_controller_base.py`): `SudTask` enables it for as
|
see `components/pid/temp_controller_base.py`). `HeaterTask` owns this
|
||||||
long as a run is in progress (any state other than idle/finished) and
|
switch via its `closed_loop` flag: the TC is enabled whenever the system is
|
||||||
disables it again - forcing the heater output to 0 - the moment it stops or
|
in closed-loop mode (the default) and disabled in open-loop mode. This is
|
||||||
finishes, handing control back to manual mode. The GUI's Manual tab has an
|
wired up in `server/brewpi.py` via
|
||||||
"Enabled" checkbox for driving this directly outside of a Sud run; while
|
`heater_task.set_on_closed_loop_changed(tc.set_enabled)`, and fired once
|
||||||
disabled, its temperature/heat-rate setpoint controls are inactive.
|
at startup from `HeaterTask.on_process()` so the TC is live immediately.
|
||||||
|
`SudTask` no longer manages the TC's enabled state — its
|
||||||
|
`on_state_changed()` only stops the stirrer when a run ends. See "Heater
|
||||||
|
control modes" below.
|
||||||
|
|
||||||
|
### Heater control modes
|
||||||
|
|
||||||
|
The heater operates in one of two modes, selectable by the user while the
|
||||||
|
Sud is paused or stopped. Switching while a run is active is blocked —
|
||||||
|
the mode is automatically reset to Closed-loop the moment a run starts
|
||||||
|
(the client sends `{Heater: {ClosedLoop: true}}` before `{Sud: {Start: true}}`).
|
||||||
|
|
||||||
|
**Closed-loop** (default): the temperature controller drives the heater
|
||||||
|
exclusively. Its output `y` flows to `HeaterTask.actor()` — `power_soll`
|
||||||
|
(any direct power command from a client) is ignored entirely. While paused
|
||||||
|
or stopped, the user can adjust the temperature setpoint and heat rate;
|
||||||
|
the heater-power slider is disabled. Because the TC is always enabled in
|
||||||
|
this mode (see above), adjusting the temperature setpoint even with no Sud
|
||||||
|
loaded or running causes the heater to respond immediately.
|
||||||
|
|
||||||
|
**Open-loop**: the TC is disconnected — `HeaterTask` uses `power_soll`
|
||||||
|
(the last `{Heater: {Power: X}}` received from a client) instead of
|
||||||
|
`power_actor` (the TC output), and the TC is disabled (`y` forced to 0).
|
||||||
|
Only the heater-power slider is active; temperature and heat-rate controls
|
||||||
|
are disabled. Only available when the Sud is paused or stopped.
|
||||||
|
|
||||||
|
The `ClosedLoop` boolean is part of the server's `global_state` and is
|
||||||
|
broadcast to every client on connect, so all clients always see the same
|
||||||
|
mode (see "State is server-only" above) — flipping the checkbox in one
|
||||||
|
browser tab is immediately reflected in every other connected client.
|
||||||
|
|
||||||
|
On every resume from pause, `SudTask.recv()` restores the current step's
|
||||||
|
schedule parameters (temperature setpoint, heat rate, stirrer) before the
|
||||||
|
run continues, overwriting any manual adjustments the user made while
|
||||||
|
paused.
|
||||||
|
|
||||||
### Forecast vs. actual duration
|
### Forecast vs. actual duration
|
||||||
|
|
||||||
@@ -531,22 +588,45 @@ in its own daemon thread, `--http-port`, default `8080`) needed adding,
|
|||||||
deliberately independent of the existing `websockets`-based asyncio server
|
deliberately independent of the existing `websockets`-based asyncio server
|
||||||
so neither can affect the other's behavior or availability.
|
so neither can affect the other's behavior or availability.
|
||||||
|
|
||||||
v1 covers only the Manual tab (direct heater/stirrer/controller control)
|
The browser client covers the Manual panel (direct heater/stirrer/TC
|
||||||
and the Progress tab (above) - the two most actionable surfaces, and
|
control) and the Progress tab (above). `web/app.js` ports the relevant
|
||||||
neither needs a charting library. `web/app.js` ports the relevant logic
|
logic from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
||||||
from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
|
||||||
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
|
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
|
||||||
unresolved - every step still needs `default.step` merged in), and the
|
unresolved — every step still needs `default.step` merged in), and the
|
||||||
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
||||||
behind the Progress tab and status line. New/Load/Save (a plain
|
behind the Progress tab and status line. New/Load/Save (a plain
|
||||||
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
|
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
|
||||||
for the desktop app's native file dialogs), Start/Pause/Stop, and Confirm
|
for the desktop app's native file dialogs), Start/Pause/Stop, and Confirm
|
||||||
(a plain `<dialog>` pops with the step's message on `WAIT_USER` and sends
|
(a plain `<dialog>` pops with the step's message on `WAIT_USER` and sends
|
||||||
Confirm when dismissed) are all wired up too - see `web/TODO.md` for
|
Confirm when dismissed) are all wired up.
|
||||||
what's still missing. Deliberately deferred rather than stubbed: the
|
|
||||||
|
The browser client applies the "state is server-only" principle strictly
|
||||||
|
throughout `web/app.js`: every slider, readout, and checkbox is updated
|
||||||
|
only when a push arrives from the server, never speculatively on user
|
||||||
|
input. The Closed-loop/Open-loop checkbox in the Heater panel is the
|
||||||
|
clearest example — see "Heater control modes" above and "State is
|
||||||
|
server-only" for the full rationale.
|
||||||
|
|
||||||
|
Additional features beyond the initial port:
|
||||||
|
|
||||||
|
- **Heater control modes**: a "Closed-loop" checkbox in the Heater panel
|
||||||
|
(checked = Closed-loop, unchecked = Open-loop). Enabled only when the
|
||||||
|
Sud is paused or stopped; auto-reset to Closed-loop on play. Slider
|
||||||
|
enable/disable follows the mode: temperature setpoint and heat rate
|
||||||
|
active in Closed-loop + not running; heater power active in Open-loop +
|
||||||
|
not running.
|
||||||
|
- **Header countdown**: step remaining and total remaining timers,
|
||||||
|
displayed in accent-coloured monospace next to the transport buttons,
|
||||||
|
derived from `forecast_step_starts`/`StepStarts`.
|
||||||
|
- **Controls always reflect server values**: sliders and readouts track
|
||||||
|
every server push unconditionally — there are no "initial sync only"
|
||||||
|
guards that could leave a control stale after a reconnect or while
|
||||||
|
another client is active.
|
||||||
|
|
||||||
|
See `web/TODO.md` for what's still missing. Deliberately deferred: the
|
||||||
Automatic tab's forecast-vs-actual plot and the live Plot tab's strip
|
Automatic tab's forecast-vs-actual plot and the live Plot tab's strip
|
||||||
charts - both need a charting approach, revisit once Manual + Progress are
|
charts — both need a charting approach, revisit once the rest of the
|
||||||
validated.
|
backlog is settled.
|
||||||
|
|
||||||
No authentication - matching the existing WebSocket server's own complete
|
No authentication - matching the existing WebSocket server's own complete
|
||||||
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
||||||
|
|||||||
Reference in New Issue
Block a user