Commit Graph
514 Commits
Author SHA1 Message Date
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