import asyncio from tasks import ATask, fire_and_forget from ws.message import MsgIo from components import AStirrer from utils.value import ChangedInteger class StirrerTask(ATask): def __init__(self, stirrer_device: AStirrer, interval, msg_handler: MsgIo): ATask.__init__(self, interval) self.msg_handler = msg_handler msg_handler.set_recv_handler(self.recv) self.device = stirrer_device self._on_connected_changed = None stirrer_device.set_on_changed("speed", self.on_speed_changed) stirrer_device.set_on_changed("dutyCycle", self.on_dutycycle_changed) stirrer_device.set_on_changed("cycleTime", self.on_cycletime_changed) stirrer_device.set_on_changed("connected", self.on_connected_changed) def on_speed_changed(self, value): asyncio.create_task(self.send({'Speed': value})) def on_dutycycle_changed(self, value): asyncio.create_task(self.send({'DutyCycle': value})) def on_cycletime_changed(self, value): asyncio.create_task(self.send({'CycleTime': value})) 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 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': if self.device.connect(): self.device.activate(True) elif pair[0] == 'Disconnect': self.device.activate(False) self.disconnect() elif 'Speed' in pair[0]: self.device.set_speed(pair[1]) async def send(self, data): await self.msg_handler.send(data) async def on_process(self): await self.send({'Simulated': self.device.simulated}) self.device.set_speed(0.0) self.device.set_cycle_time(10.0) self.device.set_duty_cycle(1.0) # Auto-connect on startup - see HeaterTask.on_process()'s own # comment for why this replaces the old constructor-side connect. self.device.connect() # Explicit initial push - see HeaterTask.on_process()'s own comment # for why a simulated device's connect() alone doesn't fire the # observer. await self.send({'Connected': self.device.connected}) # Outer retry loop - see HeaterTask.on_process()'s own comment for # why: nothing restarts a dead ATask, so an exception escaping this # coroutine entirely would permanently stop this device from ever # being process()'d again, even after a later successful reconnect. while True: try: with self.device.open(): while True: 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)