Commit Graph
100 Commits
Author SHA1 Message Date
jens 1cd2f64b43 pololu1376: added firmware, controller settings, host software 2026-07-11 12:11:36 +02:00
jensandClaude Sonnet 5 af4e7ea471 docs: update README logging section for logging-module migration
The write-up still described the old print()-mirroring behavior;
recent commits (a1864a5..9f262c4) routed task/component output through
self.log (stdlib logging) instead, added Sud lifecycle/connect-state
log lines, and gave SudForecastEstimator its own silenced logger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 12:10:34 +02:00
jens 9f262c48d0 log: match Sud's step Heating/Reached lines to requested format
"{descr}({number}) Heating"/"Reached" instead of the reverse
"Heating/Reached to Step {number} ({descr})" ordering.
2026-07-10 22:53:37 +02:00
jens 280f74edaa log: rename Sud's step-entry log line to describe the ramp phase
Every step ramps toward its target first (see _advance()'s own
comment) - "Heating to Step N (...)" names that phase instead of the
generic "Entering step".
2026-07-10 22:37:16 +02:00
jens c750af922a log: drop the JSON checkpoint write's own log line
ServerLogTask/SudLogTask write() fires every log_interval seconds
(plus on run end) - logging it every time was just noise.
2026-07-10 22:35:20 +02:00
jens ce5eb3bc23 log: Sud lifecycle transitions and stirrer/heater connect state
Logs load/start/pause/continue/stop/entering-step/wait-user/finished
on Sud (components/sud.py) and connected/disconnected on
StirrerTask/HeaterTask, using the self.log added earlier.

SudForecastEstimator.estimate() (components/sud_forecast.py) drives its
own throwaway Sud through an entire schedule in a tight loop (no real
waiting) to predict its duration, and re-runs that on every real step
transition too - since it's still a Sud, it logged through the exact
same "Sud" logger as the real, server-driven one, making an instant
internal forecast run indistinguishable in the log from an actual
brew. Gave it its own "SudForecastEstimator" logger, silenced to
WARNING by default, so only the real Sud's transitions show up.
2026-07-10 22:28:01 +02:00
jens a1864a5257 log: route task/component status output through logging, not print()
AttributeChange (the common base of every ATask and component ABC) now
sets self.log = logging.getLogger(type(self).__name__), so components
no longer need to hand-type their own name into each message. Wired
logging.basicConfig() in server/brewpi.py with a bare "%(name)s:
%(message)s" formatter - Tee (see prior commit) still supplies the
"<date>T<time>:" prefix, so lines read "<date>T<time>:<component>:
<message>" without double-stamping, and third-party loggers
(websockets, asyncio) now get the same formatting for free.

Converted the print() call sites that were standing in for this in
tasks/ and components/ (leaving __main__ demo blocks and explicit
debug-dump helpers alone).
2026-07-10 22:00:04 +02:00
jens 6c746c8964 log: stamp mirrored stdout/stderr lines with <date>T<time>
Tee (server/brewpi.py) mirrors print() output to logs/brewpi.*.log but
previously wrote it verbatim, so those files had no timestamps to
correlate against the JSON sample logs. Prefix each line instead.
2026-07-10 21:44:30 +02:00
jensandClaude Sonnet 5 71b1010b27 heater/stirrer: zero the setpoint on every disconnect, not just explicit ones
A comm-error/timeout-triggered disconnect left power_soll/power_actor
(heater) or speed (stirrer) untouched, so a later reconnect - or the
closed-loop TC, which keeps computing y regardless of connection state -
could immediately resume driving the device at whatever it was doing
before the outage. HeaterTask.disconnect()/StirrerTask.disconnect() now
centralize disconnect + zero-setpoint and are used for the explicit
Disconnect message, the per-tick comm-error path, and the outer
unexpected-error recovery path alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU3NTwLygh8jkrnqPoHNXF
2026-07-07 19:46:22 +02:00
jensandClaude Sonnet 5 a19d2504fb stirrer: decode and report Pololu SMC status errors instead of guessing
Adds StatusError flags for the STATUS register and get_status_errors();
go() now reports if a fail-safe (safe-start violation or other error)
persists after GO instead of failing silently. _on_process uses the
same decoding to log any active error and only auto-recovers on a
safe-start violation, rather than the old narrow status & limit_status
heuristic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU3NTwLygh8jkrnqPoHNXF
2026-07-07 19:36:11 +02:00
jensandClaude Sonnet 5 d19f1066d1 recipe: match sud_0030's pot volumen to config.json (36 L)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 12:26:01 +02:00
jensandClaude Sonnet 5 07c3f0a7d1 style: move user-confirm control under the header's hold-time display
Repositions #user-confirm-row from the header's own row to directly
under #header-countdown's "hold" row, sharing a new #countdown-confirm-
col column so header-countdown's 'hidden' toggle (only shown once a
step is active) doesn't hide the confirm control along with it - a
WAIT_USER confirm can happen before that countdown is visible.

