From e6a9ce7fe531542d76e2de7f5afd33c84edeb630 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 28 Oct 2024 18:05:59 +0800 Subject: [PATCH 01/12] flake: Add PyThermostat to PYTHONPATH in devshell For easier testing of PyThermostat code in the development shell, so that the following is possible right in the repo root: ``` $ python -m pythermostat.plot Channel 0 target temperature: 25.000 $ python Python 3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> from pythermostat.client import Client >>> c = Client() >>> c >>> ``` --- flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flake.nix b/flake.nix index eafafa2..8c81be8 100644 --- a/flake.nix +++ b/flake.nix @@ -96,6 +96,9 @@ numpy matplotlib ]); + shellHook = '' + export PYTHONPATH=`git rev-parse --show-toplevel`/pythermostat:$PYTHONPATH + ''; }; formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style; -- 2.47.0 From 8cba8d8717fe76d287b35f27503f7cd056611a80 Mon Sep 17 00:00:00 2001 From: atse Date: Fri, 23 Aug 2024 11:30:16 +0800 Subject: [PATCH 02/12] flake: Add PyThermostat to devShell Make Python scripts in PyThermostat available in the development shell (thermostat_plot, thermostat_autotune, and thermostat_test). --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 8c81be8..5cba347 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,7 @@ ++ (with python3Packages; [ numpy matplotlib + pythermostat ]); shellHook = '' export PYTHONPATH=`git rev-parse --show-toplevel`/pythermostat:$PYTHONPATH -- 2.47.0 From 62d36662753501144afd637f0820a1a2d60eed4e Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 11:57:02 +0800 Subject: [PATCH 03/12] PyThermostat: Create asyncio clients --- pythermostat/examples/aioexample.py | 36 ++++ pythermostat/{ => examples}/example.py | 0 pythermostat/pythermostat/aioclient.py | 240 +++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 pythermostat/examples/aioexample.py rename pythermostat/{ => examples}/example.py (100%) create mode 100644 pythermostat/pythermostat/aioclient.py diff --git a/pythermostat/examples/aioexample.py b/pythermostat/examples/aioexample.py new file mode 100644 index 0000000..8f7b6a6 --- /dev/null +++ b/pythermostat/examples/aioexample.py @@ -0,0 +1,36 @@ +import asyncio +from contextlib import suppress +from pythermostat.aioclient import AsyncioClient + + +async def poll_for_info(tec): + while True: + print(tec.get_output()) + print(tec.get_b_parameter()) + print(tec.get_pid()) + print(tec.get_postfilter()) + print(tec.get_fan()) + + await asyncio.sleep(1) + + +async def main(): + tec = AsyncioClient() + await tec.connect() # (host="192.168.1.26", port=23) + await tec.set_param("b-p", 1, "t0", 20) + print(await tec.get_output()) + print(await tec.get_pid()) + print(await tec.get_output()) + print(await tec.get_postfilter()) + print(await tec.get_b_parameter()) + + polling_task = asyncio.create_task(poll_for_info(tec)) + + async for data in tec.report_mode(): + print(data) + + polling_task.cancel() + with suppress(asyncio.CancelledError): + await polling_task + +asyncio.run(main()) diff --git a/pythermostat/example.py b/pythermostat/examples/example.py similarity index 100% rename from pythermostat/example.py rename to pythermostat/examples/example.py diff --git a/pythermostat/pythermostat/aioclient.py b/pythermostat/pythermostat/aioclient.py new file mode 100644 index 0000000..31c3a79 --- /dev/null +++ b/pythermostat/pythermostat/aioclient.py @@ -0,0 +1,240 @@ +import asyncio +import json +import logging + + +class CommandError(Exception): + pass + + +class AsyncioClient: + def __init__(self): + self._reader = None + self._writer = None + self._command_lock = asyncio.Lock() + + async def connect(self, host="192.168.1.26", port=23): + """Connect to Thermostat at specified host and port. + + Example:: + client = AsyncioClient() + await client.connect() + """ + self._reader, self._writer = await asyncio.open_connection(host, port) + await self._check_zero_limits() + + def connected(self): + """Returns True if client is connected""" + return self._writer is not None + + async def disconnect(self): + """Disconnect from the Thermostat""" + + if self._writer is None: + return + + # Reader needn't be closed + self._writer.close() + await self._writer.wait_closed() + self._reader = None + self._writer = None + + async def _check_zero_limits(self): + output_report = await self.get_output() + for output_channel in output_report: + for limit in ["max_i_neg", "max_i_pos", "max_v"]: + if output_channel[limit] == 0.0: + logging.warning( + "`{}` limit is set to zero on channel {}".format( + limit, output_channel["channel"] + ) + ) + + async def _read_line(self): + # read 1 line + chunk = await self._reader.readline() + return chunk.decode("utf-8", errors="ignore") + + async def _read_write(self, command): + self._writer.write(((" ".join(command)).strip() + "\n").encode("utf-8")) + await self._writer.drain() + + return await self._read_line() + + async def _command(self, *command): + async with self._command_lock: + line = await self._read_write(command) + + response = json.loads(line) + if "error" in response: + raise CommandError(response["error"]) + return response + + async def _get_conf(self, topic): + result = [None, None] + for item in await self._command(topic): + result[int(item["channel"])] = item + return result + + async def get_output(self): + """Retrieve output limits for the TEC + + Example:: + [{'channel': 0, + 'center': 'vref', + 'i_set': -0.02002179650216762, + 'max_i_neg': 2.0, + 'max_v': : 3.988, + 'max_i_pos': 2.0, + 'polarity': 'normal'}, + {'channel': 1, + 'center': 'vref', + 'i_set': -0.02002179650216762, + 'max_i_neg': 2.0, + 'max_v': : 3.988, + 'max_i_pos': 2.0, + 'polarity': 'normal'}, + ] + """ + return await self._get_conf("output") + + async def get_pid(self): + """Retrieve PID control state + + Example:: + [{'channel': 0, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 37.0}, + {'channel': 1, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 36.5}] + """ + return await self._get_conf("pid") + + async def get_b_parameter(self): + """Retrieve B-Parameter equation parameters for resistance to temperature conversion + + Example:: + [{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0}, + {'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}] + """ + return await self._get_conf("b-p") + + async def get_postfilter(self): + """Retrieve DAC postfilter configuration + + Example:: + [{'rate': None, 'channel': 0}, + {'rate': 21.25, 'channel': 1}] + """ + return await self._get_conf("postfilter") + + async def get_report(self): + """Obtain one-time report on measurement values + + Example of yielded data: + {'channel': 0, + 'time': 2302524, + 'interval': 0.12 + 'adc': 0.6199188965423515, + 'sens': 6138.519310282602, + 'temperature': 36.87032392655527, + 'pid_engaged': True, + 'i_set': 2.0635816680889123, + 'dac_value': 2.527790834044456, + 'dac_feedback': 2.523, + 'i_tec': 2.331, + 'tec_i': 2.0925, + 'tec_u_meas': 2.5340000000000003, + 'pid_output': 2.067581958092247} + """ + return await self._command("report") + + async def get_ipv4(self): + """Get the IPv4 settings of the Thermostat""" + return await self._command("ipv4") + + async def get_fan(self): + """Get Thermostat current fan settings""" + return await self._command("fan") + + async def get_hwrev(self): + """Get Thermostat hardware revision""" + return await self._command("hwrev") + + async def set_param(self, topic, channel, field="", value=""): + """Set configuration parameters + + Examples:: + await tec.set_param("output", 0, "max_v", 2.0) + await tec.set_param("pid", 1, "output_max", 2.5) + await tec.set_param("s-h", 0, "t0", 20.0) + await tec.set_param("center", 0, "vref") + await tec.set_param("postfilter", 1, 21) + + See the firmware's README.md for a full list. + """ + if type(value) is float: + value = "{:f}".format(value) + if type(value) is not str: + value = str(value) + await self._command(topic, str(channel), field, value) + + async def power_up(self, channel, target): + """Start closed-loop mode""" + await self.set_param("pid", channel, "target", value=target) + await self.set_param("output", channel, "pid") + + async def save_config(self, channel=""): + """Save current configuration to EEPROM""" + await self._command("save", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} + + async def load_config(self, channel=""): + """Load current configuration from EEPROM""" + await self._command("load", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} + + async def reset(self): + """Reset the Thermostat + + The client is disconnected as the TCP session is terminated. + """ + async with self._command_lock: + self._writer.write("reset\n".encode("utf-8")) + await self._writer.drain() + + await self.disconnect() + + async def enter_dfu_mode(self): + """Put the Thermostat in DFU mode + + The client is disconnected as the Thermostat stops responding to + TCP commands in DFU mode. To exit it, submit a DFU leave request + or power-cycle the Thermostat. + """ + async with self._command_lock: + self._writer.write("dfu\n".encode("utf-8")) + await self._writer.drain() + + await self.disconnect() + + async def set_fan(self, power="auto"): + """Set fan power""" + await self._command("fan", str(power)) + + async def set_fcurve(self, a=1.0, b=0.0, c=0.0): + """Set fan curve""" + await self._command("fcurve", str(a), str(b), str(c)) -- 2.47.0 From a2b8a85244bba0d659ea87c975bfdf384eb9bb16 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 12:45:04 +0800 Subject: [PATCH 04/12] PyThermostat: Create GUI to Thermostat - Add connection menu - Add basic GUI layout skeleton Co-authored-by: linuswck Co-authored-by: Egor Savkin --- flake.nix | 23 + .../pythermostat/gui/model/property.py | 126 ++++ .../pythermostat/gui/model/thermostat.py | 135 +++++ .../pythermostat/gui/resources/artiq.ico | Bin 0 -> 134526 bytes .../pythermostat/gui/view/MainWindow.ui | 572 ++++++++++++++++++ .../gui/view/connection_details_menu.py | 73 +++ .../pythermostat/gui/view/info_box.py | 14 + .../gui/view/waitingspinnerwidget.py | 212 +++++++ pythermostat/pythermostat/thermostat_qt.py | 169 ++++++ 9 files changed, 1324 insertions(+) create mode 100644 pythermostat/pythermostat/gui/model/property.py create mode 100644 pythermostat/pythermostat/gui/model/thermostat.py create mode 100644 pythermostat/pythermostat/gui/resources/artiq.ico create mode 100644 pythermostat/pythermostat/gui/view/MainWindow.ui create mode 100644 pythermostat/pythermostat/gui/view/connection_details_menu.py create mode 100644 pythermostat/pythermostat/gui/view/info_box.py create mode 100644 pythermostat/pythermostat/gui/view/waitingspinnerwidget.py create mode 100755 pythermostat/pythermostat/thermostat_qt.py diff --git a/flake.nix b/flake.nix index 5cba347..a44aa0d 100644 --- a/flake.nix +++ b/flake.nix @@ -68,8 +68,27 @@ with pkgs.python3Packages; [ numpy matplotlib + pyqtgraph + pyqt6 + qasync + pglive ]; }; + + pglive = pkgs.python3Packages.buildPythonPackage rec { + pname = "pglive"; + version = "0.7.2"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; + propagatedBuildInputs = with pkgs.python3Packages; [ + pyqtgraph + numpy + ]; + }; in { packages.x86_64-linux = { @@ -95,6 +114,10 @@ ++ (with python3Packages; [ numpy matplotlib + pyqtgraph + pyqt6 + qasync + pglive pythermostat ]); shellHook = '' diff --git a/pythermostat/pythermostat/gui/model/property.py b/pythermostat/pythermostat/gui/model/property.py new file mode 100644 index 0000000..badea1c --- /dev/null +++ b/pythermostat/pythermostat/gui/model/property.py @@ -0,0 +1,126 @@ +# A Custom Class that allows defining a QObject Property Dynamically +# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically + +from functools import wraps + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal + + +class PropertyMeta(type(QObject)): + """Lets a class succinctly define Qt properties.""" + + def __new__(cls, name, bases, attrs): + for key in list(attrs.keys()): + attr = attrs[key] + if not isinstance(attr, Property): + continue + + types = {list: "QVariantList", dict: "QVariantMap"} + type_ = types.get(attr.type_, attr.type_) + + notifier = pyqtSignal(type_) + attrs[f"{key}_update"] = notifier + attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier) + + return super().__new__(cls, name, bases, attrs) + + +class Property: + """Property definition. + + Instances of this class will be replaced with their full + implementation by the PropertyMeta metaclass. + """ + + def __init__(self, type_): + self.type_ = type_ + + +class PropertyImpl(pyqtProperty): + """Property implementation: gets, sets, and notifies of change.""" + + def __init__(self, type_, name, notify): + super().__init__(type_, self.getter, self.setter, notify=notify) + self.name = name + + def getter(self, instance): + return getattr(instance, f"_{self.name}") + + def setter(self, instance, value): + signal = getattr(instance, f"{self.name}_update") + + if type(value) in {list, dict}: + value = make_notified(value, signal) + + setattr(instance, f"_{self.name}", value) + signal.emit(value) + + +class MakeNotified: + """Adds notifying signals to lists and dictionaries. + + Creates the modified classes just once, on initialization. + """ + + change_methods = { + list: [ + "__delitem__", + "__iadd__", + "__imul__", + "__setitem__", + "append", + "extend", + "insert", + "pop", + "remove", + "reverse", + "sort", + ], + dict: [ + "__delitem__", + "__ior__", + "__setitem__", + "clear", + "pop", + "popitem", + "setdefault", + "update", + ], + } + + def __init__(self): + if not hasattr(dict, "__ior__"): + # Dictionaries don't have | operator in Python < 3.9. + self.change_methods[dict].remove("__ior__") + self.notified_class = { + type_: self.make_notified_class(type_) for type_ in [list, dict] + } + + def __call__(self, seq, signal): + """Returns a notifying version of the supplied list or dict.""" + notified_class = self.notified_class[type(seq)] + notified_seq = notified_class(seq) + notified_seq.signal = signal + return notified_seq + + @classmethod + def make_notified_class(cls, parent): + notified_class = type(f"notified_{parent.__name__}", (parent,), {}) + for method_name in cls.change_methods[parent]: + original = getattr(notified_class, method_name) + notified_method = cls.make_notified_method(original, parent) + setattr(notified_class, method_name, notified_method) + return notified_class + + @staticmethod + def make_notified_method(method, parent): + @wraps(method) + def notified_method(self, *args, **kwargs): + result = getattr(parent, method.__name__)(self, *args, **kwargs) + self.signal.emit(self) + return result + + return notified_method + + +make_notified = MakeNotified() diff --git a/pythermostat/pythermostat/gui/model/thermostat.py b/pythermostat/pythermostat/gui/model/thermostat.py new file mode 100644 index 0000000..c307546 --- /dev/null +++ b/pythermostat/pythermostat/gui/model/thermostat.py @@ -0,0 +1,135 @@ +import asyncio +import logging +from enum import Enum +from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot +from qasync import asyncSlot +from pythermostat.aioclient import AsyncioClient +from pythermostat.gui.model.property import Property, PropertyMeta + + +class ThermostatConnectionState(Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + + +class Thermostat(QObject, metaclass=PropertyMeta): + connection_state = Property(ThermostatConnectionState) + hw_rev = Property(dict) + fan = Property(dict) + thermistor = Property(list) + pid = Property(list) + output = Property(list) + postfilter = Property(list) + report = Property(list) + + connection_error = pyqtSignal() + + NUM_CHANNELS = 2 + + def __init__(self, parent, update_s, disconnect_cb=None): + super().__init__(parent) + + self._update_s = update_s + self._client = AsyncioClient() + self._watch_task = None + self._update_params_task = None + self.disconnect_cb = disconnect_cb + self.connection_state = ThermostatConnectionState.DISCONNECTED + + async def start_session(self, host, port): + await self._client.connect(host, port) + self.hw_rev = await self._client.get_hwrev() + + @asyncSlot() + async def end_session(self): + self.stop_watching() + + if self.disconnect_cb is not None: + if asyncio.iscoroutinefunction(self.disconnect_cb): + await self.disconnect_cb() + else: + self.disconnect_cb() + + await self._client.disconnect() + + def start_watching(self): + self._watch_task = asyncio.create_task(self.run()) + + def stop_watching(self): + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None + self._update_params_task.cancel() + self._update_params_task = None + + async def run(self): + self._update_params_task = asyncio.create_task(self.update_params()) + while True: + if self._update_params_task.done(): + try: + self._update_params_task.result() + except OSError: + logging.error( + "Encountered an error while polling for information from Thermostat.", + exc_info=True, + ) + await self.end_session() + self.connection_state = ThermostatConnectionState.DISCONNECTED + self.connection_error.emit() + return + self._update_params_task = asyncio.create_task(self.update_params()) + await asyncio.sleep(self._update_s) + + async def update_params(self): + ( + self.fan, + self.output, + self.report, + self.pid, + self.thermistor, + self.postfilter, + ) = await asyncio.gather( + self._client.get_fan(), + self._client.get_output(), + self._client.get_report(), + self._client.get_pid(), + self._client.get_b_parameter(), + self._client.get_postfilter(), + ) + + def connected(self): + return self._client.connected() + + @pyqtSlot(float) + def set_update_s(self, update_s): + self._update_s = update_s + + async def set_ipv4(self, ipv4): + await self._client.set_param("ipv4", ipv4) + + async def get_ipv4(self): + return await self._client.get_ipv4() + + @asyncSlot() + async def save_cfg(self, ch=""): + await self._client.save_config(ch) + + @asyncSlot() + async def load_cfg(self, ch=""): + await self._client.load_config(ch) + + async def dfu(self): + await self._client.enter_dfu_mode() + + async def reset(self): + await self._client.reset() + + async def set_fan(self, power="auto"): + await self._client.set_fan(power) + + async def get_fan(self): + return await self._client.get_fan() + + async def set_param(self, topic, channel, field="", value=""): + await self._client.set_param(topic, channel, field, value) diff --git a/pythermostat/pythermostat/gui/resources/artiq.ico b/pythermostat/pythermostat/gui/resources/artiq.ico new file mode 100644 index 0000000000000000000000000000000000000000..edb222d44f14e25f253f7b3ae8ee1d0c169edc38 GIT binary patch literal 134526 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 literal 0 HcmV?d00001 diff --git a/pythermostat/pythermostat/gui/view/MainWindow.ui b/pythermostat/pythermostat/gui/view/MainWindow.ui new file mode 100644 index 0000000..3d61e3d --- /dev/null +++ b/pythermostat/pythermostat/gui/view/MainWindow.ui @@ -0,0 +1,572 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + + 1280 + 720 + + + + + 3840 + 2160 + + + + Thermostat Control Panel + + + + ../resources/artiq.ico../resources/artiq.ico + + + + + 1 + 1 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 0 + + + + + false + + + + 1 + 1 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 3 + + + 3 + + + 3 + + + 3 + + + 2 + + + + + + + + + + + + + + + + + + 0 + 0 + + + + 0 + + + + + 0 + 0 + + + + Channel 0 + + + + + + + 0 + 0 + + + + + + + + + + 0 + 0 + + + + Channel 1 + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + + 16777215 + 40 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + + 100 + 0 + + + + Connect + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + Qt::ToolButtonStyle::ToolButtonFollowStyle + + + + + + + + 0 + 0 + + + + + 240 + 0 + + + + + 120 + 16777215 + + + + + 120 + 50 + + + + Disconnected + + + + + + + false + + + + + + QToolButton::ToolButtonPopupMode::InstantPopup + + + + + + + Plot Settings + + + 📉 + + + QToolButton::ToolButtonPopupMode::InstantPopup + + + + + + + 1000000000 + + + + + + + Ready. + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + false + + + + 0 + 0 + + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 0 + + + + + Poll every: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + 70 + 0 + + + + s + + + 1 + + + 0.100000000000000 + + + 0.100000000000000 + + + QAbstractSpinBox::StepType::AdaptiveDecimalStepType + + + 1.000000000000000 + + + + + + + + 0 + 0 + + + + + 80 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Apply + + + + + + + + + + + + + + + + + + + + Reset + + + Reset the Thermostat + + + QAction::MenuRole::NoRole + + + + + Enter DFU Mode + + + Reset thermostat and enter USB device firmware update (DFU) mode + + + QAction::MenuRole::NoRole + + + + + Network Settings + + + Configure IPv4 address, netmask length, and optional default gateway + + + QAction::MenuRole::NoRole + + + + + About Thermostat + + + Show Thermostat hardware revision, and settings related to i + + + QAction::MenuRole::NoRole + + + + + Load all channel configs from flash + + + Restore configuration for all channels from flash + + + QAction::MenuRole::NoRole + + + + + Save all channel configs to flash + + + Save configuration for all channels to flash + + + QAction::MenuRole::NoRole + + + + + + ParameterTree + QWidget +
pyqtgraph.parametertree
+ 1 +
+ + LivePlotWidget + QWidget +
pglive.sources.live_plot_widget
+ 1 +
+ + QtWaitingSpinner + QWidget +
pythermostat.gui.view.waitingspinnerwidget
+ 1 +
+
+ + +
diff --git a/pythermostat/pythermostat/gui/view/connection_details_menu.py b/pythermostat/pythermostat/gui/view/connection_details_menu.py new file mode 100644 index 0000000..7bef471 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/connection_details_menu.py @@ -0,0 +1,73 @@ +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import pyqtSlot +from pythermostat.gui.model.thermostat import ThermostatConnectionState + + +class ConnectionDetailsMenu(QtWidgets.QMenu): + def __init__(self, thermostat, connect_btn): + super().__init__() + self._thermostat = thermostat + self._connect_btn = connect_btn + self._thermostat.connection_state_update.connect( + self.thermostat_state_change_handler + ) + + self.setTitle("Connection Settings") + + self.host_set_line = QtWidgets.QLineEdit() + self.host_set_line.setMinimumSize(QtCore.QSize(160, 0)) + self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) + self.host_set_line.setMaxLength(15) + self.host_set_line.setClearButtonEnabled(True) + + def connect_on_enter_press(): + self._connect_btn.click() + self.hide() + + self.host_set_line.returnPressed.connect(connect_on_enter_press) + + self.host_set_line.setText("192.168.1.26") + self.host_set_line.setPlaceholderText("IP for the Thermostat") + + host = QtWidgets.QWidgetAction(self) + host.setDefaultWidget(self.host_set_line) + self.addAction(host) + self.host = host + + self.port_set_spin = QtWidgets.QSpinBox() + self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0)) + self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215)) + self.port_set_spin.setMaximum(65535) + self.port_set_spin.setValue(23) + + def connect_only_if_enter_pressed(): + if ( + not self.port_set_spin.hasFocus() + ): # Don't connect if the spinbox only lost focus + return + connect_on_enter_press() + + self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed) + + port = QtWidgets.QWidgetAction(self) + port.setDefaultWidget(self.port_set_spin) + self.addAction(port) + self.port = port + + self.exit_button = QtWidgets.QPushButton() + self.exit_button.setText("Exit GUI") + self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit) + + exit_action = QtWidgets.QWidgetAction(self.exit_button) + exit_action.setDefaultWidget(self.exit_button) + self.addAction(exit_action) + self.exit_action = exit_action + + @pyqtSlot(ThermostatConnectionState) + def thermostat_state_change_handler(self, state): + self.host_set_line.setEnabled( + state == ThermostatConnectionState.DISCONNECTED + ) + self.port_set_spin.setEnabled( + state == ThermostatConnectionState.DISCONNECTED + ) diff --git a/pythermostat/pythermostat/gui/view/info_box.py b/pythermostat/pythermostat/gui/view/info_box.py new file mode 100644 index 0000000..3d6b7bf --- /dev/null +++ b/pythermostat/pythermostat/gui/view/info_box.py @@ -0,0 +1,14 @@ +from PyQt6 import QtWidgets +from PyQt6.QtCore import pyqtSlot + + +class InfoBox(QtWidgets.QMessageBox): + def __init__(self): + super().__init__() + self.setIcon(QtWidgets.QMessageBox.Icon.Information) + + @pyqtSlot(str, str) + def display_info_box(self, title, text): + self.setWindowTitle(title) + self.setText(text) + self.show() diff --git a/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py b/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py new file mode 100644 index 0000000..e37161a --- /dev/null +++ b/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py @@ -0,0 +1,212 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * + + +class QtWaitingSpinner(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 5 + self._lineWidth = 2 + self._innerRadius = 5 + self._currentCounter = 0 + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + # END initialize() + + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate( + self._innerRadius + self._lineLength, + self._innerRadius + self._lineLength, + ) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary( + i, self._currentCounter, self._numberOfLines + ) + color = self.currentLineColor( + distance, + self._numberOfLines, + self._trailFadePercentage, + self._minimumTrailOpacity, + self._color, + ) + painter.setBrush(color) + painter.drawRoundedRect( + QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), + self._roundness, + self._roundness, + Qt.SizeMode.RelativeSize, + ) + painter.restore() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + self.size = (self._innerRadius + self._lineLength) * 2 + self.setFixedSize(self.size, self.size) + + def updateTimer(self): + self._timer.setInterval( + int(1000 / (self._numberOfLines * self._revolutionsPerSecond)) + ) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor( + self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput + ): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color + + +if __name__ == "__main__": + app = QApplication([]) + waiting_spinner = QtWaitingSpinner() + waiting_spinner.show() + waiting_spinner.start() + app.exec() diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py new file mode 100755 index 0000000..8bb5058 --- /dev/null +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -0,0 +1,169 @@ +"""GUI for the Sinara 8451 Thermostat""" + +import asyncio +import logging +import argparse +import importlib.resources +from PyQt6 import QtWidgets, QtGui, uic +from PyQt6.QtCore import pyqtSlot +import qasync +from qasync import asyncSlot, asyncClose +from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState +from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu +from pythermostat.gui.view.info_box import InfoBox + + +def get_argparser(): + parser = argparse.ArgumentParser(description="Thermostat Control Panel") + + parser.add_argument( + "--connect", + default=None, + action="store_true", + help="Automatically connect to the specified Thermostat in host:port format", + ) + parser.add_argument("host", metavar="HOST", default=None, nargs="?") + parser.add_argument("port", metavar="PORT", default=None, nargs="?") + parser.add_argument( + "-l", + "--log", + dest="logLevel", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Set the logging level", + ) + + return parser + + +class MainWindow(QtWidgets.QMainWindow): + NUM_CHANNELS = 2 + + def __init__(self): + super().__init__() + + ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.ui") + uic.loadUi(ui_file_path, self) + + self._info_box = InfoBox() + + # Models + self._thermostat = Thermostat(self, self.report_refresh_spin.value()) + self._connecting_task = None + self._thermostat.connection_state_update.connect( + self._on_connection_state_changed + ) + + @pyqtSlot() + def handle_connection_error(): + self._info_box.display_info_box( + "Connection Error", "Thermostat connection lost. Is it unplugged?" + ) + + self._thermostat.connection_error.connect(handle_connection_error) + + # Bottom bar menus + self.connection_details_menu = ConnectionDetailsMenu( + self._thermostat, self.connect_btn + ) + self.connect_btn.setMenu(self.connection_details_menu) + + self.report_apply_btn.clicked.connect( + lambda: self._thermostat.set_update_s(self.report_refresh_spin.value()) + ) + + @asyncClose + async def closeEvent(self, _event): + try: + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED + except: + pass + + @pyqtSlot(ThermostatConnectionState) + def _on_connection_state_changed(self, state): + self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED) + self.thermostat_settings.setEnabled( + state == ThermostatConnectionState.CONNECTED + ) + self.report_group.setEnabled(state == ThermostatConnectionState.CONNECTED) + + match state: + case ThermostatConnectionState.CONNECTED: + self.connect_btn.setText("Disconnect") + self.status_lbl.setText( + "Connected to Thermostat v" + f"{self._thermostat.hw_rev['rev']['major']}." + f"{self._thermostat.hw_rev['rev']['minor']}" + ) + + case ThermostatConnectionState.CONNECTING: + self.connect_btn.setText("Stop") + self.status_lbl.setText("Connecting...") + + case ThermostatConnectionState.DISCONNECTED: + self.connect_btn.setText("Connect") + self.status_lbl.setText("Disconnected") + + @asyncSlot() + async def on_connect_btn_clicked(self): + match self._thermostat.connection_state: + case ThermostatConnectionState.DISCONNECTED: + self._connecting_task = asyncio.current_task() + self._thermostat.connection_state = ThermostatConnectionState.CONNECTING + await self._thermostat.start_session( + host=self.connection_details_menu.host_set_line.text(), + port=self.connection_details_menu.port_set_spin.value(), + ) + self._connecting_task = None + self._thermostat.connection_state = ThermostatConnectionState.CONNECTED + self._thermostat.start_watching() + + case ThermostatConnectionState.CONNECTING: + self._connecting_task.cancel() + self._connecting_task = None + await self._thermostat.end_session() + self._thermostat.connection_state = ( + ThermostatConnectionState.DISCONNECTED + ) + + case ThermostatConnectionState.CONNECTED: + await self._thermostat.end_session() + self._thermostat.connection_state = ( + ThermostatConnectionState.DISCONNECTED + ) + + +async def coro_main(): + args = get_argparser().parse_args() + if args.logLevel: + logging.basicConfig(level=getattr(logging, args.logLevel)) + + app_quit_event = asyncio.Event() + + app = QtWidgets.QApplication.instance() + app.aboutToQuit.connect(app_quit_event.set) + app.setWindowIcon( + QtGui.QIcon( + str(importlib.resources.files("pythermostat.gui.resources").joinpath("artiq.ico")) + ) + ) + + main_window = MainWindow() + main_window.show() + + if args.connect: + if args.host: + main_window.connection_details_menu.host_set_line.setText(args.host) + if args.port: + main_window.connection_details_menu.port_set_spin.setValue(int(args.port)) + main_window.connect_btn.click() + + await app_quit_event.wait() + + +def main(): + qasync.run(coro_main()) + + +if __name__ == "__main__": + main() -- 2.47.0 From dd22470a30781d2bd87e32e28d6d212bb943469b Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 14:38:04 +0800 Subject: [PATCH 05/12] PyThermostat GUI: Implement status line Co-authored-by: linuswck Co-authored-by: Egor Savkin --- .../gui/view/zero_limits_warning_view.py | 50 +++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 7 +++ 2 files changed, 57 insertions(+) create mode 100644 pythermostat/pythermostat/gui/view/zero_limits_warning_view.py diff --git a/pythermostat/pythermostat/gui/view/zero_limits_warning_view.py b/pythermostat/pythermostat/gui/view/zero_limits_warning_view.py new file mode 100644 index 0000000..554f911 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/zero_limits_warning_view.py @@ -0,0 +1,50 @@ +from PyQt6.QtCore import pyqtSlot, QObject +from PyQt6 import QtWidgets, QtGui + + +class ZeroLimitsWarningView(QObject): + def __init__(self, thermostat, style, limit_warning): + super().__init__() + self._thermostat = thermostat + self._thermostat.output_update.connect(self.set_limits_warning) + self._lbl = limit_warning + self._style = style + + @pyqtSlot(list) + def set_limits_warning(self, output_data: list): + channels_zeroed_limits = [set() for i in range(self._thermostat.NUM_CHANNELS)] + + for output_params in output_data: + channel = output_params["channel"] + for limit in "max_i_pos", "max_i_neg", "max_v": + if output_params[limit] == 0.0: + channels_zeroed_limits[channel].add(limit) + + channel_disabled = [False, False] + report_str = "The following output limit(s) are set to zero:\n" + for ch, zeroed_limits in enumerate(channels_zeroed_limits): + if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits): + report_str += "Max Cooling Current, Max Heating Current" + channel_disabled[ch] = True + + if "max_v" in zeroed_limits: + if channel_disabled[ch]: + report_str += ", " + report_str += "Max Voltage Difference" + channel_disabled[ch] = True + + if channel_disabled[ch]: + report_str += f" for Channel {ch}\n" + + report_str += ( + "\nThese limit(s) are restricting the channel(s) from producing current." + ) + + if True in channel_disabled: + pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") + icon = self._style.standardIcon(pixmapi) + self._lbl.setPixmap(icon.pixmap(16, 16)) + self._lbl.setToolTip(report_str) + else: + self._lbl.setPixmap(QtGui.QPixmap()) + self._lbl.setToolTip(None) diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 8bb5058..21e458d 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -11,6 +11,7 @@ from qasync import asyncSlot, asyncClose from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox +from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView def get_argparser(): @@ -67,6 +68,12 @@ class MainWindow(QtWidgets.QMainWindow): ) self.connect_btn.setMenu(self.connection_details_menu) + # Status line + self._zero_limits_warning_view = ZeroLimitsWarningView( + self._thermostat, self.style(), self.limits_warning + ) + self.loading_spinner.hide() + self.report_apply_btn.clicked.connect( lambda: self._thermostat.set_update_s(self.report_refresh_spin.value()) ) -- 2.47.0 From 1b6939ee306032ccdaac771e91800a5b2eb39c27 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:23:26 +0800 Subject: [PATCH 06/12] PyThermostat GUI: Implement ThermostatSettingsMenu Co-authored-by: linuswck Co-authored-by: Egor Savkin --- .../gui/view/net_settings_input_diag.py | 36 +++ .../gui/view/thermostat_settings_menu.py | 215 ++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 6 + 3 files changed, 257 insertions(+) create mode 100644 pythermostat/pythermostat/gui/view/net_settings_input_diag.py create mode 100644 pythermostat/pythermostat/gui/view/thermostat_settings_menu.py diff --git a/pythermostat/pythermostat/gui/view/net_settings_input_diag.py b/pythermostat/pythermostat/gui/view/net_settings_input_diag.py new file mode 100644 index 0000000..1ef4f61 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/net_settings_input_diag.py @@ -0,0 +1,36 @@ +from PyQt6 import QtWidgets +from PyQt6.QtWidgets import QAbstractButton +from PyQt6.QtCore import pyqtSignal, pyqtSlot + + +class NetSettingsInputDiag(QtWidgets.QInputDialog): + set_ipv4_act = pyqtSignal(str) + + def __init__(self, current_ipv4_settings): + super().__init__() + self.setWindowTitle("Network Settings") + self.setLabelText( + "Set the Thermostat's IPv4 address, netmask and gateway (optional)" + ) + self.setTextValue(current_ipv4_settings) + self._new_ipv4 = "" + + @pyqtSlot(str) + def set_ipv4(ipv4_settings): + self._new_ipv4 = ipv4_settings + + sure = QtWidgets.QMessageBox(self) + sure.setWindowTitle("Set network?") + sure.setText( + f"Setting this as network and disconnecting:
{ipv4_settings}" + ) + + sure.buttonClicked.connect(self._emit_sig) + sure.show() + + self.textValueSelected.connect(set_ipv4) + self.show() + + @pyqtSlot(QAbstractButton) + def _emit_sig(self, _): + self.set_ipv4_act.emit(self._new_ipv4) diff --git a/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py b/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py new file mode 100644 index 0000000..09555e0 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/thermostat_settings_menu.py @@ -0,0 +1,215 @@ +import logging +from PyQt6 import QtWidgets, QtGui, QtCore +from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker +from qasync import asyncSlot +from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag +from pythermostat.gui.model.thermostat import ThermostatConnectionState + + +class ThermostatSettingsMenu(QtWidgets.QMenu): + def __init__(self, thermostat, info_box, style): + super().__init__() + self._thermostat = thermostat + self._info_box = info_box + self._style = style + self.setTitle("Thermostat settings") + + self.hw_rev_data = dict() + self._thermostat.hw_rev_update.connect(self.hw_rev) + self._thermostat.connection_state_update.connect( + self.thermostat_state_change_handler + ) + + self.fan_group = QtWidgets.QWidget() + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group) + self.fan_layout.setSpacing(9) + self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) + self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215)) + self.fan_lbl.setBaseSize(QtCore.QSize(40, 0)) + self.fan_layout.addWidget(self.fan_lbl) + self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group) + self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215)) + self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setRange(1, 100) + self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.fan_layout.addWidget(self.fan_power_slider) + self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group) + self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0)) + self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215)) + self.fan_layout.addWidget(self.fan_auto_box) + self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group) + self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) + self.fan_layout.addWidget(self.fan_pwm_warning) + + self.fan_power_slider.valueChanged.connect(self.fan_set_request) + self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request) + self._thermostat.fan_update.connect(self.fan_update) + + self.fan_lbl.setToolTip("Adjust the fan") + self.fan_lbl.setText("Fan:") + self.fan_auto_box.setText("Auto") + + fan = QtWidgets.QWidgetAction(self) + fan.setDefaultWidget(self.fan_group) + self.addAction(fan) + self.fan = fan + + self.actionReset = QtGui.QAction("Reset Thermostat", self) + self.actionReset.triggered.connect(self.reset_request) + self.addAction(self.actionReset) + + self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self) + self.actionEnter_DFU_Mode.triggered.connect(self.dfu_request) + self.addAction(self.actionEnter_DFU_Mode) + + self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self) + self.actionnet_settings_input_diag.triggered.connect(self.net_settings_request) + self.addAction(self.actionnet_settings_input_diag) + + @asyncSlot(bool) + async def load(_): + await self._thermostat.load_cfg() + + self._info_box.display_info_box( + "Config loaded", "All channel configs have been loaded from flash." + ) + + self.actionLoad_all_configs = QtGui.QAction("Load Config", self) + self.actionLoad_all_configs.triggered.connect(load) + self.addAction(self.actionLoad_all_configs) + + @asyncSlot(bool) + async def save(_): + await self._thermostat.save_cfg() + + self._info_box.display_info_box( + "Config saved", "All channel configs have been saved to flash." + ) + + self.actionSave_all_configs = QtGui.QAction("Save Config", self) + self.actionSave_all_configs.triggered.connect(save) + self.addAction(self.actionSave_all_configs) + + def about_thermostat(): + QtWidgets.QMessageBox.about( + self, + "About Thermostat", + f""" +

Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}

