#!/usr/bin/env python3
from main_window import Ui_MainWindow
from PyQt5 import QtWidgets, QtGui, QtCore
import json
import sys
import threading
import time
from pathlib import Path
import matplotlib
matplotlib.use("Qt5Agg")
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from matplotlib.ticker import AutoMinorLocator
from components.sud import Sud
from ws.client.ws_client import WsClient
from ws.message import MessageDispatcherSync
from queue import Queue
import asyncio
from user_config import UserConfig

# str(SudState.X) values (as sent in the 'State' message) that count as
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
SUD_WAIT_USER_STATE = "SudState.WAIT_USER"
SUD_PAUSED_STATE = "SudState.PAUSED"

# Below this (simulated seconds), an 'Elapsed' push is treated as "this run
# just started" rather than "reconnecting mid-brew" - see on_sud_changed()'s
# Elapsed handling. Generous relative to typical dt (0.1-1s/tick): a fresh
# start's first push always lands well under it, while reconnecting to an
# established run (anything past its first handful of ticks) always lands
# well over it - and seeding the history a few seconds "late" within a
# genuinely fresh run is harmless anyway, since the temperature there is
# still essentially the t=0 anchor.
FRESH_RUN_ELAPSED_THRESHOLD_S = 5.0


def _format_duration(seconds):
	"""mm:ss, or h:mm:ss past an hour - used by StepPlate's remaining-time
	label, where a brew step can run from a few minutes to a few hours."""
	seconds = max(0, int(seconds))
	hours, rem = divmod(seconds, 3600)
	minutes, secs = divmod(rem, 60)
	if hours:
		return "{}:{:02d}:{:02d}".format(hours, minutes, secs)
	return "{}:{:02d}".format(minutes, secs)


def _style_axis(ax):
	ax.set_facecolor('white')
	ax.spines['top'].set_visible(False)
	ax.spines['right'].set_visible(False)
	ax.xaxis.set_minor_locator(AutoMinorLocator())
	ax.yaxis.set_minor_locator(AutoMinorLocator())
	ax.tick_params(which='major', direction='out', labelsize='small')
	ax.tick_params(which='minor', direction='out', length=2)
	ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3)
	ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15)


