sudStepIndex was reset to null in updateSudForecast whenever
isNewSchedule=true (i.e. on every fresh page load). If the Step
message arrived before Json in the global-state replay, or in a
separate message, the schedule would be rebuilt but the index
would be null, leaving grain mass as 0 with no subsequent Step
to restore it.
Step messages are the authoritative source for the current index,
so the reset is removed. The Step handler always overwrites it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLR2133M2PyjiYq584i2G7
Water fill height now reflects water_mass + grain_mass*0.7 (grain
displacement). A small blue tick and label on the left pot wall shows
the utilized volume in litres, hidden when the pot is unconfigured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLR2133M2PyjiYq584i2G7
- Stirrer starts at speed 0 (was 50) so blades don't spin on connect
- Grain particles only levitate when stirrer is running
- Fire size driven by PowerSet (soll) instead of Power (ist)
- Split animation-play-state out of stirrer shorthand for reliable pausing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLR2133M2PyjiYq584i2G7
Replace scaleY group transform with a dynamic clipPath that reveals
flame height proportional to heater power. Base at y=256 stays fixed;
only the tips grow with intensity. Includes flicker overshoot buffer.
Fire paths shifted +8px down, viewBox extended to 265.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi
35 particles distributed across full pot height, clipped to dynamic
water area via water-clip-rect updated alongside water fill level.
Particle opacity transitions on grain_mass changes per step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi
- SVG fire group with 3 layered flame paths (red/orange/yellow gradients)
- CSS keyframe flicker animations with different timings per flame layer
- JS controls fire opacity and scaleY proportional to heater power
- Fix buildStep() reading grain_mass/water_mass from nested step.pot
instead of flat step keys, matching the JSON structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi
- Add volumen field to all sud JSON pot entries (30 L)
- Fix parseSudDoc to read from nested doc.pot (was using stale flat keys)
- SVG widget shows transparent rectangular pot with stirrer, thermometer
with live temperature, and water fill level (water_mass/volumen) when
a sud is loaded; no water shown when no sud is loaded
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi
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
In estimate()'s on_step_changed closure, the new 'pot = step.get("pot", {})'
local variable shadowed the outer 'pot' (Pot plant instance), causing
pot.set_plant_params() to be called on a plain dict and crash on load.
Renamed to 'step_pot' to avoid the collision.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T52JH848ojhXXHn1bAzdC3
Top-level pot_mass/pot_material/L/Td/grain_mass/water_mass and their
per-step overrides are now grouped under a 'pot' sub-object, matching
the structure already applied to sud_0010.json's parent level. Updated
all sude/*.json files and the parsing/consuming code in components/sud.py,
tasks/sud.py, components/sud_forecast.py, and scripts/demos/sud/demo_sud.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T52JH848ojhXXHn1bAzdC3
- 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
With TempControllerBase(APid, TempControllerFsm), APid.is_holding()
(abstract stub returning None) shadowed TempControllerFsm.is_holding()
in the MRO, so tc.is_holding() always returned None. The forecast
simulation never called sud.temp_reached() and always hit MAX_TICKS.
Swapping to TempControllerBase(TempControllerFsm, APid) puts the
concrete FSM methods before APid's abstract stubs in the MRO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
States, DEFAULT_THRESHOLDS, the three PIDs, FSM state vars, process_fsm(),
on_state_entered(), set_enabled(), and is_holding() move to a new
TempControllerFsm class in temp_controller_fsm.py. TempControllerBase
inherits from both APid and TempControllerFsm; States is re-exported so
existing imports in temp_controller_smith.py are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
All four name-dispatch factories repeated the same if/elif lazy-import
pattern. Replace with a ComponentFactory base class holding a callable
registry and a _lazy() helper for the common import-and-construct case.
PlantFactory is an orchestrator, not a dispatcher, and is left as-is.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Callbacks were registered inside the async on_process() coroutine,
meaning they weren't active until the event loop started ticking.
Moving them to __init__ ensures they're wired up at construction time.
Also removes remaining debug prints from the affected task files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Both handler loops caught all exceptions, printed e, and broke — hiding
errors entirely. Let exceptions propagate naturally; the parent handler's
FIRST_COMPLETED logic already handles task cleanup on disconnect.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Same pattern as set_model_power: these methods only existed on the Smith
controller, forcing five hasattr guards at call sites. No-op stubs on the
base class give all controllers a stable interface; Smith overrides them.
Remove the now-redundant hasattr guards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
The Normal controller had no set_model_power(), causing callers to
guard with hasattr() instead of relying on a stable interface. Adding a
no-op stub to the base class means all controllers have the method;
TempControllerSmith overrides it to actually update its internal model.
Remove the now-redundant hasattr guards at both call sites.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Notes Mainsail (Vue 3 + TypeScript + Vuetify, uPlot for live charts) as
the reference point for a future rewrite of app.js's hand-rolled DOM
wiring and the still-missing Automatic/Plot tab charts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Mirrors client/brewpi_gui.py's actionPause: btn-sud-pause sends
{"Sud": {"Pause": true}} and is only enabled while actually running
(not idle, not already paused), matching update_sud_actions()'s
enabled/disabled logic - Start already doubles as Resume while paused,
so no separate Resume control is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
New/Load/Save/Start/Stop now live in #sud-controls inside
#connection-bar, visible regardless of which tab is active, instead of
a standalone panel above the tabs. Drops the now-unused .button-row
rule along with it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Ports show_user_message() from client/brewpi_gui.py: a plain <dialog>
pops with the step's user_message the instant WAIT_USER is newly
entered (tracked via a prevState comparison in onSudChanged()'s State
branch), and sends {"Sud": {"Confirm": true}} on the dialog's close
event - covers both the OK button and Escape dismissal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Ports on_action_sud_new/_save/_load() and on_action_sud_start/_stop()
from client/brewpi_gui.py: Load/Save stand in for the native file
dialogs with <input type="file">/FileReader and a Blob/<a download>,
New sends the same hardcoded empty-doc Load, and Start/Stop are gated
by updateSudActions() mirroring update_sud_actions()'s enabled/disabled
logic. Also fixes the status line's step number being one below what
the Progress tab's plates show for the same step (same bug just fixed
in the desktop client).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
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
A new HTML/CSS/JS client, added next to client/brewpi_gui.py rather
than replacing it, speaking the exact same WebSocket pub/sub protocol
- no server-side protocol changes needed. server/brewpi.py gains a
--http-port (default 8080) stdlib http.server.ThreadingHTTPServer,
in its own daemon thread and deliberately decoupled from the existing
asyncio WebSocket server, serving web/ statically.
v1 covers the Manual tab (direct heater/stirrer/controller control)
and the new Progress tab - the two most actionable surfaces, neither
needing a charting library. web/app.js ports the relevant logic from
brewpi_gui.py directly: components/sud.py's _build_step()/
_merge_defaults() for resolving the raw schedule doc, and the
StepPlate/_update_step_plates()/update_status_step_label() logic
behind the Progress tab and status line.
Deliberately deferred rather than stubbed (see web/TODO.md for the
full parity backlog against the Qt GUI): Sud control actions (Start/
Pause/Stop/Confirm), the Automatic tab's forecast plot and the Plot
tab's live strip charts, Sud file management, and auth (matching the
WebSocket server's own existing lack of it).
No new Python dependencies - functools/threading/http.server are all
stdlib.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
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
The "Forecast vs. actual duration" section still described the old
confirm_points/_continue_forecast_after_confirm() mechanism and
claimed the forecast was deliberately not re-anchored mid-run - both
no longer true since _reanchor_forecast() now fires on every step
boundary. Replaced with the actual current mechanism (the reanchor
itself, the _forecast_generation race guard, forecast_step_starts/
StepStarts, and the Save-while-running fix on both server and
client), added a new "Progress tab" section (LED semantics, per-step
fields, energy integration/banking, status-bar sums), and documented
--dt/--sim-warp-factor replacing config.json's Controller.dt/
sim_warp_factor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
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
Both are run-mode knobs (how fast to drive this particular process),
not config.json material (a plant/hardware description that stays the
same regardless of how a run happens to be invoked) - --dt and
--sim-warp-factor, defaulting to 1.0 each (real time, 1-simulated-
second ticks) unless a dev/test run asks for a faster warp explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
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