+ +
+ +

Settings:

+ Default fan curve: + a = {self.hw_rev_data['settings']['fan_k_a']}, + b = {self.hw_rev_data['settings']['fan_k_b']}, + c = {self.hw_rev_data['settings']['fan_k_c']} +
+ Fan PWM range: + {self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']} +
+ Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz +
+ Fan available: {self.hw_rev_data['settings']['fan_available']} +
+ Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']} + """, + ) + + self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self) + self.actionAbout_Thermostat.triggered.connect(about_thermostat) + self.addAction(self.actionAbout_Thermostat) + + @pyqtSlot("QVariantMap") + def fan_update(self, fan_settings): + logging.debug(fan_settings) + if fan_settings is None: + return + with QSignalBlocker(self.fan_power_slider): + self.fan_power_slider.setValue( + fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength + ) + with QSignalBlocker(self.fan_auto_box): + self.fan_auto_box.setChecked(fan_settings["auto_mode"]) + + def set_fan_pwm_warning(self): + if self.fan_power_slider.value() != 100: + pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") + icon = self._style.standardIcon(pixmapi) + self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16)) + self.fan_pwm_warning.setToolTip( + "Throttling the fan (not recommended on this hardware rev)" + ) + else: + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") + + @pyqtSlot(ThermostatConnectionState) + def thermostat_state_change_handler(self, state): + if state == ThermostatConnectionState.DISCONNECTED: + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") + + @pyqtSlot("QVariantMap") + def hw_rev(self, hw_rev): + self.hw_rev_data = hw_rev + self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"]) + + @asyncSlot(int) + async def fan_set_request(self, value): + assert self._thermostat.connected() + + if self.fan_auto_box.isChecked(): + with QSignalBlocker(self.fan_auto_box): + self.fan_auto_box.setChecked(False) + await self._thermostat.set_fan(value) + if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: + self.set_fan_pwm_warning() + + @asyncSlot(int) + async def fan_auto_set_request(self, enabled): + assert self._thermostat.connected() + + if enabled: + await self._thermostat.set_fan("auto") + self.fan_update(await self._thermostat.get_fan()) + else: + await self.thermostat.set_fan( + self.fan_power_slider.value() + ) + + @asyncSlot(bool) + async def reset_request(self, _): + assert self._thermostat.connected() + + await self._thermostat.reset() + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED + + @asyncSlot(bool) + async def dfu_request(self, _): + assert self._thermostat.connected() + + await self._thermostat.dfu() + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED + + @asyncSlot(bool) + async def net_settings_request(self, _): + assert self._thermostat.connected() + + ipv4 = await self._thermostat.get_ipv4() + self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"]) + self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request) + + @asyncSlot(str) + async def set_net_settings_request(self, ipv4_settings): + assert self._thermostat.connected() + + await self._thermostat.set_ipv4(ipv4_settings) + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED \ No newline at end of file diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 21e458d..31be0a1 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -11,6 +11,7 @@ from qasync import asyncSlot, asyncClose from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox +from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView @@ -68,6 +69,11 @@ class MainWindow(QtWidgets.QMainWindow): ) self.connect_btn.setMenu(self.connection_details_menu) + self._thermostat_settings_menu = ThermostatSettingsMenu( + self._thermostat, self._info_box, self.style() + ) + self.thermostat_settings.setMenu(self._thermostat_settings_menu) + # Status line self._zero_limits_warning_view = ZeroLimitsWarningView( self._thermostat, self.style(), self.limits_warning -- 2.47.0 From 063dbc43a4adda0a0a615ae0758c3971ccd588d2 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 12:54:58 +0800 Subject: [PATCH 07/12] PyThermostat GUI: Incorporate autotuning Co-authored-by: topquark12 Co-authored-by: linuswck Co-authored-by: Egor Savkin --- pythermostat/pythermostat/autotune.py | 23 +++++ .../pythermostat/gui/model/pid_autotuner.py | 84 +++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 37 ++++++++ 3 files changed, 144 insertions(+) create mode 100644 pythermostat/pythermostat/gui/model/pid_autotuner.py diff --git a/pythermostat/pythermostat/autotune.py b/pythermostat/pythermostat/autotune.py index dada469..7272952 100644 --- a/pythermostat/pythermostat/autotune.py +++ b/pythermostat/pythermostat/autotune.py @@ -18,6 +18,7 @@ class PIDAutotuneState(Enum): STATE_RELAY_STEP_DOWN = 'relay step down' STATE_SUCCEEDED = 'succeeded' STATE_FAILED = 'failed' + STATE_READY = "ready" class PIDAutotune: @@ -57,6 +58,21 @@ class PIDAutotune: self._Ku = 0 self._Pu = 0 + def setParam(self, target, step, noiseband, sampletime, lookback): + self._setpoint = target + self._outputstep = step + self._out_max = step + self._out_min = -step + self._noiseband = noiseband + self._inputs = deque(maxlen=round(lookback / sampletime)) + + def setReady(self): + self._state = PIDAutotuneState.STATE_READY + self._peak_count = 0 + + def setOff(self): + self._state = PIDAutotuneState.STATE_OFF + def state(self): """Get the current state.""" return self._state @@ -82,6 +98,13 @@ class PIDAutotune: kd = divisors[2] * self._Ku * self._Pu return PIDAutotune.PIDParams(kp, ki, kd) + def get_tec_pid(self): + divisors = self._tuning_rules["tyreus-luyben"] + kp = self._Ku * divisors[0] + ki = divisors[1] * self._Ku / self._Pu + kd = divisors[2] * self._Ku * self._Pu + return kp, ki, kd + def run(self, input_val, time_input): """To autotune a system, this method must be called periodically. diff --git a/pythermostat/pythermostat/gui/model/pid_autotuner.py b/pythermostat/pythermostat/gui/model/pid_autotuner.py new file mode 100644 index 0000000..c916a21 --- /dev/null +++ b/pythermostat/pythermostat/gui/model/pid_autotuner.py @@ -0,0 +1,84 @@ +from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal +from qasync import asyncSlot +from autotune import PIDAutotuneState, PIDAutotune + + +class PIDAutoTuner(QObject): + autotune_state_changed = pyqtSignal(int, PIDAutotuneState) + + def __init__(self, parent, thermostat, num_of_channel): + super().__init__(parent) + + self._thermostat = thermostat + self._thermostat.report_update.connect(self.tick) + + self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)] + self.target_temp = [20.0 for _ in range(num_of_channel)] + self.test_current = [1.0 for _ in range(num_of_channel)] + self.temp_swing = [1.5 for _ in range(num_of_channel)] + self.lookback = [3.0 for _ in range(num_of_channel)] + self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)] + + def set_params(self, params_name, ch, val): + getattr(self, params_name)[ch] = val + + def get_state(self, ch): + return self.autotuners[ch].state() + + def load_params_and_set_ready(self, ch): + self.autotuners[ch].setParam( + self.target_temp[ch], + self.test_current[ch] / 1000, + self.temp_swing[ch], + 1 / self.sampling_interval[ch], + self.lookback[ch], + ) + self.autotuners[ch].setReady() + self.autotune_state_changed.emit(ch, self.autotuners[ch].state()) + + async def stop_pid_from_running(self, ch): + self.autotuners[ch].setOff() + self.autotune_state_changed.emit(ch, self.autotuners[ch].state()) + if self._thermostat.connected(): + await self._thermostat.set_param("output", ch, "i_set", 0) + + @asyncSlot(list) + async def tick(self, report): + for channel_report in report: + ch = channel_report["channel"] + + self.sampling_interval[ch] = channel_report["interval"] + + # TODO: Skip when PID Autotune or emit error message if NTC is not connected + if channel_report["temperature"] is None: + continue + + match self.autotuners[ch].state(): + case ( + PIDAutotuneState.STATE_READY + | PIDAutotuneState.STATE_RELAY_STEP_UP + | PIDAutotuneState.STATE_RELAY_STEP_DOWN + ): + self.autotuners[ch].run( + channel_report["temperature"], channel_report["time"] + ) + await self._thermostat.set_param( + "output", ch, "i_set", self.autotuners[ch].output() + ) + case PIDAutotuneState.STATE_SUCCEEDED: + kp, ki, kd = self.autotuners[ch].get_tec_pid() + self.autotuners[ch].setOff() + self.autotune_state_changed.emit(ch, self.autotuners[ch].state()) + + await self._thermostat.set_param("pid", ch, "kp", kp) + await self._thermostat.set_param("pid", ch, "ki", ki) + await self._thermostat.set_param("pid", ch, "kd", kd) + await self._thermostat.set_param("output", ch, "pid") + + await self._thermostat.set_param( + "pid", ch, "target", self.target_temp[ch] + ) + case PIDAutotuneState.STATE_FAILED: + self.autotuners[ch].setOff() + self.autotune_state_changed.emit(ch, self.autotuners[ch].state()) + await self._thermostat.set_param("output", ch, "i_set", 0) diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 31be0a1..ef707e6 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -8,7 +8,9 @@ from PyQt6 import QtWidgets, QtGui, uic from PyQt6.QtCore import pyqtSlot import qasync from qasync import asyncSlot, asyncClose +from pythermostat.autotune import PIDAutotuneState from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState +from pythermostat.gui.model.pid_autotuner import PIDAutoTuner from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu @@ -55,6 +57,19 @@ class MainWindow(QtWidgets.QMainWindow): self._on_connection_state_changed ) + self._autotuners = PIDAutoTuner(self, self._thermostat, 2) + self._autotuners.autotune_state_changed.connect( + self._on_pid_autotune_state_changed + ) + + # Handlers for disconnections + async def autotune_disconnect(): + for ch in range(self.NUM_CHANNELS): + if self._autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF: + await self._autotuners.stop_pid_from_running(ch) + + self._thermostat.disconnect_cb = autotune_disconnect + @pyqtSlot() def handle_connection_error(): self._info_box.display_info_box( @@ -117,6 +132,28 @@ class MainWindow(QtWidgets.QMainWindow): self.connect_btn.setText("Connect") self.status_lbl.setText("Disconnected") + @pyqtSlot(int, PIDAutotuneState) + def _on_pid_autotune_state_changed(self, _ch, _state): + autotuning_channels = [] + for ch in range(self.NUM_CHANNELS): + if self._autotuners.get_state(ch) in { + PIDAutotuneState.STATE_READY, + PIDAutotuneState.STATE_RELAY_STEP_UP, + PIDAutotuneState.STATE_RELAY_STEP_DOWN, + }: + autotuning_channels.append(ch) + + if len(autotuning_channels) == 0: + self.background_task_lbl.setText("Ready.") + self.loading_spinner.hide() + self.loading_spinner.stop() + else: + self.background_task_lbl.setText( + f"Autotuning channel {autotuning_channels}..." + ) + self.loading_spinner.start() + self.loading_spinner.show() + @asyncSlot() async def on_connect_btn_clicked(self): match self._thermostat.connection_state: -- 2.47.0 From 232fe19ae4c831fe0a60fac5488d31b075e0f509 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:04:07 +0800 Subject: [PATCH 08/12] PyThermostat GUI: Implement plotting Co-authored-by: linuswck --- .../pythermostat/gui/view/live_plot_view.py | 180 ++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 10 + 2 files changed, 190 insertions(+) create mode 100644 pythermostat/pythermostat/gui/view/live_plot_view.py diff --git a/pythermostat/pythermostat/gui/view/live_plot_view.py b/pythermostat/pythermostat/gui/view/live_plot_view.py new file mode 100644 index 0000000..cfcbb0e --- /dev/null +++ b/pythermostat/pythermostat/gui/view/live_plot_view.py @@ -0,0 +1,180 @@ +from PyQt6.QtCore import QObject, pyqtSlot +from pglive.sources.data_connector import DataConnector +from pglive.kwargs import Axis +from pglive.sources.live_plot import LiveLinePlot +from pglive.sources.live_axis import LiveAxis +from collections import deque +import pyqtgraph as pg +from pythermostat.gui.model.thermostat import ThermostatConnectionState + +pg.setConfigOptions(antialias=True) + + +class LiveDataPlotter(QObject): + def __init__(self, thermostat, live_plots): + super().__init__() + self._thermostat = thermostat + + self._thermostat.report_update.connect(self.update_report) + self._thermostat.pid_update.connect(self.update_pid) + self._thermostat.connection_state_update.connect( + self.thermostat_state_change_handler + ) + + self.NUM_CHANNELS = len(live_plots) + self.graphs = [] + + for i, live_plot in enumerate(live_plots): + live_plot[0].setTitle(f"Channel {i} Temperature") + live_plot[1].setTitle(f"Channel {i} Current") + self.graphs.append(_TecGraphs(live_plot[0], live_plot[1])) + + @pyqtSlot(ThermostatConnectionState) + def thermostat_state_change_handler(self, state): + if state == ThermostatConnectionState.DISCONNECTED: + self.clear_graphs() + + def _config_connector_max_pts(self, connector, samples): + connector.max_points = samples + connector.x = deque(maxlen=int(connector.max_points)) + connector.y = deque(maxlen=int(connector.max_points)) + + @pyqtSlot(int) + def set_max_samples(self, samples: int): + for graph in self.graphs: + self._config_connector_max_pts(graph.t_connector, samples) + self._config_connector_max_pts(graph.i_connector, samples) + self._config_connector_max_pts(graph.iset_connector, samples) + + @pyqtSlot() + def clear_graphs(self): + for graph in self.graphs: + graph.clear() + + @pyqtSlot(list) + def update_pid(self, pid_settings): + for settings in pid_settings: + channel = settings["channel"] + self.graphs[channel].update_pid(settings) + + @pyqtSlot(list) + def update_report(self, report_data): + for settings in report_data: + channel = settings["channel"] + self.graphs[channel].update_report(settings) + + +class _TecGraphs: + """The maximum number of sample points to store.""" + + DEFAULT_MAX_SAMPLES = 1000 + + def __init__(self, t_widget, i_widget): + self._t_widget = t_widget + self._i_widget = i_widget + + self._t_plot = LiveLinePlot() + self._i_plot = LiveLinePlot(name="Measured") + self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen("r")) + + self._t_line = self._t_widget.getPlotItem().addLine(label="{value} °C") + self._t_line.setVisible(False) + # Hack for keeping setpoint line in plot range + self._t_setpoint_plot = LiveLinePlot() + + for graph in t_widget, i_widget: + time_axis = LiveAxis( + "bottom", + text="Time since Thermostat reset", + **{Axis.TICK_FORMAT: Axis.DURATION}, + ) + time_axis.showLabel() + graph.setAxisItems({"bottom": time_axis}) + + graph.add_crosshair(pg.mkPen(color="red", width=1), {"color": "green"}) + + # Enable linking of axes in the graph widget's context menu + graph.register( + graph.getPlotItem().titleLabel.text # Slight hack getting the title + ) + + temperature_axis = LiveAxis("left", text="Temperature", units="°C") + temperature_axis.showLabel() + t_widget.setAxisItems({"left": temperature_axis}) + + current_axis = LiveAxis("left", text="Current", units="A") + current_axis.showLabel() + i_widget.setAxisItems({"left": current_axis}) + i_widget.addLegend(brush=(50, 50, 200, 150)) + + t_widget.addItem(self._t_plot) + t_widget.addItem(self._t_setpoint_plot) + i_widget.addItem(self._i_plot) + i_widget.addItem(self._iset_plot) + + self.t_connector = DataConnector( + self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES + ) + self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1) + self.i_connector = DataConnector( + self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES + ) + self.iset_connector = DataConnector( + self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES + ) + + self.max_samples = self.DEFAULT_MAX_SAMPLES + + def plot_append(self, report): + temperature = report["temperature"] + current = report["tec_i"] + iset = report["i_set"] + time = report["time"] + + if temperature is not None: + self.t_connector.cb_append_data_point(temperature, time) + if self._t_line.isVisible(): + self.t_setpoint_connector.cb_append_data_point( + self._t_line.value(), time + ) + else: + self.t_setpoint_connector.cb_append_data_point(temperature, time) + if current is not None: + self.i_connector.cb_append_data_point(current, time) + self.iset_connector.cb_append_data_point(iset, time) + + def set_max_sample(self, samples: int): + for connector in self.t_connector, self.i_connector, self.iset_connector: + connector.max_points(samples) + + def clear(self): + for connector in self.t_connector, self.i_connector, self.iset_connector: + connector.clear() + + def set_t_line(self, temp=None, visible=None): + if visible is not None: + self._t_line.setVisible(visible) + if temp is not None: + self._t_line.setValue(temp) + + # PyQtGraph normally does not update this text when the line + # is not visible, so make sure that the temperature label + # gets updated always, and doesn't stay at an old value. + self._t_line.label.setText(f"{temp} °C") + + def set_max_samples(self, samples: int): + for graph in self.graphs: + graph.t_connector.max_points = samples + graph.i_connector.max_points = samples + graph.iset_connector.max_points = samples + + def clear_graphs(self): + for graph in self.graphs: + graph.clear() + + def update_pid(self, pid_settings): + self.set_t_line(temp=round(pid_settings["target"], 6)) + + def update_report(self, report_data): + self.plot_append(report_data) + self.set_t_line(visible=report_data["pid_engaged"]) diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index ef707e6..65ff551 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -13,6 +13,7 @@ from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionSt from pythermostat.gui.model.pid_autotuner import PIDAutoTuner from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox +from pythermostat.gui.view.live_plot_view import LiveDataPlotter from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView @@ -78,6 +79,15 @@ class MainWindow(QtWidgets.QMainWindow): self._thermostat.connection_error.connect(handle_connection_error) + # Graphs + self._channel_graphs = LiveDataPlotter( + self._thermostat, + [ + [getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")] + for ch in range(self.NUM_CHANNELS) + ], + ) + # Bottom bar menus self.connection_details_menu = ConnectionDetailsMenu( self._thermostat, self.connect_btn -- 2.47.0 From d948d1d0a0586feaa6b2048c44e5c43115fffb4b Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:06:32 +0800 Subject: [PATCH 09/12] PyThermostat GUI: Implement PlotSettingsMenu Co-authored-by: linuswck --- .../gui/view/plot_options_menu.py | 25 +++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 4 +++ 2 files changed, 29 insertions(+) create mode 100644 pythermostat/pythermostat/gui/view/plot_options_menu.py diff --git a/pythermostat/pythermostat/gui/view/plot_options_menu.py b/pythermostat/pythermostat/gui/view/plot_options_menu.py new file mode 100644 index 0000000..8ec6f76 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/plot_options_menu.py @@ -0,0 +1,25 @@ +from PyQt6 import QtWidgets, QtGui + + +class PlotOptionsMenu(QtWidgets.QMenu): + def __init__(self, channel_graphs, max_samples=1000): + super().__init__() + self.channel_graphs = channel_graphs + + self.setTitle("Plot Settings") + + clear = QtGui.QAction("Clear graphs", self) + self.addAction(clear) + self.clear = clear + self.clear.triggered.connect(self.channel_graphs.clear_graphs) + + self.samples_spinbox = QtWidgets.QSpinBox() + self.samples_spinbox.setRange(2, 100000) + self.samples_spinbox.setSuffix(" samples") + self.samples_spinbox.setValue(max_samples) + self.samples_spinbox.valueChanged.connect(self.channel_graphs.set_max_samples) + + limit_samples = QtWidgets.QWidgetAction(self) + limit_samples.setDefaultWidget(self.samples_spinbox) + self.addAction(limit_samples) + self.limit_samples = limit_samples diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 65ff551..116555f 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -14,6 +14,7 @@ from pythermostat.gui.model.pid_autotuner import PIDAutoTuner from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox from pythermostat.gui.view.live_plot_view import LiveDataPlotter +from pythermostat.gui.view.plot_options_menu import PlotOptionsMenu from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView @@ -99,6 +100,9 @@ class MainWindow(QtWidgets.QMainWindow): ) self.thermostat_settings.setMenu(self._thermostat_settings_menu) + self._plot_options_menu = PlotOptionsMenu(self._channel_graphs) + self.plot_settings.setMenu(self._plot_options_menu) + # Status line self._zero_limits_warning_view = ZeroLimitsWarningView( self._thermostat, self.style(), self.limits_warning -- 2.47.0 From 0d58d70607a9f86cb5f85b14057dca819716de4f Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:09:01 +0800 Subject: [PATCH 10/12] PyThermostat GUI: Implement Control Panel Co-authored-by: linuswck Co-authored-by: Egor Savkin --- .../pythermostat/gui/view/ctrl_panel.py | 306 ++++++++++++++++ .../pythermostat/gui/view/param_tree.json | 336 ++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 25 +- 3 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 pythermostat/pythermostat/gui/view/ctrl_panel.py create mode 100644 pythermostat/pythermostat/gui/view/param_tree.json diff --git a/pythermostat/pythermostat/gui/view/ctrl_panel.py b/pythermostat/pythermostat/gui/view/ctrl_panel.py new file mode 100644 index 0000000..8ceed8c --- /dev/null +++ b/pythermostat/pythermostat/gui/view/ctrl_panel.py @@ -0,0 +1,306 @@ +from functools import partial +from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot +import pyqtgraph.parametertree.parameterTypes as pTypes +from pyqtgraph.parametertree import ( + Parameter, + registerParameterType, +) +from qasync import asyncSlot +from autotune import PIDAutotuneState + + +class MutexParameter(pTypes.ListParameter): + """ + Mutually exclusive parameter where only one of its children is visible at a time, list selectable. + + The ordering of the list items determines which children will be visible. + """ + + def __init__(self, **opts): + super().__init__(**opts) + + self.sigValueChanged.connect(self.show_chosen_child) + self.sigValueChanged.emit(self, self.opts["value"]) + + def _get_param_from_value(self, value): + if isinstance(self.opts["limits"], dict): + values_list = list(self.opts["limits"].values()) + else: + values_list = self.opts["limits"] + + return self.children()[values_list.index(value)] + + @pyqtSlot(object, object) + def show_chosen_child(self, value): + for param in self.children(): + param.hide() + + child_to_show = self._get_param_from_value(value.value()) + child_to_show.show() + + if child_to_show.opts.get("triggerOnShow", None): + child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value()) + + +registerParameterType("mutex", MutexParameter) + + +class CtrlPanel(QObject): + def __init__( + self, + thermostat, + autotuners, + info_box, + trees_ui, + param_tree, + parent=None, + ): + super().__init__(parent) + + self.thermostat = thermostat + self.autotuners = autotuners + self.info_box = info_box + self.trees_ui = trees_ui + self.NUM_CHANNELS = len(trees_ui) + + self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)] + + self.params = [ + Parameter.create( + name=f"Thermostat Channel {ch} Parameters", + type="group", + value=ch, + children=self.THERMOSTAT_PARAMETERS[ch], + ) + for ch in range(self.NUM_CHANNELS) + ] + + for i, param in enumerate(self.params): + param.channel = i + + for i, tree in enumerate(self.trees_ui): + tree.setHeaderHidden(True) + tree.setParameters(self.params[i], showTop=False) + self.params[i].setValue = self._setValue + self.params[i].sigTreeStateChanged.connect(self.send_command) + + self.params[i].child("Save to flash").sigActivated.connect( + partial(self.save_settings, i) + ) + self.params[i].child("Load from flash").sigActivated.connect( + partial(self.load_settings, i) + ) + self.params[i].child( + "PID Config", "PID Auto Tune", "Run" + ).sigActivated.connect(partial(self.pid_auto_tune_request, i)) + + self.thermostat.pid_update.connect(self.update_pid) + self.thermostat.report_update.connect(self.update_report) + self.thermostat.thermistor_update.connect(self.update_thermistor) + self.thermostat.output_update.connect(self.update_output) + self.thermostat.postfilter_update.connect(self.update_postfilter) + self.autotuners.autotune_state_changed.connect(self.update_pid_autotune) + + def _setValue(self, value, blockSignal=None): + """ + Implement 'lock' mechanism for Parameter Type + + Modified from the source + """ + try: + if blockSignal is not None: + self.sigValueChanged.disconnect(blockSignal) + value = self._interpretValue(value) + if fn.eq(self.opts["value"], value): + return value + + if "lock" in self.opts.keys(): + if self.opts["lock"]: + return value + self.opts["value"] = value + self.sigValueChanged.emit( + self, value + ) # value might change after signal is received by tree item + finally: + if blockSignal is not None: + self.sigValueChanged.connect(blockSignal) + + return self.opts["value"] + + def change_params_title(self, channel, path, title): + self.params[channel].child(*path).setOpts(title=title) + + @asyncSlot(object, object) + async def send_command(self, param, changes): + """Translates parameter tree changes into thermostat set_param calls""" + ch = param.channel + + for inner_param, change, data in changes: + if change == "value": + new_value = data + if "thermostat:set_param" in inner_param.opts: + if inner_param.opts.get("suffix", None) == "mA": + new_value /= 1000 # Given in mA + + thermostat_param = inner_param.opts["thermostat:set_param"] + + # Handle thermostat command irregularities + match inner_param.name(), new_value: + case "Postfilter Rate", None: + thermostat_param = thermostat_param.copy() + thermostat_param["field"] = "off" + new_value = "" + case "Control Method", "Constant Current": + return + case "Control Method", "Temperature PID": + new_value = "" + + inner_param.setOpts(lock=True) + await self.thermostat.set_param( + channel=ch, value=new_value, **thermostat_param + ) + inner_param.setOpts(lock=False) + + if "pid_autotune" in inner_param.opts: + auto_tuner_param = inner_param.opts["pid_autotune"] + self.autotuners.set_params(auto_tuner_param, ch, new_value) + + @pyqtSlot(list) + def update_pid(self, pid_settings): + for settings in pid_settings: + channel = settings["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("PID Config", "Kp").setValue( + settings["parameters"]["kp"] + ) + self.params[channel].child("PID Config", "Ki").setValue( + settings["parameters"]["ki"] + ) + self.params[channel].child("PID Config", "Kd").setValue( + settings["parameters"]["kd"] + ) + self.params[channel].child( + "PID Config", "PID Output Clamping", "Minimum" + ).setValue(settings["parameters"]["output_min"] * 1000) + self.params[channel].child( + "PID Config", "PID Output Clamping", "Maximum" + ).setValue(settings["parameters"]["output_max"] * 1000) + self.params[channel].child( + "Output Config", "Control Method", "Set Temperature" + ).setValue(settings["target"]) + + @pyqtSlot(list) + def update_report(self, report_data): + for settings in report_data: + channel = settings["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("Output Config", "Control Method").setValue( + "Temperature PID" if settings["pid_engaged"] else "Constant Current" + ) + self.params[channel].child( + "Output Config", "Control Method", "Set Current" + ).setValue(settings["i_set"] * 1000) + if settings["temperature"] is not None: + self.params[channel].child("Temperature").setValue( + settings["temperature"] + ) + if settings["tec_i"] is not None: + self.params[channel].child("Current through TEC").setValue( + settings["tec_i"] * 1000 + ) + + @pyqtSlot(list) + def update_thermistor(self, sh_data): + for sh_param in sh_data: + channel = sh_param["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("Thermistor Config", "T₀").setValue( + sh_param["params"]["t0"] - 273.15 + ) + self.params[channel].child("Thermistor Config", "R₀").setValue( + sh_param["params"]["r0"] + ) + self.params[channel].child("Thermistor Config", "B").setValue( + sh_param["params"]["b"] + ) + + @pyqtSlot(list) + def update_output(self, output_data): + for output_params in output_data: + channel = output_params["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child( + "Output Config", "Limits", "Max Voltage Difference" + ).setValue(output_params["max_v"]) + self.params[channel].child( + "Output Config", "Limits", "Max Cooling Current" + ).setValue(output_params["max_i_pos"] * 1000) + self.params[channel].child( + "Output Config", "Limits", "Max Heating Current" + ).setValue(output_params["max_i_neg"] * 1000) + + @pyqtSlot(list) + def update_postfilter(self, postfilter_data): + for postfilter_params in postfilter_data: + channel = postfilter_params["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child( + "Thermistor Config", "Postfilter Rate" + ).setValue(postfilter_params["rate"]) + + def update_pid_autotune(self, ch, state): + match state: + case PIDAutotuneState.STATE_OFF: + self.change_params_title( + ch, ("PID Config", "PID Auto Tune", "Run"), "Run" + ) + case ( + PIDAutotuneState.STATE_READY + | PIDAutotuneState.STATE_RELAY_STEP_UP + | PIDAutotuneState.STATE_RELAY_STEP_DOWN + ): + self.change_params_title( + ch, ("PID Config", "PID Auto Tune", "Run"), "Stop" + ) + case PIDAutotuneState.STATE_SUCCEEDED: + self.info_box.display_info_box( + "PID Autotune Success", + f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.", + ) + case PIDAutotuneState.STATE_FAILED: + self.info_box.display_info_box( + "PID Autotune Failed", + f"Channel {ch} PID Autotune has failed.", + ) + + @asyncSlot(int) + async def load_settings(self, ch): + await self.thermostat.load_cfg(ch) + + self.info_box.display_info_box( + f"Channel {ch} settings loaded", + f"Channel {ch} settings has been loaded from flash.", + ) + + @asyncSlot(int) + async def save_settings(self, ch): + await self.thermostat.save_cfg(ch) + + self.info_box.display_info_box( + f"Channel {ch} settings saved", + f"Channel {ch} settings has been saved to flash.\n" + "It will be loaded on Thermostat reset, or when settings are explicitly loaded.", + ) + + @asyncSlot() + async def pid_auto_tune_request(self, ch=0): + match self.autotuners.get_state(ch): + case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED: + self.autotuners.load_params_and_set_ready(ch) + + case ( + PIDAutotuneState.STATE_READY + | PIDAutotuneState.STATE_RELAY_STEP_UP + | PIDAutotuneState.STATE_RELAY_STEP_DOWN + ): + await self.autotuners.stop_pid_from_running(ch) diff --git a/pythermostat/pythermostat/gui/view/param_tree.json b/pythermostat/pythermostat/gui/view/param_tree.json new file mode 100644 index 0000000..03da4b9 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/param_tree.json @@ -0,0 +1,336 @@ +{ + "ctrl_panel": [ + { + "name": "Temperature", + "type": "float", + "format": "{value:.4f} °C", + "readonly": true + }, + { + "name": "Current through TEC", + "type": "float", + "suffix": "mA", + "decimals": 6, + "readonly": true + }, + { + "name": "Output Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Control Method", + "type": "mutex", + "limits": [ + "Constant Current", + "Temperature PID" + ], + "value": "Constant Current", + "thermostat:set_param": { + "topic": "output", + "field": "pid" + }, + "children": [ + { + "name": "Set Current", + "type": "float", + "value": 0, + "step": 100, + "limits": [ + -2000, + 2000 + ], + "triggerOnShow": true, + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "i_set" + }, + "lock": false + }, + { + "name": "Set Temperature", + "type": "float", + "value": 25, + "step": 0.1, + "limits": [ + -273, + 300 + ], + "format": "{value:.4f} °C", + "thermostat:set_param": { + "topic": "pid", + "field": "target" + }, + "lock": false + } + ] + }, + { + "name": "Limits", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Max Cooling Current", + "type": "float", + "value": 0, + "step": 100, + "decimals": 6, + "limits": [ + 0, + 2000 + ], + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "max_i_pos" + }, + "lock": false + }, + { + "name": "Max Heating Current", + "type": "float", + "value": 0, + "step": 100, + "decimals": 6, + "limits": [ + 0, + 2000 + ], + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "max_i_neg" + }, + "lock": false + }, + { + "name": "Max Voltage Difference", + "type": "float", + "value": 0, + "step": 0.1, + "limits": [ + 0, + 5 + ], + "siPrefix": true, + "suffix": "V", + "thermostat:set_param": { + "topic": "output", + "field": "max_v" + }, + "lock": false + } + ] + } + ] + }, + { + "name": "Thermistor Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "T₀", + "type": "float", + "value": 25, + "step": 0.1, + "limits": [ + -100, + 100 + ], + "format": "{value:.4f} °C", + "thermostat:set_param": { + "topic": "s-h", + "field": "t0" + }, + "lock": false + }, + { + "name": "R₀", + "type": "float", + "value": 10000, + "step": 1, + "siPrefix": true, + "suffix": "Ω", + "thermostat:set_param": { + "topic": "s-h", + "field": "r0" + }, + "lock": false + }, + { + "name": "B", + "type": "float", + "value": 3950, + "step": 1, + "suffix": "K", + "decimals": 4, + "thermostat:set_param": { + "topic": "s-h", + "field": "b" + }, + "lock": false + }, + { + "name": "Postfilter Rate", + "type": "list", + "value": 16.67, + "thermostat:set_param": { + "topic": "postfilter", + "field": "rate" + }, + "limits": { + "Off": null, + "16.67 Hz": 16.67, + "20 Hz": 20.0, + "21.25 Hz": 21.25, + "27 Hz": 27.0 + }, + "lock": false + } + ] + }, + { + "name": "PID Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Kp", + "type": "float", + "step": 0.1, + "suffix": "", + "thermostat:set_param": { + "topic": "pid", + "field": "kp" + }, + "lock": false + }, + { + "name": "Ki", + "type": "float", + "step": 0.1, + "suffix": "Hz", + "thermostat:set_param": { + "topic": "pid", + "field": "ki" + }, + "lock": false + }, + { + "name": "Kd", + "type": "float", + "step": 0.1, + "suffix": "s", + "thermostat:set_param": { + "topic": "pid", + "field": "kd" + }, + "lock": false + }, + { + "name": "PID Output Clamping", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Minimum", + "type": "float", + "step": 100, + "limits": [ + -2000, + 2000 + ], + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "pid", + "field": "output_min" + }, + "lock": false + }, + { + "name": "Maximum", + "type": "float", + "step": 100, + "limits": [ + -2000, + 2000 + ], + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "pid", + "field": "output_max" + }, + "lock": false + } + ] + }, + { + "name": "PID Auto Tune", + "expanded": false, + "type": "group", + "children": [ + { + "name": "Target Temperature", + "type": "float", + "value": 20, + "step": 0.1, + "format": "{value:.4f} °C", + "pid_autotune": "target_temp" + }, + { + "name": "Test Current", + "type": "float", + "value": 0, + "decimals": 6, + "step": 100, + "limits": [ + -2000, + 2000 + ], + "suffix": "mA", + "pid_autotune": "test_current" + }, + { + "name": "Temperature Swing", + "type": "float", + "value": 1.5, + "step": 0.1, + "prefix": "±", + "format": "{value:.4f} °C", + "pid_autotune": "temp_swing" + }, + { + "name": "Lookback", + "type": "float", + "value": 3.0, + "step": 0.1, + "format": "{value:.4f} s", + "pid_autotune": "lookback" + }, + { + "name": "Run", + "type": "action", + "tip": "Run" + } + ] + } + ] + }, + { + "name": "Save to flash", + "type": "action", + "tip": "Save config to thermostat, applies on reset" + }, + { + "name": "Load from flash", + "type": "action", + "tip": "Load config from flash" + } + ] +} \ No newline at end of file diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 116555f..cf08817 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -4,6 +4,7 @@ import asyncio import logging import argparse import importlib.resources +import json from PyQt6 import QtWidgets, QtGui, uic from PyQt6.QtCore import pyqtSlot import qasync @@ -12,6 +13,7 @@ from pythermostat.autotune import PIDAutotuneState from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState from pythermostat.gui.model.pid_autotuner import PIDAutoTuner from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu +from pythermostat.gui.view.ctrl_panel import CtrlPanel from pythermostat.gui.view.info_box import InfoBox from pythermostat.gui.view.live_plot_view import LiveDataPlotter from pythermostat.gui.view.plot_options_menu import PlotOptionsMenu @@ -37,6 +39,12 @@ def get_argparser(): choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Set the logging level", ) + parser.add_argument( + "-p", + "--param_tree", + default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"), + help="Param Tree Description JSON File", + ) return parser @@ -44,7 +52,7 @@ def get_argparser(): class MainWindow(QtWidgets.QMainWindow): NUM_CHANNELS = 2 - def __init__(self): + def __init__(self, args): super().__init__() ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.ui") @@ -80,6 +88,19 @@ class MainWindow(QtWidgets.QMainWindow): self._thermostat.connection_error.connect(handle_connection_error) + # Control Panel + def get_ctrl_panel_config(args): + with open(args.param_tree, "r", encoding="utf-8") as f: + return json.load(f)["ctrl_panel"] + + self._ctrl_panel_view = CtrlPanel( + self._thermostat, + self._autotuners, + self._info_box, + [self.ch0_tree, self.ch1_tree], + get_ctrl_panel_config(args), + ) + # Graphs self._channel_graphs = LiveDataPlotter( self._thermostat, @@ -212,7 +233,7 @@ async def coro_main(): ) ) - main_window = MainWindow() + main_window = MainWindow(args) main_window.show() if args.connect: -- 2.47.0 From 28df792cdc3078614011b2175cccb1684b49e2be Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 28 Oct 2024 17:52:00 +0800 Subject: [PATCH 11/12] PyThermostat GUI: Set up packaging Co-authored-by: Egor Savkin --- flake.nix | 16 ++++++++++++++-- pythermostat/MANIFEST.in | 4 ++++ pythermostat/pyproject.toml | 1 + 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 pythermostat/MANIFEST.in diff --git a/flake.nix b/flake.nix index a44aa0d..5772ba1 100644 --- a/flake.nix +++ b/flake.nix @@ -64,15 +64,22 @@ format = "pyproject"; src = "${self}/pythermostat"; + nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; propagatedBuildInputs = - with pkgs.python3Packages; [ + [ pkgs.qt6.qtbase ] + ++ (with pkgs.python3Packages; [ numpy matplotlib pyqtgraph pyqt6 qasync pglive - ]; + ]); + + dontWrapQtApps = true; + postFixup = '' + wrapQtApp "$out/bin/thermostat_qt" + ''; }; pglive = pkgs.python3Packages.buildPythonPackage rec { @@ -96,6 +103,11 @@ default = thermostat; }; + apps.x86_64-linux.thermostat_gui = { + type = "app"; + program = "${self.packages.x86_64-linux.pythermostat}/bin/thermostat_qt"; + }; + hydraJobs = { inherit thermostat; }; diff --git a/pythermostat/MANIFEST.in b/pythermostat/MANIFEST.in new file mode 100644 index 0000000..26af249 --- /dev/null +++ b/pythermostat/MANIFEST.in @@ -0,0 +1,4 @@ +graft examples +include pythermostat/gui/resources/artiq.ico +include pythermostat/gui/view/param_tree.json +include pythermostat/gui/view/tec_qt.ui diff --git a/pythermostat/pyproject.toml b/pythermostat/pyproject.toml index 5e37137..7dc8576 100644 --- a/pythermostat/pyproject.toml +++ b/pythermostat/pyproject.toml @@ -12,6 +12,7 @@ license = {text = "GPLv3"} [project.gui-scripts] thermostat_plot = "pythermostat.plot:main" +thermostat_qt = "pythermostat.thermostat_qt:main" [project.scripts] thermostat_autotune = "pythermostat.autotune:main" -- 2.47.0 From e4ad9bf271a80f845fbd81e92c8e4ab546ac9410 Mon Sep 17 00:00:00 2001 From: atse Date: Wed, 13 Sep 2023 10:58:40 +0800 Subject: [PATCH 12/12] README: Introduce Thermostat GUI Co-authored-by: topquark12 --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e37ccf..7507570 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,18 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit" ``` -## Network +## GUI Usage + +A GUI has been developed for easy configuration and plotting of key parameters. + +The Python GUI program is located at pythermostat/pythermostat/thermostat_qt.py, and is developed based on the Python library pyqtgraph. The GUI can be configured and +launched automatically by running: + +``` +nix run .#thermostat_gui +``` + +## Command Line Usage ### Connecting -- 2.47.0