Commit Graph
538 Commits
Author SHA1 Message Date
jensandClaude Sonnet 5 13bb011099 docs: plan HOLD-state pid_heat windup fix from overshoot investigation
Records the root cause of the cold-water overshoot in docs/overshoot.png
(integral windup in pid_heat with no anti-windup engagement, since y never
saturated) and the refined fix plan: gate pid_heat by FSM state instead of
a flat yi_max clamp, which was rejected after the numbers showed it would
also cripple legitimate high-rate ramps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
2026-07-04 12:31:58 +02:00
jensandClaude Sonnet 5 8689494be0 fix: zero heater power / stirrer rpm on manual disconnect
A manual Disconnect command left the last commanded power/speed in
place server-side, so a reconnect (or the UI's PowerSet/Speed echo)
could show a stale nonzero setpoint for a device that's no longer
attached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5WEDCc4v473DKqSaQhTQt
2026-07-03 23:29:29 +02:00
jensandClaude Sonnet 5 b526711595 fix: blank Heater/Stirrer status on browser disconnect instead of stale text
updateDeviceStatus() gains a tri-state: true/false once the server has
actually reported it, or null for "unknown" - rendered as a blank
'---' badge with both Connect/Disconnect buttons disabled.
setConnected() now resets Heater/Stirrer to null whenever the browser's
own websocket connection drops, so a stale Connected/Disconnected badge
from before the disconnect doesn't keep misleadingly showing as current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 21:59:15 +02:00
jensandClaude Sonnet 5 4b9a144b62 fix: temp sensor read failures could crash startup or permanently kill the task
Same bug class as the earlier heater fix: TempSensorTask.on_process()
called self.sensor.temperature() every tick with no exception handling
at all, and the constructor even called it once synchronously at
startup to prime the value. A read failure there would either crash
the whole server before the event loop started, or permanently kill
TempSensorTask's coroutine mid-run - nothing restarts a dead ATask, so
the sensor would never be read again for the rest of the process's
life.

TempSensor_max31865.temperature() now catches read failures and calls
a new reopen() (closes/reopens the spidev handle - the closest
equivalent to unplug/replug for a bus peripheral) instead of raising,
holding the last-known-good reading meanwhile. reopen() itself never
raises either, learned from HendiCtrl.disconnect() previously letting
a failure escape the same way. Belt-and-suspenders guards added at the
TempSensorTask level too, matching HeaterTask/StirrerTask.

Verified against a fake spidev: a read failure no longer raises,
reopen() is attempted automatically, and readings resume once the bus
responds again - even when reopen() itself also fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 21:45:21 +02:00
jensandClaude Sonnet 5 b6b79adff8 docs: drop stale config.json.bak cleanup reminder
The backup has already been deleted from the Pi, so the note is done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 21:37:44 +02:00
jensandClaude Sonnet 5 9cd386a22e docs: note config.json's by-id serial paths and the .bak cleanup
config.json isn't tracked in git, so this records what changed on the
Pi during today's connect/disconnect testing: Heater/Stirrer ports
switched from /dev/ttyUSB0//dev/ttyACM0 to stable /dev/serial/by-id/...
paths after a replug renumbered both devices, plus a reminder to
remove the pre-edit config.json.bak-20260703212821 left on the Pi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 21:35:30 +02:00
jensandClaude Sonnet 5 671e0c2474 fix: heater task permanently died after a mid-brew unplug, never recovered
Root cause (from the brewpi Pi's log): HendiCtrl.disconnect() only
caught HendiException (a protocol-level NAK), but a genuine unplug
fails deep inside pyserial itself - flushInput()/readline() raise
termios.error/OSError on the now-dead fd. That escaped disconnect()
uncaught (skipping self.ser.close() too), then kept propagating up
through every caller that invoked disconnect() from its own except
handler (HeaterHendi.process()/activate(), AHeater.open()'s
context-manager exit), all the way out of HeaterTask.on_process()'s
coroutine entirely. Nothing restarts a dead ATask, so the heater's
process()/duty-cycle loop was gone for the rest of the process's
life - reconnecting afterward changed `connected` back to True but
nothing was left running to ever call process() again.

Fixes:
- HendiCtrl.disconnect() now catches broadly and always closes the
  port, matching Pololu1376.disconnect()'s already-correct pattern.
- HeaterTask/StirrerTask.on_process() gain an outer retry loop as a
  safety net: even an unanticipated exception now logs, marks
  disconnected, and re-enters rather than permanently killing the
  task.

Verified by reproducing the exact failure (comm error raising a raw
OSError, not HendiException) against a fake device - the task now
survives and resumes process()ing once reconnected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 21:21:44 +02:00
jensandClaude Sonnet 5 7b162cf1f5 refactor: drop firmware-version display, move Enable toggle to Controller
Firmware version is already printed to the log at connect() time
(HendiCtrl/Pololu1376) - remove the live GUI/websocket firmware
reporting entirely (Connectable, HeaterHendi/StirrerPololu1376,
Heater/StirrerTask, web GUI) as redundant.

Also move the Heater panel's Closed-loop checkbox into the Controller
panel and rename it "Enable" - the Controller panel now shows a
live Enabled (green) / Disabled (gray) status badge next to its
heading, matching the Connected/Disconnected badges on the Heater/
Stirrer panels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 20:23:55 +02:00
jensandClaude Sonnet 5 7377fe3b6b fix: move heater/stirrer connection status next to the panel heading
The Connected/Disconnected badge, F/W version text, and both Connect/
Disconnect buttons were crammed into one row, clipping the Disconnect
button off-panel. Move the status badge into the h3 heading itself
(right-aligned) so the row below has room for the F/W text and both
buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 20:03:52 +02:00
jensandClaude Sonnet 5 069fc352bc fix: guard connection-state callbacks against no running event loop
The previous hotfix's disconnect()-on-comm-error path can now fire
during server/brewpi.py's own final best-effort hardware-release
cleanup (finally: block's heater.close()/stirrer.activate(False)),
which runs after the event loop has already stopped - previously
nothing about hardware teardown touched an AttributeChange-tracked
field, so this path was dormant. asyncio.create_task() there raises
RuntimeError: no running event loop, crashing the shutdown sequence.

Adds fire_and_forget() (tasks/task.py) - asyncio.create_task() that
silently drops the message instead of raising when there's no loop to
schedule on - and uses it for the Connected/FirmwareVersion broadcasts.
SudTask.check_connections() also now no-ops entirely without a running
loop, since self.sud.stop() cascades into further unguarded
create_task() calls (on_state_changed, heater_task.shutdown(), ...)
that a stopped loop can't help with anyway.

Reproduced and verified fixed directly against HeaterTask/HeaterHendi
with no event loop running (mirrors the exact server/brewpi.py
shutdown-time call path that crashed on the brewpi Pi).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 19:47:17 +02:00
jensandClaude Sonnet 5 0877e7754b fix: guard HeaterHendi/StirrerPololu1376 activate() against comm errors
Live-testing on the brewpi Pi found that a connected-but-unresponsive
device (e.g. the hendi sitting in its ungraceful-disconnect lockout, see
docs/hendi_lockout_findings.md) crashed the whole server: activate() -
called both from HeaterTask's `with device.open():` and from a client's
Connect/Disconnect command - raised uncaught, escaping the gathered task
and killing the entire asyncio.gather in TaskManager.start().

activate()/is_activated()/process() on both real actors now catch comm
exceptions and self-heal via disconnect() (the same path a genuine
unplug already takes), instead of letting the exception propagate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 19:39:07 +02:00
jens 1db0e66ab7 Merge pull request 'Add connect/disconnect status for real heater/stirrer hardware' (#3) from observer_refactor into master
Reviewed-on: http://192.168.22.90:3001/jayfield/brewpi/pulls/3
2026-07-03 19:26:15 +02:00
jensandClaude Sonnet 5 f20e617d81 feat: add connect/disconnect status for real heater/stirrer hardware
Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.

Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 19:18:59 +02:00
jensandClaude Sonnet 5 698c019581 refactor: make ATemperatureSensor's temp observable directly on the sensor
TempSensorSim/TempSensor_max31865's temperature() now stores its
reading on self.temp, so ATemperatureSensor's inherited AttributeChange
(previously never triggered by anything) actually fires. TempSensorTask
no longer keeps its own shadow copy of the reading - it registers its
websocket-push callback on self.sensor directly and just drives the
read each tick; server/brewpi.py's TC-feeding registration moved from
sensor_task to sensor for the same reason.

Priming read happens before registering the callback (not after) since
all tasks are built synchronously at module level, before the asyncio
event loop starts - registering first would fire on_temp_changed's
asyncio.create_task() with no running loop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 22:03:50 +02:00
jensandClaude Sonnet 5 75a886950c refactor: collect hendi firmware images under firmware/hendi_ctrl/
Moves components/actor/HendiCtrl.srec (v1.12) alongside the v1.14/
v1.15 images gathered during lockout testing, all under one versioned
naming scheme (hendi_ctrl_0112/0114/0115.srec) instead of scattered
across the repo root and components/actor/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 18:58:32 +02:00
jensandClaude Sonnet 5 bd6249af6b docs: record 10s idle-timeout (remote->suspend) findings for v1.16
Confirmed on hardware: 15s idle while remote-enabled with the heater
on and powered auto-suspends (R? and S? both drop to 0). P? does not
read literal 0 though - it lands on 4095, the raw register's max value
past the calibrated curve's low end, not the digit 0. Any check
expecting P?=="0" to confirm zero power would be wrong on this
firmware; S? is the correct check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 18:51:18 +02:00
jensandClaude Sonnet 5 5bc278e875 feat: add --debug tracing to hendi_ctrl_app.py, document v1.16 fix
HendiCtrl.cmd() previously raised a bare "Communication error" for any
non-OK response, discarding the firmware's actual reply. It now
includes the raw echo/answer bytes, and --debug prints every
request/response. Using this, the rejected R1 during lockout turned
out to be an explicit "ERR:Invalid state" reply, not a timeout.

Also flashed and retested v1.16: plain reconnect (no --reset) now
recovers from the lockout reliably (4/4 trials), fixing the
intermittent behavior seen on v1.15.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 18:44:00 +02:00
jensandClaude Sonnet 5 7ebd2aaa63 docs: update hendi lockout findings with v1.15 retest results
--reset still reliably clears the lockout on v1.15, same as v1.14. A
plain reconnect without --reset now succeeds intermittently (1 of 3
trials) where it always failed before, but that's unreliable enough to
flag as a remaining race condition rather than a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 18:19:06 +02:00
jensandClaude Sonnet 5 bc0aeb0ffd docs: update hendi lockout findings with v1.14 retest results
Firmware v1.14 makes --reset (DTR/RTS toggle) reliably clear the
ungraceful-disconnect lockout, unlike v1.12. A plain reconnect without
--reset still fails on both versions, so recovery is specifically tied
to the reset line, not just a fresh connection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 17:54:28 +02:00
jensandClaude Sonnet 5 91a1b1ea62 docs: record hendi remote-control lockout findings for firmware v1.12
Baseline for the planned firmware fix: reset/reconnect don't clear the
ungraceful-disconnect lockout, only a physical power cycle does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 17:45:44 +02:00
jensandClaude Sonnet 5 d079db8b37 feat: add --reset and --trigger-error-state to hendi_ctrl_app.py
--trigger-error-state deliberately reproduces the Hendi's
ungraceful-disconnect lockout (remoteEnable(True) without a matching
disable) so it can be tested on demand. --reset toggles the reset
line, but per hardware testing it does NOT clear that lockout - only
the device's physical knob/power cycle does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 17:35:05 +02:00
jensandClaude Sonnet 5 5522b9c491 refactor: move hendiCtrl.py's main() into scripts/hendi_ctrl_app.py
hendiCtrl.py is now import-only. The new CLI app exposes serial
port/baudrate, version/capability info, converter test, heater test,
and firmware update as separate flags, instead of one fixed script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 17:02:21 +02:00
jensandClaude Sonnet 5 97a8ad5bc3 feat: mirror server text log to a fixed brewpi.latest.log
Same principle as log_latest.json/log_latest_sud.json: a tail-style
consumer can point at one unchanging path instead of tracking the
current run's timestamped filename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
2026-07-02 16:38:32 +02:00
jens 1bcabb0ccc added firmware info to hendi and motor driver 2026-07-02 16:33:37 +02:00
jensandClaude Sonnet 5 452d8ddd4b docs: document per-logger elapsed timelines and the log_latest.* files
Covers the last two commits: tick-counted t/_elapsed() semantics and why
PlantParams must be stamped with each logger's own (not Sud.elapsed
universally), plus the log_latest.json/log_latest_sud.json fixed-name
copies and the caveat that the two aren't guaranteed to be in sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
2026-07-01 20:55:49 +02:00
jensandClaude Sonnet 5 0382bb01aa fix: stamp PlantParams with each logger's own elapsed, not Sud.elapsed
log_plant_params() stored whatever elapsed it was handed - always
Sud.elapsed - regardless of which logger's own Samples.t timeline it
needed to match. SudLogTask._elapsed() is Sud.elapsed, so its PlantParams
lined up fine, but ServerLogTask._elapsed() is a separate tick counter
since server startup - using Sud.elapsed there put PlantParams.t on a
different scale than its own Samples.t entirely, causing
utils/replay_sim.py to apply grain/water-mass changes at the wrong point
when replaying a server-session log (spotted by comparing its output for
log_latest.json vs log_latest_sud.json from the same session).

Fixed by dropping the elapsed parameter - log_plant_params(params) now
stamps with self._elapsed(), so each logger always uses its own correct
timeline. Cascades into SudTask.set_on_plant_params()'s callback signature
and its wiring in server/brewpi.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
2026-07-01 20:53:50 +02:00
jensandClaude Sonnet 5 a495e758fd feat: always write a fixed-name copy of server/sud logs, use .json for sud logs
Alongside the dated log files, ServerLogTask/SudLogTask now also write
(and overwrite) a fixed-name copy on every write() - logs/log_latest.json
and logs/log_latest_sud.json - so a dashboard/tail-style consumer can
point at one unchanging path instead of tracking the current run's
timestamped filename.

Also switches SudLogTask's own dated/latest filenames from .log to .json,
matching what they've always actually contained. This incidentally fixes
utils/analyze_log.py's two-argument CLI form, which already assumed a
.json extension for sud logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
2026-07-01 20:23:16 +02:00
jensandClaude Sonnet 5 da7e954b7c fix: make sim_warp_factor real-hardware-safe and sud logs forecast-reproducible
sim_warp_factor was scaling DT_TASK unconditionally, including against real
hardware, and was leaking into recorded sample timestamps via wall-clock
time - both violate its intended meaning (a reciprocal scale on task wait
time only). Clamp it to 1.0 whenever Heater.type isn't "sim", and track
elapsed time via tick-counted accumulators (Sud.elapsed for SudLogTask, a
matching one for ServerLogTask) instead of time.monotonic().

Sud/server logs now also carry HeaterPowers, the effective SimWarpFactor,
the raw Doc, a correct Name, and ForecastAnchors (one entry per real
_reanchor_forecast() trigger) - enough for utils/analyze_log.py to rebuild
a SudForecastEstimator and replay the exact same splice-per-transition
forecast correction offline that a live client sees, replacing the old
(always-dead) forecast_*.json sidecar lookup.

Two bugs found and fixed along the way:
- ServerLogTask's periodic wall-clock checkpoint had no way to know a
  SudLogTask run had just ended, so it would immediately re-write the
  just-completed log with PlantParams/ForecastAnchors cleared back to
  empty. Gated the checkpoint on _should_sample().
- _reanchor_forecast() truncated the old forecast against real elapsed
  time, which can end up numerically less than the old forecast's own
  (very wrong) speculative timestamps once a WAIT_USER confirmation runs
  long - leaving a "ghost" segment instead of a clean cut. Truncate
  against forecast_step_starts[index] instead, which lives in the same
  coordinate space as the forecast being cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
2026-07-01 19:50:50 +02:00
jensandClaude Sonnet 5 27317d9efd docs: document the systemd deployment and its graceful-shutdown behavior
deploy/install.sh and deploy/brewpi.service existed but were never
mentioned in the README - the only documented deployment path was the
older virtualenvwrapper-based server/brewpi.sh. Also ties in the
SIGTERM handling fix, since systemctl stop is exactly the path it
protects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 09:46:47 +02:00
jensandClaude Sonnet 5 0e60860cf0 feat: periodically checkpoint server/sud logs to disk
Both loggers previously only wrote their JSON log on their end trigger
(shutdown / Stop-or-DONE), losing the whole session/run's data on a
hard crash or power loss. Add a log_interval (config.json, default
300s) that rewrites the same file whole every N seconds in between.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 09:44:11 +02:00
jensandClaude Sonnet 5 d3c8a47562 fix: force heater/stirrer safe on SIGTERM, not just SIGINT
systemctl stop/restart send SIGTERM, whose default disposition kills
the process immediately - skipping the shutdown finally: block that
cancels tasks and forces both actors safe via their activate(False).
Route SIGTERM through the same KeyboardInterrupt path SIGINT already
uses, and add stirrer.activate(False) alongside the existing
heater.close() as a belt-and-suspenders fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 09:37:24 +02:00
jensandClaude Sonnet 5 aeeecc4ddc docs: refresh README for sud/server log split and reconnect pump task
The Logging & analysis section and a couple of cross-references still
described the old SudLogTask (forecast_*.json pairing) that predated
the ServerLogTask refactor and the just-reintroduced per-run SudLogTask.
Also documents the state_pump() task's role in reconnect behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 09:18:23 +02:00
jensandClaude Sonnet 5 0b2a2be035 feat: re-add per-run sud log alongside the continuous server log
SudLogTask reuses ServerLogTask's sample format but only records while
a Sud run is active: starts on Play (fresh start only, not a Pause
resume) and writes logs/log_{date_time}_{sud_name}.log on Stop or
natural completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 09:06:39 +02:00
jensandClaude Sonnet 5 0736f76ca3 fix: drain dispatcher queue via single pump task, not per-connection
Each connection used to run its own handler_send loop consuming from
the shared dispatcher queue. With zero clients connected (e.g. an
iPad that silently drops on sleep), nothing drained the queue, so
state updates piled up unbounded and got replayed as a burst on
reconnect. Now one pump task drains the queue continuously regardless
of connection count, and reconnecting clients resync from
global_state instead of a backlog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
2026-07-01 08:46:25 +02:00
jensandClaude Sonnet 4.6 f7d3001ca5 fix: set PYTHONPATH in service unit so brewpi modules resolve
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJfJngYUpB4dr77GvcEb8
2026-07-01 01:03:51 +02:00
jensandClaude Sonnet 4.6 ab52c7e599 fix: derive brewpi home from script location in service install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJfJngYUpB4dr77GvcEb8
2026-07-01 00:58:48 +02:00
jensandClaude Sonnet 4.6 6651d43314 feat: add systemd service unit and install script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsJfJngYUpB4dr77GvcEb8
2026-07-01 00:53:09 +02:00
jensandClaude Sonnet 4.6 9f221c5d19 fix: re-enable closed-loop on Sud start regardless of client
HeaterTask.shutdown() switched the heater to open-loop when a brew
ended, but nothing switched it back when a new Sud was started.  The
browser happened to send {ClosedLoop: true} as part of its UI setup,
so its connect-and-start path worked; PyQt (and any other client that
doesn't send that message) left the heater in open-loop and the TC
never drove it.

Add SudTask._on_start / set_on_start() hook, HeaterTask.start_closed_loop()
(mirror of shutdown()), and wire them in brewpi.py so the server
switches to closed-loop and broadcasts the new state to all clients the
moment any client starts a brew.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:43:55 +02:00
jensandClaude Sonnet 4.6 57c23f293a fix: embed dt and startup plant params in server log; open-loop replay_sim
server/brewpi.py: pass physics DT to ServerLogTask (not wall-clock DT_TASK)
so logs carry the correct integration timestep; log startup plant params as
the first PlantParams event so replay_sim can reconstruct them even when no
Sud is ever loaded (previously fell back to hardcoded defaults, causing ~3°C
temperature offset in replay).

tasks/server_log.py: accept and embed dt in log JSON; switch
log_plant_params() to wall-clock monotonic time (same base as sample t),
since the elapsed argument was simulated time and not comparable.

utils/replay_sim.py: switch to open-loop replay (plant driven by logged
power_eff, not sim controller output); use PidFactory so Smith vs Normal
controller type is honoured; set Td=0 in replay plant for Smith mode (logged
temp_ist is already Smith-corrected); feed Smith predictor's internal model
with real power; apply embedded PlantParams mid-run; prefer embedded dt over
sample-based fallback; clamp sim power ≥ 0; replace meaningless sim-power
trace in power subplot with power_set vs power_eff from the log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:31:46 +02:00
jensandClaude Sonnet 4.6 4a1c969ad0 refactor: replace SudLogTask with ServerLogTask for all logging
SudLogTask is removed; ServerLogTask now covers the full server session
and gains PlantParams tracking (log_plant_params callback wired from
SudTask.set_on_plant_params) so step transitions are still captured in
the log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 23:22:11 +02:00
jensandClaude Sonnet 4.6 dc687c3559 feat: embed config in server log; replay_sim takes a single log path
ServerLogTask now embeds the full config in its JSON output, matching
SudLogTask — so replay_sim and analyze_log work on server logs without
needing a separate config file.

replay_sim.py switches from two positional args (date_time, sud_name) to
a single log file path, with config and plant params resolved from the
log's embedded "Config"/"PlantParams" sections when present and falling
back to config.json / hardcoded defaults for older logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 23:17:50 +02:00
jensandClaude Sonnet 4.6 4de6efcf82 feat: include full config and plant param changes in Sud run log
Each log_*.json now carries a "Config" key (the full server config) and
a "PlantParams" list of {t, params} entries — one per step transition —
so offline analysis tools have the PID gains and plant model that were
active at every point of the run without having to cross-reference a
separate config file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:59:55 +02:00
jensandClaude Sonnet 4.6 bb5af3c0ed fix: clamp pid_hold output to [0,1] in HOLD state to break limit cycle
During hold, a small temperature overshoot causes pid_hold.y to go
slightly negative, inverting heatrate_soll and driving pid_heat's
power to 0. With no power the pot coasts back down, pid_hold goes
positive again, power builds back up, overshoot repeats — a ~110s
limit cycle visible as pumping in the power trace.

Clamping pid_hold.y to [0, 1] in HOLD state lets the outer loop
reduce the inner rate setpoint to zero (stop adding heat) but not
invert it (actively demand cooling), breaking the limit cycle while
preserving normal HEAT/COOL cascade behaviour.

Note: Hold.kt must remain 0 when Hold.ki=0 — a non-zero kt drains
pid_hold.yi during ramp saturation, suppressing heatrate_soll at the
start of each hold phase and leaking into the next ramp via the
bumpless HOLD→HEAT transfer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:13:30 +02:00
jensandClaude Sonnet 4.6 b3923fb1c0 fix: smooth HOLD→HEAT power transition (bumpless transfer + cascade timing)
Two root causes for the visible power dip at every ramp-to-ramp
step boundary:

1. One-tick cascade delay: heatrate_soll was computed from
   pid_hold.get_y() before pid_hold.process() ran in that tick,
   so the first tick of a new ramp used the stale hold-phase
   output (≈0) as the rate setpoint, driving pid_heat to near
   zero.  Fix: move heatrate_soll computation inside process_pid(),
   after pid_hold.process().

2. Cold-start reset: pid_heat.reset() at HOLD→HEAT discarded
   the hold-phase integral, so pid_heat had to ramp up from
   zero on every new step.  Fix: remove the reset (bumpless
   transfer) — the hold-phase integral is a warm starting point
   that carries the baseline hold power into the new ramp.

Together these eliminate the ~1s dip to near-zero and replace
the slow 20–30s integral ramp-up with an immediate start at
roughly hold-level power, climbing monotonically to the target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 20:33:12 +02:00
jensandClaude Sonnet 4.6 e7824dc297 ui: show water level and status line before any sud is loaded
Pot config (water_mass, volumen) sent in System message at startup.
app.js stores potConfig and uses it in updatePotVisualization/
updateStatusLine when sudEmpty, showing startup fill level and
"No schedule – N L water" without requiring a sud to be loaded first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:53:54 +02:00
jensandClaude Sonnet 4.6 d0a137e39e config: seed plant params from Pot.water_mass at server startup
Sud._parse_data now falls through to pot_config for all hardware keys
(mass, material, L, Td) — EMPTY_SUD no longer shadows them. brewpi.py
seeds pot/tc plant params from Pot.water_mass on startup so the
simulated plant is configured before any sud is loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:45:13 +02:00
jensandClaude Sonnet 4.6 5142fcbf3d refactor: move pot hardware params and pid_type out of per-brew docs
Pot hardware constants (mass, material, L, Td) moved from sude/*.json
to config.json's Pot section; Sud/SudForecastEstimator accept pot_config
as baseline, per-brew pot sections may still override. Controller key
removed; pid_type moved into TempCtrl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:41:04 +02:00
jensandClaude Sonnet 4.6 0495e177c4 refactor: replace plant_name with per-component type keys in config
PlantFactory.create() no longer takes plant_name; sim vs real is derived
from Heater.type. StirrerFactory is wired from Stirrer.type. HeaterFactory
registry key lowercased to "hendi" to match config. Controller.plant_name
removed from both config templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:31:39 +02:00
jensandClaude Sonnet 4.6 0156e689ca config: wire TempSensor section from config.json to TempSensorSim
PlantFactory.create() now accepts sensor_config dict; extracts type and
kwargs, falls back to sigma=0.05/temp_offset=-0.15 defaults. brewpi.py
passes config.get('TempSensor', {}). Templates updated with full
TempSensor sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:27:04 +02:00
jensandClaude Sonnet 4.6 ca1953c9ee sensor: model real temp sensor noise in TempSensorSim
Replaces the opaque variance/√12 formula with two explicit noise components:

  sigma (default 0.05 °C) — white Gaussian noise, calibrated against the
  20260628T184903 Sud-0010 log (detrended hold-phase tick-to-tick std ≈ 0.053 °C).

  stirrer_sigma / stirrer_tau — optional AR(1) low-frequency component for
  stirrer-induced fluctuations (off by default); steady-state std equals
  stirrer_sigma, correlation time equals stirrer_tau ticks.

plant_factory.py updated to use sigma=0.05 instead of the old variance=0.01
(which gave std ≈ 0.003 °C — far too quiet).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:17:48 +02:00