Add Closed-loop/Open-loop heater control mode

Closed-loop (default, checkbox checked): TC has exclusive control over
the heater; temp-soll and heatrate-soll sliders are active; direct
heater power entry is disabled. TC now stays enabled at server startup
and whenever closed-loop mode is active — SudTask no longer disables it
on IDLE/DONE, so adjusting temp-soll manually with no sud running
immediately drives the heater via the TC.

Open-loop (checkbox unchecked, only selectable when paused/stopped):
TC is disconnected from the heater; only the heater-power slider is
active. Switching back to closed-loop resets power_soll to 0 and
re-enables the TC.

On play, the mode is automatically reset to closed-loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
This commit is contained in:
2026-06-28 10:29:30 +02:00
co-authored by Claude Sonnet 4.6
parent 956f32e6ea
commit f55d085ae4
6 changed files with 53 additions and 16 deletions
+18 -3
View File
@@ -15,10 +15,15 @@ class HeaterTask(ATask):
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
device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set)
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 actor(self, y):
self.power_actor = max(0, self.device.get_power_max() * y)
@@ -30,7 +35,14 @@ class HeaterTask(ATask):
async def recv(self, data):
for pair in data.items():
if 'Power' in pair[0]:
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 'Power' in pair[0]:
self.power_soll = pair[1]
async def send(self, data):
@@ -52,14 +64,17 @@ class HeaterTask(ATask):
async def on_process(self):
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}})
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
with self.device.open():
while True:
# Combine power set from different sources
power_soll = max(self.power_soll, self.power_actor)
# 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
-8
View File
@@ -220,14 +220,6 @@ class SudTask(ATask):
if value in (SudState.DONE, SudState.IDLE):
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(0)
# A finished/stopped run no longer owns the controller - hand
# control back to manual mode (off by default there too).
self.tc.set_enabled(False)
else:
# Any other state (RAMPING/HOLDING/WAIT_USER/PAUSED) means a
# run is in progress and needs the controller actively driving
# the heater.
self.tc.set_enabled(True)
def on_user_message_changed(self, value):
asyncio.create_task(self.send({'UserMessage': value}))