PyThermostat: Create GUI to Thermostat
- Add connection menu - Add basic GUI layout skeleton Co-authored-by: linuswck <linuswck@m-labs.hk> Co-authored-by: Egor Savkin <es@m-labs.hk>
This commit is contained in:
parent
62d3666275
commit
a2b8a85244
23
flake.nix
23
flake.nix
@ -68,8 +68,27 @@
|
|||||||
with pkgs.python3Packages; [
|
with pkgs.python3Packages; [
|
||||||
numpy
|
numpy
|
||||||
matplotlib
|
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
|
in
|
||||||
{
|
{
|
||||||
packages.x86_64-linux = {
|
packages.x86_64-linux = {
|
||||||
@ -95,6 +114,10 @@
|
|||||||
++ (with python3Packages; [
|
++ (with python3Packages; [
|
||||||
numpy
|
numpy
|
||||||
matplotlib
|
matplotlib
|
||||||
|
pyqtgraph
|
||||||
|
pyqt6
|
||||||
|
qasync
|
||||||
|
pglive
|
||||||
pythermostat
|
pythermostat
|
||||||
]);
|
]);
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
126
pythermostat/pythermostat/gui/model/property.py
Normal file
126
pythermostat/pythermostat/gui/model/property.py
Normal file
@ -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()
|
135
pythermostat/pythermostat/gui/model/thermostat.py
Normal file
135
pythermostat/pythermostat/gui/model/thermostat.py
Normal file
@ -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)
|
BIN
pythermostat/pythermostat/gui/resources/artiq.ico
Normal file
BIN
pythermostat/pythermostat/gui/resources/artiq.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 131 KiB |
572
pythermostat/pythermostat/gui/view/MainWindow.ui
Normal file
572
pythermostat/pythermostat/gui/view/MainWindow.ui
Normal file
@ -0,0 +1,572 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>MainWindow</class>
|
||||||
|
<widget class="QMainWindow" name="MainWindow">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>1280</width>
|
||||||
|
<height>720</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>1280</width>
|
||||||
|
<height>720</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>3840</width>
|
||||||
|
<height>2160</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Thermostat Control Panel</string>
|
||||||
|
</property>
|
||||||
|
<property name="windowIcon">
|
||||||
|
<iconset>
|
||||||
|
<normaloff>../resources/artiq.ico</normaloff>../resources/artiq.ico</iconset>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="main_widget">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>1</horstretch>
|
||||||
|
<verstretch>1</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout_2">
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<layout class="QVBoxLayout" name="main_layout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QFrame" name="graph_group">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>1</horstretch>
|
||||||
|
<verstretch>1</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::StyledPanel</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Raised</enum>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
|
||||||
|
<property name="sizeConstraint">
|
||||||
|
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing">
|
||||||
|
<number>2</number>
|
||||||
|
</property>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="LivePlotWidget" name="ch0_t_graph" native="true"/>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="2">
|
||||||
|
<widget class="LivePlotWidget" name="ch0_i_graph" native="true"/>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="2">
|
||||||
|
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="0" rowspan="2">
|
||||||
|
<widget class="QTabWidget" name="tabWidget">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="currentIndex">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="ch0_tab">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<attribute name="title">
|
||||||
|
<string>Channel 0</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="ParameterTree" name="ch0_tree" native="true">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QWidget" name="ch1_tab">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<attribute name="title">
|
||||||
|
<string>Channel 1</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="ParameterTree" name="ch1_tree" native="true">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QFrame" name="bottom_settings_group">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>16777215</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::StyledPanel</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Raised</enum>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>3</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="settings_layout">
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="connect_btn">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="baseSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Connect</string>
|
||||||
|
</property>
|
||||||
|
<property name="popupMode">
|
||||||
|
<enum>QToolButton::ToolButtonPopupMode::MenuButtonPopup</enum>
|
||||||
|
</property>
|
||||||
|
<property name="toolButtonStyle">
|
||||||
|
<enum>Qt::ToolButtonStyle::ToolButtonFollowStyle</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="status_lbl">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>240</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>120</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="baseSize">
|
||||||
|
<size>
|
||||||
|
<width>120</width>
|
||||||
|
<height>50</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Disconnected</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="thermostat_settings">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">⚙</string>
|
||||||
|
</property>
|
||||||
|
<property name="popupMode">
|
||||||
|
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="plot_settings">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Plot Settings</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>📉</string>
|
||||||
|
</property>
|
||||||
|
<property name="popupMode">
|
||||||
|
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="limits_warning">
|
||||||
|
<property name="toolTipDuration">
|
||||||
|
<number>1000000000</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="background_task_lbl">
|
||||||
|
<property name="text">
|
||||||
|
<string>Ready.</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QtWaitingSpinner" name="loading_spinner" native="true"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QWidget" name="report_group" native="true">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<property name="sizeConstraint">
|
||||||
|
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="report_lbl">
|
||||||
|
<property name="text">
|
||||||
|
<string>Poll every: </string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDoubleSpinBox" name="report_refresh_spin">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="baseSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="suffix">
|
||||||
|
<string> s</string>
|
||||||
|
</property>
|
||||||
|
<property name="decimals">
|
||||||
|
<number>1</number>
|
||||||
|
</property>
|
||||||
|
<property name="minimum">
|
||||||
|
<double>0.100000000000000</double>
|
||||||
|
</property>
|
||||||
|
<property name="singleStep">
|
||||||
|
<double>0.100000000000000</double>
|
||||||
|
</property>
|
||||||
|
<property name="stepType">
|
||||||
|
<enum>QAbstractSpinBox::StepType::AdaptiveDecimalStepType</enum>
|
||||||
|
</property>
|
||||||
|
<property name="value">
|
||||||
|
<double>1.000000000000000</double>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="report_apply_btn">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>80</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>80</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="baseSize">
|
||||||
|
<size>
|
||||||
|
<width>80</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Apply</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<action name="actionReset">
|
||||||
|
<property name="text">
|
||||||
|
<string>Reset</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Reset the Thermostat</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionEnter_DFU_Mode">
|
||||||
|
<property name="text">
|
||||||
|
<string>Enter DFU Mode</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Reset thermostat and enter USB device firmware update (DFU) mode</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionNetwork_Settings">
|
||||||
|
<property name="text">
|
||||||
|
<string>Network Settings</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Configure IPv4 address, netmask length, and optional default gateway</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionAbout_Thermostat">
|
||||||
|
<property name="text">
|
||||||
|
<string>About Thermostat</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Show Thermostat hardware revision, and settings related to i</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionLoad_all_configs">
|
||||||
|
<property name="text">
|
||||||
|
<string>Load all channel configs from flash</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Restore configuration for all channels from flash</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionSave_all_configs">
|
||||||
|
<property name="text">
|
||||||
|
<string>Save all channel configs to flash</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Save configuration for all channels to flash</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
</widget>
|
||||||
|
<customwidgets>
|
||||||
|
<customwidget>
|
||||||
|
<class>ParameterTree</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>pyqtgraph.parametertree</header>
|
||||||
|
<container>1</container>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>LivePlotWidget</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>pglive.sources.live_plot_widget</header>
|
||||||
|
<container>1</container>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>QtWaitingSpinner</class>
|
||||||
|
<extends>QWidget</extends>
|
||||||
|
<header>pythermostat.gui.view.waitingspinnerwidget</header>
|
||||||
|
<container>1</container>
|
||||||
|
</customwidget>
|
||||||
|
</customwidgets>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
@ -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
|
||||||
|
)
|
14
pythermostat/pythermostat/gui/view/info_box.py
Normal file
14
pythermostat/pythermostat/gui/view/info_box.py
Normal file
@ -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()
|
212
pythermostat/pythermostat/gui/view/waitingspinnerwidget.py
Normal file
212
pythermostat/pythermostat/gui/view/waitingspinnerwidget.py
Normal file
@ -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()
|
169
pythermostat/pythermostat/thermostat_qt.py
Executable file
169
pythermostat/pythermostat/thermostat_qt.py
Executable file
@ -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()
|
Loading…
Reference in New Issue
Block a user