Commit Graph
52 Commits
Author SHA1 Message Date
jensandClaude Sonnet 4.6 b925934e57 GUI: show estimated pot/water/grain mass on the step status line
Appends a "Pot: X kg, Water: Y kg, Grain Z kg, total W kg" breakdown to
the status bar's step line, using the pot_mass already in the loaded doc
(captured from Sud._parse_data(), previously discarded) and the current
step's already-resolved grain_mass/water_mass from self.sud_schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 20:05:41 +02:00
jensandClaude Sonnet 4.6 f1688d3c71 Derive Pot's L/Td from the Sud doc instead of a fixed server default
Sud now parses top-level L/Td fields from sude/*.json (defaulting to 0.2/30,
matching the old hardcoded server constants), and derive_plant_params()
returns them alongside M/C - constant for the whole brew, unlike M/C which
vary per step with grain/water mass. All sude/*.json docs gain explicit
L/Td fields.

Callers (tasks/sud.py, SudForecastEstimator, demo_sud.py) switch from the
narrow set_thermal_params(M, C)/set_model_params(M, C) to the full
set_plant_params(params)/set_model_plant_params(params), since
derive_plant_params() now always returns all four keys; both narrow setters
are removed as dead code.

Caught along the way: Pot.set_plant_params() unconditionally rebuilt the
Delay ring buffer, which was harmless when only ever called once at
construction - but now running on every Sud step change, it was wiping
in-flight delayed power at each step boundary. Fixed to only rebuild when
Td actually changes; verified this restores the exact original forecast
result for sud_0010.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 18:45:07 +02:00
jensandClaude Sonnet 4.6 84b1607c78 Forecast a Sud schedule through its confirm steps, not just up to the first one
A user_wait_for_continue step used to stop SudForecastEstimator.estimate()
dead, so the GUI's forecast plot only ever showed the first segment on Load.
Model the wait as a zero-delay auto-confirm instead, so the whole schedule's
projected curve is visible right away; correct that assumption piecewise as
real confirmations actually happen, anchored at the real elapsed time and
temperature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 17:12:19 +02:00
jensandClaude Sonnet 4.6 57b86bc075 Make the Sud forecast piecewise, computed once per segment
Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.

components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.

tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().

client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.

Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 16:33:40 +02:00
jensandClaude Sonnet 4.6 41b06d8961 Eliminate the naive forecast; fix two bugs that hijacked the plot
Root cause of "forecast vanishes right after loading, before Start":
Sud.elapsed resets to 0 on every fresh Load too, not just on an actual
run start (components/sud.py's _reset_run_state()), and that reset
broadcasts unconditionally. The GUI's Elapsed handler applied it
unconditionally too, flipping sud_elapsed_seconds from None to 0.0
purely from loading a schedule - which made on_plot_timer() think a
run had started, hijacking the plot into show_dynamic() (empty
history, visible progress line, "(est.)" title) instead of the static
forecast preview. Fixed by only applying an incoming Elapsed value
once the State-transition logic has already put the client into
dynamic-view mode, so a mere Load while idle can't masquerade as a
run starting.

Separately, eliminates the naive abs(delta)/rate forecast entirely, as
instructed - both the immediate static placeholder shown the instant a
schedule loaded (show_schedule()/_estimate_course()) and the dynamic
per-tick fallback used until the server's simulated RemainingForecast
arrived after a step change. The static preview now always waits for
and shows only the simulation-based forecast (show_computing() while
it's pending, then show_precomputed_course()); the dynamic dashed
projection simply holds its last value during that brief gap instead
of guessing. _remaining_schedule() and SUD_HOLDING_STATE, now unused,
removed too.

Also fixes the axes-scaling bug found along the way: relim()+
autoscale_view() leaves autoscaling switched on, so a later, unrelated
redraw (e.g. set_current_temp()'s continuous updates, which keep that
indicator live regardless of whether a forecast is even showing) could
silently re-autoscale and stretch the view out to fit it - e.g.
leftover heat from a previous, different run sitting well outside a
newly loaded schedule's own range. show_precomputed_course() now
computes and locks explicit xlim/ylim from the forecast data alone via
the new _set_fixed_limits().

Verified live: a fresh load shows only the simulated forecast,
correctly scaled, with no premature dynamic-mode hijacking; loading a
schedule after a much hotter previous run no longer distorts the axes
to fit the leftover actual temperature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 14:04:02 +02:00
jensandClaude Sonnet 4.6 8c23a2c6ac Clear the Sud Load-rejection Error so it doesn't get replayed as stale
WsServerMultiUser.global_state persists every message ever broadcast
and replays the full accumulated state to any client that subscribes
- by design, so a client connecting fresh or reconnecting mid-run
always gets the same complete picture (see README's new "State replay
on connect" section). That mechanism has no concept of "transient":
the {'Sud': {'Error': ...}} added for Load-rejection got treated as
durable state just like everything else, so any client connecting
after the fact - even one that never touched Load - got the stale
error replayed on connect. Confirmed live before this fix.

tasks/sud.py: send {'Sud': {'Error': None}} immediately after the
error itself, clearing it back to neutral in global_state.

client/brewpi_gui.py: on_sud_changed() treats a falsy Error as a
no-op instead of popping an empty dialog.

README.md: documents the underlying principle (client state replay
on connect, and the corollary that one-shot events need explicit
clearing) under a new Architecture subsection, and refreshes the Sud
channel/forecast sections to match this session's other changes
(Elapsed-based dynamic forecast anchoring instead of wall-clock time
times the nominal warp factor, Load's Error reply).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 13:20:24 +02:00
jensandClaude Sonnet 4.6 1af8c39926 Surface Sud's Load rejection as a server-driven error, not GUI guesswork
Sud.load() already refuses a new schedule while a run is in progress
(state not IDLE/DONE) - but tasks/sud.py's SudTask.recv() silently
dropped that refusal, and an earlier attempt to handle it client-side
by pre-emptively greying out the File menu based on the GUI's own
(replicated, laggy) view of sud_state was reverted: the intelligence
belongs on the server, which already knows definitively whether it's
running, not on the client guessing from a mirrored state that can
lag or miss the message that changed it.

tasks/sud.py: when Sud.load() returns False, send {'Sud': {'Error':
...}} explaining why, instead of doing nothing.

client/brewpi_gui.py: New/Save/Load Sud stay enabled unconditionally;
on_sud_changed() shows whatever 'Error' the server sends via
QMessageBox.warning(). on_action_sud_new()'s comment updated to match
(it goes through the same Load path, so gets the same rejection/error
for free).

Verified live: triggering Load while a run is in progress on an
isolated test server round-trips the Error over the wire and pops the
expected dialog ("Cannot load a new schedule while a run is in
progress - stop it first.").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 13:01:01 +02:00
jensandClaude Sonnet 4.6 44b1b0c705 Have Sud report its own tick-counted elapsed time to clients
Window._elapsed_min() (client/brewpi_gui.py) reconstructed the dynamic
forecast's x-axis from wall-clock time times the server's nominal
configured sim_warp_factor. But that warp factor is only nominal -
real asyncio/Python scheduling overhead across the 7 concurrent tasks
means the actually-achieved speedup runs measurably below it (~80x
instead of 100x, confirmed by timing HoldRemaining countdowns against
real elapsed time), especially under heavy message/print load. Since
the GUI's reconstruction assumed a precise, constant mapping, the live
measured trace visibly drifted behind the forecast - most visible on
short, transition-dense test schedules where the timing slip is a
large fraction of the total duration.

components/sud.py: Sud now tracks 'elapsed' (tick-counted simulated
seconds since the run started, frozen while PAUSED/IDLE/DONE, reset on
start()) - the ground truth for run progress, immune to real-world
pacing jitter. tasks/sud.py broadcasts it as {'Sud': {'Elapsed': ...}}.

client/brewpi_gui.py: replaced sud_start_time/sud_paused_total/
sud_pause_started_at and all the wall-clock reconstruction in
_elapsed_min() with sud_elapsed_seconds, set directly from the
server's 'Elapsed' broadcasts - simpler too, since pause-freezing is
now handled server-side rather than needing separate client-side
bookkeeping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 11:21:39 +02:00
jensandClaude Sonnet 4.6 7c95a3bde3 Marshal websocket recv callbacks onto the Qt GUI thread
WsClient's background thread (ws/client/ws_client.py) called every
on_*_changed handler - and on_ws_connect_changed - directly, but
they all mutate Qt widgets (lcdNumber.display(), setText(),
setChecked(), the plot canvas, ...), which Qt only allows from the
GUI thread. Caught live as "QObject: Cannot create children for a
parent that is in a different thread" on startup, and is a latent
crash/connection-drop risk under load (a cross-thread Qt exception
would be swallowed by ws_client.py's bare except, silently tearing
down the connection).

Adds a generic recv_signal (pyqtSignal(object, object)) carrying the
real handler and its message; _dispatch_recv (its slot) runs on the
GUI thread regardless of which thread emitted, so every handler's
body is now safe. user_message_signal - previously the only handler
already using this pattern, just for one case - becomes redundant
now that on_sud_changed itself always runs on the GUI thread, so its
modal dialog can be shown directly (renamed to show_user_message()).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 10:22:18 +02:00
jensandClaude Sonnet 4.6 2bdd3203cf Make Sud steps ramp based on the actual temperature gap, not a 'ramp' key
Previously, whether a schedule step ramped at all was decided purely by
the presence of a 'ramp' key (components/sud.py's _advance()) - a
hold-only step jumped straight into its hold countdown, assuming the
plant was already at temperature. Now every step ramps toward
'temperature' first, for as long as the controller's own gap-tracking
FSM (TempControllerBase, already distinguishing HEAT/COOL/HOLD) says
the gap actually warrants it - via the new is_holding() (on APid and
TempControllerBase), which replaces a separate, redundant
TEMP_REACHED_TOLERANCE constant duplicated across tasks/sud.py,
components/sud_forecast.py, and scripts/demos/sud/demo_sud.py.

set_theta_soll() now recomputes the FSM eagerly so is_holding() can't
read stale HOLD for a tick after a much-further-away target is pushed.

components/sud.py's _build_step() always synthesizes a 'ramp' block
from default.step.ramp (so 'rate' is always available), but leaves
'temperature' undefaulted - every real sude/*.json's
default.step.temperature is inert template filler, and defaulting it
would send hold-only steps chasing 0 degrees. SudTask/
SudForecastEstimator/the demo only push a new theta_soll/heatrate_soll
when a step actually specifies its own temperature; otherwise the
controller keeps whatever the previous step left running.

Also fixes a related crash this surfaced: SudTask.remaining_schedule()'s
synthetic mid-hold step dropped 'ramp' to signal "don't re-ramp" - now
that every step always carries a 'ramp' dict, dropping it left 'rate'
missing the moment the synthetic step was re-resolved by
SudForecastEstimator. Drops 'temperature' instead, which is what
actually signals "no new target" under the new model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 09:35:53 +02:00
jensandClaude Sonnet 4.6 7d79d885b5 Fix dynamic forecast dashed line drifting right during a run
on_plot_timer() re-anchored the server's RemainingForecast to the
live, ever-advancing elapsed_min on every 1s tick, even though that
forecast is only recomputed on step changes. Between step changes,
the unchanged (t_rem, theta_rem) array kept getting pushed further
right by the full live elapsed_min each tick instead of staying
anchored to when it was actually computed.

Store server_remaining_forecast as (anchor_min, T, Theta), with
anchor_min captured at receipt via a new _elapsed_min() helper, and
anchor the dashed projection to that fixed point instead of the live
tick's elapsed_min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 08:14:16 +02:00
jens d91bba1ba2 Use the server's simulated forecast for the dynamic remainder too
SudForecastPlot.show_dynamic()'s dashed "remaining" line was still
using the naive abs(delta)/rate estimate even after a run started,
even though the (much more accurate) server-side
SudForecastEstimator was already available - the static forecast got
the upgrade, the live one didn't.

tasks/sud.py: SudTask now recomputes a forecast for just the remaining
steps (seeded from the live current temperature) on every step change
and sends it as {'RemainingForecast': {...}}. Building the synthetic
"rest of this step" entry for a step currently HOLDING needs to keep
the current step's grain_mass/water_mass/temperature/etc - a bare
{'hold': {...}} re-resolves those to None against the synthetic doc's
empty default.step, breaking derive_plant_params() (caught live: a
TypeError on every step change, silently swallowed by asyncio).

client/brewpi_gui.py: on_plot_timer() now uses the server's
RemainingForecast for the dashed projection whenever available,
falling back to the naive estimate only for the brief window before
each step's recomputation arrives. show_dynamic() takes the already-
computed (t_rem, theta_rem) instead of computing it internally, so the
GUI/server sources are interchangeable from its point of view.
2026-06-21 17:24:26 +02:00
jens 567ab80c9c Add a simulation-based Sud forecast, computed server-side
New components/sud_forecast.py: SudForecastEstimator simulates a whole
schedule with the same kind of plant/controller (and the same
configured params - ambient, plant params, pid_type, TempCtrl gains,
heater max power) the server uses for real, by driving a throwaway
Sud/Pot/controller trio through it exactly as tasks/sud.py's SudTask
would. It's pure CPU-bound iteration (no real time/IO), so a multi-
hour brew simulates in well under a second.

tasks/sud.py: SudTask now takes an optional forecast_estimator and
sends its result ({'Forecast': {'T': ..., 'Theta': ...}}) on the Sud
channel after every successful Save/Load, run in a worker thread via
run_in_executor so the ~100-500ms simulation doesn't stall the other
tasks. server/brewpi.py constructs one with the real server's config
and wires it in; AmbientTemp changes update it too.

client/brewpi_gui.py: the quick naive show_schedule() estimate (drawn
immediately on Load, before the server's simulation finishes) is now
superseded by show_precomputed_course() once the Forecast message
arrives - only while not actively running, so it doesn't fight the
dynamic re-anchored view.

Verified: matches a standalone run of the estimator (177 min for
sude/sud_0010.json) and replaces the old naive 164 min estimate live
within a couple seconds of loading.
2026-06-21 16:46:52 +02:00
jens c27a3b06d4 gui: don't recompute a misleading static forecast after a run ends
On DONE/Stop, the forecast used to revert to show_schedule() seeded
with the *current* temperature - which by then is wherever the brew
ended up (e.g. near the last step's target), so walking the whole
original schedule again from there produced a nonsensical total
("152 min" for a brew that actually took ~250). Now it just leaves the
dynamic view's last frame (the real measured history) up instead.
Recomputing from the current temperature is still correct for a fresh
Load, where no run has happened yet - distinguished via whether
sud_start_time was set before this transition.
2026-06-21 14:37:52 +02:00
jens 929b8758c4 Remove the ad-hoc cooling override now that COOL handles it natively
TempControllerBase.cooling/set_cooling() forced y=0 in advance of a
Sud ramp-down step; redundant now that the FSM's own COOL state
detects and handles a negative diff itself. Removed from the
controller, SudTask's anticipatory detection (and the Step message's
now-unused "Cooling" field), and demo_sud.py's mirror of it.

gui: the "Cool down" status-bar indicator now reflects the
controller's actual State ("States.COOL") via the TempCtrl channel,
rather than Sud's removed anticipatory flag - renamed
sud_cooling/set_sud_cooling to tc_cooling/set_tc_cooling to match.
2026-06-21 13:48:23 +02:00
jens 3fea29cc58 Add an enable/disable switch for the temperature controller
New TempControllerBase.enabled (default False) forces y=0 in
process_pid() when off, same mechanism as the existing cooling
override - the controller tries not to drive the heater at all.

tasks/tempctrl.py: new 'Enable' recv command and 'Enabled' broadcast
on the TempCtrl channel. tasks/sud.py: SudTask now enables the
controller on any non-IDLE/DONE Sud state and disables it (forcing
power back to 0) on IDLE/DONE, so automatic mode owns it for the
duration of a run and hands back manual control once it stops/finishes.

gui: new "Enabled" checkbox on the Manual tab's Controller group,
synced from the server; the temp/heatrate setpoint controls are
disabled whenever the controller itself is disabled.
2026-06-21 12:54:09 +02:00
jens 0a74aab43f gui: remember the ambient temperature in the user config
Persists whenever set via the entry, and re-applies automatically on
connect - so it survives a server restart (which would otherwise fall
back to config.json's ambient_temperature) without retyping it.
2026-06-21 12:18:30 +02:00
jens 19be2a7670 Add a "Pot reset to ambient temperature" button, sim-only
New btn_pot_reset next to the ambient entry, sends {"Pot": {"Reset":
true}}; tasks/pot.py's PotTask.recv() (previously a no-op) now resets
the plant to its current ambient temperature (Pot.initial()) on that
command.

The button only appears when the server reports its Pot is simulated
(new "PlantSim" field on the System channel, derived from
config.json's previously-unused Controller.plant_name) - resetting a
simulated plant's temperature is meaningless for a real one.
2026-06-21 12:15:35 +02:00
jens 40cf4b69cc Add a GUI text entry to set the ambient temperature live
New lineEdit_ambient next to the host/connect row - type a value and
press Enter to send {"System": {"AmbientTemp": ...}}. The server now
has a recv handler for the System channel (previously send-only):
updates the real Pot's ambient temperature and, where the controller
has an internal model (Smith), its model Pots too (new
set_ambient_temperature() on TempControllerSmith), then echoes the new
value back so the status bar label and entry itself stay in sync.
2026-06-21 11:53:45 +02:00
jens a44060a9bd gui: add a persisted user config; remember the last Sud file dialog dir
New client/user_config.py - a small JSON-backed key/value store at
~/.config/brewpi/gui.json for GUI preferences that should survive
restarts (distinct from the server's config.json and from sude/*.json
schedules). First use: Save/Load Sud's file dialogs now open in
whichever directory was last used, instead of always starting at cwd.
2026-06-21 11:46:01 +02:00
jens 8bb402416a gui: show remaining hold duration in the status line
The status bar now appends "(MM:SS remaining)" to a hold step's text,
kept live by recomputing it on every HoldRemaining message (not just
when the step itself changes) - tasks/sud.py already sent this, the
GUI just wasn't tracking/displaying it.
2026-06-21 11:39:40 +02:00
jens b767053c18 gui: dynamically re-anchor the Sud forecast instead of a static estimate
Nonideal ramp rates and user pauses make the schedule's nominal-rate
estimate drift from reality the longer a brew runs. While a run is in
progress, the forecast plot now redraws every tick: the already-
elapsed part is the actual measured trace (solid line), and only the
remaining steps are projected forward (dashed line) from the live
current temperature, step index, and hold_remaining - rather than
recomputing the whole curve once from t=0 at load time.

components/sud.py's Step/HoldRemaining messages already carried
everything needed; the GUI just wasn't tracking step index or
hold_remaining at all before this. Reverts to the static full-schedule
view once the run stops/finishes.
2026-06-21 11:14:00 +02:00
jens d6b6548d88 gui: green progress marker + background red current-temp line in forecast
Progress line switches from red to green; a new horizontal red line
tracks the live measured temperature, drawn at a lower zorder than the
forecast/progress lines so it never visually covers them.
2026-06-21 10:59:40 +02:00
jens de84fb2342 gui: append the Sud's description to the window caption
Caption becomes "BrewPi - <name> - <description>" once a schedule
with both is loaded, degrading gracefully to just the name, or plain
"BrewPi", when either is empty.
2026-06-21 10:55:07 +02:00
jens e855076f55 gui: show the loaded Sud's name in the window caption and forecast title
Window caption becomes "BrewPi - <name>" once a schedule is loaded
(back to plain "BrewPi" when empty/disconnected); the Automatic tab's
forecast title is now "<name> - N min total", replacing the generic
"Estimated course..." text while keeping the time estimate.
2026-06-21 10:47:26 +02:00
jens b51d802b31 gui: don't echo manual-mode sliders/checkboxes back to the server on sync
On (re)connect, the manual-mode controls (heater power/activate, temp
soll, heatrate soll, stirrer speed/activate) sync themselves once to
the server's current value via setValue()/setCheckState(). Without
blockSignals(), that programmatic update also fires valueChanged/
stateChanged, echoing the value straight back as a command.

For the heater power slider this was the cause of the "stale power >
0 after restart" bug: if the slider happened to sync to a nonzero
power (e.g. reconnecting mid-heat), the echoed {'Power': ...} set
tasks/heater.py's power_soll, which power_soll = max(power_soll,
power_actor) then latches as a floor that never drops back down on
its own - the heater would then stay at least that hot regardless of
what the actual controller/Sud wanted afterwards.
2026-06-21 10:34:28 +02:00
jens c659fcee37 gui: add "New Sud" menu action, disable Start/Pause/Stop for an empty sud
File > New Sud loads an empty schedule (zero steps) onto the server,
same mechanism as Load but with a built-in empty document. Tracks
whether the loaded schedule actually has steps (sud_empty) separately
from whether a Sud is configured at all (sud_loaded) - Start/Pause/Stop
now require both, and the Automatic tab's forecast shows "No sud
loaded" instead of an empty plot while sud_empty.
2026-06-21 10:01:26 +02:00
jens 194156f244 Move Sud step's target temperature from ramp.temp to step.temperature
A step's target temperature applies to the whole step (it's what a
ramp ramps to and what a hold then holds at), not just its ramp phase
- promote it from a nested "ramp": {"temp": ...} to a step-level
"temperature" field, defaulted like descr/grain_mass/etc. Updates all
consumers (Sud._build_step, SudTask, the GUI's forecast estimator,
demo_sud.py) and migrates sude/sud_0010.json and the README.
2026-06-21 09:47:45 +02:00
jens 1170718c3b Force the heater off and show a "Cool down" indicator during a passive cooldown
A ramp step whose target is below the current actual temperature can
only be reached by ambient cooling - the heater can't actively cool.
SudTask now detects this once per ramp phase and calls the controller's
new set_cooling() override (forces y=0, bypassing the FSM) instead of
waiting for its hysteresis-based IDLE transition. The GUI shows a
blinking blue "Cool down" label in the status bar while this is active.
Progression already only advances once theta_ist matches theta_soll
regardless of direction, so no change was needed there.
2026-06-21 09:10:13 +02:00
jens 9bd9889cb0 Allow a Sud step to combine a ramp and a hold
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.
2026-06-21 09:01:04 +02:00
jens 56de5cf4d6 Show ambient temperature and warp factor in the GUI's status bar
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).
2026-06-21 00:38:31 +02:00
jens 2f7d48b2cb Revert Sud hold "duration" back to minutes
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.
2026-06-21 00:32:55 +02:00
jens 189d1e24d2 Scale the Sud forecast progress by the server's actual warp factor
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.
2026-06-21 00:27:14 +02:00
jens e1d0deb8d2 Show user_message as a modal dialog when the Sud waits for confirmation
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.
2026-06-21 00:15:31 +02:00
jens 13e507e2b5 Rename Sud's Download/Upload to Save/Load throughout
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.
2026-06-20 23:58:21 +02:00
jens 5d884ca3da Wire Pause/Stop to the Sud state machine, with a resumable pause
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.
2026-06-20 23:48:03 +02:00
jens 4a824d09cc Show the active step in the status bar and progress on the forecast plot
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.
2026-06-20 23:32:01 +02:00
jens bd658199fe Wire the Start toolbar action to actually start the Sud sequence
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().
2026-06-20 23:23:26 +02:00
jens 2adcca6716 Gate Start/Pause/Stop on whether a Sud is loaded and running
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.
2026-06-20 23:19:01 +02:00
jens 127dd9da51 Don't freeze the GUI thread on disconnect
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.
2026-06-20 23:09:23 +02:00
jens d1a69f19ce Toggle Connect/Disconnect on the same button once connected
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.
2026-06-20 23:04:43 +02:00
jens 9c3ec1dfdf Add a Connect button next to the host field; unwire it from Start
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.
2026-06-20 23:01:18 +02:00
jens 62dcd6b541 Default the GUI's websocket URI to localhost instead of brewpi 2026-06-20 22:56:54 +02:00
jens 3289288488 Pre-visualize the estimated Sud course in the Automatic tab
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.
2026-06-20 22:54:34 +02:00
jens caa94c7508 Switch the Plot tab to a bright theme with a refined grid and ticks
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.
2026-06-20 22:39:38 +02:00
jens 93b5667a64 Add a real-time Plot tab to brewpi_gui, mirroring demo_sud figure 1
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.
2026-06-20 22:31:07 +02:00
jens bb94e065f4 Wire Sud upload/download into the brewpi_gui client
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).
2026-06-20 22:18:49 +02:00
jensandClaude Sonnet 4.6 d02b7cbe72 Clean up after Smith predictor rewrite and drop unused/stale config
[brewpi] - drop trace vars for the dtheta_ist_* attributes removed
when temp_controller_smith.py dropped Kalman filtering
[Pot] - drop unused kn field (never read since the noise-injection
code that used it was dropped)
[GUI] - default WS connection to ws://localhost:8765

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:09:36 +02:00
jensandClaude Sonnet 4.6 44372364c4 Rename brewpi_win.py to main_window.py
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>
2026-06-18 21:28:22 +02:00
jensandClaude Sonnet 4.6 88357b31f6 Remove PyQt5.Qwt dependency from the GUI client
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>
2026-06-18 21:24:09 +02:00