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).
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
Root cause (from the brewpi Pi's log): HendiCtrl.disconnect() only
caught HendiException (a protocol-level NAK), but a genuine unplug
fails deep inside pyserial itself - flushInput()/readline() raise
termios.error/OSError on the now-dead fd. That escaped disconnect()
uncaught (skipping self.ser.close() too), then kept propagating up
through every caller that invoked disconnect() from its own except
handler (HeaterHendi.process()/activate(), AHeater.open()'s
context-manager exit), all the way out of HeaterTask.on_process()'s
coroutine entirely. Nothing restarts a dead ATask, so the heater's
process()/duty-cycle loop was gone for the rest of the process's
life - reconnecting afterward changed `connected` back to True but
nothing was left running to ever call process() again.
Fixes:
- HendiCtrl.disconnect() now catches broadly and always closes the
port, matching Pololu1376.disconnect()'s already-correct pattern.
- HeaterTask/StirrerTask.on_process() gain an outer retry loop as a
safety net: even an unanticipated exception now logs, marks
disconnected, and re-enters rather than permanently killing the
task.
Verified by reproducing the exact failure (comm error raising a raw
OSError, not HendiException) against a fake device - the task now
survives and resumes process()ing once reconnected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Firmware version is already printed to the log at connect() time
(HendiCtrl/Pololu1376) - remove the live GUI/websocket firmware
reporting entirely (Connectable, HeaterHendi/StirrerPololu1376,
Heater/StirrerTask, web GUI) as redundant.
Also move the Heater panel's Closed-loop checkbox into the Controller
panel and rename it "Enable" - the Controller panel now shows a
live Enabled (green) / Disabled (gray) status badge next to its
heading, matching the Connected/Disconnected badges on the Heater/
Stirrer panels.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Live-testing on the brewpi Pi found that a connected-but-unresponsive
device (e.g. the hendi sitting in its ungraceful-disconnect lockout, see
docs/hendi_lockout_findings.md) crashed the whole server: activate() -
called both from HeaterTask's `with device.open():` and from a client's
Connect/Disconnect command - raised uncaught, escaping the gathered task
and killing the entire asyncio.gather in TaskManager.start().
activate()/is_activated()/process() on both real actors now catch comm
exceptions and self-heal via disconnect() (the same path a genuine
unplug already takes), instead of letting the exception propagate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.
Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Moves components/actor/HendiCtrl.srec (v1.12) alongside the v1.14/
v1.15 images gathered during lockout testing, all under one versioned
naming scheme (hendi_ctrl_0112/0114/0115.srec) instead of scattered
across the repo root and components/actor/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
HendiCtrl.cmd() previously raised a bare "Communication error" for any
non-OK response, discarding the firmware's actual reply. It now
includes the raw echo/answer bytes, and --debug prints every
request/response. Using this, the rejected R1 during lockout turned
out to be an explicit "ERR:Invalid state" reply, not a timeout.
Also flashed and retested v1.16: plain reconnect (no --reset) now
recovers from the lockout reliably (4/4 trials), fixing the
intermittent behavior seen on v1.15.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
hendiCtrl.py is now import-only. The new CLI app exposes serial
port/baudrate, version/capability info, converter test, heater test,
and firmware update as separate flags, instead of one fixed script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
PlantFactory.create() no longer takes plant_name; sim vs real is derived
from Heater.type. StirrerFactory is wired from Stirrer.type. HeaterFactory
registry key lowercased to "hendi" to match config. Controller.plant_name
removed from both config templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On KeyboardInterrupt, brewpi.py cancels all asyncio tasks (letting
HeaterTask's `with device.open()` finally block run), then calls
heater.close() as belt-and-suspenders before closing the event loop.
On shutdown HeaterHendi.activate(False) now sets the switch to 0 instead
of disabling remote control, so the Hendi stays in remote mode at 0 W
rather than dropping back to its local panel state. HendiCtrl.close()
does the same (setSwitch(0)) then closes the serial port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All four name-dispatch factories repeated the same if/elif lazy-import
pattern. Replace with a ComponentFactory base class holding a callable
registry and a _lazy() helper for the common import-and-construct case.
PlantFactory is an orchestrator, not a dispatcher, and is left as-is.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8