Author SHA1 Message Date
jensandClaude Sonnet 5 3a1184ef63 docs: add CLAUDE.md for future Claude Code sessions
Orients Claude Code fast in this repo: test/run commands and the
component/task/factory, WebSocket, PID/FSM, Sud/forecast, and
validation architecture, pointing back to README.md for deep detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9TKZynb4CdkwEh41kVZuy
2026-07-26 23:00:36 +02:00
jens 7bf475f257 commit stale changes 2026-07-26 21:37:07 +02:00
jensandClaude Sonnet 5 92b9bdc1ba config: generalize config_validate into a shared schema engine
require_schema(config, schema) is a generic recursive declarative
checker (nested-dict schema, leaves name required type(s), each
intermediate object node gets its own dict-type check so a wrong-typed
section is named directly instead of surfacing as a confusing "missing
child" error). validate_temp_ctrl() is now just require_schema(config,
TEMPCTRL_SCHEMA) against a declarative schema dict, replacing the
hand-rolled nested loops.

utils/sud_validate.py's static structural check ('steps' must be a
list) now goes through the same require_schema()/SUD_SCHEMA - one
shared mechanism for both server config.json and sud.json documents.
The genuinely dynamic per-step check (ramp.rate required only when a
step sets its own temperature) stays hand-written, since it depends on
the document's content, not just its shape.

Added TestRequireSchema, exercising the generic engine directly
against a schema unrelated to TempCtrl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 20:40:32 +02:00
jensandClaude Sonnet 5 4d59e11c1e sud: fail fast on malformed sud.json documents, one test per fixture
Sud.load() now calls utils/sud_validate.py's validate_sud() before
parsing: requires a non-empty 'steps' list, and for every resolved
step that sets its own 'temperature', a numeric ramp.rate - the one
genuinely unguarded crash site (ramp['rate'], no .get() fallback) hit
in tasks/sud.py, components/sud_forecast.py and demo_sud.py the moment
that step is reached, which for SudForecastEstimator can be as early
as the Load itself. A refused load is now logged with the specific
reason (previously silent). validate_sud() reuses Sud._parse_data()
directly rather than reimplementing default-merging/step-building.

