Commit Graph
73 Commits
Author SHA1 Message Date
jensandClaude Sonnet 5 d1e556024d fix: thread config.json's Pot.mass into both UI clients' displayed mass
Sud._parse_data() already falls back to config.json's Pot section when
a loaded sud.json doesn't override 'mass', but neither client actually
passed that config through, so displayed pot mass silently read 0 kg
whenever a recipe didn't set its own 'pot.mass'.

client/brewpi_gui.py: capture the System message's 'Pot' key (already
sent by the server, previously ignored) into self.pot_config, and pass
it to Sud._parse_data(doc, self.pot_config).

web/app.js: parseSudDoc()'s potMass now falls back to the module-level
potConfig.mass (already captured from msg.Pot elsewhere, just never
consulted here) before defaulting to 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 10:25:09 +02:00
jensandClaude Sonnet 4.6 2b73ff7d5c Zero heater and stirrer sliders when sud stops in both clients
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF16nV2ispNw1C9SbvqbRQ
2026-06-27 22:00:18 +02:00
jensandClaude Sonnet 4.6 ce6f14eccf Disable manual sliders during sud, track live values continuously
During a running sud, all four manual sliders (temp soll, heat rate,
heater power, stirrer speed) are disabled for user input but update
their positions continuously from server pushes. They re-enable when
the sud stops or is paused, or on disconnect. Browser client also
resets initial-sync flags on reconnect so sliders re-sync correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF16nV2ispNw1C9SbvqbRQ
2026-06-27 21:10:40 +02:00
jensandClaude Sonnet 4.6 dd6193fa46 Remove manual tab override/enable checkboxes from browser and PyQt clients
Drops the tc-enable, heater-activate and stirrer-activate checkboxes
and all associated state, signal connections, initial-sync flags, and
channel-handler logic. Regenerates main_window.py from the updated .ui.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF16nV2ispNw1C9SbvqbRQ
2026-06-27 20:37:14 +02:00
jensandClaude Sonnet 4.6 99201833ff Fix GUI client references to nested pot params
Update set_step(), status bar mass display, and on_action_sud_new() to
read grain_mass/water_mass from step['pot'] and use the new pot key
structure when sending an empty Load document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T52JH848ojhXXHn1bAzdC3
2026-06-26 08:04:01 +02:00
jensandClaude Sonnet 4.6 7dd0400278 Format temperature and rate LCD displays to always show 1 decimal place
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 22:26:06 +02:00
jensandClaude Sonnet 4.6 0215bac13e Distributed confirmation dialog: all GUIs open on WAIT_USER, server closes all
- Browser: OK button sends Confirm without closing dialog; Escape blocked;
  dialog closes when state leaves WAIT_USER (server-driven).
- PyQt: non-blocking QDialog replaces blocking QMessageBox; OK sends Confirm
  without closing; dialog closed by server state change or on disconnect.
- ws_client.py: remove deprecated loop= params from websockets.connect() and
  asyncio.ensure_future() (same fix already applied to ws_server.py) so the
  PyQt client can actually connect with websockets >= 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 22:21:41 +02:00
jensandClaude Sonnet 4.6 dabec1fd31 Wire PyQt power ist/soll LCDs to match browser GUI
lcdNumber_power_pot (col 0, ist) now shows heater effective Power;
lcdNumber_power_heater (col 1, soll) now shows heater PowerSet.
Removes debug prints from on_heater_changed and on_pot_changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 21:39:10 +02:00
jensandClaude Sonnet 4.6 35d33530ef Fix step-1-skipped-on-restart race and Progress tab step number mismatch
Re-enabling the temp controller landed unconditionally in HOLD and
relied on the next tick to correct it, but SudTask.on_process() reads
is_holding() synchronously in the same call chain that pushes a fresh
step's setpoint - so a restart could finish a hold-less step instantly
instead of actually ramping. Resolve the FSM against the real gap right
away instead. Also fix the status bar showing one step number below
what the Progress tab's plates show for the same step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
2026-06-24 23:30:37 +02:00
jensandClaude Sonnet 4.6 2913b41cab Fix elapsed showing --:-- at DONE, and energy not reset on restart
sud_elapsed_seconds is intentionally reset to None once a run leaves
a running state (it gates the forecast plot's dynamic-vs-static view
switch) - but the status bar/Progress tab reused it too, so they lost
the final total exactly when it mattered most: right as the run
finished. Added sud_elapsed_last, which only ever gets overwritten by
a real 'Elapsed' push and is reset on a fresh Start/Load, for those
two displays to use instead.

