Files
brewpi/tasks/heater.py
T
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

195 lines
7.0 KiB
Python
Executable File

import asyncio
from tasks import ATask, fire_and_forget
from components import AHeater
from ws.message import MsgIo
from utils.value import ChangedInteger
class HeaterTask(ATask):
def __init__(self, device: AHeater, interval, msg_handler: MsgIo):
ATask.__init__(self, interval)
self.device = device
self.interval = interval
self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv)
self.power_soll = 0
self.power_actor = 0
self.closed_loop = True
self._on_closed_loop_changed = None
self.pulse_counter = 0
self._on_connected_changed = None
device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set)
device.set_on_changed('connected', self.on_connected_changed)
self.power_set_changed = ChangedInteger(self.on_changed_power_set).set
def set_on_closed_loop_changed(self, callback):
self._on_closed_loop_changed = callback
def set_on_connected_changed(self, callback):
"""Registers a callback(connected: bool) invoked whenever the
device's connection state changes - lets server/brewpi.py wire this
into SudTask's start-gating/mid-brew auto-stop."""
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 shutdown(self):
"""Called when the brew ends (DONE/IDLE). Switches to open-loop at
power 0 and disables TC, mirroring what a Stop press should do."""
self.closed_loop = False
self.power_soll = 0
if self._on_closed_loop_changed:
self._on_closed_loop_changed(False)
asyncio.create_task(self.send({'ClosedLoop': False}))
self.power_set_changed(0)
def start_closed_loop(self):
"""Called when a Sud starts. Re-enables closed-loop so the TC drives
the heater regardless of which client started the brew."""
self.closed_loop = True
if self._on_closed_loop_changed:
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)
def on_changed_power(self, power):
asyncio.create_task(self.send({'Power': power}))
def on_changed_power_set(self, power):
asyncio.create_task(self.send({'PowerSet': power}))
async def recv(self, data):
for pair in data.items():
if pair[0] == 'ClosedLoop':
self.closed_loop = pair[1]
if self.closed_loop:
self.power_soll = 0
if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop)
await self.send({'ClosedLoop': self.closed_loop})
elif pair[0] == 'Connect':
if self.device.connect():
self.device.activate(True)
elif pair[0] == 'Disconnect':
self.device.activate(False)
self.disconnect()
elif 'Power' in pair[0]:
self.power_soll = pair[1]
async def send(self, data):
await self.msg_handler.send(data)
def get_next_smaller_power(self, power):
result = self.device.get_power_min()
p_list = [p for p in self.device.get_powers() if p <= power]
if len(p_list) > 0:
result = p_list[-1]
return result
def get_next_greater_power(self, power):
result = self.device.get_power_min()
p_list = [p for p in self.device.get_powers() if p > power]
if len(p_list) > 0:
result = p_list[0]
return result
async def on_process(self):
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}})
await self.send({'Simulated': self.device.simulated})
await self.send({'ClosedLoop': self.closed_loop})
if self._on_closed_loop_changed:
self._on_closed_loop_changed(self.closed_loop)
pulse_period_s = 10
pulse_period_count = pulse_period_s/self.interval
# Auto-connect on startup - replaces what used to happen implicitly
# in the device's own constructor; a missing/unplugged device just
# stays disconnected instead of crashing the server (see
# components/actor/heater_hendi.py's connect()).
self.device.connect()
# Explicit initial push, same as Capabilities/Simulated/ClosedLoop
# above - a simulated device's connect() is a no-op that never
# reassigns self.connected (see components/connectable.py), so its
# set_on_changed('connected', ...) observer would otherwise never
# fire and a freshly-subscribed client would never learn it's
# connected.
await self.send({'Connected': self.device.connected})
# Outer retry loop - belt-and-suspenders around the tick loop's own
# per-call guards below. A comm failure a device-level guard didn't
# anticipate (e.g. a raw pyserial/termios exception escaping from
# somewhere other than device.process(), the one call already
# wrapped below) must never be allowed to fall out of this
# coroutine entirely: nothing restarts a dead ATask, so a single
# uncaught exception here would permanently stop this device from
# ever being process()'d again - even after a later, otherwise-
# successful reconnect. See the incident this was added for: a
# mid-brew unplug raised termios.error from deep inside
# HendiCtrl.disconnect() itself (fixed there too), which escaped
# all the way out of this loop and killed it for the rest of the
# process's life.
while True:
try:
with self.device.open():
while True:
# Closed-loop: TC has full control. Open-loop: direct manual power only.
power_soll = self.power_actor if self.closed_loop else self.power_soll
self.power_set_changed(power_soll)
# Calculate duty cycle
power_low = self.get_next_smaller_power(power_soll)
power_high = self.get_next_greater_power(power_soll)
power_step = power_high - power_low
power_diff = power_soll - power_low
duty = power_diff / power_step
on_count = pulse_period_count*duty
self.pulse_counter += 1
if self.pulse_counter >= pulse_period_count:
self.pulse_counter = 0
if power_soll == 0 or self.pulse_counter >= on_count:
self.device.set_power(power_low)
elif self.pulse_counter < on_count:
self.device.set_power(power_high)
try:
self.device.process()
except Exception as e:
self.log.error(f"comm error, marking disconnected: {e}")
self.disconnect()
await asyncio.sleep(self.interval)
except Exception as e:
self.log.error(f"unexpected error, recovering: {e}")
try:
self.disconnect()
except Exception:
pass
await asyncio.sleep(self.interval)