Kept the max-width + wrap safety (now min(60vw, 20em)) so a long
confirmation message still wraps instead of pushing the header sideways
on an iPad's narrower viewport, per the earlier upper-right-corner
placement this replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 12:19:04 +02:00
jensandClaude Sonnet 5 3b34f52bb0 feat: replace modal user-confirm dialog with persistent flashing button
Both clients used a modal OK dialog for a Sud step's WAIT_USER
confirmation (e.g. "Bitte Malz einfuellen und bestaetigen"). Replaced
with a persistent "Confirm" control in the top toolbar/header instead:
flashes yellow with the step's message while pending, and modally
disables the rest of the GUI (main content plus Start/Pause/Stop/
New/Save/Load, and Connect) until it's clicked - forcing the pending
step to be dealt with first. Disabled and blank otherwise.

client/brewpi_gui.py: set_user_confirm_pending() drives a QTimer blink
(same pattern as the existing "Cool down" indicator) and disables
centralwidget + the relevant toolbar QActions individually, so the new
button (a toolbar sibling) stays enabled; Start/Pause/Stop are restored
via update_sud_actions() rather than a blanket re-enable.

web/app.js: setUserConfirmPending() toggles a 'confirm-pending' class
(CSS @keyframes flash, matching the file's existing animation-by-class
convention) and a 'modally-disabled' class on #layout; also wired into
ws.onclose so a dropped connection doesn't leave the page stuck modally
disabled on a confirm that'll never resolve.

Also fixes a message-ordering race in both clients (found testing the
web version on an iPad, connecting fresh while a step was already
WAIT_USER): a step's UserMessage is set once when the step starts, well
before State later flips to WAIT_USER, so an already-connected client
sees them at genuinely different times - but a client connecting fresh
gets both bundled into one replayed snapshot, where State's key can
sort before UserMessage's. Processing State first checked the pending
guard before that same message had set the text, silently failing it
and leaving the button looking merely disabled. Both onSudChanged()/
on_sud_changed() now force UserMessage (and, in the web client, Json)
to process before State/Step regardless of the bundled dict's key
order, same pattern already used for the pre-existing Json-before-Step
case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 11:57:23 +02:00
jensandClaude Sonnet 5 6d1429daca feat: show status line earlier and count energy without a running Sud
Both clients' status line only showed pot mass/water/energy once a Sud
schedule was loaded and running, leaving it blank/minimal at connect
time and while idle. Show it as early as possible instead, falling back
to config.json's Pot section (mass/water_mass/volumen) when no schedule
is loaded yet; step description/remaining-time still only appear once a
schedule is actually active.

web/app.js: updateStatusLine() now mirrors updatePotVisualization()'s
existing sudEmpty ? potConfig... : sud... pattern, and adds a Volume
figure that was previously only used for the pot-fill visualization.