Separately, a fresh Start (restarting an already-loaded, now IDLE/
DONE schedule) never sent a new Load, so SudTask's energy bookkeeping
- previously only reset in the Load handler - kept showing the
previous run's banked totals until each step was revisited. Now also
reset on a fresh Start (not a Pause->resume, which should keep it).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 19:39:03 +02:00
jensandClaude Sonnet 4.6 fa6acf7c82 Show summed process time and total energy in the status bar
Elapsed is just Sud's own tick-counted total (every step's time,
WAIT_USER dwell included, already adds up sequentially with no gaps).
Energy has no equivalent single running counter server-side (it's
banked per step, not accumulated as one grand total - see tasks/
sud.py's SudTask) - summed here instead from every finished step's
own Wh total plus the active step's running one, then converted to
kWh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 19:21:01 +02:00
jensandClaude Sonnet 4.6 6e19162c16 Track and display energy consumption per Sud step
Each StepPlate on the Progress tab now shows a step's energy use in
Wh - live and growing while it's the active step, frozen at its final
total once finished, blank before it's ever been reached.

Integrated server-side from the heater's own live effective power
(pot.get_power(), already fed from heater.power_eff - see server/
brewpi.py's wiring), since actual energy used can't be predicted from
the forecast like the timing/temperature fields are, only measured as
it happens. Banked into energy_by_step on every genuine step
transition (on_step_changed(), keyed off the same index-change check
used for the ramp->hold phase switch), including the final transition
to DONE; reset on every fresh Load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 19:09:10 +02:00
jensandClaude Sonnet 4.6 8896f216d1 Fix Progress tab highlighting the wrong step on reconnect/Save
Reconnecting to (or any client-side Save fetch of) an already-running
Sud showed the wrong step active and bogus remaining times, traced to
two independent bugs:

- recv()'s 'Save' handler always called send_forecast(doc), which
  resimulates the *entire* schedule cold from step 0 - fine for a
  not-yet-started Sud, but for one already running it silently
  overwrote the forecast/forecast_step_starts that _reanchor_forecast()
  had been accurately, continuously maintaining with a context-free
  "starting now" guess. Now skipped whenever a run is already in
  progress, re-sending what's already there instead.

- _reanchor_forecast() recomputes each step's real start time
  asynchronously, so that recomputation can itself be (and routinely
  is) superseded and discarded by a later transition's reanchor before
  ever committing - and the next *successful* commit's "everything
  before my index is real" filter would then preserve whatever stale
  prediction was sitting there before, sometimes for a step that
  hadn't even happened yet by the schedule's real position.
  on_step_changed() now also records each step's start synchronously,
  immune to that race.

- Client-side, update_sud_forecast() unconditionally reset sud_step_
  index/forecast_step_starts/etc. on every 'Json' push - correct for
  an actual Load, but connect()'s unconditional Save request produces
  a standalone 'Json' push (no Step/State alongside it, unlike the
  subscribe-triggered replay) for the *same* already-running schedule,
  wiping the correct state with nothing left to restore it. Now only
  resets when the incoming doc actually differs from the last one
  processed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 18:15:45 +02:00
jensandClaude Sonnet 4.6 6faf1a1f12 StepPlate: force description/remaining-time labels to black text
Both relied on the theme's default label color, which washed out
against this dark theme - explicit color keeps them readable
regardless.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 17:30:30 +02:00
jensandClaude Sonnet 4.6 e49af8a347 Add a Progress tab: one step plate per schedule step, stacked
A glance at the Automatic tab's forecast plot couldn't show where the
brew actually stands within its own schedule - just a curve. The new
Progress tab stacks one StepPlate per step instead: an LED for its
status (off/done/active-ramping/active-holding), its resolved target
temp, masses and ramp rate, plus, for whichever step is currently
active, live actual temp and stirrer state (frozen at their last
value once that step finishes) and a forecast-based remaining-time
countdown.

The countdown needs to know where each step actually begins in the
forecast's timeline, which the server didn't track before -
SudForecastEstimator.estimate() now returns per-step start times
alongside t/theta, and SudTask threads them through send_forecast()/
_reanchor_forecast() the same way (including the same generation-guard
against the concurrent-call race) as a new 'StepStarts' field on the
'Forecast' message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 17:15:14 +02:00
jensandClaude Sonnet 4.6 17e8386002 Generalize Sud forecast reanchoring to every step boundary
Reanchoring used to only happen when a user confirmed a WAIT_USER
step, so anything the schedule advanced through on its own (a ramp
reaching target, a hold timing out) left the forecast showing a
stale, increasingly wrong prediction once reality diverged from it.
_reanchor_forecast() now fires from on_step_changed() on every real
transition, splicing in a fresh simulation anchored at the real
current temperature/elapsed time instead.

Also fixes two bugs surfaced while testing that change:
- estimate()'s early-return for an empty/not-yet-loaded schedule still
  returned the old 4-tuple shape, crashing every connection before a
  Sud was ever Loaded.
- send_forecast() and _reanchor_forecast() can run concurrently (e.g.
  a fresh Start triggers both at once), and whichever resumed second
  after its own worker-thread simulation would blindly splice its tail
  onto whatever the other had already written, producing a spurious
  connecting line across the plot. Both now carry a generation counter
  and discard their result if a newer call has since committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 10:50:17 +02:00
jens d8e9c39fda Revert "GUI: stop pre-dialing the Pot reset spinbox to ambient temperature"
This reverts commit 9373cc408a.
2026-06-23 22:28:37 +02:00
jens 9373cc408a GUI: stop pre-dialing the Pot reset spinbox to ambient temperature
doubleSpinBox_pot_temp no longer seeds itself from the remembered
ambient value at startup - it now starts at its plain default and
only changes once the user actually dials in a value, rather than
silently implying "ambient" is the reset target.
2026-06-23 22:24:36 +02:00
jens f035e1decd GUI: add a spinbox to pre-dial the simulated Pot's reset temperature
btn_pot_reset always reset the simulated Pot to the ambient
temperature. Add doubleSpinBox_pot_temp next to it (shown/hidden
together, same as the button) so the user can dial in a different
starting temperature before committing it - it seeds from the same
remembered ambient value on startup but is otherwise independent and
not persisted.

tasks/pot.py's PotTask.recv() now resets the plant to whatever
temperature the 'Reset' message carries, falling back to ambient for
the bare True a caller might still send.
2026-06-23 19:34:33 +02:00
jens deb370257d GUI: widen ambient-temp spinbox range and persist it client-side
doubleSpinBox_ambient now allows -999..999 in whole degrees (was
0..50 with 1 decimal), and main_window.py is regenerated to match.

The ambient temperature is also now a properly client-owned setting:
it's written to the user config on shutdown (not on every spinbox
tick), restored into the spinbox immediately on GUI start, resent to
the server on every (re)connect using the spinbox's live value, and
no longer reset to the spinbox minimum on disconnect.
2026-06-23 19:11:02 +02:00
jensandClaude Sonnet 4.6 8726b0a62d GUI: use a spinbox for ambient temperature instead of a text entry
Replaces lineEdit_ambient (QLineEdit) with doubleSpinBox_ambient
(QDoubleSpinBox, 0-50 degC, 0.5 deg steps) in brewpi.ui, regenerated
main_window.py via pyuic5 (same generator version, verified minimal diff).

brewpi_gui.py wires valueChanged instead of returnPressed - no more
manual float()/ValueError parsing of typed text, the signal already
hands over a valid float - with blockSignals guards around server-driven
updates (mirroring the existing doubleSpinBox_heatrate_soll pattern) so
applying a server echo doesn't immediately re-send the same value back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 22:52:36 +02:00
jensandClaude Sonnet 4.6 b5755f8a52 GUI: seed the actual-trace history with the real t=0 point on a fresh start
The actual trace's first sample otherwise only lands at on_plot_timer()'s
next 1-second tick - under a real sim_warp_factor that's already a
noticeable way into the run, leaving a small visible gap between the
forecast's exact t=0 anchor and where the actual trace first appears.

Seeds forecast_history with (elapsed, plot_temp_ist) the moment a fresh
Elapsed push arrives, gated on elapsed still being small
(FRESH_RUN_ELAPSED_THRESHOLD_S) so reconnecting mid-brew - where the
current reading has nothing to do with that run's actual t=0 - doesn't get
a bogus point plotted at x=0.

Verified live: without this, the actual trace's first point landed at
(0.5min, 20.6°) against a forecast anchored at (0min, 19.92°) - a visible
gap. With it, the seeded point (0.017min, 19.9°) lines up with the anchor
almost exactly, and the following natural samples continue smoothly from
there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 22:19:08 +02:00
jensandClaude Sonnet 4.6 1f146dbc0d GUI: draw the forecast line solid instead of dashed
Same color, same alpha-based fade distinguishing it from the actual
measured trace - just '-b' instead of '--b'. Updates the surrounding
docstrings/comments and README that described it as dashed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 20:43:07 +02:00
jensandClaude Sonnet 4.6 5ccfcef679 Add explicit initial grain_mass/water_mass to the Sud doc
Sud now parses top-level grain_mass/water_mass (sibling to pot_mass/
pot_material/L/Td, defaulting to 0/0) - what's actually in the pot before
the brew starts, as opposed to default.step's grain_mass/water_mass, which
is just inert template filler. All sude/*.json docs gain explicit values
matching what their first step already resolved to.

tasks/sud.py's apply_plant_params() now takes grain_mass/water_mass
directly instead of a step dict: on_step_changed() still passes the active
step's own (these vary as malt goes in/water boils off), but the Load
handler now reads Sud.grain_mass/water_mass directly instead of parsing
schedule[0]. The GUI's status-line mass preview (shown immediately on
Load, before Start) does the same.

Also fixes a latent bug found along the way: _continue_forecast_after_confirm()'s
reconstructed sub-doc was missing L/Td/grain_mass/water_mass entirely,
silently falling back to generic defaults instead of the real Sud's own
values - invisible only because every current sude/*.json happens to use
those same defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 20:19:25 +02:00
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