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:
2026-06-28 10:41:31 +02:00
co-authored by Claude Sonnet 4.6
parent 05358e7c4c
commit 6d65d11856
+118 -38
View File
@@ -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,