components/sud.py: adds SudState.PAUSED. pause() freezes RAMPING/ HOLDING (the hold countdown and ramp-reached checks both no-op while PAUSED, since they're gated on the exact state they froze); start() now doubles as resume when paused. stop() aborts back to IDLE from any in-progress state, reusing the same reset logic as __init__/ upload() (factored into _reset_run_state()). tasks/sud.py: maps 'Pause'/'Stop' messages to the new methods, and turns the stirrer off on IDLE (not just DONE) so stop() actually silences it instead of leaving the last step's stirring running. client/brewpi_gui.py: wires the Pause/Stop toolbar actions, extends update_sud_actions() so Start doubles as Resume and Stop stays usable while paused, and freezes the forecast plot's progress line for the duration of a pause (tracked via accumulated paused time) instead of letting it drift ahead of actual progress.
192 lines
5.8 KiB
Python
192 lines
5.8 KiB
Python
import json
|
|
from enum import Enum
|
|
from utils.value import AttributeChange
|
|
|
|
# Specific heat capacities [J/(kg*K)] used to derive a single lumped
|
|
# (mass, specific heat) pair for the pot's contents from pot_mass/
|
|
# grain_mass/water_mass. Approximate, typical values.
|
|
SPECIFIC_HEAT_WATER = 4190
|
|
SPECIFIC_HEAT_GRAIN = 1800
|
|
SPECIFIC_HEAT_BY_MATERIAL = {
|
|
"Edelstahl 18/10": 500,
|
|
}
|
|
|
|
|
|
class SudState(Enum):
|
|
IDLE = 0
|
|
RAMPING = 1
|
|
HOLDING = 2
|
|
WAIT_USER = 3
|
|
DONE = 4
|
|
PAUSED = 5
|
|
|
|
|
|
def _merge_defaults(default, override):
|
|
"""Deep-merges override onto default, recursing into nested dicts so
|
|
values missing from override fall back to default's at every level."""
|
|
merged = dict(default)
|
|
for key, value in override.items():
|
|
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
merged[key] = _merge_defaults(merged[key], value)
|
|
else:
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
def _build_step(number, defaults, raw_step):
|
|
"""Fills in a schedule step with default.step's values. A step is a
|
|
ramp or a hold depending on which of those keys it specifies; the other
|
|
is left out rather than synthesized from defaults."""
|
|
step = {'number': number}
|
|
for key in ('descr', 'user_message', 'user_wait_for_continue', 'grain_mass', 'water_mass'):
|
|
step[key] = raw_step.get(key, defaults.get(key))
|
|
for key in ('ramp', 'hold'):
|
|
if key in raw_step:
|
|
step[key] = _merge_defaults(defaults.get(key, {}), raw_step[key])
|
|
return step
|
|
|
|
|
|
class Sud(AttributeChange):
|
|
def __init__(self, path):
|
|
AttributeChange.__init__(self)
|
|
self.path = path
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
|
|
(self.name, self.description, self.schedule,
|
|
self.pot_mass, self.pot_material) = self._parse_data(data)
|
|
|
|
self._paused_from = None
|
|
self._reset_run_state()
|
|
|
|
@staticmethod
|
|
def _parse_data(data):
|
|
"""Parses a sud.json document into the (name, description, schedule,
|
|
pot_mass, pot_material) tuple Sud needs. Computed up front rather
|
|
than assigned straight onto self, so a malformed upload() can't
|
|
leave a half-applied schedule in place."""
|
|
name = data.get('Name', '')
|
|
description = data.get('Description', '')
|
|
|
|
step_defaults = data.get('default', {}).get('step', {})
|
|
schedule = [_build_step(i + 1, step_defaults, raw) for i, raw in enumerate(data['steps'])]
|
|
|
|
pot_mass = data.get('pot_mass', 0)
|
|
pot_material = data.get('pot_material')
|
|
|
|
return name, description, schedule, pot_mass, pot_material
|
|
|
|
def _reset_run_state(self):
|
|
"""Resets run-time progress back to a freshly-loaded, not-yet-started
|
|
IDLE state. Shared by __init__, upload(), and stop()."""
|
|
self.index = -1
|
|
self.hold_remaining = 0.0
|
|
self.state = SudState.IDLE
|
|
self.step = None
|
|
self.user_message = None
|
|
self._paused_from = None
|
|
|
|
def download(self):
|
|
"""Returns the sud.json document currently on disk, for a client to
|
|
edit and hand back to upload()."""
|
|
with open(self.path) as f:
|
|
return json.load(f)
|
|
|
|
def upload(self, data):
|
|
"""Replaces the schedule with a new sud.json document and persists
|
|
it to disk, resetting to IDLE as if freshly loaded from that file.
|
|
Refused while a brew is in progress, or if data is malformed - in
|
|
either case the current schedule is left untouched."""
|
|
if self.state not in (SudState.IDLE, SudState.DONE):
|
|
return False
|
|
try:
|
|
parsed = self._parse_data(data)
|
|
except (KeyError, TypeError):
|
|
return False
|
|
|
|
self.name, self.description, self.schedule, self.pot_mass, self.pot_material = parsed
|
|
with open(self.path, 'w') as f:
|
|
json.dump(data, f, indent='\t')
|
|
|
|
self._reset_run_state()
|
|
return True
|
|
|
|
def derive_plant_params(self, grain_mass, water_mass):
|
|
"""Lumped (mass, specific heat) lifted from pot_mass/pot_material and
|
|
the current step's grain_mass/water_mass, for use as Pot's "M"/"C"
|
|
params. grain_mass/water_mass vary per step (e.g. malt going in,
|
|
water boiling off), so this is recomputed on every step change
|
|
rather than once at startup."""
|
|
c_pot = SPECIFIC_HEAT_BY_MATERIAL.get(self.pot_material, SPECIFIC_HEAT_WATER)
|
|
mass = water_mass + grain_mass + self.pot_mass
|
|
capacitance = (water_mass * SPECIFIC_HEAT_WATER
|
|
+ grain_mass * SPECIFIC_HEAT_GRAIN
|
|
+ self.pot_mass * c_pot)
|
|
|
|
return {
|
|
'M': mass,
|
|
'C': capacitance / mass if mass > 0 else SPECIFIC_HEAT_WATER,
|
|
}
|
|
|
|
def start(self):
|
|
"""Starts a fresh run from IDLE, or resumes a paused one - whichever
|
|
applies. No-op otherwise."""
|
|
if self.state == SudState.PAUSED:
|
|
self.state = self._paused_from
|
|
self._paused_from = None
|
|
return
|
|
if self.state != SudState.IDLE:
|
|
return
|
|
self.index = -1
|
|
self._advance()
|
|
|
|
def pause(self):
|
|
"""Freezes progress (the hold countdown and ramp-reached checks both
|
|
no-op while PAUSED) without losing where we are; start() resumes."""
|
|
if self.state in (SudState.RAMPING, SudState.HOLDING):
|
|
self._paused_from = self.state
|
|
self.state = SudState.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()
|
|
|
|
def confirm(self):
|
|
if self.state == SudState.WAIT_USER:
|
|
self._advance()
|
|
|
|
def temp_reached(self):
|
|
if self.state == SudState.RAMPING:
|
|
self._finish_step()
|
|
|
|
def tick(self, dt):
|
|
if self.state == SudState.HOLDING:
|
|
self.hold_remaining -= dt
|
|
if self.hold_remaining <= 0:
|
|
self._finish_step()
|
|
|
|
def _advance(self):
|
|
self.index += 1
|
|
if self.index >= len(self.schedule):
|
|
self.state = SudState.DONE
|
|
self.user_message = None
|
|
self.step = None
|
|
return
|
|
|
|
next_step = self.schedule[self.index]
|
|
if 'ramp' in next_step:
|
|
self.state = SudState.RAMPING
|
|
else:
|
|
self.hold_remaining = next_step['hold'].get('duration', 0)
|
|
self.state = SudState.HOLDING
|
|
|
|
self.user_message = next_step.get('user_message')
|
|
self.step = next_step
|
|
|
|
def _finish_step(self):
|
|
if self.step.get('user_wait_for_continue', False):
|
|
self.state = SudState.WAIT_USER
|
|
else:
|
|
self._advance()
|