client/brewpi_gui.py: same treatment, plus new sud_volumen tracking
(the desktop GUI had no volume concept at all before - added by reading
the doc's own pot.volumen with a config fallback, since Sud._parse_data()
doesn't return it). The System message's 'Pot' key now also refreshes
the status label immediately so it doesn't wait for some unrelated
message to trigger a repaint.

tasks/sud.py: energy was only accumulated outside IDLE/DONE, so a
manually-driven heater with no Sud loaded/started never counted toward
the total shown in either client. Now counts in every state except
DONE - IDLE banks into a stable index -1 that's never shown per-step
(Progress tab only covers the schedule's own indices) but is included
in the status line's summed total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 10:45:55 +02:00
jensandClaude Sonnet 5 57677d7881 recipe: retune sud_0030 rest steps and mash-out temperature
Rename "1. Rast"/"Glucose-Rast" to "Protease-Rast"/"Maltose-Rast" to
match what each rest actually targets, drop the Maltose rest to 62°C,
and raise "Abmaischen" (mash-out) to 78°C.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 10:26:12 +02:00
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
jens 1e7042d906 Merge pull request 'Fix HOLD-state PID overshoot (transient windup + steady-state offset)' (#4) from overshoot_hold_windup into master 2026-07-06 08:37:19 +02:00
jensandClaude Sonnet 5 5a75d15416 docs: note grounding y_hold_min in passive-cooling capacity in pid TODO
Captures the idea of computing the HOLD-state negative floor from the
Smith controller's internal Pot model (L*(theta_ist-theta_amb)/C) instead
of the fixed -0.1 heuristic, plus the caveat that the Normal controller
has no internal plant model to draw the same estimate from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 08:30:40 +02:00
jensandClaude Sonnet 5 6d17419067 docs: note active-cooler architecture assessment in pid TODO
Captures the answer to "would the cascade/FSM design still hold up with
a real active cooler instead of heat-only + passive loss": PID/plant
model already generalize (symmetric y range, sign-agnostic plant math,
existing COOL state/pid_inner_cool), but the actor-level max(0, y)
clamp in tasks/heater.py is the one thing making today's tuning
mismatches free - removing it exposes both Outer.y_hold_min and
pid_inner_cool as live, untuned negative-power paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 08:26:25 +02:00
jensandClaude Sonnet 5 2d032bf9b2 fix: allow gentle negative HOLD floor to fix steady-state overshoot
Testing the windup-fix rework live against sude/sud_0030.json surfaced a
distinct bug: a HOLD overshoot from grain-fill-in cooling never decayed -
process_pid()'s pid_outer_y floor clamped to exactly 0.0, so pid_inner
fought the pot's own ambient loss to hold the overshot temperature flat
instead of declining back to setpoint (see docs/overshoot2.png).

Replace the hardcoded 0.0 floor with a configurable Outer.y_hold_min
(default 0.0, backward compatible), set to -0.1 in config.json, both
.tpl templates, and the demo scripts - small enough to avoid
reintroducing the bb5af3c limit cycle while letting HOLD request a
gentle decline matching passive ambient cooling.

Adds TestHoldOvershootRecoversToSetpoint and documents the finding in
docs/overshoot_hold_windup.md's Follow-up section and
components/pid/TODO.md. Confirmed against a live sud_0030 re-run, not
just the unit test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 08:19:10 +02:00
jensandClaude Sonnet 5 11c3d92f25 docs: refresh windup writeup to past tense, link architecture diagram
overshoot_hold_windup.md read like a live plan ("not yet implemented")
even though the fix had already shipped; reworks it into a past-tense
record with corrected line numbers and test descriptions matching what
was actually built. Fixes a stale pid_heat reference in
temp_control_calibration.md.

TODO.md's windup item still claimed test coverage was "outstanding"
from before the test suite was added; corrects that and the "No
automated tests" item above it to reflect current coverage. Both docs
now link docs/fsm_states.png (durable) and the Claude Artifact URL
(session-scoped) for the cascade architecture diagram.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
2026-07-05 22:08:19 +02:00
jensandClaude Sonnet 5 acfb98e186 docs: add FSM states diagram screenshot
Reference screenshot of the FSM-states panel from the temp controller
architecture diagram (states, thresholds, and the HOLD->HEAT no-reset
bumpless-transfer note).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
2026-07-05 21:59:20 +02:00
jensandClaude Sonnet 5 f69d63c31b fix: split inner-loop yi_max clamp to fix HOLD-state windup overshoot
Renames pid_hold/pid_heat/pid_cool to pid_outer/pid_inner/pid_inner_cool
to match what actually runs when, and splits inner-loop config into
Inner.Heat/Inner.Hold/Inner.Cool so the same PID instance gets a tight
yi_max ceiling only while HOLD drives it, without capping legitimate
1.5 K/min ramps. Fixes the overshoot from docs/overshoot_hold_windup.md
where a cold-water disturbance during HOLD wound up pid_heat's integral
term with no anti-windup engagement, taking ~35s+ to unwind naturally.

Breaking config change: Hold/Heat/Cool -> Outer/Inner.{Heat,Hold,Cool}
in config.json, both .tpl templates, the pid/sud demo scripts, and
replay_sim.py's CLI flags. Adds tests/components/pid/ (stdlib unittest)
covering the Pid clamp/recovery behavior and closed-loop disturbance,
ramp, and HOLD<->HEAT transition cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
2026-07-05 21:23:55 +02:00
jensandClaude Sonnet 5 da832b961e docs: add claude-faq.md, starting with memory-vs-docs Q&A
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
2026-07-04 12:57:21 +02:00
jensandClaude Sonnet 5 de11b849d8 docs: revise pid_heat windup plan to pid_outer/pid_inner rename + Inner.Hold split
Supersedes the earlier flat yi_max clamp / FSM-gating ideas: same inner
PID instance switches its active param set (Inner.Heat vs Inner.Hold)
by state instead of freezing, giving bumpless transfer for free and a
tight yi_max that only applies while HOLD is driving it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
2026-07-04 12:48:59 +02:00
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
jensandClaude Sonnet 4.6 9be0e49a63 analyze_log: accept a direct log path as single argument
The two-argument form (date_time sud_name) is unchanged.  When called
with a single argument it is treated as a direct path to any log_*.json
(e.g. a server-session log without a Sud name); the forecast panel is
shown only if a matching forecast_*.json exists alongside it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:11:31 +02:00
jensandClaude Sonnet 4.6 4f1e05bab1 server: always write log_{date_time}.json for the full server session
Adds ServerLogTask (tasks/server_log.py) which records temp/power samples
from startup to shutdown regardless of Sud state, in the same Name/Samples
JSON format as SudLogTask so analyze_log.py and replay_sim.py can consume
it directly.  The file is written on graceful exit (Ctrl-C) from brewpi.py's
finally block, just before the heater is closed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:04:37 +02:00
jens 801e9809a6 hendiCtrl: fixed graceful close 2026-06-30 18:59:33 +02:00
jensandClaude Sonnet 4.6 5751d26873 server: graceful Ctrl-C shutdown; HendiCtrl closes connection cleanly
On KeyboardInterrupt, brewpi.py cancels all asyncio tasks (letting
HeaterTask's `with device.open()` finally block run), then calls
heater.close() as belt-and-suspenders before closing the event loop.

On shutdown HeaterHendi.activate(False) now sets the switch to 0 instead
of disabling remote control, so the Hendi stays in remote mode at 0 W
rather than dropping back to its local panel state.  HendiCtrl.close()
does the same (setSwitch(0)) then closes the serial port.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:26:54 +02:00
jens 29dd5267a9 - fixed hendiCtrl exceptions 2026-06-30 17:05:43 +02:00
jensandClaude Sonnet 4.6 c0646fada3 sude/sud_0010: set Td=17 s (calibrated transport delay)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 17:02:52 +02:00
jensandClaude Sonnet 4.6 5be1d4e862 pid: option A heat-rate pre-filter (beta IIR before differentiation)
Adds a configurable IIR pre-filter (beta, default 0.05, τ≈19 s at dt=1 s)
applied to theta_ist before the derivative, so stirrer-induced temperature
noise is attenuated before being amplified by differentiation rather than
after.  The shared filter/derivative/post-filter logic is factored into
TempControllerBase._compute_heatrate(); both Normal and Smith subclasses
call it, replacing their duplicated inline rate blocks.

beta is read from TempCtrl.beta in the config (default 0.05 if absent) and
added to config-sim.json.tpl and config-real.json.tpl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 17:02:14 +02:00
jens 8ca4e4d803 fixed duplicate class declaration 2026-06-30 16:38:51 +02:00
jens 6547478d00 HendiCtrl: better Exceptions 2026-06-30 16:22:06 +02:00
jensandClaude Sonnet 4.6 2dfaf8dee6 replay_sim: switch to closed-loop plant simulation
Replaces the open-loop (feed real temp_ist) approach with a proper
closed-loop simulation: a Pot plant model is driven by the controller's
power output each tick, generating sim_temp_ist that diverges from the
real log's real_temp_ist whenever the PID params produce different
behaviour.  Temperature panel now shows real_temp_ist vs sim_temp_ist
vs temp_soll so the effect of candidate gains is immediately visible.

Plant params (M, C, L, Td) default to 20 kg water / L=0.2 / Td=30 s
and can be overridden via --plant-* flags.  Ambient defaults from config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
2026-06-30 16:08:25 +02:00
jensandClaude Sonnet 4.6 bd08833df5 Add utils/replay_sim.py: replay log through Normal TC to verify PID params
Feeds real temp_ist/temp_soll readings from a run log back into a fresh
TempController (Normal mode) tick by tick, then plots a 3-panel comparison
of heat rate and normalized power against the original logged values.  Any
PID gain can be overridden via --<section>-<gain> flags so candidate params
can be evaluated against a past run without touching hardware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
2026-06-30 08:52:58 +02:00
jensandClaude Sonnet 4.6 dd331c164f docs: add heat rate noise section to temp control calibration
Documents the stirring-induced rate noise problem, its root cause
(post-differentiation filtering), and three mitigation options: pre-filter
before differentiating, sliding-window regression, and model-based rate
from Pot.p_pot (Smith only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
2026-06-29 22:16:10 +02:00
jensandClaude Sonnet 4.6 abcd011a9f Add docs/temp_control_calibration.md
Covers Smith predictor parameter identification (C, M, L, Td) from log
data, model-plant replay verification, and controller performance metrics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
2026-06-29 22:05:37 +02:00
jensandClaude Sonnet 4.6 6ecfa2d38a Add utils/analyze_log.py: offline log/forecast plot script
Renders the same two plots the PyQt GUI shows (realtime 3-panel strip
chart and forecast-vs-actual comparison) from a log/forecast JSON pair,
specified by date_time and sud_name on the command line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
2026-06-29 21:28:35 +02:00
jensandClaude Sonnet 4.6 c6e9258351 Rename config templates: .templ → -sim.json.tpl, config_real.json → -real.json.tpl
Consistent naming: config-sim.json.tpl and config-real.json.tpl.
Update README and TODO references accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:23:29 +02:00
jensandClaude Sonnet 4.6 2e14978210 Split hardware-only deps into server/requirements-hw.txt
pyserial and spidev are Linux/Pi-only (spidev requires the kernel SPI
driver and won't install on macOS/Windows). Dev machines use
requirements.txt; the Pi uses requirements-hw.txt which pulls in the
base file via -r.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:19:15 +02:00
jensandClaude Sonnet 4.6 9d12dd9a95 Move scipy and matplotlib from server to demos requirements
Both are only used by scripts/demos — the server itself only needs numpy
(used by kalman.py, delay.py, tempSensorSim.py, hendiCtrl.py).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:11:43 +02:00
jensandClaude Sonnet 4.6 5b0be831e2 Fix hold countdown not updating for clients connecting mid-hold
HoldRemaining now calls updateHeaderCountdown() directly so the header
hold row refreshes every second from its own message, not only via the
Elapsed → updateStepPlates → updateHeaderCountdown chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:07:55 +02:00
jensandClaude Sonnet 4.6 967b0a8eed Add pyserial and spidev to server requirements
Required for real hardware backends (HeaterHendi serial protocol,
TempSensor_max31865 SPI interface).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 17:40:29 +02:00
jensandClaude Sonnet 4.6 19d04804dc Add power soll and stirrer RPM to pot SVG; tune flame position
- Power soll label centered below flame area (amber, clears when off)
- Stirrer RPM label next to motor housing (clears when stopped)
- Flame base moved 20 units closer to pot bottom
- Drop Ist/Soll LCD header row to save vertical space

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 16:47:19 +02:00
jensandClaude Sonnet 4.6 ddd7de4b7c Remove Ist/Soll header row from LCD panel to save vertical space
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 16:36:08 +02:00
jensandClaude Sonnet 4.6 d0d0c7d3ee Add collapsible manual panel to give progress pane more room
Toggle button (top-left of column, always same position) collapses the
manual panel from 16em to 2em with a CSS transition; state persists in
localStorage across reloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 16:31:43 +02:00
jensandClaude Sonnet 4.6 2020207af0 Update README: browser layout, iPad/Safari, WebSocket disconnect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 15:36:08 +02:00
jensandClaude Sonnet 4.6 0f31cc41cc Handle abrupt WebSocket disconnect (e.g. Safari tab close)
Catch ConnectionClosed in User.send() and handler_recv() so that an
unclean client disconnect no longer produces "Task exception was never
retrieved" / "connection handler failed" log noise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 15:34:53 +02:00
jensandClaude Sonnet 4.6 d44601fee1 Optimize browser GUI layout for iPad Air 4 / Safari
- Add viewport meta tag so Safari uses device CSS pixels
- Move LCD units to a dedicated right-hand column; strip them from labels
- Tighten LCD grid: 4-column layout, narrower gap, font-size 1.9em

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 15:18:54 +02:00