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
|
||||
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
|
||||
it's connecting to. 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 (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.
|
||||
**The server is the single source of truth for all state. Neither GUI client
|
||||
ever maintains its own model of what the server should be doing.**
|
||||
|
||||
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, because the
|
||||
server doesn't distinguish between the two cases at all - there's no
|
||||
"first connect" special-casing anywhere in this path.
|
||||
When a user moves a slider or clicks a button, the client sends a command
|
||||
and *waits* for the server's reply before updating any display — the reply
|
||||
is the only authority. Consequences:
|
||||
|
||||
The corollary, easy to miss: this mechanism has no concept of "transient" -
|
||||
anything sent through it is implicitly durable state that will be replayed
|
||||
- **Multiple clients stay in sync automatically.** Every state change is
|
||||
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*
|
||||
(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
|
||||
@@ -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.
|
||||
|
||||
The temperature controller has a master enabled switch (off by default -
|
||||
see `components/pid/temp_controller_base.py`): `SudTask` enables it for as
|
||||
long as a run is in progress (any state other than idle/finished) and
|
||||
disables it again - forcing the heater output to 0 - the moment it stops or
|
||||
finishes, handing control back to manual mode. The GUI's Manual tab has an
|
||||
"Enabled" checkbox for driving this directly outside of a Sud run; while
|
||||
disabled, its temperature/heat-rate setpoint controls are inactive.
|
||||
see `components/pid/temp_controller_base.py`). `HeaterTask` owns this
|
||||
switch via its `closed_loop` flag: the TC is enabled whenever the system is
|
||||
in closed-loop mode (the default) and disabled in open-loop mode. This is
|
||||
wired up in `server/brewpi.py` via
|
||||
`heater_task.set_on_closed_loop_changed(tc.set_enabled)`, and fired once
|
||||
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
|
||||
|
||||
@@ -531,22 +588,45 @@ in its own daemon thread, `--http-port`, default `8080`) needed adding,
|
||||
deliberately independent of the existing `websockets`-based asyncio server
|
||||
so neither can affect the other's behavior or availability.
|
||||
|
||||
v1 covers only the Manual tab (direct heater/stirrer/controller control)
|
||||
and the Progress tab (above) - the two most actionable surfaces, and
|
||||
neither needs a charting library. `web/app.js` ports the relevant logic
|
||||
from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
||||
The browser client covers the Manual panel (direct heater/stirrer/TC
|
||||
control) and the Progress tab (above). `web/app.js` ports the relevant
|
||||
logic from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
||||
`_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
|
||||
behind the Progress tab and status line. New/Load/Save (a plain
|
||||
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
|
||||
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
|
||||
Confirm when dismissed) are all wired up too - see `web/TODO.md` for
|
||||
what's still missing. Deliberately deferred rather than stubbed: the
|
||||
Confirm when dismissed) are all wired up.
|
||||
|
||||
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
|
||||
charts - both need a charting approach, revisit once Manual + Progress are
|
||||
validated.
|
||||
charts — both need a charting approach, revisit once the rest of the
|
||||
backlog is settled.
|
||||
|
||||
No authentication - matching the existing WebSocket server's own complete
|
||||
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
||||
|
||||
Reference in New Issue
Block a user