A step can now carry both 'ramp' and 'hold': it ramps to the target
temp, then holds there for the given duration, instead of needing two
separate schedule entries. Sud.temp_reached() switches such a step
from its ramp phase into its hold phase in place (re-firing the
step-changed callback so SudTask/demo_sud re-apply the hold's stirrer
settings); user_wait_for_continue still applies once the whole step -
both phases - is done.
Merges sud_0010.json's three ramp/hold pairs into combined steps.
Adds a 'System' channel for static, global info that doesn't belong
to any one task - sent once at startup directly from brewpi.py's
__main__ (a client connecting later still gets it via the
dispatcher's global_state replay on subscribe, same mechanism
SudTask's startup Name/Description already relies on).
Moves warp_factor out of SudTask's startup message and into this new
channel, since it's not actually Sud-specific - it's now available
(and the status bar shows it) even without a Sud configured. The
forecast plot's progress-line scaling switches to the same general
Window.warp_factor field.
Displayed via a permanent status bar widget (not cleared by the
existing transient step/schedule showMessage() calls).
Reverts 19dc7a9 ("Fix Sud hold duration units: seconds, not minutes")
per explicit instruction - duration is minutes again. Re-adds the
*60.0 conversion in Sud._advance() (hold_remaining is ticked in
simulated seconds), the matching conversion in the forecast plot's
_estimate_course(), and flips the README wording back.
SudTask now computes warp_factor = dt/interval (the same simulated-vs-
wall-clock split Pot/TempController/Stirrer already use internally)
and sends it once at startup as 'WarpFactor'. The GUI uses it to scale
real elapsed time into the forecast's simulated-schedule time axis,
so the progress line lines up with the actual sim speed instead of
assuming real time.
Also fixes SudTask.tick() to decrement hold_remaining by dt (simulated
seconds) instead of interval (wall-clock seconds) - it was the only
place in the sim using the wall-clock interval for something meant to
track simulated time, so hold steps were taking warp_factor times
longer in real time than their declared duration intends. Without
this fix the GUI's new warp-scaled progress line would race ahead of
the real server during holds.
Adds a thread-safe path from on_sud_changed() (websocket recv thread)
to a modal QMessageBox on the GUI thread via Qt's cross-thread
signal/slot queuing, fired when the State transition lands on
WAIT_USER - which the schedule is already blocked on server-side, so
the dialog just gives it a UI: closing it (OK) sends Confirm.
Deliberately scoped to WAIT_USER transitions, not every UserMessage -
most steps in sude/sud_0010.json inherit the schedule's default
placeholder text ("Put user message here") rather than overriding it,
so popping a dialog for every step would mostly show that placeholder.
Renames everywhere the concept appears: Sud.download()/upload() ->
save()/load(), the 'Download'/'Upload' websocket messages ->
'Save'/'Load', the brewpi_gui menu actions, handlers and
sud_download_path -> sud_save_path, and the demo script. Behavior is
unchanged - this is naming only, from the client's perspective (save
to / load from a local file) rather than the server's.
components/sud.py: adds SudState.PAUSED. pause() freezes RAMPING/
HOLDING (the hold countdown and ramp-reached checks both no-op while
PAUSED, since they're gated on the exact state they froze); start()
now doubles as resume when paused. stop() aborts back to IDLE from
any in-progress state, reusing the same reset logic as __init__/
upload() (factored into _reset_run_state()).
tasks/sud.py: maps 'Pause'/'Stop' messages to the new methods, and
turns the stirrer off on IDLE (not just DONE) so stop() actually
silences it instead of leaving the last step's stirring running.
client/brewpi_gui.py: wires the Pause/Stop toolbar actions, extends
update_sud_actions() so Start doubles as Resume and Stop stays usable
while paused, and freezes the forecast plot's progress line for the
duration of a pause (tracked via accumulated paused time) instead of
letting it drift ahead of actual progress.
on_sud_changed() now handles 'Step' messages, showing the step's
description (and clearing it once there's no active step), and tracks
IDLE/DONE <-> running transitions to time a red vertical progress line
on the Automatic tab's forecast - elapsed real time since the brew
started, plotted against the forecast's nominal-schedule time axis so
you can see how actual progress compares to the plan.
Sends {'Start': True} on the Sud channel, which SudTask.recv() already
maps to sud.start(). Only reachable while enabled (loaded and idle),
per update_sud_actions().
Start is enabled only once a schedule is loaded and idle; Pause/Stop
only while it's actually running (RAMPING/HOLDING/WAIT_USER). All
three stay disabled until something arrives on the Sud channel (which
only happens if a SudTask is actually configured server-side), and
get disabled again on disconnect since nothing reported over a dead
connection can still be trusted.
Also drops actionStop's leftover wiring to disconnect() - now that
Stop is meant to stop a running brew, it shouldn't tear down the
websocket connection instead.
ws_client.disconnect() blocks until the close handshake completes
(observed ~10s when the server doesn't ack promptly), and was being
called straight from the Connect/Disconnect button's click handler -
joining that wait on the GUI thread, so the click appeared to do
nothing until the timeout passed. Run it off a throwaway thread
instead; on_ws_connect_changed() already flips the button once the
disconnect actually finishes.
MessageDispatcherSync gains an optional on_connect_changed(bool)
callback, fired from the websocket client's background loop once the
connection is actually live/lost, so the button only flips state on a
real connection change rather than optimistically on click.
Start/Pause/Stop on the toolbar were never about the websocket
connection - Start now does nothing until that's wired to its own
purpose, and connecting is its own explicit action next to the host
text entry.
Adds a static forecast plot to the (previously empty) Automatic tab,
showing the planned temperature course derived from a schedule's
declared ramp rates and hold durations alone (no plant/controller
model - just how long the schedule itself says each step takes).
The client now requests the schedule via Download right after
connecting, routing the reply to the forecast instead of a save
dialog since sud_download_path is only set for the user-initiated
download. SudTask also echoes the just-applied document back after a
successful Upload, so editing the schedule refreshes the forecast
without an extra round trip.
Drop dark_background in favor of a plain white figure, matching
demo_sud's blue/red ist/soll color convention. Add faint major/minor
gridlines, drop the top/right spines, and use label_outer() so only
the bottom subplot repeats x tick labels across the shared time axis.
Embeds a dark-themed matplotlib canvas (3 stacked strip charts: theta
ist/soll, heatrate ist/soll, heater power_set/power_eff) in a new Plot
tab, sampled once per second from the latest websocket-received values.
HeaterTask now also emits a PowerSet message (the pre-discretization
target power) alongside the existing Power (effective) message, since
the GUI had no way to see the set-point side of figure 1's bottom plot
otherwise.
Adds File menu actions to save the running Sud's schedule to a local
sud.json and to replace it from one, via the Sud websocket channel's
new Download/Upload messages. File dialogs are opened up front on the
GUI thread; the actual read/write happens off the websocket recv
callback, which runs on a worker thread and must not touch Qt itself.
main_window.py is regenerated from brewpi.ui via pyuic5 5.15.11, which
also picks up an unrelated pre-existing drift (the default URI had
fallen out of sync with the .ui source).
Pure rename of the pyuic5-generated UI module plus its one import
site in brewpi_gui.py; regenerated from brewpi.ui, no content changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PyQt5.Qwt isn't pip-installable; it only existed via the apt package
python3-pyqt5.qwt, so pip-installing client/requirements.txt's plain
PyPI PyQt5 always crashed with "No module named 'PyQt5.Qwt'".
Qwt was only used for 3 QwtSlider widgets (Slider_temp_soll,
Slider_pwr_soll, Slider_speed_soll). Swap them for stock QSlider in
brewpi.ui, regenerate brewpi_win.py via pyuic5, and update
brewpi_gui.py's setLowerBound/setUpperBound calls to QSlider's
integer-only setMinimum/setMaximum. The GUI now runs from a plain
`pip install -r client/requirements.txt` venv with no system PyQt5
packages required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the server, GUI client, and websocket layer into their own
packages with dedicated requirements.txt files, and adds a README
documenting the project's architecture and usage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>