diff --git a/README.md b/README.md index 5db79ae..7c08003 100644 --- a/README.md +++ b/README.md @@ -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 ``/`FileReader` and a `Blob`/`` standing in for the desktop app's native file dialogs), Start/Pause/Stop, and Confirm (a plain `` 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,