tests/utils/test_sud_validate_fixtures.py covers 8 fixture docs plus
all 6 real sude/*.json files; tests/components/sud/test_sud_load.py
exercises the Sud.load() wiring itself (rejects, keeps prior schedule).

Also reworked both this and test_config_fixtures.py to generate one
dedicated test method per fixture file (dynamic setattr) instead of
looping with subTest inside a single method - subTest iterations don't
show up individually in `unittest -v`/IDE test runners, which made it
look like far fewer fixtures were covered than actually are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 13:27:20 +02:00
jensandClaude Sonnet 5 5a874e3869 test: add fixture-driven suite for config_validate
Complements the inline-dict unit tests in test_config_validate.py with
10 real JSON config files under tests/utils/fixtures/ (valid sim/real/
Normal configs, one with legacy unknown keys that must NOT fail, and
six broken variants covering each distinct failure mode: missing gain,
missing Inner branch, missing pid_type, missing TempCtrl entirely,
wrong-typed gain, TempCtrl not a dict).

test_config_fixtures.py drives all of them from one CASES table
(fixture name -> expected pass/ConfigError-regex), plus a guard test
that fails if fixtures/ and CASES ever drift apart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 12:59:38 +02:00
jensandClaude Sonnet 5 0171cb20c7 config: fail fast on missing TempCtrl PID gains
Config access was unchecked dict[key] everywhere, so a missing/
misnamed gain (we've hit this once already: config.json.sim's gain/
Model nesting mismatch) surfaced as a bare KeyError from deep inside
some component's __init__ or first process() tick, with no indication
which config key was wrong.

utils/config_validate.py adds a small stdlib-only dotted-path checker
(no schema library - matches this project's stdlib-first leaning) and
validate_temp_ctrl(), scoped to the section that actually caused an
incident: TempCtrl.pid_type plus every kp/ki/kd/kt gain under
TempCtrl.Outer and TempCtrl.Inner.{Heat,Hold,Cool}. Called once in
server/brewpi.py right after json.load(), before any factory runs.
Optional-with-default keys (Outer.y_hold_min, Inner.Hold.yi_max,
Thresholds) stay optional here too.

Heater/Stirrer/TempSensor/Pot sections and unknown-key warnings are
deliberately left for later - see components/pid/TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 12:33:57 +02:00
jensandClaude Sonnet 5 c60fdff74d docs: check off set_model_power gap in PID design backlog
Already fixed in 2f2067d (no-op stub on TempControllerBase, 2026-06-25)
but the TODO entry was never marked done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
2026-07-11 12:17:13 +02:00
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
53 changed files with 3749 additions and 211 deletions
+151
View File
@@ -0,0 +1,151 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A Python asyncio server (`server/brewpi.py`) that controls a mash/brewing rig: drives a
heater and stirrer to hold or ramp a pot of liquid to target temperatures per a
configurable mash schedule ("Sud"), and exposes live state/control over a WebSocket to a
PyQt5 desktop client (`client/`) and a dependency-free browser client (`web/`). It began as
a control-theory playground (Smith-predictor temperature control) and has grown real-hardware
backends (induction hob, RTD probe) alongside the simulation ones.
**Read `README.md` before making non-trivial changes** — it documents the architecture,
protocol, and a long list of subtle behaviors (state-replay-on-connect, forecast
reanchoring, sim-warp-factor semantics, log field meanings) in detail this file doesn't
repeat. This file only orients you fast; README is the source of truth for *why* things
work the way they do.
## Commands
Run everything from the repo root.
```bash
# Run the whole test suite (stdlib unittest, NOT pytest)
python -m unittest discover -t . -s tests
# Run a single test file
python -m unittest tests.components.pid.test_pid
# Run a single test case / method
python -m unittest tests.components.pid.test_pid.TestPidClamp.test_yi_clamped_to_max
# Start the server (simulated plant, default config)
cp config-sim.json.tpl config.json # once, or config-real.json.tpl for real hardware
cd server && ./brewpi.py
cd server && ./brewpi.py --sim-warp-factor 50 # faster-than-real-time dev/testing
cd server && ./brewpi.py --http-port 8888 # browser client port
# Desktop client
cd client && ./brewpi_gui.py
# Browser client: open http://<server-host>:8080/
# Offline forecast-vs-actual replay from a saved log
python utils/analyze_log.py <date_time> <sud_name> # e.g. 20260701T192824 Sud-0010
python utils/analyze_log.py path/to/log_*.json
```
There is no build step, linter config, or packaging beyond `pip install -r
server/requirements.txt` (+ `client/requirements.txt` for the desktop GUI, +
`server/requirements-hw.txt` for real hardware backends).
**Tests**: use stdlib `unittest`, not pytest. Tests live under `tests/<mirror of source
path>/test_*.py` (e.g. `tests/components/pid/` mirrors `components/pid/`) and every
directory needs an `__init__.py`. The `-t .` flag on `discover` is mandatory — without it,
`top_level_dir` defaults to `tests/`, which gets put on `sys.path` instead of the repo root,
and since test packages mirror real package names (`tests/components/pid` vs
`components/pid`), imports silently resolve against the wrong directory instead of failing
loudly.
## Architecture
### Component / task / factory pattern
Almost everything under `components/` follows the same shape: an abstract base (e.g.
`AHeater`, `AStirrer`, `APid`, `AAplant`, `ATemperatureSensor`) defines the interface, and
concrete implementations (sim vs. real hardware, or PID variant) are picked at runtime via a
`*Factory.create(name, ...)` (`components/factory.py`'s `ComponentFactory`, matching `name`
against a registry by substring — e.g. `"sim"` in `plant_name` selects the whole simulated
heater/pot/sensor trio via `PlantFactory`). Adding a new backend means: subclass the ABC,
register it in the relevant factory, no changes needed anywhere that already calls the
factory.
Each component that needs periodic work is wrapped in an `ATask` (`tasks/task.py`) —
`on_process()` runs on its own `--dt`/`--sim-warp-factor`-scaled interval as an asyncio task,
independent of every other task. `AttributeChange` (`utils/value.py`), the common base for
both tasks and components, is what makes an attribute observable: setting it fires
`set_on_changed` callbacks only if the value actually changed. `server/brewpi.py` is where
everything gets wired together — it builds the components via factories based on
`config.json`'s `Controller` section, then chains their outputs to each other's inputs via
`set_on_changed` (sensor temp → controller `theta_ist`, controller output → heater power,
heater effective power → pot model, etc.).
### Server is the single source of truth
Neither client ever maintains its own model of desired state. A client sends a command and
waits for the server's broadcast reply before updating its display — never an optimistic
local update. This is why reconnecting a client (or connecting a second one) needs no special
handshake: `ws/server/ws_server_multi_user.py`'s `global_state` accumulates every message
ever broadcast, keyed the same way the live channels are, and a fresh subscribe replays the
full current state for that channel in one message. See README's "State is server-only"
section for the full rationale and the `Error`-then-`Error: None` one-shot-event pattern this
implies for anything that isn't durable state.
### PID / temperature control (`components/pid/`)
Two controller variants (`pid_factory.py`): plain PID ("Normal") or PID + Smith predictor
("Smith", compensates for the pot's transport delay using two internal models). Both are a
4-state FSM (IDLE/HEAT/HOLD/COOL, `temp_controller_fsm.py`) with thresholds driven entirely
from `config.json`'s `TempCtrl.Thresholds` — never hardcoded. `components/pid/TODO.md` is the
live design-flaw backlog for this subsystem (windup, config validation gaps, etc.) — check it
before "fixing" something that touches PID/FSM behavior, since some oddities are known and
deliberately deferred rather than bugs.
### Mash schedules ("Sud")
`sude/*.json` files describe a brew: fixed pot/thermal params plus a `steps` list, each
optionally ramping to a `temperature` and/or holding for a `duration`. `components/sud.py`'s
`Sud` resolves each raw step against `default.step` and tracks progress; `tasks/sud.py`'s
`SudTask` drives the temperature controller toward each step's target, advancing only once
the controller's own FSM reports the gap closed. Schedules are loaded into server memory over
the WebSocket (`{"Sud": {"Load": ...}}`) — the server never reads/writes `sude/*.json`
itself, only the client's file dialog does.
`components/sud_forecast.py`'s `SudForecastEstimator` runs a throwaway `Sud`/`Pot`/controller
trio through the whole schedule to predict its trajectory (for the GUI's forecast plot),
re-anchored at every real step transition (`SudTask._reanchor_forecast()`) so the displayed
forecast keeps converging with reality rather than showing one static, increasingly-wrong
guess made at Load time. This reanchoring logic, its race-condition guard
(`_forecast_generation`), and the coordinate-space subtleties around it are among the most
gotcha-prone parts of the codebase — read README's "Forecast vs. actual duration" section in
full before touching it.
### Validation
`utils/config_validate.py` / `utils/sud_validate.py` share a declarative `require_schema()`
engine (`utils/config_validate.py`) that walks a nested schema dict and raises `ConfigError`
named by full dotted path on a missing key or wrong type — used for both `config.json`'s
`TempCtrl` section and (with extra per-step conditional checks) `sude/*.json` documents.
Fixtures for both live under `tests/utils/fixtures/` and `tests/utils/sud_fixtures/`, one
fixture per failure mode, driving a fixture-parameterized test suite rather than one test per
assertion.
### Logging
Every task/component logs through stdlib `logging` (never `print()`) via `AttributeChange`'s
`self.log`. Two independent JSON loggers (`tasks/server_log.py`'s `ServerLogTask`, running for
the whole server session, and `tasks/sud_log.py`'s `SudLogTask`, a subclass active only during
a Sud run) record the six live-plotted signals plus enough context (`Config`, `HeaterPowers`,
effective `SimWarpFactor`, and for Sud logs the raw `Doc` and `ForecastAnchors`) to fully
reconstruct a forecast-vs-actual comparison offline via `utils/analyze_log.py`, with no live
server or hardware needed. Sample timestamps are always tick-counted (`dt`-based), never
wall-clock — see README's "Logging & analysis" section for why that matters for forecast
alignment.
### `--sim-warp-factor`
Only ever scales the *wait* time between task ticks — never `--dt` itself, and it has no
effect at all against real hardware (clamped to `1.0` whenever the plant isn't simulated).
Never let a change reintroduce a path where this scales a plant-model constant or a recorded
timestamp.
+21 -4
View File
@@ -771,10 +771,27 @@ session - don't assume they're a matched pair unless you've just watched a
run finish (`log_latest_sud.json` is only ever current *immediately* after
a `Stop`/`DONE`).
`server/brewpi.py` also mirrors everything it prints (every component's
`print()`-based status/debug output) to a plain text log,
`logs/brewpi.<timestamp>.log`, alongside those JSON logs - useful for
post-mortems without needing to have been watching the console live.
`server/brewpi.py` also mirrors console output to a plain text log,
`logs/brewpi.<timestamp>.log` (plus an always-truncated `logs/brewpi.latest.log`
for tail-style consumers), alongside those JSON logs - useful for post-mortems
without needing to have been watching the console live. Every task/component
logs through the stdlib `logging` module rather than `print()`: `AttributeChange`
(`utils/value.py`, the common base of every `ATask` and component ABC) gives
each one a `self.log = logging.getLogger(type(self).__name__)`, so messages
are automatically tagged with the component's class name with no hand-typing
needed. `server/brewpi.py`'s `Tee` class mirrors `stdout`/`stderr` to both log
files and stamps each line with a `<date>T<time>:` prefix; `logging.basicConfig()`
uses a bare `%(name)s:%(message)s` formatter, so the combined line reads
`<date>T<time>:<component>:<message>` without double-stamping (and third-party
loggers like `websockets`/`asyncio` get the same formatting for free).
`components/sud.py`'s `Sud` logs its own lifecycle (load/start/pause/continue/
stop, each step's `"<descr>(<number>) Heating"`/`"Reached"` transitions,
waiting for user interaction, finished), and `HeaterTask`/`StirrerTask` log
connected/disconnected state. `SudForecastEstimator` drives its own throwaway
`Sud` through an entire schedule with no real waiting to predict duration, so
it's given its own `"SudForecastEstimator"` logger (silenced to `WARNING` by
default in `components/sud_forecast.py`) rather than sharing `Sud`'s, keeping
an instant internal forecast run out of the log next to actual brew activity.
Both JSON logs also carry `dt`, the effective `SimWarpFactor` (already
clamped to `1.0` for real hardware - see above), `Config` (the whole
+133 -52
View File
@@ -422,7 +422,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_empty = True
self.sud_state = None
self.sud_user_message = None
self._confirm_box = None
self.user_confirm_pending = False
# Global, not Sud-specific - from the 'System' channel's one-time
# startup message. warp_factor defaults to 1.0 (no scaling) until
# that arrives, or for a server that doesn't send it at all.
@@ -449,9 +449,15 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_elapsed_last = None
self.sud_name = ''
self.sud_schedule = []
# Hardware baseline from config.json's Pot section (see
# on_system_changed()) - passed to Sud._parse_data() so a loaded
# sud.json that doesn't override 'mass' still resolves to the real
# kettle mass instead of _parse_data()'s own bare 0 default.
self.pot_config = {}
self.sud_pot_mass = 0
self.sud_grain_mass = 0
self.sud_water_mass = 0
self.sud_volumen = 0
self.sud_step_index = None
self.sud_step_descr = None
self.sud_step_type = None
@@ -539,6 +545,24 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.cooling_blink_timer = QtCore.QTimer(self)
self.cooling_blink_timer.timeout.connect(self.on_cooling_blink)
# Persistent user-confirm control (a Sud step's WAIT_USER, e.g.
# "Bitte Malz einfuellen und bestaetigen") - lives in the top
# toolbar rather than a transient dialog, so it's visible
# regardless of which tab is active. Flashes yellow with the
# step's message while pending (same blink-timer pattern as
# status_label_cooling above); disabled and blank otherwise.
# set_user_confirm_pending() also disables the rest of the GUI
# while pending, forcing this button to be dealt with first.
self.toolBar.addSeparator()
self.label_user_confirm = QtWidgets.QLabel()
self.toolBar.addWidget(self.label_user_confirm)
self.btn_user_confirm = QtWidgets.QPushButton("Confirm")
self.btn_user_confirm.setEnabled(False)
self.btn_user_confirm.clicked.connect(self.on_action_user_confirm)
self.toolBar.addWidget(self.btn_user_confirm)
self.user_confirm_blink_timer = QtCore.QTimer(self)
self.user_confirm_blink_timer.timeout.connect(self.on_user_confirm_blink)
self.update_sud_actions()
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
self.actionStart.triggered.connect(self.on_action_sud_start)
@@ -721,7 +745,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_empty = True
self.sud_state = None
self.sud_user_message = None
self._close_confirm_dialog()
self.set_user_confirm_pending(False)
for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll,
self.Slider_pwr_soll, self.Slider_speed_soll):
w.setEnabled(True)
@@ -740,6 +764,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_pot_mass = 0
self.sud_grain_mass = 0
self.sud_water_mass = 0
self.sud_volumen = 0
self.sud_step_index = None
self.sud_step_descr = None
self.sud_step_type = None
@@ -780,6 +805,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.btn_pot_reset.setVisible(msg['PlantSim'])
self.label_pot_temp.setVisible(msg['PlantSim'])
self.doubleSpinBox_pot_temp.setVisible(msg['PlantSim'])
elif "Pot" in key:
self.pot_config = msg['Pot']
if self.sud_empty:
self.update_status_step_label()
self.update_status_env_label()
def on_action_pot_reset(self):
@@ -849,24 +878,47 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
def on_action_sud_stop(self):
self.msg_sud.send({'Stop': True})
def show_user_message(self, message):
if self._confirm_box is not None:
def set_user_confirm_pending(self, pending, message=''):
if pending == self.user_confirm_pending:
return
dlg = QtWidgets.QDialog(self)
dlg.setWindowTitle("Sud")
dlg.setWindowModality(QtCore.Qt.NonModal)
layout = QtWidgets.QVBoxLayout(dlg)
layout.addWidget(QtWidgets.QLabel(message))
btn = QtWidgets.QPushButton("OK")
btn.clicked.connect(lambda: self.msg_sud.send({'Confirm': True}))
layout.addWidget(btn)
self._confirm_box = dlg
dlg.show()
self.user_confirm_pending = pending
if pending:
self.label_user_confirm.setText(message)
self.btn_user_confirm.setEnabled(True)
self.btn_user_confirm.setStyleSheet("background-color: yellow; font-weight: bold;")
self.user_confirm_blink_timer.start(500)
# Modally disabled: nothing else in the GUI should be usable
# until the pending step is actually confirmed - centralwidget
# covers every tab's content; the toolbar/menu actions are
# disabled individually (rather than disabling self.toolBar
# itself) so btn_user_confirm, a sibling widget on the same
# toolbar, stays enabled.
self.centralwidget.setEnabled(False)
for action in (self.actionStart, self.actionPause, self.actionStop,
self.actionSudNew, self.actionSudSave, self.actionSudLoad):
action.setEnabled(False)
else:
self.user_confirm_blink_timer.stop()
self.btn_user_confirm.setStyleSheet("")
self.btn_user_confirm.setEnabled(False)
self.label_user_confirm.setText('')
self.centralwidget.setEnabled(True)
self.actionSudNew.setEnabled(True)
self.actionSudSave.setEnabled(True)
self.actionSudLoad.setEnabled(True)
# Start/Pause/Stop depend on sud_state/sud_empty, not just
# "was disabled for confirm" - let the usual logic pick the
# right one back up rather than assuming all three should
# simply return to enabled.
self.update_sud_actions()
def _close_confirm_dialog(self):
if self._confirm_box is not None:
self._confirm_box.close()
self._confirm_box = None
def on_user_confirm_blink(self):
yellow = self.btn_user_confirm.styleSheet() == ""
self.btn_user_confirm.setStyleSheet(
"background-color: yellow; font-weight: bold;" if yellow else "")
def on_action_user_confirm(self):
self.msg_sud.send({'Confirm': True})
def on_action_sud_new(self):
# Sends an empty schedule via the same Load path Load Sud uses, with
@@ -1002,7 +1054,22 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# Receiving anything at all on this channel means a Sud is loaded
# server-side - the schedule it's running may still be coming.
self.sud_loaded = True
for key in msg:
# UserMessage must be processed before State: a step's user_message
# is set once, when the step starts (components/sud.py's
# _advance()), well before state later flips to WAIT_USER
# (_finish_step()) - so UserMessage's key was inserted into the
# server's global_state earlier in its lifetime and would precede
# State there. A client connecting fresh while a step is already
# WAIT_USER gets both bundled into this same replay dict; processing
# State first would check self.sud_user_message before this very
# message has set it, silently failing set_user_confirm_pending()'s
# guard and leaving the Confirm button looking merely disabled
# instead of pending - see web/app.js's onSudChanged() for the twin
# fix (same server, same race).
ordered_keys = ['UserMessage'] + [k for k in msg if k != 'UserMessage']
for key in ordered_keys:
if key not in msg:
continue
if "Json" in key:
if self.sud_save_path:
with open(self.sud_save_path, 'w') as f:
@@ -1067,9 +1134,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.forecast_plot.show_computing(self.sud_name)
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
self.show_user_message(self.sud_user_message)
self.set_user_confirm_pending(True, self.sud_user_message)
elif prev_state == SUD_WAIT_USER_STATE and self.sud_state != SUD_WAIT_USER_STATE:
self._close_confirm_dialog()
self.set_user_confirm_pending(False)
self._update_step_plates()
elif "UserMessage" in key:
self.sud_user_message = msg['UserMessage']
@@ -1159,42 +1226,50 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self._update_step_plates()
def update_status_step_label(self):
if self.sud_step_descr:
# +1: sud_step_index is the 0-based index Sud.index uses, but
# StepPlate (the Progress tab) labels steps 1-based - match it
# here so the status bar doesn't announce a step one number
# below what the Progress tab shows for the same step.
text = "Step {}: {}".format(self.sud_step_index + 1, self.sud_step_descr)
if self.sud_step_type == 'hold':
remaining = max(self.sud_hold_remaining, 0.0)
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
# sud_step_index is the same 0-based index Sud.index uses into
# its own schedule list (see components/sud.py) - self.
# sud_schedule is built from the very same doc, so it lines up
# directly; grain_mass/water_mass are already resolved against
# default.step there (see components/sud.py's _build_step()).
if 0 <= self.sud_step_index < len(self.sud_schedule):
step = self.sud_schedule[self.sud_step_index]
step_pot = step.get('pot', {})
grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0)
else:
grain, water = self.sud_grain_mass, self.sud_water_mass
elif self.sud_schedule:
# Loaded but not started yet (no real Step message has
text = ""
# Before any schedule is loaded, fall back to config.json's Pot
# section (self.pot_config, captured in on_system_changed()) so the
# status bar already shows pot mass/water/volume/energy as soon as
# the server connects, rather than a blank status bar until Load -
# mirrors web/app.js's updateStatusLine(). Step description/
# remaining-time only exist once a schedule is actually loaded, so
# they stay inside the "not empty" branch below.
if self.sud_empty:
pot = self.pot_config.get('mass', 0)
water = self.pot_config.get('water_mass', 0)
volumen = self.pot_config.get('volumen', 0)
grain = 0
else:
pot = self.sud_pot_mass
volumen = self.sud_volumen
grain, water = self.sud_grain_mass, self.sud_water_mass
if self.sud_step_descr:
# +1: sud_step_index is the 0-based index Sud.index uses, but
# StepPlate (the Progress tab) labels steps 1-based - match it
# here so the status bar doesn't announce a step one number
# below what the Progress tab shows for the same step.
text = "Step {}: {}".format(self.sud_step_index + 1, self.sud_step_descr)
if self.sud_step_type == 'hold':
remaining = max(self.sud_hold_remaining, 0.0)
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
# sud_step_index is the same 0-based index Sud.index uses into
# its own schedule list (see components/sud.py) - self.
# sud_schedule is built from the very same doc, so it lines up
# directly; grain_mass/water_mass are already resolved against
# default.step there (see components/sud.py's _build_step()).
if 0 <= self.sud_step_index < len(self.sud_schedule):
step = self.sud_schedule[self.sud_step_index]
step_pot = step.get('pot', {})
grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0)
# else: loaded but not started yet (no real Step message has
# arrived) - preview the doc's own initial grain_mass/
# water_mass right away instead of leaving the status line
# blank until Start, mirroring tasks/sud.py's SudTask.
# apply_plant_params() applying it to the real plant/
# controller immediately on Load too.
text = ""
grain, water = self.sud_grain_mass, self.sud_water_mass
else:
self.statusBar().clearMessage()
return
pot = self.sud_pot_mass
mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format(
pot, water, grain, pot + water + grain)
mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg, Volume {:.1f} L".format(
pot, water, grain, pot + water + grain, volumen)
text = text + " " + mass_text if text else mass_text
# Sums, not just the active step's own figures: total process time
# is just self.sud_elapsed_last (Sud's own tick-counted total -
@@ -1215,7 +1290,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
def update_sud_forecast(self, doc):
try:
name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc)
name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc, self.pot_config)
except (KeyError, TypeError):
return
# A genuinely different schedule (an actual Load, or "New Sud")
@@ -1244,6 +1319,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_pot_mass = pot_mass
self.sud_grain_mass = grain_mass
self.sud_water_mass = water_mass
# Sud._parse_data() has no notion of 'volumen' (unlike mass/L/Td,
# nothing downstream needs it for the actual physics) - read it
# straight off the doc, falling back to config.json's Pot.volumen
# same as _parse_data() does for mass, mirroring web/app.js's
# parseSudDoc().
self.sud_volumen = doc.get('pot', {}).get('volumen', self.pot_config.get('volumen', 0))
self.sud_empty = len(schedule) == 0
if is_new_schedule:
self.sud_step_descr = None
+3 -3
View File
@@ -45,7 +45,7 @@ class HeaterHendi(AHeater):
# tick loop (with self.device.open(): activate(True)/(False)) and
# from a client's Connect/Disconnect command, neither of which
# expect activate() itself to ever raise.
print(f"HeaterHendi: comm error in activate({enable}): {e}")
self.log.error(f"comm error in activate({enable}): {e}")
self.disconnect()
def is_activated(self):
@@ -55,7 +55,7 @@ class HeaterHendi(AHeater):
s = self.hendi.isRemoteEnable()
return '1' in s
except Exception as e:
print(f"HeaterHendi: comm error in is_activated: {e}")
self.log.error(f"comm error in is_activated: {e}")
self.disconnect()
return False
@@ -72,7 +72,7 @@ class HeaterHendi(AHeater):
self.hendi.setPowerWatts(power)
self.power_eff = self.hendi.getPowerWatts() if power > 0 else 0
except Exception as e:
print(f"HeaterHendi: comm error in process: {e}")
self.log.error(f"comm error in process: {e}")
self.disconnect()
self.power_eff = 0
+8 -6
View File
@@ -1,5 +1,6 @@
#!/usr/bin/python3
import logging
import time
import serial
import sys
@@ -11,6 +12,7 @@ class HendiException(Exception):
class HendiCtrl:
def __init__(self, port, speed, debug=False):
self.log = logging.getLogger(type(self).__name__)
self.debug = debug
self.prompt = b':'
self.sw_id = None
@@ -48,7 +50,7 @@ class HendiCtrl:
try:
self.ser.open()
except serial.SerialException as e:
print(f"HendiCtrl: connect failed: {e}")
self.log.error(f"connect failed: {e}")
return False
# The port itself is what "connected" means - a failed version query
# (e.g. the device sitting in its ungraceful-disconnect lockout, see
@@ -57,9 +59,9 @@ class HendiCtrl:
try:
self.sw_id = self.getSoftwareIdentifier()
self.sw_ver = self.getSoftwareVersion()
print(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
self.log.info(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
except HendiException:
print("HendiCtrl: Hendi not found")
self.log.warning("Hendi not found")
return True
@contextmanager
@@ -91,7 +93,7 @@ class HendiCtrl:
self.ser.rts = True
def firmware_update(self, filename):
print("Start firmware update")
self.log.info("Start firmware update")
self.enter_bootloader()
self.ser.readline()
@@ -122,7 +124,7 @@ class HendiCtrl:
self.ser.flushInput()
data = s.encode()
if self.debug:
print(f"HendiCtrl: -> {data!r}")
self.log.debug(f"-> {data!r}")
self.ser.write(data + b'\r')
def __read(self):
@@ -132,7 +134,7 @@ class HendiCtrl:
# Read answer line
answer_raw = self.ser.readline()
if self.debug:
print(f"HendiCtrl: <- echo={echo!r} answer={answer_raw!r}")
self.log.debug(f"<- echo={echo!r} answer={answer_raw!r}")
answer = answer_raw.decode('utf-8').replace('\r', '').replace('\n', '')
if ':' in answer:
result = answer.split(':')
+31 -4
View File
@@ -1,3 +1,4 @@
import logging
import time
import serial
import enum
@@ -48,8 +49,27 @@ class Varid(enum.Enum):
MOTOR_CURRENT_LIMIT_OCCURRENCE_COUNT = 45
# Bit flags of the STATUS variable (Varid.STATUS). A non-zero
# SAFE_START_VIOLATION is the "fail-safe" state: the controller accepted
# the GO command but still refuses to spin the motor. The other bits are
# the SMC's other error conditions, which also stop the motor and can
# make GO fail to bring it back.
class StatusError(enum.IntFlag):
SAFE_START_VIOLATION = 0x0001
REQUIRED_CHANNEL_INVALID = 0x0002
SERIAL_ERROR = 0x0004
COMMAND_TIMEOUT = 0x0008
LIMIT_KILL_SWITCH = 0x0010
LOW_VIN = 0x0020
HIGH_VIN = 0x0040
OVER_TEMPERATURE = 0x0080
MOTOR_DRIVER_ERROR = 0x0100
ERR_LINE_HIGH = 0x0200
class Pololu1376:
def __init__(self, serial_port):
self.log = logging.getLogger(type(self).__name__)
# Deferred-open pyserial idiom: serial_port is constructed with no
# port set (see components/actor/stirrerpololu1376.py), so it isn't
# actually opened until connect() is called - a missing/unplugged
@@ -70,7 +90,7 @@ class Pololu1376:
self.ser.close()
self.ser.open()
except serial.SerialException as e:
print(f"Pololu1376: connect failed: {e}")
self.log.error(f"connect failed: {e}")
return False
# set_output_flow_control() requires the port to already be open -
# can only happen here, after open() above, not at construction time.
@@ -78,10 +98,10 @@ class Pololu1376:
try:
self.stop()
self.firmware_version = self.get_firmware_version()
print("Pololu1376 F/W-Version:", self.firmware_version)
self.log.info("F/W-Version: {}".format(self.firmware_version))
return True
except Exception as e:
print(f"Pololu1376: connect failed: {e}")
self.log.error(f"connect failed: {e}")
self.ser.close()
self.firmware_version = None
return False
@@ -117,8 +137,15 @@ class Pololu1376:
return rsp[1:]
def get_status_errors(self):
return StatusError(self.get_variable(Varid.STATUS))
def go(self):
return self.cmd("GO")
rsp = self.cmd("GO")
errors = self.get_status_errors()
if errors:
self.log.error(f"still in fail-safe after GO, active errors: {errors!r}")
return rsp
def stop(self):
return self.cmd("X")
+10 -10
View File
@@ -46,7 +46,7 @@ class StirrerPololu1376(AStirrer):
if not self.connected:
return
try:
print("activate {}".format(enable))
self.log.debug("activate {}".format(enable))
if enable:
self.drv.go()
else:
@@ -58,7 +58,7 @@ class StirrerPololu1376(AStirrer):
# loop (with self.device.open(): activate(True)/(False)) and
# from a client's Connect/Disconnect command, neither of which
# expect activate() itself to ever raise.
print(f"StirrerPololu1376: comm error in activate({enable}): {e}")
self.log.error(f"comm error in activate({enable}): {e}")
self.disconnect()
def _on_set_speed(self, speed):
@@ -66,23 +66,23 @@ class StirrerPololu1376(AStirrer):
return
try:
self.drv.motor_forward(speed)
print("Set speed to {} %".format(speed))
self.log.debug("Set speed to {} %".format(speed))
except Exception as e:
print(f"StirrerPololu1376: comm error in _on_set_speed: {e}")
self.log.error(f"comm error in _on_set_speed: {e}")
self.disconnect()
def _on_process(self):
if not self.connected:
return
try:
status = self.drv.get_variable(Varid.STATUS)
limit_status = self.drv.get_variable(Varid.STATUS_LIMIT_STATUS)
if status & 0x01 == 0x01:
if limit_status & 0x01 == 0x01:
print("Recover after motor error!")
errors = self.drv.get_status_errors()
if errors:
self.log.warning(f"motor controller reports errors: {errors!r}")
if StatusError.SAFE_START_VIOLATION in errors:
self.log.warning("Recover after safe start violation!")
self.drv.go()
except Exception as e:
print(f"StirrerPololu1376: comm error in _on_process: {e}")
self.log.error(f"comm error in _on_process: {e}")
self.disconnect()
+43 -32
View File
@@ -32,40 +32,51 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
hold_scale)` — the overshoot-compensation scheduling logic now lives
entirely in the temp controller, not the generic `Pid` block.
- [ ] **No automated tests for the heat-rate filtering or Smith
correction specifically.** An earlier `tests/components/pid/` suite
(stdlib `unittest`, no pytest) covering FSM transitions, anti-windup
clamping, and Kalman convergence was removed before the Kalman-filter
removal/Smith rewrite. A new `tests/components/pid/` suite was added
alongside the HOLD-windup fix below (`test_pid.py`,
`test_temp_controller_closed_loop.py`) covering the `yi_max` clamp and
closed-loop disturbance/ramp/transition behavior, but
`_compute_heatrate()`'s backward-difference + low-pass filtering and
the two-model Smith correction itself still have no dedicated
coverage. This drives a physical heater — worth extending.
- [x] **No automated tests for the heat-rate filtering or Smith
correction specifically.** Fixed: `tests/components/pid/test_heatrate_filter.py`
covers `_compute_heatrate()` - a first-tick-reads-zero regression test
(verified it would catch the old hardcoded `last_theta_ist = 20` bug),
convergence to the true rate on a steady ramp (heating and cooling),
and the `beta` pre-filter measurably suppressing noise amplification vs.
raw differentiation. `tests/components/pid/test_temp_controller_smith_correction.py`
covers the two-model correction in `temp_controller_smith.py`: with a
matched internal model, `theta_ist` stays close to the fast (zero-delay)
model's own prediction (contrasted against a deliberately mismatched
model, which diverges 10x+ further); and the corrected estimate visibly
rises before the real transport delay (`Td`) has elapsed, while the raw
plant reading is still flat.
- [ ] **`set_model_power` isn't defined on every controller, but
`brewpi.py` wires it unconditionally.** `brewpi.py` always does
`heater.set_on_changed("power_set", tc.set_model_power)` regardless
of `Controller.pid_type`. Only `temp_controller_smith.py` (the Smith
predictor) defines `set_model_power`; `temp_controller.py` (`"Normal"`)
does not, so starting the server with `"pid_type": "Normal"` crashes
at wiring time with `AttributeError: 'TempController' object has no
attribute 'set_model_power'`. Either add a no-op `set_model_power` to
`TempControllerBase`, or only wire it when the configured controller
actually exposes a model to feed.
- [x] **`set_model_power` isn't defined on every controller, but
`brewpi.py` wires it unconditionally.** Fixed: `TempControllerBase`
now has a no-op `set_model_power`/`set_ambient_temperature`/
`set_model_plant_params` (`temp_controller_base.py:83-90`), so
`brewpi.py`'s unconditional `heater.set_on_changed("power_set",
tc.set_model_power)` no longer crashes for `"pid_type": "Normal"` -
`temp_controller_smith.py` still overrides `set_model_power` to
actually feed its internal model. The redundant `hasattr()` guards
at both call sites (`brewpi.py`, `components/sud_forecast.py`) were
removed along with it.
- [ ] **Config access is unchecked `dict[key]` everywhere.**
`params['Hold']`, `params['Td']`, etc., throughout, with no schema
validation at load time. We already hit this bug class once
(`config.json.sim`'s `gain`/`Model` nesting mismatch). A small
schema/dataclass validation layer at config-load would surface
errors immediately instead of mid-`__init__` — and would have caught
the dead `TempCtrl.Kalman` / `Model.kn` / `Plant.kn` keys that used
to sit unused in `config-sim.json.tpl`/`.sim` (since deleted), and the
`Model.gain`/`Plant.gain` keys that did the same in `config.json.sim`
before that file was removed entirely — `Pot` dropped `gain`
entirely, see `components/plant/TODO.md`.
- [x] **Config access is unchecked `dict[key]` everywhere.** Partially
fixed, scoped to the section that actually caused an incident
(`config.json.sim`'s `gain`/`Model` nesting mismatch): `utils/
config_validate.py`'s `require()` is a small stdlib-only dotted-path
checker (no schema library added - matches this project's no-pytest/
stdlib-first preference), and `validate_temp_ctrl()` requires
`TempCtrl.pid_type` plus every `kp`/`ki`/`kd`/`kt` gain under
`TempCtrl.Outer` and `TempCtrl.Inner.{Heat,Hold,Cool}` - the ones every
`pid_type` reads unconditionally. Called once in `server/brewpi.py`
right after `json.load()`, before any factory runs, so a missing/
misnamed gain now fails fast with `ConfigError: missing required
config key: TempCtrl.Inner.Hold.kp` instead of a bare `KeyError` from
inside some component's `__init__` or first `process()` tick. Covered
by `tests/utils/test_config_validate.py`.
Deliberately left for later, since neither has actually crashed
anything yet: `Heater`/`Stirrer`/`TempSensor`/`Pot` config sections
(all already default gracefully via `.get(..., default)` in
`PlantFactory` etc.), and erroring (vs. just `log.warning()`) on
unknown/stale keys like the old `TempCtrl.Kalman`/`Model.kn`/
`Plant.kn`/`Model.gain` leftovers - see `components/plant/TODO.md`.
- [x] **`pid_heat` can wind up during a `HOLD`-state disturbance with no
anti-windup engagement.** A cold-water disturbance while holding drove
+3 -3
View File
@@ -25,7 +25,7 @@ class TempSensor_max31865(ATemperatureSensor):
# temperature()'s own except handler already retries via
# reopen() on the next tick, same recovery path as a later
# runtime failure.
print(f"TempSensor_max31865: could not open SPI at startup: {e}")
self.log.error(f"could not open SPI at startup: {e}")
def _open_spi(self):
self.spi.open(0, 0)
@@ -56,7 +56,7 @@ class TempSensor_max31865(ATemperatureSensor):
try:
self._open_spi()
except Exception as e:
print(f"TempSensor_max31865: reopen failed: {e}")
self.log.error(f"reopen failed: {e}")
def read_reg(self, addr):
reg = self.spi.xfer([addr, 0xFF])
@@ -98,7 +98,7 @@ class TempSensor_max31865(ATemperatureSensor):
# nothing restarts a dead ATask). reopen() gives the SPI bus a
# chance to recover from a wedged state without a full process/
# Pi reboot - see docs/pi_deployment_notes.md.
print(f"TempSensor_max31865: comm error reading sensor, reopening SPI: {e}")
self.log.error(f"comm error reading sensor, reopening SPI: {e}")
self.reopen()
return self.temp
+29 -2
View File
@@ -140,12 +140,30 @@ class Sud(AttributeChange):
a schedule does so explicitly itself, e.g. by writing save()'s
result to one of the sude/*.json files), resetting to IDLE as if
freshly loaded. Refused while a brew is in progress, or if data is
malformed - in either case the current schedule is left untouched."""
malformed - in either case the current schedule is left untouched.
validate_sud() catches the same malformed-document cases _parse_data()
itself would raise on, plus content _parse_data() happily resolves
but that would crash much later - a step setting its own 'temperature'
with no ramp.rate anywhere to inherit only blows up when tasks/sud.py
or SudForecastEstimator actually reaches that step (ramp['rate'] has
no .get() fallback), which for the forecast estimator can be as soon
as this very Load. Refusing it here instead, with the reason logged
(nothing else here logs *why* a load was refused), is strictly better
than a bare KeyError from inside a run or a worker-thread forecast."""
# Imported here, not at module level: utils/sud_validate.py imports
# Sud from this very module to reuse _parse_data(), so a top-level
# import here would be circular.
from utils.sud_validate import validate_sud
from utils.config_validate import ConfigError
if self.state not in (SudState.IDLE, SudState.DONE):
return False
try:
validate_sud(data)
parsed = self._parse_data(data, self._pot_config)
except (KeyError, TypeError):
except (KeyError, TypeError, ConfigError) as e:
self.log.warning("Refusing to load malformed sud document: {}".format(e))
return False
(self.name, self.description, self.schedule, self.pot_mass,
@@ -154,6 +172,7 @@ class Sud(AttributeChange):
self._data = data
self._reset_run_state()
self.log.info("Loaded '{}'".format(self.name))
return True
def derive_plant_params(self, grain_mass, water_mass):
@@ -183,11 +202,13 @@ class Sud(AttributeChange):
if self.state == SudState.PAUSED:
self.state = self._paused_from
self._paused_from = None
self.log.info("Continued")
return
if self.state not in (SudState.IDLE, SudState.DONE):
return
self.index = -1
self.elapsed = 0.0
self.log.info("Started")
self._advance()
def pause(self):
@@ -196,11 +217,13 @@ class Sud(AttributeChange):
if self.state in (SudState.RAMPING, SudState.HOLDING):
self._paused_from = self.state
self.state = SudState.PAUSED
self.log.info("Paused")
def stop(self):
"""Aborts the run, discarding progress back to IDLE."""
if self.state not in (SudState.IDLE, SudState.DONE):
self._reset_run_state()
self.log.info("Stopped")
def confirm(self):
if self.state == SudState.WAIT_USER:
@@ -209,6 +232,7 @@ class Sud(AttributeChange):
def temp_reached(self):
if self.state != SudState.RAMPING:
return
self.log.info("{}({}) Reached".format(self.step.get('descr'), self.step['number']))
if 'hold' in self.step:
# Same step, ramp phase done - move into its hold phase rather
# than finishing the step. Re-assign self.step (AttributeChange
@@ -243,6 +267,7 @@ class Sud(AttributeChange):
self.state = SudState.DONE
self.user_message = None
self.step = None
self.log.info("Finished")
return
next_step = self.schedule[self.index]
@@ -254,9 +279,11 @@ class Sud(AttributeChange):
self.state = SudState.RAMPING
self.user_message = next_step.get('user_message')
self.step = next_step
self.log.info("{}({}) Heating".format(next_step.get('descr'), next_step['number']))
def _finish_step(self):
if self.step.get('user_wait_for_continue', False):
self.state = SudState.WAIT_USER
self.log.info("Waiting for user interaction")
else:
self._advance()
+10
View File
@@ -1,3 +1,4 @@
import logging
from components.plant import Pot
from components.pid import PidFactory
from components.sud import Sud, SudState
@@ -7,6 +8,14 @@ from components.sud import Sud, SudState
# simulation forever - the estimate is simply cut off there.
MAX_TICKS = 200000
# estimate() below drives a throwaway Sud through an entire schedule in a
# tight loop (no real waiting) purely to predict its duration - since it's
# still a Sud, it would otherwise log through the exact same "Sud" logger
# the real, server-driven one uses (components/sud.py's self.log), making
# an instant internal forecast run indistinguishable in the log from an
# actual brew. Quiet by default; raise this logger's own level to see it.
logging.getLogger('SudForecastEstimator').setLevel(logging.WARNING)
# Mirrors tasks/heater.py's own pulse_period_s - HeaterTask duty-cycles its
# device's discrete power steps over a rolling window this many *real*
# seconds wide, regardless of warp factor (see estimate()'s actuate()
@@ -95,6 +104,7 @@ class SudForecastEstimator:
start_theta = self.theta_amb
sud = Sud(self.pot_config)
sud.log = logging.getLogger('SudForecastEstimator')
if not sud.load(doc) or not sud.schedule:
return [0.0], [start_theta], SudState.DONE, {}
Binary file not shown.
+85
View File
@@ -0,0 +1,85 @@
<!--Pololu Simple Motor Controller settings file. http://www.pololu.com/docs/0J44-->
<!--Created on: 2026-07-07 19:14:45-->
<!--Device model: Pololu Simple High-Power Motor Controller 18v15-->
<!--Device serial number: 53FF-6F06-7277-5049-3950-0767-->
<!--Device firmware version: 1.04-->
<SmcSettings version="1">
<InputMode>SerialUsb</InputMode>
<MixingMode>None</MixingMode>
<DisableSafeStart>false</DisableSafeStart>
<IgnorePotDisconnect>false</IgnorePotDisconnect>
<IgnoreErrLineHigh>false</IgnoreErrLineHigh>
<NeverSuspend>false</NeverSuspend>
<TempLimitGradual>false</TempLimitGradual>
<OverTemp Min="700" Max="800" />
<LowVinShutoffTimeout>250</LowVinShutoffTimeout>
<LowVinShutoffMv>5500</LowVinShutoffMv>
<LowVinStartupMv>6000</LowVinStartupMv>
<HighVinShutoffMv>25000</HighVinShutoffMv>
<SerialMode>Ascii</SerialMode>
<SerialDeviceNumber>13</SerialDeviceNumber>
<CommandTimeout>10</CommandTimeout>
<CrcMode>Disabled</CrcMode>
<UartResponseDelay>false</UartResponseDelay>
<UseFixedBaudRate>true</UseFixedBaudRate>
<FixedBaudRate>115200</FixedBaudRate>
<!--Input Settings-->
<Rc1>
<AlternateUse>None</AlternateUse>
<Invert>false</Invert>
<ScalingDegree>0</ScalingDegree>
<Error Min="2000" Max="10000" />
<Input Min="4000" Max="8000" />
<InputNeutral Min="5900" Max="6100" />
</Rc1>
<Rc2>
<AlternateUse>None</AlternateUse>
<Invert>false</Invert>
<ScalingDegree>0</ScalingDegree>
<Error Min="2000" Max="10000" />
<Input Min="4000" Max="8000" />
<InputNeutral Min="5900" Max="6100" />
</Rc2>
<Analog1>
<AlternateUse>None</AlternateUse>
<PinMode>Floating</PinMode>
<Invert>false</Invert>
<ScalingDegree>0</ScalingDegree>
<Error Min="0" Max="4095" />
<Input Min="40" Max="4055" />
<InputNeutral Min="2015" Max="2080" />
</Analog1>
<Analog2>
<AlternateUse>None</AlternateUse>
<PinMode>Floating</PinMode>
<Invert>false</Invert>
<ScalingDegree>0</ScalingDegree>
<Error Min="0" Max="4095" />
<Input Min="40" Max="4055" />
<InputNeutral Min="2015" Max="2080" />
</Analog2>
<!--Motor Settings-->
<PwmPeriodFactor>1</PwmPeriodFactor>
<MotorInvert>false</MotorInvert>
<SpeedZeroBrakeAmount>0</SpeedZeroBrakeAmount>
<SpeedUpdatePeriod>1</SpeedUpdatePeriod>
<ForwardLimits>
<MaxSpeed>3200</MaxSpeed>
<MaxAcceleration>1</MaxAcceleration>
<MaxDeceleration>1</MaxDeceleration>
<BrakeDuration>0</BrakeDuration>
<StartingSpeed>0</StartingSpeed>
</ForwardLimits>
<ReverseLimits>
<MaxSpeed>0</MaxSpeed>
<MaxAcceleration>1</MaxAcceleration>
<MaxDeceleration>1</MaxDeceleration>
<BrakeDuration>0</BrakeDuration>
<StartingSpeed>0</StartingSpeed>
</ReverseLimits>
<!--Advanced Settings-->
<PulsePeriod Min="9" Max="100" />
<RcTimeout>500</RcTimeout>
<ConsecGoodPulses>2</ConsecGoodPulses>
<VinMultiplierOffset>0</VinMultiplierOffset>
</SmcSettings>
File diff suppressed because it is too large Load Diff
+36 -8
View File
@@ -2,6 +2,7 @@
import asyncio
import functools
import json
import logging
import os
import signal
import sys
@@ -9,6 +10,7 @@ import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from utils import ChangedFloat
from utils.config_validate import validate_temp_ctrl
from ws.message import MessageDispatcher
from ws.server.ws_server_multi_user import WsServerMultiUser
from components.pid import PidFactory
@@ -20,19 +22,40 @@ from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, Stir
import argparse as ap
log = logging.getLogger('brewpi')
class Tee:
"""Duplicates writes to multiple streams - used to mirror stdout/stderr
(everything the rest of the codebase already reaches via print()) into
a log file under logs/ as well as the console, without having to touch
every print() call site."""
into a log file under logs/ as well as the console, without every
writer needing to know about either.
Also stamps every line with a "<date>T<time>:" prefix. Every task/
component logs via logging (see utils/value.py's AttributeChange, which
gives self.log to all of them), configured below with a bare
"%(name)s:%(message)s" formatter - Tee supplies the timestamp instead,
so the combined line reads "<date>T<time>:<component>:<message>" (see
logging.basicConfig() call below) without double-stamping. This lets
log entries be correlated with the JSON sample logs (ServerLogTask/
SudLogTask) without relying on journalctl's own timestamps, which
aren't present in the mirrored log files themselves."""
def __init__(self, *streams):
self.streams = streams
self._at_line_start = True
def write(self, data):
if not data:
return
out = []
for line in data.splitlines(keepends=True):
if self._at_line_start:
out.append(time.strftime("%Y-%m-%dT%H:%M:%S:", time.localtime()))
out.append(line)
self._at_line_start = line.endswith('\n')
text = ''.join(out)
for stream in self.streams:
stream.write(data)
stream.write(text)
def flush(self):
for stream in self.streams:
@@ -74,9 +97,14 @@ if __name__ == '__main__':
latest_log_file = open(latest_log_path, "w", buffering=1)
sys.stdout = Tee(sys.stdout, log_file, latest_log_file)
sys.stderr = Tee(sys.stderr, log_file, latest_log_file)
print("Logging to {}".format(log_path))
# No "%(asctime)s" here - Tee (above) already stamps every mirrored
# line with "<date>T<time>:", so this only contributes the
# "<component>:<message>" part (see Tee's own docstring).
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(name)s:%(message)s')
log.info("Logging to {}".format(log_path))
config = json.load(open(args.config))
validate_temp_ctrl(config)
dispatcher = MessageDispatcher()
server = WsServerMultiUser(listener=dispatcher)
taskmgr = TaskManager()
@@ -228,7 +256,7 @@ if __name__ == '__main__':
http_handler = functools.partial(SimpleHTTPRequestHandler, directory=web_dir)
http_server = ThreadingHTTPServer(("0.0.0.0", args.http_port), http_handler)
threading.Thread(target=http_server.serve_forever, daemon=True).start()
print("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port))
log.info("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port))
# systemd's `stop`/`restart` send SIGTERM, whose default disposition is to
# kill the process immediately - skipping the finally: block below
@@ -241,7 +269,7 @@ if __name__ == '__main__':
try:
loop.run_forever()
except KeyboardInterrupt:
print("\nShutting down...")
log.info("Shutting down...")
finally:
# Cancel all tasks so their finally blocks (e.g. HeaterTask's/
# StirrerTask's `with device.open()`) get a chance to run before we
@@ -260,4 +288,4 @@ if __name__ == '__main__':
stirrer.activate(False)
http_server.shutdown()
loop.close()
print("Server stopped.")
log.info("Server stopped.")
+5 -5
View File
@@ -4,7 +4,7 @@
"pot": {
"grain_mass": 0,
"water_mass": 22,
"volumen": 30
"volumen": 36
},
"default": {
"step": {
@@ -62,15 +62,15 @@
}
},
{
"descr": "1. Rast",
"descr": "Protease-Rast",
"temperature": 55,
"hold": {
"duration": 20
}
},
{
"descr": "Glucose-Rast",
"temperature": 63,
"descr": "Maltose-Rast",
"temperature": 62,
"ramp": {
"rate": 1.0
},
@@ -104,7 +104,7 @@
"descr": "Abmaischen",
"user_message": "Quittieren zum Abmaischen",
"user_wait_for_continue": true,
"temperature": 76,
"temperature": 78,
"ramp": {
"rate": 1.0
},
+20 -7
View File
@@ -33,6 +33,7 @@ class HeaterTask(ATask):
self._on_connected_changed = callback
def on_connected_changed(self, value):
self.log.info("Connected" if value else "Disconnected")
fire_and_forget(self.send({'Connected': value}))
if self._on_connected_changed:
self._on_connected_changed(value)
@@ -55,6 +56,20 @@ class HeaterTask(ATask):
self._on_closed_loop_changed(True)
asyncio.create_task(self.send({'ClosedLoop': True}))
def disconnect(self):
"""Disconnects the device and zeroes its setpoint, so a later
reconnect (whether from an explicit Connect or an auto-reconnect)
never resumes at a stale power. Used for every disconnect - a
user-requested Disconnect, a comm-error timeout, and the
unexpected-exception recovery path alike - not just the explicit
one, since power_actor otherwise keeps whatever the TC last
computed (which can be stale/wound-up from before the outage) and
would be re-applied on the very next tick after reconnecting."""
self.device.disconnect()
self.power_soll = 0
self.power_actor = 0
self.power_set_changed(0)
def actor(self, y):
self.power_actor = max(0, self.device.get_power_max() * y)
@@ -78,9 +93,7 @@ class HeaterTask(ATask):
self.device.activate(True)
elif pair[0] == 'Disconnect':
self.device.activate(False)
self.device.disconnect()
self.power_soll = 0
self.power_set_changed(0)
self.disconnect()
elif 'Power' in pair[0]:
self.power_soll = pair[1]
@@ -166,13 +179,13 @@ class HeaterTask(ATask):
try:
self.device.process()
except Exception as e:
print(f"HeaterTask: comm error, marking disconnected: {e}")
self.device.disconnect()
self.log.error(f"comm error, marking disconnected: {e}")
self.disconnect()
await asyncio.sleep(self.interval)
except Exception as e:
print(f"HeaterTask: unexpected error, recovering: {e}")
self.log.error(f"unexpected error, recovering: {e}")
try:
self.device.disconnect()
self.disconnect()
except Exception:
pass
await asyncio.sleep(self.interval)
-1
View File
@@ -143,4 +143,3 @@ class ServerLogTask(ATask):
for name in (filename, self._latest_filename()):
with open(os.path.join(self.path, name), 'w') as f:
json.dump(log_data, f, indent='\t')
print('Server log: wrote {} samples to {}'.format(len(self._samples), filename))
+17 -6
View File
@@ -33,10 +33,22 @@ class StirrerTask(ATask):
self._on_connected_changed = callback
def on_connected_changed(self, value):
self.log.info("Connected" if value else "Disconnected")
fire_and_forget(self.send({'Connected': value}))
if self._on_connected_changed:
self._on_connected_changed(value)
def disconnect(self):
"""Disconnects the device and zeroes its speed setpoint, so a later
reconnect never resumes spinning at whatever speed was last set.
Used for every disconnect - a user-requested Disconnect, a
comm-error timeout, and the unexpected-exception recovery path
alike - not just the explicit one, since self.device.speed
otherwise still holds the old value and the duty-cycle pulsing in
AStirrer.process() re-sends it on the next isOn transition."""
self.device.disconnect()
self.device.set_speed(0.0)
async def recv(self, data):
for pair in data.items():
if pair[0] == 'Connect':
@@ -44,8 +56,7 @@ class StirrerTask(ATask):
self.device.activate(True)
elif pair[0] == 'Disconnect':
self.device.activate(False)
self.device.disconnect()
self.device.set_speed(0.0)
self.disconnect()
elif 'Speed' in pair[0]:
self.device.set_speed(pair[1])
@@ -78,13 +89,13 @@ class StirrerTask(ATask):
try:
self.device.process()
except Exception as e:
print(f"StirrerTask: comm error, marking disconnected: {e}")
self.device.disconnect()
self.log.error(f"comm error, marking disconnected: {e}")
self.disconnect()
await asyncio.sleep(self.interval)
except Exception as e:
print(f"StirrerTask: unexpected error, recovering: {e}")
self.log.error(f"unexpected error, recovering: {e}")
try:
self.device.disconnect()
self.disconnect()
except Exception:
pass
await asyncio.sleep(self.interval)
+11 -6
View File
@@ -634,7 +634,7 @@ class SudTask(ATask):
await self.msg_handler.send(data)
async def on_process(self):
print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval))
self.log.info("Started with interval {} s".format(self.interval))
self.sud.set_on_changed('step', self.on_step_changed)
self.sud.set_on_changed('state', self.on_state_changed)
@@ -653,11 +653,16 @@ class SudTask(ATask):
# wiring), in Watts; dt is this tick's simulated seconds, so
# the product is Joules, accumulated for whichever step is
# current (see on_step_changed()). Counted through every
# non-idle state, WAIT_USER/PAUSED included - the controller
# stays enabled (see on_state_changed()) and may well still
# be actively holding, drawing real power, even though the
# schedule itself isn't progressing.
if self.sud.state not in (SudState.IDLE, SudState.DONE):
# state except DONE, IDLE included - a manually-driven heater
# (no schedule loaded/started yet, e.g. pre-heating strike
# water by hand) still draws real power and should show up in
# the same total; self.sud.index is a stable -1 while IDLE
# (see components/sud.py's _reset_run_state()), so this banks
# into energy_by_step[-1] same as any other step once a real
# Start moves the index on - never shown per-step (Progress
# tab's step-plates only cover the schedule's own indices) but
# still included in the status line's summed total.
if self.sud.state != SudState.DONE:
self.energy_step_accum_j += self.pot.get_power() * self.dt
self._energy_changed.set(self.energy_step_accum_j / 3600.0)
self.sud.tick(self.dt)
+2 -2
View File
@@ -24,7 +24,7 @@ class TempSensorTask(ATask):
try:
self.sensor.temperature()
except Exception as e:
print(f"TempSensorTask: comm error priming sensor: {e}")
self.log.error(f"comm error priming sensor: {e}")
self.sensor.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set)
def on_temp_changed(self, value):
@@ -44,5 +44,5 @@ class TempSensorTask(ATask):
try:
self.sensor.temperature()
except Exception as e:
print(f"TempSensorTask: comm error reading sensor: {e}")
self.log.error(f"comm error reading sensor: {e}")
await asyncio.sleep(self.interval)
@@ -0,0 +1,80 @@
import unittest
from components.pid.temp_controller_base import TempControllerBase
def _make(beta=0.05, dt=1.0):
ctrl = TempControllerBase(dt=dt)
ctrl.beta = beta
return ctrl
class TestComputeHeatrateFirstTick(unittest.TestCase):
"""Regression test for the historical bug (see README's "Forecast vs.
actual duration" section): both TempController variants used to
hardcode last_theta_ist = 20 at construction, producing a bogus
heatrate_ist spike on the very first tick whenever the real starting
temperature wasn't actually 20 - most visible in SudForecastEstimator,
which builds a fresh controller on every call. last_theta_ist now
starts None and the first tick's rate reads as 0 regardless of the
absolute starting temperature."""
def test_first_tick_reads_zero_rate_regardless_of_starting_temperature(self):
for starting_temp in (0.0, 20.0, 87.3, -5.0):
with self.subTest(starting_temp=starting_temp):
ctrl = _make()
ctrl._compute_heatrate(starting_temp)
self.assertEqual(ctrl.heatrate_ist, 0.0)
class TestComputeHeatrateConvergence(unittest.TestCase):
"""For a perfectly linear ramp, both low-pass stages (the beta
pre-filter on theta_ist, then the alpha filter on the differentiated
rate) settle to the true underlying rate once their transient decays -
a linear signal's slope survives exponential smoothing unchanged."""
def _run_ramp(self, rate_per_min, ticks=400, dt=1.0, start=30.0):
ctrl = _make(dt=dt)
theta = start
for _ in range(ticks):
theta += rate_per_min / 60.0 * dt
ctrl._compute_heatrate(theta)
return ctrl.heatrate_ist
def test_converges_to_true_heating_rate(self):
self.assertAlmostEqual(self._run_ramp(1.2), 1.2, delta=0.01)
def test_converges_to_true_cooling_rate(self):
self.assertAlmostEqual(self._run_ramp(-0.8), -0.8, delta=0.01)
class TestComputeHeatratePrefilterSuppressesNoise(unittest.TestCase):
"""Filtering theta_ist before differentiating (see _compute_heatrate()'s
own docstring) keeps stirrer-induced temperature wobble from being
amplified into heatrate_ist noise of the same order as a typical
rate_soll. beta=1.0 disables the pre-filter entirely (theta_ist_filtered
reduces to the raw reading each tick), giving a same-signal comparison
against the real default beta."""
def _peak_abs_heatrate(self, beta, amplitude=0.05, period=4, ticks=300):
ctrl = _make(beta=beta)
peak = 0.0
for i in range(ticks):
wobble = amplitude if (i // (period // 2)) % 2 == 0 else -amplitude
ctrl._compute_heatrate(30.0 + wobble)
if i > 20: # past the initial filter transient
peak = max(peak, abs(ctrl.heatrate_ist))
return peak
def test_prefilter_reduces_noise_amplification(self):
filtered_peak = self._peak_abs_heatrate(beta=0.05)
raw_peak = self._peak_abs_heatrate(beta=1.0)
# The unfiltered case must actually show strong noise amplification
# for this comparison to mean anything.
self.assertGreater(raw_peak, 0.25)
self.assertLess(filtered_peak, raw_peak / 2)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,116 @@
import unittest
from components.pid.temp_controller_smith import TempController
from components.plant.pot import Pot
DT = 1.0
AMBIENT = 20.0
PLANT_PARAMS = {"M": 27.96, "C": 3403.43, "L": 0.2, "Td": 17}
GAINS = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
def make_controller(model_params):
ctrl = TempController(DT)
ctrl.set_params({
"beta": 0.9,
"Outer": dict(GAINS),
"Inner": {"Heat": dict(GAINS), "Hold": dict(GAINS), "Cool": dict(GAINS)},
})
ctrl.set_model_plant_params(model_params)
ctrl.set_ambient_temperature(AMBIENT)
ctrl.set_enabled(True)
return ctrl
def make_plant(initial_temp):
plant = Pot(DT)
plant.set_plant_params(PLANT_PARAMS)
plant.set_ambient_temperature(AMBIENT)
plant.initial(initial_temp)
return plant
def step(ctrl, plant, power):
"""Open-loop: power is an externally chosen step function, not fed back
from ctrl.get_power() - isolates the Smith correction's own math from
PID tuning. Ordering (plant.process() -> read theta_ist -> ctrl.process()
-> only then set next tick's power on both plant and model) mirrors
tasks/sud.py's real wiring and test_temp_controller_closed_loop.py's own
tick() helper, keeping plant and internal model symmetrically one tick
behind whatever power was last commanded."""
plant.process()
ctrl.set_theta_ist(plant.get_temperature())
ctrl.set_theta_soll(100.0)
ctrl.set_heatrate_soll(1.0)
ctrl.process()
plant.set_power(power)
ctrl.set_model_power(power)
def _run_power_step(ctrl, plant, ticks=200, power=2000.0):
max_gap = 0.0
for i in range(ticks):
step(ctrl, plant, power if i > 0 else 0.0)
max_gap = max(max_gap, abs(ctrl.theta_ist - ctrl.theta_ist_model))
return max_gap
class TestSmithCorrectionTracksFastModelWhenMatched(unittest.TestCase):
"""theta_ist = theta_ist_model + (theta_ist_plant - theta_ist_model_delay)
(temp_controller_smith.py's process()): when the internal model's plant
params exactly match the real plant's, model_delay tracks the real
plant so closely that the correction term stays near zero and theta_ist
reduces to the fast (zero transport-delay) model's own prediction -
the core Smith-predictor identity. A small residual persists even here:
theta_ist_model_delay is one tick behind theta_ist_plant by construction
(post_pid() advances the models after this tick's comparison, so a real
plant.process() call always has a one-tick head start - see step()'s
own comment), so this checks a small bound, not exact equality."""
def test_matched_model_stays_close_to_fast_model(self):
ctrl = make_controller(PLANT_PARAMS)
plant = make_plant(AMBIENT)
self.assertLess(_run_power_step(ctrl, plant), 0.1)
def test_mismatched_model_diverges_much_further(self):
# 4x the real mass - the internal model predicts a much slower,
# smaller temperature response than the real plant actually has.
bad_params = {**PLANT_PARAMS, "M": PLANT_PARAMS["M"] * 4}
ctrl = make_controller(bad_params)
plant = make_plant(AMBIENT)
mismatched_gap = _run_power_step(ctrl, plant)
matched_gap = _run_power_step(make_controller(PLANT_PARAMS), make_plant(AMBIENT))
# Confirms the tight bound above is actually meaningful (the
# correction mechanism responds to a wrong model), not just always
# true regardless of whether the model matches anything.
self.assertGreater(mismatched_gap, matched_gap * 10)
class TestSmithCorrectionSeesThroughDeadTime(unittest.TestCase):
"""The whole point of the two-model correction: theta_ist must start
responding to a commanded power step well before Td ticks have elapsed,
even though the real (delayed) plant's own raw temperature - Pot.process()'s
transport-delay line - hasn't moved yet. Without this, the PID would only
ever see the effect of a power change Td seconds after commanding it."""
def test_corrected_estimate_rises_before_raw_plant_reading_does(self):
ctrl = make_controller(PLANT_PARAMS)
plant = make_plant(AMBIENT)
ticks_well_inside_dead_time = 5
self.assertLess(ticks_well_inside_dead_time, PLANT_PARAMS["Td"])
for i in range(ticks_well_inside_dead_time):
step(ctrl, plant, 2000.0 if i > 0 else 0.0)
# Raw plant reading: still at the transport delay's input queue,
# not showing any temperature rise yet.
self.assertEqual(ctrl.theta_ist_plant, AMBIENT)
# Corrected estimate: already rising, fed by the zero-delay model.
self.assertGreater(ctrl.theta_ist, AMBIENT + 0.01)
if __name__ == '__main__':
unittest.main()
View File
+48
View File
@@ -0,0 +1,48 @@
import json
import os
import unittest
from components.sud import Sud
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'utils', 'sud_fixtures')
def _load_fixture(name):
with open(os.path.join(FIXTURES_DIR, name)) as f:
return json.load(f)
class TestSudLoadRejectsMalformedDocs(unittest.TestCase):
"""Sud.load() wires in utils/sud_validate.py's validate_sud() so a
malformed sud.json is refused (same False-returning contract as any
other load() failure - see components/sud.py's own docstring) instead
of parsing cleanly and only crashing much later, mid-run or during a
forecast simulation, on the unguarded ramp['rate'] lookup."""
def test_valid_doc_loads(self):
sud = Sud()
self.assertTrue(sud.load(_load_fixture('valid_multi_step.json')))
self.assertEqual(sud.name, 'MultiStep')
def test_missing_ramp_rate_is_refused(self):
sud = Sud()
self.assertFalse(sud.load(_load_fixture('missing_ramp_rate.json')))
def test_wrong_type_ramp_rate_is_refused(self):
sud = Sud()
self.assertFalse(sud.load(_load_fixture('ramp_rate_wrong_type.json')))
def test_empty_steps_is_refused(self):
sud = Sud()
self.assertFalse(sud.load(_load_fixture('empty_steps_list.json')))
def test_failed_load_leaves_previous_schedule_untouched(self):
sud = Sud()
self.assertTrue(sud.load(_load_fixture('valid_multi_step.json')))
self.assertFalse(sud.load(_load_fixture('missing_ramp_rate.json')))
self.assertEqual(sud.name, 'MultiStep')
self.assertEqual(len(sud.schedule), 3)
if __name__ == '__main__':
unittest.main()
View File
@@ -0,0 +1,78 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
},
"Kalman": {
"var_P": 1.0,
"var_Q": 0.01,
"var_R": 0.1
},
"Model": {
"kn": 0.0,
"gain": 0.8
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
},
"Plant": {
"kn": 0.0,
"gain": 0.8
}
}
@@ -0,0 +1,58 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,64 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,64 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,25 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Normal",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "hendi",
"port": "/dev/ttyUSB0",
"speed": "115200"
},
"Stirrer": {
"type": "pololu1376",
"port": "/dev/ttyACM0",
"speed": "115200"
},
"TempSensor": {
"type": "max31865",
"temp_offset": -0.15
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,65 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.05,
"Outer": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0,
"y_hold_min": -0.1
},
"Inner": {
"Heat": {
"kp": "0.08",
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
},
"Hold": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
},
"Thresholds": {
"HoldHeat": 1.0,
"HoldCool": 1.0,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0
}
},
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,26 @@
{
"ambient_temperature": 20,
"log_interval": 300,
"TempCtrl": "Smith",
"Heater": {
"type": "sim"
},
"Stirrer": {
"type": "sim"
},
"TempSensor": {
"type": "sim",
"temp_offset": 0.0,
"sigma": 0.05,
"stirrer_sigma": 0.0,
"stirrer_tau": 20.0
},
"Pot": {
"mass": 5.96,
"material": "Edelstahl 18/10",
"L": 0.2,
"Td": 17,
"water_mass": 22,
"volumen": 30
}
}
@@ -0,0 +1,17 @@
{
"Name": "EmptySteps",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": []
}
@@ -0,0 +1,24 @@
{
"Name": "MissingRampRate",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"hold": {
"duration": 0
},
"ramp": {}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65
}
]
}
@@ -0,0 +1,16 @@
{
"Name": "NoSteps",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
}
}
@@ -0,0 +1,29 @@
{
"Name": "WrongType",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65,
"ramp": {
"rate": "fast"
}
}
]
}
@@ -0,0 +1,19 @@
{
"Name": "StepNotDict",
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
"not a step object"
]
}
@@ -0,0 +1,25 @@
{
"Name": "Minimal",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Only step"
}
]
}
@@ -0,0 +1,43 @@
{
"Name": "MultiStep",
"pot": {
"grain_mass": 0,
"water_mass": 22
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Aufheizen",
"temperature": 57,
"ramp": {
"rate": 1.5
}
},
{
"descr": "Rast",
"temperature": 63,
"hold": {
"duration": 40
}
},
{
"descr": "Ausmaischen",
"temperature": 78,
"ramp": {
"rate": 1.0
}
}
]
}
@@ -0,0 +1,32 @@
{
"Name": "WithTarget",
"pot": {
"grain_mass": 0,
"water_mass": 20
},
"default": {
"step": {
"descr": "Put description here",
"user_wait_for_continue": false,
"temperature": 0,
"ramp": {
"rate": 1.0
},
"hold": {
"duration": 0
}
}
},
"steps": [
{
"descr": "Heat up",
"temperature": 65
},
{
"descr": "Hold",
"hold": {
"duration": 20
}
}
]
}
+61
View File
@@ -0,0 +1,61 @@
import json
import os
import re
import unittest
from utils.config_validate import ConfigError, validate_temp_ctrl
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
# filename -> None if the fixture should pass validation, otherwise a regex
# matched against the raised ConfigError's message.
CASES = {
'valid_sim.json': None,
'valid_real.json': None,
'valid_normal.json': None,
'legacy_unknown_keys.json': None,
'missing_outer_kp.json': r'TempCtrl\.Outer\.kp',
'missing_inner_hold_branch.json': r'TempCtrl\.Inner\.Hold',
'missing_pid_type.json': r'TempCtrl\.pid_type',
'missing_tempctrl_section.json': r'missing required config key: TempCtrl$',
'wrong_type_kp_string.json': r'TempCtrl\.Inner\.Heat\.kp',
'wrong_type_tempctrl_not_dict.json': r'TempCtrl must be dict',
}
def _load(name):
with open(os.path.join(FIXTURES_DIR, name)) as f:
return json.load(f)
def _test_name(filename):
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
class TestConfigFixtures(unittest.TestCase):
def test_every_fixture_file_is_covered(self):
on_disk = {f for f in os.listdir(FIXTURES_DIR) if f.endswith('.json')}
self.assertEqual(on_disk, set(CASES), "fixtures/ and CASES have drifted apart")
def _make_fixture_test(name, expected_error):
def test(self):
config = _load(name)
if expected_error is None:
validate_temp_ctrl(config) # must not raise
else:
with self.assertRaisesRegex(ConfigError, expected_error):
validate_temp_ctrl(config)
return test
# One dedicated test method per fixture file - not a single method looping
# over CASES with subTest - so `unittest discover -v` (and any IDE test
# runner) lists and can re-run each fixture individually instead of folding
# them all into one opaque "did any of these fail" result.
for _name, _expected_error in CASES.items():
setattr(TestConfigFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
if __name__ == '__main__':
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import unittest
from utils.config_validate import ConfigError, require, require_schema, validate_temp_ctrl
def _valid_temp_ctrl_config():
gains = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
return {
"TempCtrl": {
"pid_type": "Smith",
"Outer": dict(gains),
"Inner": {
"Heat": dict(gains),
"Hold": dict(gains),
"Cool": dict(gains),
},
}
}
class TestRequire(unittest.TestCase):
def test_returns_value_when_present(self):
self.assertEqual(require({"a": {"b": 1}}, "a.b"), 1)
def test_raises_on_missing_leaf(self):
with self.assertRaises(ConfigError):
require({"a": {}}, "a.b")
def test_raises_on_missing_intermediate(self):
with self.assertRaises(ConfigError):
require({}, "a.b")
def test_error_names_full_path_up_to_missing_key(self):
with self.assertRaisesRegex(ConfigError, r"a\.b"):
require({"a": {}}, "a.b.c")
def test_raises_on_wrong_type(self):
with self.assertRaises(ConfigError):
require({"a": "not a number"}, "a", type=(int, float))
def test_accepts_matching_type(self):
self.assertEqual(require({"a": 1.5}, "a", type=(int, float)), 1.5)
class TestRequireSchema(unittest.TestCase):
"""require_schema() is the generic engine both validate_temp_ctrl()
(config.json's TempCtrl section) and utils/sud_validate.py's
validate_sud() (a sud.json's fixed 'steps' shape) are built on - tested
here directly, against a schema unrelated to either, to confirm it's
not accidentally coupled to TempCtrl's specific shape."""
SCHEMA = {'a': {'b': int, 'c': (int, float)}, 'd': str}
def test_valid_document_passes(self):
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x'}, self.SCHEMA)
def test_missing_nested_leaf_raises(self):
with self.assertRaisesRegex(ConfigError, r'a\.b'):
require_schema({'a': {'c': 2.5}, 'd': 'x'}, self.SCHEMA)
def test_missing_top_level_leaf_raises(self):
with self.assertRaisesRegex(ConfigError, r'missing required config key: d$'):
require_schema({'a': {'b': 1, 'c': 2.5}}, self.SCHEMA)
def test_wrong_type_on_nested_object_itself_is_named_directly(self):
# 'a' present but not an object - must be reported as 'a' itself,
# not as a confusing "missing a.b"/"missing a.c".
with self.assertRaisesRegex(ConfigError, r'^config key a must be dict'):
require_schema({'a': 'not an object', 'd': 'x'}, self.SCHEMA)
def test_extra_keys_not_in_schema_are_ignored(self):
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x', 'extra': 'stray'}, self.SCHEMA)
class TestValidateTempCtrl(unittest.TestCase):
def test_valid_config_passes(self):
validate_temp_ctrl(_valid_temp_ctrl_config())
def test_missing_gain_raises(self):
config = _valid_temp_ctrl_config()
del config["TempCtrl"]["Inner"]["Hold"]["kp"]
with self.assertRaisesRegex(ConfigError, r"TempCtrl\.Inner\.Hold\.kp"):
validate_temp_ctrl(config)
def test_missing_pid_type_raises(self):
config = _valid_temp_ctrl_config()
del config["TempCtrl"]["pid_type"]
with self.assertRaises(ConfigError):
validate_temp_ctrl(config)
def test_optional_keys_not_required(self):
config = _valid_temp_ctrl_config()
# Outer.y_hold_min, Inner.Hold.yi_max, and the whole Thresholds
# section all have their own defaults elsewhere and must stay
# optional here too.
validate_temp_ctrl(config)
self.assertNotIn("y_hold_min", config["TempCtrl"]["Outer"])
self.assertNotIn("Thresholds", config["TempCtrl"])
if __name__ == '__main__':
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
import json
import os
import re
import unittest
from utils.config_validate import ConfigError
from utils.sud_validate import validate_sud
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'sud_fixtures')
SUDE_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'sude')
# filename -> None if the fixture should pass validation, otherwise a regex
# matched against the raised ConfigError's message.
CASES = {
'valid_minimal.json': None,
'valid_with_target.json': None,
'valid_multi_step.json': None,
'missing_steps_key.json': r"missing required config key: steps",
'empty_steps_list.json': r"'steps' must be a non-empty list",
'missing_ramp_rate.json': r'step 1 \(Heat up\): .*ramp\.rate',
'ramp_rate_wrong_type.json': r'step 1 \(Heat up\): .*ramp\.rate',
'step_not_dict.json': r'malformed sud document',
}
def _load(directory, name):
with open(os.path.join(directory, name)) as f:
return json.load(f)
def _test_name(filename):
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
class TestSudValidateFixtures(unittest.TestCase):
def test_every_fixture_file_is_covered(self):
on_disk = {f for f in os.listdir(FIXTURES_DIR) if f.endswith('.json')}
self.assertEqual(on_disk, set(CASES), "sud_fixtures/ and CASES have drifted apart")
def _make_fixture_test(name, expected_error):
def test(self):
data = _load(FIXTURES_DIR, name)
if expected_error is None:
validate_sud(data) # must not raise
else:
with self.assertRaisesRegex(ConfigError, expected_error):
validate_sud(data)
return test
# One dedicated test method per fixture file - not a single method looping
# over CASES with subTest - so `unittest discover -v` (and any IDE test
# runner) lists and can re-run each fixture individually instead of folding
# them all into one opaque "did any of these fail" result.
for _name, _expected_error in CASES.items():
setattr(TestSudValidateFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
class TestRealSudFiles(unittest.TestCase):
"""The actual sude/*.json schedules must always pass - a failure here
means either a real schedule is broken, or validate_sud() itself has
drifted from what Sud._parse_data() actually accepts."""
def _make_real_file_test(name):
def test(self):
validate_sud(_load(SUDE_DIR, name))
return test
_real_sud_files = [f for f in os.listdir(SUDE_DIR) if f.endswith('.json')]
assert _real_sud_files, "expected at least one sude/*.json file"
for _name in _real_sud_files:
setattr(TestRealSudFiles, _test_name(_name), _make_real_file_test(_name))
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
class ConfigError(Exception):
"""Raised for a missing or wrong-typed config key, named by its full
dotted path (e.g. "TempCtrl.Inner.Hold.kp") so the error points straight
at the offending line in config.json instead of surfacing as a bare
KeyError/TypeError from wherever the value first gets used."""
def require(config, path, type=None):
"""Generic dotted-path lookup against any nested-dict document - not
tied to server config.json's own shape, so this is exactly as usable
for a sud.json schedule (see utils/sud_validate.py) as for TempCtrl
below."""
node = config
parts = path.split('.')
for i, key in enumerate(parts):
if not isinstance(node, dict) or key not in node:
raise ConfigError("missing required config key: {}".format('.'.join(parts[:i + 1])))
node = node[key]
if type is not None and not isinstance(node, type):
type_name = ' or '.join(t.__name__ for t in type) if isinstance(type, tuple) else type.__name__
raise ConfigError("config key {} must be {}, got {!r}".format(path, type_name, node))
return node
def require_schema(config, schema, _prefix=''):
"""Generic declarative counterpart to require(): schema mirrors the
document's own shape, with every leaf naming the required type(s) (see
TEMPCTRL_SCHEMA below for an example) - one shared mechanism for any
config-shaped document with a fixed structure, whether that's server
config.json's TempCtrl section or (for the parts of it that are static -
see utils/sud_validate.py for the parts that aren't, e.g. per-step
conditional requirements) a sud.json schedule.
A schema value that's itself a dict describes a required sub-object:
checked (and reported, on failure, as its own dotted path) before
recursing into it, so a wrong-typed intermediate node (e.g. 'TempCtrl'
itself not being an object) is named directly rather than surfacing as
a confusing "missing" error on one of its children instead."""
for key, spec in schema.items():
path = '{}.{}'.format(_prefix, key) if _prefix else key
if isinstance(spec, dict):
require(config, path, type=dict)
require_schema(config, spec, path)
else:
require(config, path, type=spec)
_GAIN_SCHEMA = {'kp': (int, float), 'ki': (int, float), 'kd': (int, float), 'kt': (int, float)}
# Only the gains every pid_type actually reads unconditionally are
# required; anything with its own default elsewhere (Outer.y_hold_min,
# Inner.Hold.yi_max, the whole Thresholds section - see
# temp_controller_base.py/temp_controller_fsm.py's DEFAULT_THRESHOLDS)
# stays optional here too, on purpose.
TEMPCTRL_SCHEMA = {
'TempCtrl': {
'pid_type': str,
'Outer': _GAIN_SCHEMA,
'Inner': {
'Heat': _GAIN_SCHEMA,
'Hold': _GAIN_SCHEMA,
'Cool': _GAIN_SCHEMA,
},
},
}
def validate_temp_ctrl(config):
"""Checked eagerly at startup so a missing/misnamed PID gain fails with
a clear message here instead of a bare KeyError mid-__init__ or on the
first process() tick (this bit us once already: config.json.sim's gain/
Model nesting mismatch)."""
require_schema(config, TEMPCTRL_SCHEMA)
+45
View File
@@ -0,0 +1,45 @@
from utils.config_validate import ConfigError, require, require_schema
from components.sud import Sud
# The only part of a sud.json document's shape that's fixed regardless of
# content - same declarative mechanism validate_temp_ctrl() uses for
# config.json's TempCtrl section (see config_validate.py's require_schema()).
# Everything else about a schedule (how many steps, which fields each one
# overrides vs. inherits from default.step, whether a step needs ramp.rate
# at all) depends on the document's own content, not just its shape, so it
# can't be expressed as a static schema - handled by the step loop below
# instead, still built on the same require()/ConfigError primitives.
SUD_SCHEMA = {
'steps': list,
}
def validate_sud(data):
"""Fail-fast structural check for a sud.json document - same rationale
as config_validate.py's validate_temp_ctrl(): a malformed field here
doesn't surface until deep inside a run, or worse, during
SudForecastEstimator's very first Load-time simulation (tasks/sud.py,
components/sud_forecast.py and scripts/demos/sud/demo_sud.py all read
ramp['rate'] directly, no .get() fallback, the moment any step actually
sets its own 'temperature') - as a bare KeyError with no indication of
which schedule step or field is wrong.
Reuses Sud._parse_data() itself (rather than re-implementing default-
merging/step-building) so what's checked here can never drift from what
actually gets parsed at runtime."""
require_schema(data, SUD_SCHEMA)
if not data['steps']:
raise ConfigError("'steps' must be a non-empty list")
try:
schedule = Sud._parse_data(data)[2]
except (KeyError, TypeError, AttributeError) as e:
raise ConfigError("malformed sud document: {}".format(e))
for step in schedule:
if step['temperature'] is None:
continue
try:
require(step, 'ramp.rate', type=(int, float))
except ConfigError as e:
raise ConfigError("step {} ({}): {}".format(step['number'], step.get('descr'), e))
+7
View File
@@ -1,3 +1,4 @@
import logging
class Value:
@@ -53,6 +54,12 @@ class ChangedInteger(object):
class AttributeChange(object):
def __init__(self):
self.callbacks = {}
# Common ancestor of every task (tasks/task.py's ATask) and component
# (Connectable/APid/APlant/ATemperatureSensor) - giving it a logger
# here makes self.log available everywhere via one change, named
# after the concrete subclass so log lines self-identify their
# component without each call site typing it out.
self.log = logging.getLogger(type(self).__name__)
def set_on_changed(self, key, on_changed):
if key not in self.callbacks:
+83 -35
View File
@@ -153,7 +153,10 @@ function parseSudDoc(doc) {
return {
name: doc.Name || '',
schedule: schedule,
potMass: pot.mass || 0,
// Falls back to config.json's Pot.mass (via potConfig, set from
// the System message) when the loaded doc doesn't override it -
// mirrors components/sud.py's Sud._parse_data().
potMass: pot.mass || potConfig.mass || 0,
grainMass: pot.grain_mass || 0,
waterMass: pot.water_mass || 0,
volumen: pot.volumen || 0,
@@ -476,31 +479,39 @@ function updatePotVisualization() {
function updateStatusLine() {
const el = document.getElementById('sud-status-line');
if (sudSchedule.length === 0) {
const w = potConfig.water_mass || 0;
el.textContent = w > 0 ? `No schedule ${w} L water` : '';
return;
}
let text = '';
let grain = sudGrainMass, water = sudWaterMass;
if (sudStepDescr) {
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
// Progress tab's step-plates (createStepPlate()) label 1-based -
// match it here, same fix as client/brewpi_gui.py's
// update_status_step_label().
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
if (sudStepType === 'hold') {
const remaining = Math.max(sudHoldRemaining, 0);
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
}
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
const step = sudSchedule[sudStepIndex];
grain = step.grain_mass || 0;
water = step.water_mass || 0;
// Before any schedule is loaded, fall back to config.json's Pot
// section (via potConfig) so the status line already shows pot mass/
// water/volume/energy as soon as the System message arrives - same
// sudEmpty ? potConfig... : sud... pattern as updatePotVisualization()
// above. Step description/remaining-time only exist once a schedule
// is actually loaded, so they stay inside the sudEmpty===false branch.
let grain = 0, water = potConfig.water_mass || 0;
let pot = potConfig.mass || 0;
let volumen = potConfig.volumen || 0;
if (!sudEmpty) {
grain = sudGrainMass;
water = sudWaterMass;
pot = sudPotMass;
volumen = sudVolumen;
if (sudStepDescr) {
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
// Progress tab's step-plates (createStepPlate()) label 1-based -
// match it here, same fix as client/brewpi_gui.py's
// update_status_step_label().
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
if (sudStepType === 'hold') {
const remaining = Math.max(sudHoldRemaining, 0);
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
}
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
const step = sudSchedule[sudStepIndex];
grain = step.grain_mass || 0;
water = step.water_mass || 0;
}
}
}
const pot = sudPotMass;
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg`;
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg, Volume ${volumen.toFixed(1)} L`;
text = text ? `${text} ${massText}` : massText;
const elapsedText = `Elapsed ${elapsed !== null ? formatDuration(elapsed) : '-:--:--'}`;
const totalEnergyKwh = (Object.values(energyByStep).reduce((a, b) => a + b, 0) + energyCurrent) / 1000.0;
@@ -571,12 +582,34 @@ function updateClosedLoopStatus() {
statusEl.className = `panel-status ${closedLoop ? 'status-connected' : 'status-disconnected'}`;
}
// Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already
// blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape,
// both fire the dialog's 'close' event) is what unblocks it.
function showUserMessage(message) {
document.getElementById('sud-message-text').textContent = message;
document.getElementById('sud-message-dialog').showModal();
// Mirrors client/brewpi_gui.py's set_user_confirm_pending(): a persistent
// header control rather than a modal dialog. While pending, the Confirm
// button flashes yellow with the step's message and #layout (everything
// except the header) is modally disabled via the 'modally-disabled' class
// (opacity + pointer-events:none - see style.css) so the pending step has
// to be dealt with before anything else. The Sud is already blocked in
// WAIT_USER server-side; clicking Confirm is what unblocks it.
let userConfirmPending = false;
const CONFIRM_DISABLE_IDS = ['btn-connect', 'btn-sud-start', 'btn-sud-pause', 'btn-sud-stop',
'btn-sud-new', 'btn-sud-load', 'btn-sud-save'];
function setUserConfirmPending(pending, message = '') {
if (pending === userConfirmPending) return;
userConfirmPending = pending;
const btn = document.getElementById('btn-user-confirm');
document.getElementById('user-confirm-text').textContent = pending ? message : '';
btn.disabled = !pending;
btn.classList.toggle('confirm-pending', pending);
document.getElementById('layout').classList.toggle('modally-disabled', pending);
for (const id of CONFIRM_DISABLE_IDS) {
document.getElementById(id).disabled = pending;
}
if (!pending) {
// Start/Pause/Stop depend on sudState/sudEmpty/connected, not just
// "was disabled for confirm" - let the usual logic pick the right
// one back up rather than assuming all should simply re-enable.
updateSudActions();
}
}
function downloadJson(doc) {
@@ -744,7 +777,19 @@ function onSudChanged(msg) {
// Processing Step first would set sudStepIndex before the schedule
// exists, then Json would reset it to null (isNewSchedule=true on
// first/reloaded connect) with no subsequent Step to restore it.
const keys = ['Json', ...Object.keys(msg).filter(k => k !== 'Json')];
//
// UserMessage must likewise be processed before State: a step's
// user_message is set once, when the step starts (components/sud.py's
// _advance()), well before state later flips to WAIT_USER
// (_finish_step()) - so UserMessage's key was inserted into
// global_state earlier in the server's lifetime and would otherwise
// precede State here too. A client connecting fresh mid-confirm gets
// both bundled into this same replay message; processing State first
// would check sudUserMessage before this very message has set it,
// silently failing setUserConfirmPending()'s guard and leaving the
// Confirm button looking merely disabled instead of pending - the
// same race the old modal dialog had, just never noticed until now.
const keys = ['Json', 'UserMessage', ...Object.keys(msg).filter(k => k !== 'Json' && k !== 'UserMessage')];
for (const key of keys) {
if (!(key in msg)) continue;
if (key === 'Json') {
@@ -780,10 +825,9 @@ function onSudChanged(msg) {
sudState = newState;
updateControlsEnabled();
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
showUserMessage(sudUserMessage);
setUserConfirmPending(true, sudUserMessage);
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
const dlg = document.getElementById('sud-message-dialog');
if (dlg.open) dlg.close();
setUserConfirmPending(false);
}
updateStepPlates();
updateSudActions();
@@ -934,6 +978,11 @@ function connect() {
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) {
document.getElementById(id).disabled = false;
}
// Nothing reported over a dead connection can be trusted anymore -
// don't leave the page modally stuck on a pending confirm that'll
// never resolve now (mirrors client/brewpi_gui.py's disconnect
// handling).
setUserConfirmPending(false);
setConnected(false);
};
ws.onmessage = (event) => {
@@ -1042,8 +1091,7 @@ document.getElementById('btn-sud-pause').addEventListener('click', () => {
document.getElementById('btn-sud-stop').addEventListener('click', () => {
sendMsg('Sud', {Stop: true});
});
document.getElementById('sud-message-dialog').addEventListener('cancel', e => e.preventDefault());
document.getElementById('sud-message-ok').addEventListener('click', () => {
document.getElementById('btn-user-confirm').addEventListener('click', () => {
sendMsg('Sud', {Confirm: true});
});
+10 -11
View File
@@ -17,10 +17,16 @@
<button id="btn-sud-start" class="transport-btn transport-start" title="Start / Resume">&#9654;</button>
<button id="btn-sud-pause" class="transport-btn transport-pause" title="Pause">&#9646;&#9646;</button>
<button id="btn-sud-stop" class="transport-btn transport-stop" title="Stop">&#9632;</button>
<span id="header-countdown" class="hidden">
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">total</span><span id="hdr-total-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">ramp</span><span id="hdr-step-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">hold</span><span id="hdr-hold-remaining">-:--:--</span></span>
<span id="countdown-confirm-col">
<span id="header-countdown" class="hidden">
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">total</span><span id="hdr-total-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">ramp</span><span id="hdr-step-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">hold</span><span id="hdr-hold-remaining">-:--:--</span></span>
</span>
<div id="user-confirm-row">
<span id="user-confirm-text"></span>
<button id="btn-user-confirm" type="button" disabled>Confirm</button>
</div>
</span>
</span>
</div>
@@ -286,13 +292,6 @@
</main>
<dialog id="sud-message-dialog">
<p id="sud-message-text"></p>
<form>
<button id="sud-message-ok" type="button">OK</button>
</form>
</dialog>
<footer>
<div id="sud-status-line"></div>
<div id="env-status-line"></div>
+53 -14
View File
@@ -11,6 +11,7 @@
--orange: #ffa726;
--orange-dim: #6d3b00;
--red: #f44336;
--yellow: #ffeb3b;
--led-off: #444444;
--led-green: var(--green);
--led-green-dim: var(--green-dim);
@@ -80,6 +81,58 @@ header#connection-bar {
}
#host { width: 18em; }
/* --- User-confirm control (replaces a modal OK dialog) --- */
/* Sits directly under the hold-time row of #header-countdown (the two
share this column so #header-countdown's own 'hidden' toggle doesn't
hide the confirm row along with it - a WAIT_USER confirm can happen
before the countdown ever becomes visible). max-width + wrapping keeps
a long message from overflowing off-screen on an iPad's tighter
viewport (~768-834 CSS px in portrait) instead of just widening the
whole header sideways. */
#countdown-confirm-col {
display: flex;
flex-direction: column;
gap: 0.3em;
}
#user-confirm-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4em;
max-width: min(60vw, 20em);
}
#user-confirm-text {
color: var(--yellow);
font-weight: bold;
font-size: 0.9em;
}
#btn-user-confirm {
font-weight: bold;
border: none;
border-radius: 4px;
padding: 0.4em 1em;
cursor: pointer;
background: var(--surface-2);
color: var(--text-muted);
}
#btn-user-confirm.confirm-pending {
color: #000;
cursor: pointer;
animation: confirm-flash 1s step-start infinite;
}
@keyframes confirm-flash {
0%, 49% { background: var(--surface-2); }
50%, 100% { background: var(--yellow); }
}
/* Modally disabled while a user-confirm is pending - #user-confirm-row
(and its Confirm button) stays outside #layout so it's untouched. */
#layout.modally-disabled {
opacity: 0.5;
pointer-events: none;
}
.status-connected { color: var(--green); font-weight: bold; }
.status-disconnected { color: var(--text-muted); }
@@ -392,20 +445,6 @@ header#connection-bar {
}
.step-plate .span2 { grid-column: span 2; }
/* --- Message dialog --- */
#sud-message-dialog {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2em 1.6em;
max-width: 24em;
box-shadow: 0 8px 32px rgba(0,0,0,0.7);
}
#sud-message-dialog::backdrop { background: rgba(0,0,0,0.65); }
#sud-message-dialog form { margin-top: 1em; text-align: right; }
/* --- Footer --- */
footer {