class RealtimePlot(FigureCanvasQTAgg):
	"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
	scripts/demos/sud/demo_sud.py but fed from the websocket connection
	instead of an offline simulation run."""

	def __init__(self):
		figure = Figure(facecolor='white')
		FigureCanvasQTAgg.__init__(self, figure)

		self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True)

		self.line_temp_ist, = self.ax_temp.plot([], [], '-b', linewidth=1, label="theta_ist")
		self.line_temp_soll, = self.ax_temp.plot([], [], '-r', linewidth=1, label="theta_soll")
		self.ax_temp.set_ylabel("Temp [°C]")
		self.ax_temp.legend(loc='upper left', fontsize='small')

		self.line_rate_ist, = self.ax_rate.plot([], [], '-b', linewidth=1, label="heatrate_ist")
		self.line_rate_soll, = self.ax_rate.plot([], [], '-r', linewidth=1, label="heatrate_soll")
		self.ax_rate.set_ylabel("Rate [K/min]")
		self.ax_rate.legend(loc='upper left', fontsize='small')

		self.line_power_set, = self.ax_power.plot([], [], '-r', linewidth=1, label="power_set")
		self.line_power_eff, = self.ax_power.plot([], [], '-b', linewidth=1, label="power_eff")
		self.ax_power.set_ylabel("Power [W]")
		self.ax_power.set_xlabel("t [s]")
		self.ax_power.legend(loc='upper left', fontsize='small')

		for ax in (self.ax_temp, self.ax_rate, self.ax_power):
			_style_axis(ax)

		# Only the bottom subplot needs x tick labels - sharex keeps the
		# others in sync without repeating them.
		self.ax_temp.label_outer()
		self.ax_rate.label_outer()

		figure.tight_layout()
		self.reset()

	def reset(self):
		self.t0 = time.monotonic()
		self.t = []
		self.temp_ist = []
		self.temp_soll = []
		self.rate_ist = []
		self.rate_soll = []
		self.power_set = []
		self.power_eff = []

	def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff):
		self.t.append(time.monotonic() - self.t0)
		self.temp_ist.append(temp_ist)
		self.temp_soll.append(temp_soll)
		self.rate_ist.append(rate_ist)
		self.rate_soll.append(rate_soll)
		self.power_set.append(power_set)
		self.power_eff.append(power_eff)

		self.line_temp_ist.set_data(self.t, self.temp_ist)
		self.line_temp_soll.set_data(self.t, self.temp_soll)
		self.line_rate_ist.set_data(self.t, self.rate_ist)
		self.line_rate_soll.set_data(self.t, self.rate_soll)
		self.line_power_set.set_data(self.t, self.power_set)
		self.line_power_eff.set_data(self.t, self.power_eff)

		for ax in (self.ax_temp, self.ax_rate, self.ax_power):
			ax.relim()
			ax.autoscale_view()

		self.draw_idle()


class SudForecastPlot(FigureCanvasQTAgg):
	"""Compares a Sud schedule's actual control behavior (line, solid)
	against a simulation-based forecast (line_projected, also solid but
	faded via alpha - see __init__()) - the same plant/controller model
	the real run uses
	(components/sud_forecast.py's SudForecastEstimator - see
	show_computing()/show_forecast()). The forecast covers the whole
	schedule from the very first Load, including through any step
	requiring user confirmation - a human's response time genuinely
	can't be forecast, so it's modeled as a zero-delay auto-confirm
	there instead of stopping. That assumption gets corrected once the
	schedule actually reaches the next real step boundary (anchored at
	the real elapsed time and temperature) - see tasks/sud.py's
	SudTask._reanchor_forecast() - at which point show_forecast() is
	called again with the corrected curve, replacing the optimistic
	guess outright rather than the GUI patching it up itself."""

	def __init__(self):
		figure = Figure(facecolor='white')
		FigureCanvasQTAgg.__init__(self, figure)

		self.ax = figure.subplots(1, 1)
		self.line, = self.ax.plot([], [], '-b', linewidth=1.5, zorder=2)
		self.line_projected, = self.ax.plot([], [], '-b', linewidth=1.5, alpha=0.5, zorder=2)
		self.progress_line = self.ax.axvline(0, color='g', linewidth=1.5, visible=False, zorder=2)
		# Current temperature - drawn behind (lower zorder than) the
		# forecast/progress lines so it never covers them.
		self.current_temp_line = self.ax.axhline(0, color='r', linewidth=1.5, visible=False, zorder=1)
		self.ax.set_xlabel("t [min]")
		self.ax.set_ylabel("Temp [°C]")
		_style_axis(self.ax)

		figure.tight_layout()

	def show_computing(self, name):
		"""Shown right after a schedule loads, while the first
		simulation-based forecast segment is still being computed
		server-side (see Window.on_sud_forecast_received()) - typically
		resolved within a second. Deliberately shows nothing rather than
		an approximate placeholder; leaves the axes' current view alone
		(there's nothing meaningful to fit yet), so whatever was on
		screen before just stays frozen underneath the title change
		until show_forecast() replaces it."""
		self.line.set_data([], [])
		self.line_projected.set_data([], [])
		self.ax.set_title("{} - computing forecast...".format(name), fontsize='small')
		self.figure.tight_layout()
		self.draw_idle()

	def show_forecast(self, t_min, theta, name, finished):
		"""The forecast curve (faded, via alpha) - computed by the server simulating
		the schedule with its real plant/controller
		(components/sud_forecast.py) - see Window.on_sud_forecast_
		received(). Called again, with a corrected t_min/theta, each time
		a real user confirmation replaces a zero-delay guess with one
		anchored at the real elapsed time and temperature; finished is
		False only in the pathological case of a step whose target can
		never be reached (components/sud_forecast.py's MAX_TICKS). Locks
		the axes to fit the forecast alone, regardless of whatever the
		actual trace (line) is currently doing."""
		self.line_projected.set_data(t_min, theta)
		self._set_fixed_limits(t_min, theta)
		total = t_min[-1] if t_min else 0.0
		suffix = "" if finished else " so far"
		self.ax.set_title("{} - {:.0f} min{} total".format(name, total, suffix), fontsize='small')
		self.figure.tight_layout()
		self.draw_idle()

	def _set_fixed_limits(self, t_list, theta_list):
		"""Explicitly sets (and, unlike relim()+autoscale_view(), locks)
		the axes' view to fit exactly the given data, with matplotlib's
		usual ~5% margin. Locking matters here: relim()+autoscale_view()
		leaves autoscaling switched on, so a later, unrelated redraw -
		e.g. set_current_temp()'s continuous updates, which keep that
		indicator live regardless of whether a forecast is even showing -
		can silently re-autoscale and stretch the view back out to fit it
		instead, squashing the actual forecast curve down to nothing."""
		if not t_list or not theta_list:
			return
		t_lo, t_hi = min(t_list), max(t_list)
		th_lo, th_hi = min(theta_list), max(theta_list)
		t_pad = (t_hi - t_lo) * 0.05 or 1.0
		th_pad = (th_hi - th_lo) * 0.05 or 1.0
		self.ax.set_xlim(t_lo - t_pad, t_hi + t_pad)
		self.ax.set_ylim(th_lo - th_pad, th_hi + th_pad)

	def show_dynamic(self, history):
		"""Updates the actual measured trace (solid) only - the forecast
		(also solid, but faded via alpha) is left exactly as
		show_forecast() last drew it (that's the whole point: an honest,
		unmoving baseline - see this class's docstring), and the axes
		stay locked to whatever it set, so divergence between the two is
		visible rather than auto-hidden by the view rescaling to chase
		whichever line is currently drawing."""
		hist_t = [t for t, _ in history]
		hist_theta = [theta for _, theta in history]
		self.line.set_data(hist_t, hist_theta)
		self.draw_idle()

	def show_no_schedule(self):
		self.line.set_data([], [])
		self.line_projected.set_data([], [])
		self.clear_progress()
		self.ax.relim()
		self.ax.autoscale_view()
		self.ax.set_title("No sud loaded", fontsize='small')
		self.figure.tight_layout()
		self.draw_idle()

	def set_progress(self, t_min):
		self.progress_line.set_xdata([t_min, t_min])
		self.progress_line.set_visible(True)
		self.draw_idle()

	def clear_progress(self):
		self.progress_line.set_visible(False)
		self.draw_idle()

	def set_current_temp(self, theta):
		self.current_temp_line.set_ydata([theta, theta])
		self.current_temp_line.set_visible(True)
		self.draw_idle()

	def clear_current_temp(self):
		self.current_temp_line.set_visible(False)
		self.draw_idle()


class StepPlate(QtWidgets.QFrame):
	"""One rectangle per schedule step, stacked top-to-bottom on the
	Progress tab to represent the whole Sud - see Window.
	_rebuild_step_plates()/_update_step_plates(). The LED at a glance
	communicates this step's status (not yet entered/finished/active);
	the row below it mirrors what's actually driving the brew at that
	step: target temp (resolved by the caller - a step without its own
	'temperature' inherits whatever the previous one set, see
	components/sud.py's _build_step()), masses and ramp rate as
	configured in the schedule, plus, for whichever step is currently
	active, the same live actual-temp/stirrer readings the Manual tab
	shows (frozen at their last value once that step finishes, blank
	before it's ever been reached)."""

	LED_OFF = "#555555"
	LED_GREEN = "#2ecc71"
	LED_GREEN_DIM = "#1b5e36"
	LED_ORANGE = "#e67e22"
	LED_ORANGE_DIM = "#7a4111"

	def __init__(self, index, step, resolved_temp):
		QtWidgets.QFrame.__init__(self)
		self.setFrameShape(QtWidgets.QFrame.StyledPanel)
		self.setFrameShadow(QtWidgets.QFrame.Raised)
		# 'off' (not yet entered) | 'done' (finished, solid green) |
		# 'ramp'/'hold' (active, flashing orange/green - see apply_blink()).
		self.led_mode = 'off'

		self.led = QtWidgets.QLabel()
		self.led.setFixedSize(16, 16)

		self.label_title = QtWidgets.QLabel("Step {}: {}".format(index + 1, step.get('descr') or ''))
		self.label_title.setStyleSheet("font-weight: bold; color: black;")
		self.label_title.setWordWrap(True)

		self.label_remaining = QtWidgets.QLabel("--:--")
		self.label_remaining.setStyleSheet("font-weight: bold; color: black;")
		self.label_remaining.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)

		self.label_target = QtWidgets.QLabel()
		self.label_actual = QtWidgets.QLabel()
		self.label_masses = QtWidgets.QLabel()
		self.label_rate = QtWidgets.QLabel()
		self.label_stirrer = QtWidgets.QLabel()
		self.label_energy = QtWidgets.QLabel()
		for label in (self.label_target, self.label_actual, self.label_masses, self.label_rate,
				self.label_stirrer, self.label_energy):
			label.setStyleSheet("color: #555;")

		# A grid, not a single row - the Progress tab shares its pane's
		# width with the LCD panel on the Manual tab's side of the same
		# splitter (see Window.__init__), which only leaves room for two
		# short columns before needing a horizontal scrollbar.
		layout = QtWidgets.QGridLayout(self)
		layout.setColumnStretch(1, 1)
		layout.addWidget(self.led, 0, 0)
		layout.addWidget(self.label_title, 0, 1)
		layout.addWidget(self.label_remaining, 0, 2)
		layout.addWidget(self.label_target, 1, 0, 1, 2)
		layout.addWidget(self.label_actual, 1, 2)
		layout.addWidget(self.label_masses, 2, 0, 1, 2)
		layout.addWidget(self.label_energy, 2, 2)
		layout.addWidget(self.label_rate, 3, 0, 1, 2)
		layout.addWidget(self.label_stirrer, 3, 2)

		self.set_step(step, resolved_temp)
		self.set_actual_temp(None)
		self.set_live_stirrer(None)
		self.set_energy(None)

	def set_step(self, step, resolved_temp):
		"""Applies this step's static, schedule-config fields - called once,
		right after construction (see __init__)."""
		step_pot = step.get('pot', {})
		grain = step_pot.get('grain_mass', 0)
		water = step_pot.get('water_mass', 0)
		self.label_masses.setText("Grain {:.2f} kg / Water {:.2f} kg".format(grain, water))
		ramp = step.get('ramp') or {}
		self.label_rate.setText("Rate {:.2f} °/min".format(ramp.get('rate', 0)))
		hold = step.get('hold')
		# The hold phase's speed, not the ramp phase's, as the "configured"
		# fallback shown for an inactive step - hold is the long-running
		# phase a glance at the plate cares about; ramp's own speed still
		# shows live (see set_live_stirrer()) while this step is actually
		# ramping.
		hold_stirrer = hold.get('stirrer', {}) if hold else {}
		self._configured_speed = hold_stirrer.get('speed', ramp.get('stirrer', {}).get('speed', 0))
		self.label_target.setText(
			"Target {}".format("{:.1f} °C".format(resolved_temp) if resolved_temp is not None else "—"))

	def set_actual_temp(self, temp):
		self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else "—"))

	def set_energy(self, wh):
		"""wh is this step's energy consumption so far (Wh) - live and
		still growing while this is the active step, frozen at its final
		total once finished, or None before this step has ever been
		reached (see Window._update_step_plates())."""
		self.label_energy.setText("Energy {}".format("{:.0f} Wh".format(wh) if wh is not None else "—"))

	def set_live_stirrer(self, speed):
		"""speed is the live rpm while this step is the active one, or None
		to fall back to its configured (hold-phase) speed for a step
		that's inactive (not yet reached, or already finished)."""
		if speed is not None:
			self.label_stirrer.setText("Stirrer {:.0f} rpm".format(speed))
		else:
			self.label_stirrer.setText("Stirrer {:.0f} rpm (cfg)".format(self._configured_speed))

	def set_remaining(self, seconds):
		self.label_remaining.setText(_format_duration(seconds) if seconds is not None else "--:--")

	def set_led(self, mode):
		self.led_mode = mode
		if mode == 'done':
			self._led_color(self.LED_GREEN)
		elif mode == 'ramp':
			self._led_color(self.LED_ORANGE)
		elif mode == 'hold':
			self._led_color(self.LED_GREEN)
		else:
			self._led_color(self.LED_OFF)

	def apply_blink(self, blink_on):
		"""Ticked by Window's shared blink timer - only an active step's LED
		(mode 'ramp'/'hold') actually alternates; 'off'/'done' ignore it."""
		if self.led_mode == 'ramp':
			self._led_color(self.LED_ORANGE if blink_on else self.LED_ORANGE_DIM)
		elif self.led_mode == 'hold':
			self._led_color(self.LED_GREEN if blink_on else self.LED_GREEN_DIM)

	def _led_color(self, color):
		self.led.setStyleSheet("background-color: {}; border-radius: 8px; border: 1px solid #222;".format(color))


class Window(QtWidgets.QMainWindow, Ui_MainWindow):
	# on_*_changed()/on_ws_connect_changed() are invoked straight from the
	# websocket client's background thread (WsClient.bg_thread), but Qt
	# widgets may only be touched from the GUI thread. A signal/slot crosses
	# that boundary safely (Qt auto-queues the slot call onto the receiver's
	# thread when sender and receiver differ) - recv_signal carries the
	# handler itself plus its one argument, so every channel can share it
	# instead of needing one signal per handler.
	recv_signal = QtCore.pyqtSignal(object, object)

	def __init__(self):
		QtWidgets.QMainWindow.__init__(self)
		self.setWindowIcon(QtGui.QIcon('res/beer.png'))
		self.recv_signal.connect(self._dispatch_recv)
		self.user_config = UserConfig()

		loop = asyncio.new_event_loop()
		asyncio.set_event_loop(loop)
		self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
		self.msg_dispatch.on_connect_changed = lambda connected: self.recv_signal.emit(self.on_ws_connect_changed, connected)
		self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
		self.connected = False
		self.sud_loaded = False
		# Whether the loaded schedule has zero steps (e.g. just after "New
		# Sud") - distinct from sud_loaded, which just means a Sud is
		# configured server-side at all. Defaults True so Start/Pause/Stop
		# stay disabled until a non-empty schedule is actually confirmed.
		self.sud_empty = True
		self.sud_state = None
		self.sud_user_message = None
		self._confirm_box = None
		# Global, not Sud-specific - from the 'System' channel's one-time
		# startup message. warp_factor defaults to 1.0 (no scaling) until
		# that arrives, or for a server that doesn't send it at all.
		self.ambient_temp = None
		self.warp_factor = 1.0
		# Elapsed simulated seconds of the current run, as reported by the
		# server (Sud's own tick-counted 'elapsed' - see components/sud.py) -
		# None while not running. Used to position the forecast plot's
		# progress line; ground truth, not reconstructed from wall-clock
		# time, since the server's actual achieved speedup runs measurably
		# below its nominal configured warp factor under real scheduling
		# load, which used to make the live trace visibly drift from the
		# forecast.
		self.sud_elapsed_seconds = None
		# The last real 'Elapsed' value seen, full stop - unlike
		# sud_elapsed_seconds above, never reset back to None once the run
		# leaves a running state (that's specifically what the forecast
		# plot's dynamic-vs-static view switch needs - see _elapsed_min()),
		# so the status bar/Progress tab can still show where the run
		# actually ended up rather than "--:--" the moment it finishes.
		# Reset on a fresh Start/Load (see on_sud_changed()/
		# update_sud_forecast()) - a new run's total shouldn't open by
		# showing the previous one's.
		self.sud_elapsed_last = None
		self.sud_name = ''
		self.sud_schedule = []
		self.sud_pot_mass = 0
		self.sud_grain_mass = 0
		self.sud_water_mass = 0
		self.sud_step_index = None
		self.sud_step_descr = None
		self.sud_step_type = None
		self.sud_hold_remaining = 0.0
		# The last doc update_sud_forecast() actually processed - lets it
		# tell a genuinely different schedule (a real Load/New Sud) apart
		# from a standalone re-fetch of the *same*, already-running one
		# (e.g. connect()'s unconditional Save request) - see its own
		# comment for why that distinction matters.
		self.sud_last_loaded_doc = None
		# [(t_min, theta), ...] actually measured since the current run
		# started - the "actual" half of the forecast-vs-actual
		# comparison (SudForecastPlot.show_dynamic()).
		self.forecast_history = []
		# Per-step boundary times (absolute schedule index -> simulated
		# second), straight from the latest 'Forecast' message's
		# 'StepStarts' (see tasks/sud.py's SudTask.forecast_step_starts) -
		# drives the Progress tab's remaining-time labels (see
		# _update_step_plates()). sud_forecast_final_t is the forecast's
		# own last t, used as the implicit "end" of the last step once
		# sud_forecast_finished says the simulation actually reached it.
		self.sud_forecast_step_starts = {}
		self.sud_forecast_finished = False
		self.sud_forecast_final_t = None
		# One StepPlate per self.sud_schedule entry, same order/index - see
		# _rebuild_step_plates()/_update_step_plates().
		self.step_plates = []
		# Index -> the live actual temperature last seen while that step
		# was active, captured the moment a later 'Step' message reports
		# the schedule has moved past it (see on_sud_changed()) - lets a
		# finished step's plate keep showing where it actually ended up
		# rather than going blank.
		self.step_actual_frozen = {}
		# Live stirrer speed (rpm), tracked unconditionally (unlike
		# Slider_speed_soll, which only syncs once - see
		# on_stirrer_changed()) so the Progress tab's active-step plate can
		# show it.
		self.stirrer_speed_ist = 0.0
		# Energy consumption (Wh), straight from the latest 'Energy'
		# message (see tasks/sud.py's SudTask._on_energy_changed()) -
		# sud_energy_by_step covers every *finished* step (absolute index
		# -> Wh), sud_energy_current is the running total for whichever
		# step is presently active. Unlike the timing/temperature fields
		# above, there's no forecast equivalent to preview before a run
		# starts - energy actually used can only be measured, not
		# predicted - so both stay empty/zero until one actually does.
		self.sud_energy_by_step = {}
		self.sud_energy_current = 0.0
		self.msg_pot = self.msg_dispatch.msgio_get('Pot')
		self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
		self.msg_heater = self.msg_dispatch.msgio_get('Heater')
		self.msg_stirrer = self.msg_dispatch.msgio_get('Stirrer')
		self.msg_tempctrl = self.msg_dispatch.msgio_get('TempCtrl')
		self.msg_sud = self.msg_dispatch.msgio_get('Sud')
		self.msg_system = self.msg_dispatch.msgio_get('System')

		# set_recv_handler's callback runs on WsClient's background thread -
		# route each through recv_signal instead of wiring the real handler
		# directly, so its body always runs on the GUI thread.
		self.msg_pot.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_pot_changed, msg))
		self.msg_sensor.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sensor_changed, msg))
		self.msg_heater.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_heater_changed, msg))
		self.msg_stirrer.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_stirrer_changed, msg))
		self.msg_tempctrl.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_tempctrl_changed, msg))
		self.msg_sud.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sud_changed, msg))
		self.msg_system.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_system_changed, msg))

		self.setupUi(self)
		# Permanent (right-aligned, not cleared by showMessage()/clearMessage())
		# label for static environment info - distinct from the status bar's
		# transient step/schedule messages.
		self.status_label_env = QtWidgets.QLabel()
		self.statusBar().addPermanentWidget(self.status_label_env)
		self.update_status_env_label()

		# Blinking indicator for the temperature controller's own COOL
		# state (target below the current temperature - the heater is
		# forced off server-side and we just wait for ambient loss to
		# catch up).
		self.tc_cooling = False
		self.status_label_cooling = QtWidgets.QLabel("Cool down")
		self.status_label_cooling.setStyleSheet("color: blue; font-weight: bold;")
		self.status_label_cooling.setVisible(False)
		self.statusBar().addPermanentWidget(self.status_label_cooling)
		self.cooling_blink_timer = QtCore.QTimer(self)
		self.cooling_blink_timer.timeout.connect(self.on_cooling_blink)

		self.update_sud_actions()
		self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
		self.actionStart.triggered.connect(self.on_action_sud_start)
		self.actionPause.triggered.connect(self.on_action_sud_pause)
		self.actionStop.triggered.connect(self.on_action_sud_stop)
		self.actionSudNew.triggered.connect(self.on_action_sud_new)
		self.actionSudSave.triggered.connect(self.on_action_sud_save)
		self.actionSudLoad.triggered.connect(self.on_action_sud_load)
		self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed)
		self.Slider_temp_soll.valueChanged.connect(self.on_slider_temp_soll_changed)
		self.Slider_speed_soll.valueChanged.connect(self.on_slider_stirrer_speed_soll_changed)

		self.doubleSpinBox_heatrate_soll.valueChanged.connect(self.on_heatrate_soll_changed)

		# Show the last value we had before the GUI last closed (see
		# closeEvent()) right away, rather than the spinbox's bare design-time
		# default - blockSignals so this doesn't queue a spurious 'AmbientTemp'
		# send before the user has touched anything.
		remembered_ambient = self.user_config.get('ambient_temperature')
		if remembered_ambient is not None:
			self.doubleSpinBox_ambient.blockSignals(True)
			self.doubleSpinBox_ambient.setValue(remembered_ambient)
			self.doubleSpinBox_ambient.blockSignals(False)
		self.doubleSpinBox_ambient.valueChanged.connect(self.on_ambient_entry_changed)
		# Pre-dial the Pot reset target at the same starting point btn_pot_
		# reset used to hardcode (ambient) - purely a one-off starting value
		# for the user to optionally dial away from before clicking the
		# button, so unlike the ambient spinbox it's never persisted/synced
		# again afterwards.
		if remembered_ambient is not None:
			self.doubleSpinBox_pot_temp.setValue(remembered_ambient)
		self.btn_pot_reset.clicked.connect(self.on_action_pot_reset)
		# Only meaningful (and only shown) once the server confirms its Pot
		# is simulated - see on_system_changed()'s "PlantSim" handling.
		self.btn_pot_reset.setVisible(False)
		self.label_pot_temp.setVisible(False)
		self.doubleSpinBox_pot_temp.setVisible(False)

		self.send_data = Queue()
		self.slider_pwr_initial_update = True
		self.slider_temp_soll_initial_update = True
		self.slider_speed_initial_update = True
		self.heatrate_soll_initial_update = True
		self.sud_save_path = None

		# Latest values sampled by the plot timer - written from the
		# websocket recv thread, read from the GUI thread; plain floats so
		# no locking, consistent with how the rest of the GUI is wired.
		self.plot_temp_ist = 0.0
		self.plot_temp_soll = 0.0
		self.plot_rate_ist = 0.0
		self.plot_rate_soll = 0.0
		self.plot_power_set = 0.0
		self.plot_power_eff = 0.0

		self.plot = RealtimePlot()
		plot_layout = QtWidgets.QVBoxLayout(self.widget_plot)
		plot_layout.setContentsMargins(0, 0, 0, 0)
		plot_layout.addWidget(self.plot)

		self.plot_timer = QtCore.QTimer(self)
		self.plot_timer.timeout.connect(self.on_plot_timer)
		self.plot_timer.start(1000)

		self.forecast_plot = SudForecastPlot()
		forecast_layout = QtWidgets.QVBoxLayout(self.widget_plot_forecast)
		forecast_layout.setContentsMargins(0, 0, 0, 0)
		forecast_layout.addWidget(self.forecast_plot)

		# Progress tab - one StepPlate per schedule step, stacked inside a
		# scroll area (a long mash schedule can easily run past the visible
		# height). Built entirely in code rather than via brewpi.ui/
		# main_window.py, since its content (the plates themselves) is
		# already fully dynamic - there's nothing for Designer to usefully
		# own here, unlike widget_plot/widget_plot_forecast above, which at
		# least have a fixed placeholder to hand a single child widget to.
		progress_tab = QtWidgets.QWidget()
		progress_layout = QtWidgets.QVBoxLayout(progress_tab)
		progress_layout.setContentsMargins(0, 0, 0, 0)
		progress_scroll = QtWidgets.QScrollArea()
		progress_scroll.setWidgetResizable(True)
		progress_container = QtWidgets.QWidget()
		self.progress_container_layout = QtWidgets.QVBoxLayout(progress_container)
		self.progress_container_layout.setSpacing(6)
		# Kept as the layout's last item by _rebuild_step_plates() (which
		# only ever inserts plates before it) - without it, fewer plates
		# than the scroll area's height would stretch to fill the gap
		# instead of stacking at the top.
		self.progress_container_layout.addStretch(1)
		progress_scroll.setWidget(progress_container)
		progress_layout.addWidget(progress_scroll)
		self.horizontalTabWidget.addTab(progress_tab, "Progress")

		# Single shared timer for every active plate's flashing LED -
		# mirrors status_label_cooling/cooling_blink_timer's pattern above.
		self.progress_blink_on = True
		self.progress_blink_timer = QtCore.QTimer(self)
		self.progress_blink_timer.timeout.connect(self.on_progress_blink)
		self.progress_blink_timer.start(500)

	def _dispatch_recv(self, handler, msg):
		"""recv_signal's slot - runs on the GUI thread regardless of which
		thread emitted the signal, so handler(msg) can touch Qt widgets
		safely."""
		handler(msg)

	def connect(self):
		self.slider_pwr_initial_update = True
		self.slider_temp_soll_initial_update = True
		self.slider_speed_initial_update = True
		self.heatrate_soll_initial_update = True
		self.plot.reset()
		self.ws_client.connect(uri=self.plainTextUri.toPlainText())
		# Fetch the schedule for the Automatic tab's forecast preview - not
		# a user-initiated save, so sud_save_path stays None and
		# on_sud_changed() routes the reply to update_sud_forecast() instead
		# of a save dialog.
		self.msg_sud.send({'Save': True})
		# Re-apply our own ambient temperature, in case the server restarted
		# since (and so fell back to its own config.json default) - the
		# spinbox's current value, not the on-disk user config (which is
		# only refreshed on shutdown - see closeEvent()), so this also picks
		# up a change made earlier in the same session, before a reconnect.
		self.msg_system.send({'AmbientTemp': self.doubleSpinBox_ambient.value()})

	def _elapsed_min(self):
		"""Simulated-schedule minutes elapsed since the run started, or
		None if no run is in progress - straight from the server's own
		tick-counted Sud.elapsed (see on_sud_changed()'s 'Elapsed'
		handling), already frozen while PAUSED there."""
		if self.sud_elapsed_seconds is None:
			return None
		return self.sud_elapsed_seconds / 60.0

	def on_plot_timer(self):
		self.plot.sample(
			self.plot_temp_ist, self.plot_temp_soll,
			self.plot_rate_ist, self.plot_rate_soll,
			self.plot_power_set, self.plot_power_eff)

		elapsed_min = self._elapsed_min()
		if elapsed_min is not None:
			self.forecast_plot.set_progress(elapsed_min)

			# Frozen while PAUSED (elapsed_min isn't advancing either, and
			# the paused phase - ramp or hold - isn't visible to the client
			# to resume the trace from correctly) - just leave the last
			# draw up.
			if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist:
				self.forecast_history.append((elapsed_min, self.plot_temp_ist))
				self.forecast_plot.show_dynamic(self.forecast_history)
		else:
			self.forecast_plot.clear_progress()

		if self.plot_temp_ist:
			self.forecast_plot.set_current_temp(self.plot_temp_ist)
		else:
			self.forecast_plot.clear_current_temp()

	def disconnect(self):
		# ws_client.disconnect() blocks until the websocket's close handshake
		# completes (or times out, ~10s if the server doesn't ack promptly),
		# joining the connection's background thread - run it off a throwaway
		# thread so that wait doesn't freeze the GUI. on_ws_connect_changed()
		# still flips the button once the disconnect actually finishes.
		threading.Thread(target=self.ws_client.disconnect, daemon=True).start()

	def on_btn_connect_clicked(self):
		if self.connected:
			self.disconnect()
		else:
			self.connect()

	def on_ws_connect_changed(self, connected):
		self.connected = connected
		self.btn_connect.setText("Disconnect" if connected else "Connect")
		if not connected:
			# Nothing reported over a dead connection can be trusted anymore.
			self.sud_loaded = False
			self.sud_empty = True
			self.sud_state = None
			self.sud_user_message = None
			self._close_confirm_dialog()
			for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll,
					  self.Slider_pwr_soll, self.Slider_speed_soll):
				w.setEnabled(True)
			# ambient_temp (the server's last echoed reading, shown in the
			# status bar) is no longer trustworthy, but the spinbox itself is
			# a client-owned setting (see closeEvent()/connect()) - leave it
			# showing whatever the user last set rather than blanking it.
			self.ambient_temp = None
			self.btn_pot_reset.setVisible(False)
			self.label_pot_temp.setVisible(False)
			self.doubleSpinBox_pot_temp.setVisible(False)
			self.warp_factor = 1.0
			self.sud_elapsed_seconds = None
			self.sud_name = ''
			self.sud_schedule = []
			self.sud_pot_mass = 0
			self.sud_grain_mass = 0
			self.sud_water_mass = 0
			self.sud_step_index = None
			self.sud_step_descr = None
			self.sud_step_type = None
			self.sud_hold_remaining = 0.0
			self.forecast_history = []
			self.set_tc_cooling(False)
			self.update_sud_actions()
			self.update_status_env_label()
			self.setWindowTitle("BrewPi")

	def update_sud_actions(self):
		can_run = self.sud_loaded and not self.sud_empty
		running = can_run and self.sud_state in SUD_RUNNING_STATES
		paused = can_run and self.sud_state == SUD_PAUSED_STATE
		# Start also doubles as Resume while paused; Stop can still abort a
		# paused run, but there's nothing left to Pause once already paused.
		self.actionStart.setEnabled(can_run and not running)
		self.actionPause.setEnabled(running)
		self.actionStop.setEnabled(running or paused)

	def on_system_changed(self, msg):
		print("on_system_changed {}".format(msg))
		for key in msg:
			if "AmbientTemp" in key:
				self.ambient_temp = msg['AmbientTemp']
				# Only sync the spinbox while the user isn't actively
				# interacting with it, so a server echo doesn't clobber an
				# in-progress edit - blockSignals avoids this setValue()
				# itself re-triggering on_ambient_entry_changed() and
				# echoing the same value straight back.
				if not self.doubleSpinBox_ambient.hasFocus():
					self.doubleSpinBox_ambient.blockSignals(True)
					self.doubleSpinBox_ambient.setValue(self.ambient_temp)
					self.doubleSpinBox_ambient.blockSignals(False)
			elif "WarpFactor" in key:
				self.warp_factor = msg['WarpFactor']
			elif "PlantSim" in key:
				self.btn_pot_reset.setVisible(msg['PlantSim'])
				self.label_pot_temp.setVisible(msg['PlantSim'])
				self.doubleSpinBox_pot_temp.setVisible(msg['PlantSim'])
		self.update_status_env_label()

	def on_action_pot_reset(self):
		self.msg_pot.send({'Reset': self.doubleSpinBox_pot_temp.value()})

	def on_ambient_entry_changed(self, value):
		# Persisted on shutdown instead (see closeEvent()), not on every
		# spinbox tick - this fires on every keystroke/arrow click while
		# editing.
		self.msg_system.send({'AmbientTemp': value})

	def update_status_env_label(self):
		ambient = "{:.1f}°C".format(self.ambient_temp) if self.ambient_temp is not None else "-"
		self.status_label_env.setText("Ambient: {}   Warp: {:.1f}x".format(ambient, self.warp_factor))

	@staticmethod
	def _make_window_title(name, description):
		title = "BrewPi"
		if name:
			title += " - {}".format(name)
		if description:
			title += " - {}".format(description)
		return title

	def set_tc_cooling(self, cooling):
		if cooling == self.tc_cooling:
			return
		self.tc_cooling = cooling
		if cooling:
			self.status_label_cooling.setVisible(True)
			self.cooling_blink_timer.start(500)
		else:
			self.cooling_blink_timer.stop()
			self.status_label_cooling.setVisible(False)

	def on_cooling_blink(self):
		self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible())

	def on_progress_blink(self):
		self.progress_blink_on = not self.progress_blink_on
		for plate in self.step_plates:
			plate.apply_blink(self.progress_blink_on)

	def on_slider_temp_soll_changed(self, value):
		print("on_slider_temp_soll_changed {}".format(value))
		self.msg_tempctrl.send({'Soll': {'Temp': value}})

	def on_heatrate_soll_changed(self, value):
		value = round(value, 1)
		print("on_textbox_heatrate_soll_changed {}".format(value))
		self.msg_tempctrl.send({'Soll': {'Rate': value}})

	def on_slider_pwr_soll_changed(self, value):
		print("on_slider_pwr_soll_changed {}".format(value))
		self.msg_heater.send({'Power': value})

	def on_slider_stirrer_speed_soll_changed(self, value):
		print("on_slider_stirrer_speed_soll_changed {}".format(value))
		self.msg_stirrer.send({'Speed': value})

	def on_action_sud_start(self):
		self.msg_sud.send({'Start': True})

	def on_action_sud_pause(self):
		self.msg_sud.send({'Pause': True})

	def on_action_sud_stop(self):
		self.msg_sud.send({'Stop': True})

	def show_user_message(self, message):
		if self._confirm_box is not None:
			return
		dlg = QtWidgets.QDialog(self)
		dlg.setWindowTitle("Sud")
		dlg.setWindowModality(QtCore.Qt.NonModal)
		layout = QtWidgets.QVBoxLayout(dlg)
		layout.addWidget(QtWidgets.QLabel(message))
		btn = QtWidgets.QPushButton("OK")
		btn.clicked.connect(lambda: self.msg_sud.send({'Confirm': True}))
		layout.addWidget(btn)
		self._confirm_box = dlg
		dlg.show()

	def _close_confirm_dialog(self):
		if self._confirm_box is not None:
			self._confirm_box.close()
			self._confirm_box = None

	def on_action_sud_new(self):
		# Sends an empty schedule via the same Load path Load Sud uses, with
		# a built-in empty document instead of a file dialog - the server
		# itself refuses this (like any Load) while a run is in progress,
		# surfacing an error (see on_sud_changed()'s 'Error' handling)
		# rather than the GUI trying to guess that here.
		self.msg_sud.send({'Load': {'Name': '', 'Description': '', 'pot': {'mass': 0, 'material': None}, 'steps': []}})

	def on_action_sud_save(self):
		# Pick the destination up front, on the GUI thread - the actual
		# write happens in on_sud_changed(), which runs off a worker thread
		# and must not touch Qt (e.g. open a file dialog) itself.
		start_dir = self.user_config.get('sud_dir', '')
		path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", start_dir, filter="JSON files (*.json)")
		if not path:
			return
		self.user_config.set('sud_dir', str(Path(path).parent))
		self.sud_save_path = path
		self.msg_sud.send({'Save': True})

	def on_action_sud_load(self):
		start_dir = self.user_config.get('sud_dir', '')
		path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", start_dir, filter="JSON files (*.json)")
		if not path:
			return
		self.user_config.set('sud_dir', str(Path(path).parent))
		with open(path) as f:
			data = json.load(f)
		self.msg_sud.send({'Load': data})

	def on_pot_changed(self, msg):
		pass

	def on_sensor_changed(self, msg):
		print("on_sensor_changed {}".format(msg))

	def on_tempctrl_changed(self, msg):
		print("on_tempctrl_changed {}".format(msg))
		for key in msg:
			if 'State' in key:
				self.label_state.setText(msg['State'])
				self.set_tc_cooling(msg['State'] == 'States.COOL')
			elif "Soll" in key:
				submsg = msg['Soll']
				if 'Temp' in submsg:
					self.plot_temp_soll = submsg['Temp']
					self.lcdNumber_temp_soll.display(f'{submsg["Temp"]:.1f}')
					sud_running = self.sud_state in SUD_RUNNING_STATES
					if self.slider_temp_soll_initial_update or sud_running:
						self.Slider_temp_soll.blockSignals(True)
						self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
						self.Slider_temp_soll.blockSignals(False)
						if not sud_running:
							self.slider_temp_soll_initial_update = False
				if 'Rate' in submsg:
					subsubmsg = submsg['Rate']
					for subkey in subsubmsg:
						if "Current" in subkey:
							self.plot_rate_soll = subsubmsg['Current']
							self.lcdNumber_heatrate_soll.display(f'{subsubmsg["Current"]:.1f}')
						if "Set" in subkey:
							sud_running = self.sud_state in SUD_RUNNING_STATES
							if self.heatrate_soll_initial_update or sud_running:
								self.doubleSpinBox_heatrate_soll.blockSignals(True)
								self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set'])
								self.doubleSpinBox_heatrate_soll.blockSignals(False)
								if not sud_running:
									self.heatrate_soll_initial_update = False
			if "Ist" in key:
				submsg = msg['Ist']
				if 'Temp' in submsg:
					self.plot_temp_ist = submsg['Temp']
					self.lcdNumber_temp_ist.display(f'{submsg["Temp"]:.1f}')
					self._update_step_plates()
				if 'Rate' in submsg:
					self.plot_rate_ist = submsg['Rate']
					self.lcdNumber_heatrate_ist.display(f'{submsg["Rate"]:.1f}')

	def on_heater_changed(self, msg):
		for key in msg:
			if "PowerSet" in key:
				self.plot_power_set = msg['PowerSet']
				self.lcdNumber_power_heater.display(msg['PowerSet'])
				if self.sud_state in SUD_RUNNING_STATES:
					self.Slider_pwr_soll.blockSignals(True)
					self.Slider_pwr_soll.setValue(int(round(msg['PowerSet'])))
					self.Slider_pwr_soll.blockSignals(False)
			elif "Power" in key:
				self.plot_power_eff = msg['Power']
				self.lcdNumber_power_pot.display(msg['Power'])
				if self.slider_pwr_initial_update:
					# blockSignals: this is a display-only sync of the
					# slider to the heater's current power, not a manual
					# override - letting valueChanged fire here would echo
					# it back as {'Power': ...}, which tasks/heater.py's
					# power_soll = max(power_soll, power_actor) latches as a
					# floor that never goes back down on its own. That's the
					# "stale power > 0 after reconnect" bug.
					self.Slider_pwr_soll.blockSignals(True)
					self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
					self.Slider_pwr_soll.blockSignals(False)
					self.slider_pwr_initial_update = False
			elif "Capabilities" in key:
				submsg = msg['Capabilities']
				if "Power" in submsg:
					submsg = submsg['Power']
					self.Slider_pwr_soll.setMinimum(int(submsg['Min']))
					self.Slider_pwr_soll.setMaximum(int(submsg['Max']))

	def on_stirrer_changed(self, msg):
		print("on_stirrer_changed {}".format(msg))
		for key in msg:
			if "Speed" in key:
				self.stirrer_speed_ist = msg['Speed']
				self._update_step_plates()
				sud_running = self.sud_state in SUD_RUNNING_STATES
				if self.slider_speed_initial_update or sud_running:
					self.Slider_speed_soll.blockSignals(True)
					self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
					self.Slider_speed_soll.blockSignals(False)
					if not sud_running:
						self.slider_speed_initial_update = False
			elif "Capabilities" in key:
				submsg = msg['Capabilities']
				if "Power" in submsg:
					submsg = submsg['Power']
					self.Slider_speed_soll.setMinimum(int(submsg['Min']))
					self.Slider_speed_soll.setMaximum(int(submsg['Max']))

	def on_sud_changed(self, msg):
		print("on_sud_changed {}".format(msg))
		# Receiving anything at all on this channel means a Sud is loaded
		# server-side - the schedule it's running may still be coming.
		self.sud_loaded = True
		for key in msg:
			if "Json" in key:
				if self.sud_save_path:
					with open(self.sud_save_path, 'w') as f:
						json.dump(msg['Json'], f, indent='\t')
					self.sud_save_path = None
				else:
					self.update_sud_forecast(msg['Json'])
			elif "Name" in key:
				self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
				self.setWindowTitle(self._make_window_title(msg.get('Name', ''), msg.get('Description', '')))
			elif "State" in key:
				prev_state = self.sud_state
				self.sud_state = msg['State']
				running = self.sud_state in SUD_RUNNING_STATES
				for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll,
						  self.Slider_pwr_soll, self.Slider_speed_soll):
					w.setEnabled(not running)

				if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None:
					self.sud_elapsed_seconds = 0.0
					self.sud_elapsed_last = 0.0
					# A genuine fresh start (excludes Pause->resume, which
					# never hits this branch - sud_elapsed_seconds is
					# already non-None throughout a pause) means whatever
					# energy the previous run banked belongs to that run,
					# not this one - mirrors tasks/sud.py's SudTask.recv()
					# resetting its own server-side bookkeeping on the same
					# event. The server will overwrite these with the
					# now-reset real figures within a tick or two regardless,
					# this just avoids a stale flash of the old run's totals
					# in between.
					self.sud_energy_by_step = {}
					self.sud_energy_current = 0.0
					# Start the dynamic forecast's measured-history trace
					# fresh for this run.
					self.forecast_history = []
				elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
					was_running = self.sud_elapsed_seconds is not None
					self.sud_elapsed_seconds = None
					# on_plot_timer() only redraws the dynamic forecast while
					# sud_elapsed_seconds is set.
					if was_running:
						# A run just finished or was stopped - leave the
						# dynamic view's last frame (the actual measured
						# history) up rather than recomputing a "static"
						# plan from here, which would walk the *whole*
						# original schedule again starting from wherever
						# the brew happens to have ended up (e.g. close to
						# the last step's target), producing a nonsensical
						# total.
						for slider in (self.Slider_pwr_soll, self.Slider_speed_soll):
							slider.blockSignals(True)
							slider.setValue(0)
							slider.blockSignals(False)
					elif self.sud_schedule:
						# Fresh load (or reconnecting to an already-idle
						# server) - no run has happened yet, so the upfront
						# plan is exactly right; on_sud_forecast_received()
						# fills it in once the simulated forecast arrives
						# (a fresh Save request goes out on every connect,
						# so this resolves shortly either way).
						self.forecast_plot.show_computing(self.sud_name)

				if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
					self.show_user_message(self.sud_user_message)
				elif prev_state == SUD_WAIT_USER_STATE and self.sud_state != SUD_WAIT_USER_STATE:
					self._close_confirm_dialog()
				self._update_step_plates()
			elif "UserMessage" in key:
				self.sud_user_message = msg['UserMessage']
			elif "Step" in key:
				step = msg['Step']
				prev_index = self.sud_step_index
				new_index = step.get('Index')
				if prev_index is not None and new_index is not None and new_index > prev_index:
					# The step(s) just left (usually exactly one, but a hold
					# whose duration is already 0 can advance straight
					# through without this client ever seeing it as active -
					# see tasks/sud.py's _reanchor_forecast() comment on the
					# same race) freeze their last live actual temperature
					# for the Progress tab - see StepPlate.set_actual_temp().
					for passed_index in range(prev_index, new_index):
						self.step_actual_frozen[passed_index] = self.plot_temp_ist
				self.sud_step_index = new_index
				self.sud_step_descr = step.get('Descr')
				self.sud_step_type = step.get('Type')
				self.update_status_step_label()
				self._update_step_plates()
			elif "HoldRemaining" in key:
				self.sud_hold_remaining = msg['HoldRemaining']
				self.update_status_step_label()
			elif "Elapsed" in key:
				# Unlike sud_elapsed_seconds below, kept regardless of
				# dynamic-view mode - see its own comment for why - so the
				# status bar/Progress tab still have the real final total
				# once a run finishes rather than "--:--".
				self.sud_elapsed_last = msg['Elapsed']
				self._update_step_plates()
				self.update_status_step_label()
				# Sud.elapsed resets to 0 on every fresh Load too (not just
				# on an actual run start - see components/sud.py's
				# _reset_run_state()) and broadcasts unconditionally - only
				# apply it once the State handling above has already put
				# this client into dynamic-view mode (sud_elapsed_seconds
				# not None), so a mere Load while idle can't masquerade as
				# a run starting.
				if self.sud_elapsed_seconds is not None:
					self.sud_elapsed_seconds = msg['Elapsed']
					# Seed the actual-trace history with this run's real
					# t=0 point - without it, the first sample only lands
					# at on_plot_timer()'s next 1-second tick, which under
					# a real sim_warp_factor can already be a noticeable
					# way into the run, leaving a small visible gap between
					# the forecast's exact t=0 anchor and where the actual
					# trace first appears. Gated on elapsed still being
					# small so reconnecting mid-brew (large elapsed) - where
					# this temperature reading has nothing to do with that
					# run's actual t=0 - doesn't get a bogus point plotted
					# at x=0.
					if not self.forecast_history and self.plot_temp_ist and msg['Elapsed'] < FRESH_RUN_ELAPSED_THRESHOLD_S:
						self.forecast_history.append((msg['Elapsed'] / 60.0, self.plot_temp_ist))
			elif "Forecast" in key:
				self.on_sud_forecast_received(msg['Forecast'])
			elif "Energy" in key:
				self.sud_energy_by_step = dict(msg['Energy']['StepEnergy'])
				self.sud_energy_current = msg['Energy']['Current']
				self._update_step_plates()
				self.update_status_step_label()
			elif "Error" in key:
				# The server clears this back to None right after sending
				# it (see tasks/sud.py's recv()) so it doesn't linger in
				# global_state and get replayed as stale to a client
				# connecting later - the clearing message itself must be
				# a no-op here, not an empty dialog.
				if msg['Error']:
					QtWidgets.QMessageBox.warning(self, "Sud", msg['Error'])
		self.update_sud_actions()

	def on_sud_forecast_received(self, forecast):
		# Computed asynchronously server-side and can arrive slightly
		# after the schedule itself - applies regardless of whether a
		# run is in progress, since this can also be a later piecewise
		# segment appended after a real step transition (see tasks/
		# sud.py's SudTask._reanchor_forecast()), not just the initial
		# one computed at Load.
		if self.sud_empty:
			return
		t_min = [seconds / 60.0 for seconds in forecast['T']]
		self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished'])

		self.sud_forecast_step_starts = dict(forecast['StepStarts'])
		self.sud_forecast_finished = forecast['Finished']
		self.sud_forecast_final_t = forecast['T'][-1] if forecast['T'] else None
		self._update_step_plates()

	def update_status_step_label(self):
		if self.sud_step_descr:
			# +1: sud_step_index is the 0-based index Sud.index uses, but
			# StepPlate (the Progress tab) labels steps 1-based - match it
			# here so the status bar doesn't announce a step one number
			# below what the Progress tab shows for the same step.
			text = "Step {}: {}".format(self.sud_step_index + 1, self.sud_step_descr)
			if self.sud_step_type == 'hold':
				remaining = max(self.sud_hold_remaining, 0.0)
				text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
			# sud_step_index is the same 0-based index Sud.index uses into
			# its own schedule list (see components/sud.py) - self.
			# sud_schedule is built from the very same doc, so it lines up
			# directly; grain_mass/water_mass are already resolved against
			# default.step there (see components/sud.py's _build_step()).
			if 0 <= self.sud_step_index < len(self.sud_schedule):
				step = self.sud_schedule[self.sud_step_index]
				step_pot = step.get('pot', {})
				grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0)
			else:
				grain, water = self.sud_grain_mass, self.sud_water_mass
		elif self.sud_schedule:
			# Loaded but not started yet (no real Step message has
			# arrived) - preview the doc's own initial grain_mass/
			# water_mass right away instead of leaving the status line
			# blank until Start, mirroring tasks/sud.py's SudTask.
			# apply_plant_params() applying it to the real plant/
			# controller immediately on Load too.
			text = ""
			grain, water = self.sud_grain_mass, self.sud_water_mass
		else:
			self.statusBar().clearMessage()
			return

		pot = self.sud_pot_mass
		mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format(
			pot, water, grain, pot + water + grain)
		text = text + "   " + mass_text if text else mass_text
		# Sums, not just the active step's own figures: total process time
		# is just self.sud_elapsed_last (Sud's own tick-counted total -
		# every step's time, including any WAIT_USER dwell, already adds
		# up into it sequentially with no gaps) - sud_elapsed_seconds
		# itself would go back to "--:--" the moment a run finishes, see
		# its own comment, which is exactly when this total matters most.
		# Total energy has no such single running counter server-side
		# (see tasks/sud.py's SudTask - energy is banked per step, not
		# accumulated as one grand total), so it's summed here from every
		# finished step's own Wh total plus the currently active one.
		elapsed_text = "Elapsed {}".format(
			_format_duration(self.sud_elapsed_last) if self.sud_elapsed_last is not None else "--:--")
		total_energy_kwh = (sum(self.sud_energy_by_step.values()) + self.sud_energy_current) / 1000.0
		energy_text = "Energy {:.2f} kWh".format(total_energy_kwh)
		text = "{}   {}   {}".format(text, elapsed_text, energy_text)
		self.statusBar().showMessage(text)

	def update_sud_forecast(self, doc):
		try:
			name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc)
		except (KeyError, TypeError):
			return
		# A genuinely different schedule (an actual Load, or "New Sud")
		# means no step has actually started yet, regardless of whatever
		# the previously loaded Sud last reported - don't wait for the
		# server's own 'Step' (None) reset (asyncio.create_task() there
		# means its arrival order relative to this doc isn't guaranteed)
		# to clear a stale step from the *previous* schedule before
		# showing this one's preview below.
		#
		# But this same 'Json' also arrives for the *same*, already-
		# running schedule - e.g. connect()'s unconditional Save request,
		# whose server-side reply is two standalone pushes (this Json,
		# then a Forecast - see tasks/sud.py's recv()), unlike the bundled
		# replay a subscribe triggers, which carries Step/State alongside
		# it to immediately fix up any such reset. Without this doc
		# equality check, that standalone Json would wipe sud_step_index
		# back to None with nothing in the same message to restore it -
		# exactly what made a freshly-connected client briefly highlight
		# (and then, once the next real Step/Elapsed push finally
		# arrived, fail to correct) the wrong step.
		is_new_schedule = (doc != self.sud_last_loaded_doc)
		self.sud_last_loaded_doc = doc
		self.sud_name = name
		self.sud_schedule = schedule
		self.sud_pot_mass = pot_mass
		self.sud_grain_mass = grain_mass
		self.sud_water_mass = water_mass
		self.sud_empty = len(schedule) == 0
		if is_new_schedule:
			self.sud_step_descr = None
			self.sud_step_index = None
			self.sud_step_type = None
			self.sud_hold_remaining = 0.0
			# Belongs to whatever schedule was loaded before - on_sud_
			# forecast_received() re-fills these once a forecast for
			# *this* one arrives.
			self.sud_forecast_step_starts = {}
			self.sud_forecast_finished = False
			self.sud_forecast_final_t = None
			self.step_actual_frozen = {}
			self.sud_energy_by_step = {}
			self.sud_energy_current = 0.0
			self.sud_elapsed_last = None
		self._rebuild_step_plates()
		self.update_sud_actions()
		self.update_status_step_label()
		if self.sud_empty:
			self.forecast_plot.show_no_schedule()
			return
		# on_sud_forecast_received() fills this in once the simulated
		# forecast arrives. If a run is already in progress (e.g.
		# reconnecting mid-brew), the next on_plot_timer() tick switches
		# this over to the dynamic view instead.
		self.forecast_plot.show_computing(name)

	def _rebuild_step_plates(self):
		"""(Re)builds the Progress tab's stack of StepPlates from
		self.sud_schedule - called whenever a (possibly different) Sud is
		loaded (see update_sud_forecast()). Keeps the trailing stretch
		progress_container_layout was seeded with at construction (see
		Window.__init__) as its last item, so plates always stack at the
		top regardless of how few there are."""
		while self.progress_container_layout.count() > 1:
			item = self.progress_container_layout.takeAt(0)
			widget = item.widget()
			if widget:
				widget.deleteLater()
		self.step_plates = []
		resolved_temp = None
		for step in self.sud_schedule:
			if step.get('temperature') is not None:
				resolved_temp = step['temperature']
			plate = StepPlate(len(self.step_plates), step, resolved_temp)
			self.step_plates.append(plate)
			self.progress_container_layout.insertWidget(self.progress_container_layout.count() - 1, plate)
		self._update_step_plates()

	def _update_step_plates(self):
		"""Refreshes every StepPlate's LED/remaining-time/live fields from
		current state - called on every relevant push from the server
		(Step/State/Elapsed/Forecast on the Sud channel, Ist on TempCtrl,
		Speed on Stirrer). Cheap enough to just redo in full each time: a
		mash schedule is at most a handful of steps."""
		if not self.step_plates:
			return
		# sud_elapsed_last, not sud_elapsed_seconds - the latter resets to
		# None the moment a run finishes (see its own comment), which
		# would otherwise make every finished step's remaining time jump
		# back to its full duration right when the run ends instead of
		# staying at 0:00.
		current_elapsed = self.sud_elapsed_last if self.sud_elapsed_last is not None else 0.0
		starts = self.sud_forecast_step_starts
		n = len(self.step_plates)
		for i, plate in enumerate(self.step_plates):
			start_t = starts.get(i)
			end_t = starts.get(i + 1)
			if end_t is None and i == n - 1 and self.sud_forecast_finished:
				end_t = self.sud_forecast_final_t
			if start_t is not None and end_t is not None:
				total = max(0.0, end_t - start_t)
				plate.set_remaining(max(0.0, min(total, end_t - current_elapsed)))
			else:
				plate.set_remaining(None)

			if self.sud_step_index is None or i > self.sud_step_index:
				plate.set_led('off')
			elif i < self.sud_step_index:
				plate.set_led('done')
			else:
				# WAIT_USER/PAUSED don't change sud_step_type (see
				# components/sud.py's Sud._finish_step()/pause()) - it
				# stays whatever phase was actually last active, which is
				# exactly the color this step's LED should keep flashing.
				plate.set_led(self.sud_step_type if self.sud_step_type in ('ramp', 'hold') else 'hold')

			if self.sud_step_index is not None and i == self.sud_step_index:
				plate.set_actual_temp(self.plot_temp_ist)
				plate.set_live_stirrer(self.stirrer_speed_ist)
			elif i in self.step_actual_frozen:
				plate.set_actual_temp(self.step_actual_frozen[i])
				plate.set_live_stirrer(None)
			else:
				plate.set_actual_temp(None)
				plate.set_live_stirrer(None)

			if self.sud_step_index is not None and i == self.sud_step_index:
				plate.set_energy(self.sud_energy_current)
			elif i in self.sud_energy_by_step:
				plate.set_energy(self.sud_energy_by_step[i])
			else:
				plate.set_energy(None)

	def closeEvent(self, event):
		print("Exitting gracefully!")
		self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value())
		self.ws_client.disconnect()


if __name__ == '__main__':
	app = QtWidgets.QApplication(sys.argv)
	win = Window()
	win.show()
	sys.exit(app.exec_())

	print ("End of program.")
