Files
brewpi/components/sud.py
T
jens 13e507e2b5 Rename Sud's Download/Upload to Save/Load throughout
Renames everywhere the concept appears: Sud.download()/upload() ->
save()/load(), the 'Download'/'Upload' websocket messages ->
'Save'/'Load', the brewpi_gui menu actions, handlers and
sud_download_path -> sud_save_path, and the demo script. Behavior is
unchanged - this is naming only, from the client's perspective (save
to / load from a local file) rather than the server's.
2026-06-20 23:58:21 +02:00

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 load() 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__, load(), 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 save(self):
"""Returns the sud.json document currently on disk, for a client to
edit and hand back to load()."""
with open(self.path) as f:
return json.load(f)
def load(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()