Compare commits

..

93 Commits

Author SHA1 Message Date
dc5460f591 aioclient: Add missing readline for saving
Saving all channels returns multiple JSON objects, read the extra {}.
2024-08-27 17:08:38 +08:00
df79d4d977 Fix not actually awaiting for load/save 2024-08-27 17:07:45 +08:00
9910f935a9 Add back the info_box 2024-08-27 17:07:12 +08:00
e3ac7debc4 Fix info boxes for load/saving from flash 2024-08-27 17:00:16 +08:00
a8d7986c82 Move reset request to thermostat control menu
We don't get auto reconnect anymore
2024-08-27 16:51:01 +08:00
5a09c026fa Move pid autotuning request to CtrlPanel
And update autotune UI only on state change instead of every single
report update
2024-08-27 16:46:48 +08:00
8ab04ac3cd autotuner*s* 2024-08-27 16:04:07 +08:00
5ecbb262be Move channelGraph stuff inside LiveDataPlotter 2024-08-27 16:01:07 +08:00
f141705b0e Move plot_options_menu stuff into menu 2024-08-27 16:01:03 +08:00
19470b3d02 Move autotune ticking connect 2024-08-27 15:20:50 +08:00
5862a2f7d5 Moove functools up 2024-08-27 14:56:17 +08:00
ac34472d31 Get thermostat_ctrl_menu to subscribe to hwrev updates 2024-08-27 13:31:23 +08:00
f45061a652 Save/load info box content 2024-08-27 13:21:41 +08:00
d9a3fcdc4b Remove unused load/save request & signal 2024-08-27 13:19:06 +08:00
22fc7cbf22 Fix loading all channel settings would bring up 2 info boxes 2024-08-27 13:17:25 +08:00
0133d2e41b ok which commit to fixup huh 2024-08-27 12:06:57 +08:00
43758e12a3 hw_rev 2024-08-27 11:27:31 +08:00
5dcf9e8f31 fan_update: tec_qt to thermostat_ctrl_menu 2024-08-27 10:45:19 +08:00
40c0519237 Fix updating status label for hwrev = None 2024-08-27 10:40:06 +08:00
260a466078 Put some menu requests in menu itself 2024-08-26 18:01:01 +08:00
8a13ce2b47 Tie Thermostat ConnectionState to Qt signal for now
Change this to callback-based later for decoupling from Qt
2024-08-26 17:09:29 +08:00
ba369c880e Move sigActivatedHandle to CtrlPanel 2024-08-26 15:23:55 +08:00
375e159c39 Get rid of timeout on readline 2024-08-26 15:21:26 +08:00
4240312bf4 Put send_command in CtrlPanel 2024-08-26 13:49:56 +08:00
20f168e04c flake update 2024-08-26 13:11:26 +08:00
efe0c086d8 Extra bail removed 2024-08-26 12:21:57 +08:00
9f3591770a return hwrev when start_session 2024-08-26 12:21:57 +08:00
17157fd890 state str 2024-08-26 12:21:57 +08:00
42268e2186 params update concurrently 2024-08-26 12:21:57 +08:00
db15c0052e Compact 2024-08-26 12:21:57 +08:00
bfddfd8e20 Stuff non-UI changes in Thermostat model 2024-08-26 12:21:57 +08:00
77b66e15cc State dependend UI 2024-08-26 12:21:57 +08:00
ae51fc739e State 2024-08-26 12:21:57 +08:00
7279c4d64a Actually its OSError 2024-08-26 12:21:57 +08:00
71f40027f5 conneting 2024-08-26 12:21:57 +08:00
388c914c18 Remove wait_for
OSError raised anyways
2024-08-26 12:21:57 +08:00
659ed384ae {start,end}_session -> [dis]connect 2024-08-26 12:21:57 +08:00
b252dc6a44 Simplify on_connect_btn_clicked
Raise if OSError
2024-08-26 12:21:57 +08:00
0dbed18ba1 Connecting task moved? 2024-08-26 12:21:57 +08:00
2b9a4c168e AsyncIO version Client -> AsyncioClient 2024-08-26 12:21:57 +08:00
83405103f2 Exclusively use the Thermostat object as a medium
All calls to the Thermostat should be forwarded by the medium.
2024-08-26 12:21:57 +08:00
71f4ad6e34 Integrate WrappedClient into Thermostat model 2024-08-26 12:21:57 +08:00
4d21770542 Should not stop cancelling read if timeout'd 2024-08-26 12:21:57 +08:00
52ee422a70 Fix Autotuner state for forceful disconnect 2024-08-26 12:21:57 +08:00
5475bf7951 _ 2024-08-26 12:21:57 +08:00
da70430c35 Make connection loss handling more elegant
Show an info box on connection lost informing the user that the
Thermostat was forcefully disconnected.
2024-08-26 12:21:57 +08:00
5e8f61be9e ================gui_dev-fix_asyncio=============== 2024-08-26 12:21:57 +08:00
b6f936a65f This is bail 2024-08-26 12:21:57 +08:00
79cc11dd14 thermostat: Properly register task
Also Thermostat.task -> Thermostat._update_params_task
2024-08-26 12:21:57 +08:00
7a5bb8d308 ip -> host 2024-08-26 12:21:57 +08:00
7245e514e8 Lazy evaluating for debug string command 2024-08-26 12:21:57 +08:00
fbaeb870c6 Add pytec runnables 2024-08-26 12:21:52 +08:00
f922ea906f PYTHON shell 2024-08-26 12:18:22 +08:00
aef3a9870b Exactlier wording 2024-08-26 12:18:22 +08:00
752d6f8eab unused 2024-08-26 12:18:22 +08:00
7a5ec14b95 encoding 2024-08-26 12:18:22 +08:00
b2f188b556 Just catch asyncio.TimeoutError
Will just change to TimeoutError once we switch to Python 3.11 in the
flake.
2024-08-26 12:18:22 +08:00
a2afd81dcd Remove exception too general 2024-08-26 12:18:22 +08:00
89319c0cd9 Use asserts to check for connectivity 2024-08-26 12:18:22 +08:00
f75de51447 Add back the parent 2024-08-26 12:18:22 +08:00
6f0956b35c Fix method call 2024-08-26 12:18:22 +08:00
6067c41ca4 README: Proofread 2024-08-26 12:18:22 +08:00
8a01249d60 Swap order arounda bit more 2024-08-26 12:18:22 +08:00
0ec18dfbff Formatting 2024-08-26 12:18:22 +08:00
6f7b46bc2f Use qtextras 2024-08-26 12:18:19 +08:00
2fc0d265e4 flake.nix: nixfmt-rfc-style 2024-08-26 12:16:57 +08:00
b768d61e39 Merge pull request 'GUI: Text changes' (#3) from gui_dev-str_changes into gui_dev
Reviewed-on: linuswck/thermostat#3
2024-07-19 15:36:01 +08:00
d244ba392a Fix typos 2024-07-19 15:34:31 +08:00
93d6df5e92 Merge pull request 'GUI: Some repo organisation' (#1) from gui_dev-repo_org into gui_dev
Reviewed-on: linuswck/thermostat#1
2024-07-12 10:34:23 +08:00
44bea87f03 Thermostat.disconnect -> Thermostat.end_session
QObject already has a disconnect method, avoid overriding it.
2024-07-10 15:56:43 +08:00
e6f62e9e19 flake: sha256 -> hash 2024-07-10 15:56:43 +08:00
271fe449ba Remove duplicated show call
MainWindow.show() already called in coro_main
2024-07-10 15:56:43 +08:00
70db0a39eb Remove duplicated antialias config option
Already set in live_plot_view.py
2024-07-10 15:56:43 +08:00
26c7382b1e Move GUI components and examples into folder
For better organisation
2024-07-10 15:56:43 +08:00
c415d9de8a Use MANIFEST.in
Allows for more accurate control over included files in pytec package
2024-07-10 15:56:43 +08:00
7069111e21 Expose frontend scripts exclusively in pytec 2024-07-10 15:56:43 +08:00
1707728c3c thermostat_data_model.py -> thermostat.py 2024-07-10 15:56:34 +08:00
a16d2e9a9e Follow CapWords convention for class names
Re: PEP8
2024-07-10 15:45:03 +08:00
bc4ac43e0b Put comments in right place 2024-07-10 13:07:31 +08:00
9acff86547 Restructure GUI Code, Improve and Fix Bugs
- Bugs fix:
1. Params Tree user input will not get overwritten
    by incoming report thermostat_data_model.
2. PID Autotune Sampling Period is now set according to Thermostat sampling interval
3. PID Autotune won't get stuck in Fail State
4. Various types disconnection related Bugs
5. Number of Samples stored in the plot cannot be set
6. Limit the max settable output current to be 2000mA

- Improvement:
1. Params Tree settings can be changed with external json
2. Use a Tab system to show a single channel of config instead of two
3. Expose PID Autotune lookback params
4. Icon is changed to Artiq logo

- Restructure:
1. Restructure the code to follow Model-View-Delegate Design Pattern
2024-06-06 17:34:15 +08:00
8753f4a0fc Finish GUI 2024-05-08 14:49:03 +08:00
9a83d6850d Remove unused as clause 2024-05-08 14:49:03 +08:00
772863f4b2 Add paramtree view, without updates
Signed-off-by: Egor Savkin <es@m-labs.hk>

Fix signal blocker argument -atse
2024-05-08 14:49:03 +08:00
44ef2c04e3 Fix bugs, grammar, text, and refactor into class 2024-05-08 14:49:03 +08:00
623011fabb Change title 2024-05-08 14:49:03 +08:00
400f3a98e8 Stop polling drift
Just waiting for the update_s doesn't take into account the time to
execute update_params, and causes time drift.
2024-05-08 14:49:03 +08:00
7dd5d15047 Remove unused 'as' clause 2024-05-08 14:49:03 +08:00
297e589c30 Update docs 2024-05-08 14:49:03 +08:00
65e1f4a146 Finish moving over to qasync
Also:

* Add aioclient

The old client is synchronous and blocking, and the only way to achieve
true asynchronous IO is to create a new client that interfaces with
asyncio.

* Finish Nix Flake description and make the GUI available for `nix run`
2024-05-08 14:49:03 +08:00
e0ce14c616 Try move from Qthreads to qasync
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-05-08 14:49:03 +08:00
a79679a074 Create client watcher, that would poll Thermostat for config
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-05-08 14:49:03 +08:00
b9acba69d3 Create basic GUI, that would connect and control thermostat's fan
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-05-08 14:49:03 +08:00
b4b4ec987c add autotune 2024-05-08 14:49:03 +08:00
21 changed files with 863 additions and 928 deletions

46
flake.lock generated
View File

@ -1,12 +1,28 @@
{ {
"nodes": { "nodes": {
"mozilla-overlay": {
"flake": false,
"locked": {
"lastModified": 1704373101,
"narHash": "sha256-+gi59LRWRQmwROrmE1E2b3mtocwueCQqZ60CwLG+gbg=",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "9b11a87c0cc54e308fa83aac5b4ee1816d5418a2",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1691421349, "lastModified": 1704290814,
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=", "narHash": "sha256-LWvKHp7kGxk/GEtlrGYV68qIvPHkU9iToomNFGagixU=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4", "rev": "70bdadeb94ffc8806c0570eb5c2695ad29f0e421",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -18,28 +34,8 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs", "mozilla-overlay": "mozilla-overlay",
"rust-overlay": "rust-overlay" "nixpkgs": "nixpkgs"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1719281921,
"narHash": "sha256-LIBMfhM9pMOlEvBI757GOK5l0R58SRi6YpwfYMbf4yc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b6032d3a404d8a52ecfc8571ff0c26dfbe221d07",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
} }
} }
}, },

View File

@ -2,32 +2,36 @@
description = "Firmware for the Sinara 8451 Thermostat"; description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
inputs.rust-overlay = { inputs.mozilla-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:mozilla/nixpkgs-mozilla";
inputs.nixpkgs.follows = "nixpkgs"; flake = false;
}; };
outputs = outputs = { self, nixpkgs, mozilla-overlay, }:
{
self,
nixpkgs,
rust-overlay,
}:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
system = "x86_64-linux"; system = "x86_64-linux";
overlays = [ (import rust-overlay) ]; overlays = [ (import mozilla-overlay) ];
};
rustManifest = pkgs.fetchurl {
url =
"https://static.rust-lang.org/dist/2022-12-15/channel-rust-stable.toml";
hash = "sha256-S7epLlflwt0d1GZP44u5Xosgf6dRrmr8xxC+Ml2Pq7c=";
}; };
rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = [ "rust-src" ];
targets = [ "thumbv7em-none-eabihf" ]; targets = [ "thumbv7em-none-eabihf" ];
rustChannelOfTargets = _channel: _date: targets:
(pkgs.lib.rustLib.fromManifestFile rustManifest {
inherit (pkgs) stdenv lib fetchurl patchelf;
}).rust.override {
inherit targets;
extensions = [ "rust-src" ];
}; };
rustPlatform = pkgs.makeRustPlatform { rust = rustChannelOfTargets "stable" null targets;
rustPlatform = pkgs.recurseIntoAttrs (pkgs.makeRustPlatform {
rustc = rust; rustc = rust;
cargo = rust; cargo = rust;
}; });
thermostat = rustPlatform.buildRustPackage { thermostat = rustPlatform.buildRustPackage {
name = "thermostat"; name = "thermostat";
version = "0.0.0"; version = "0.0.0";
@ -36,7 +40,8 @@
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
outputHashes = { outputHashes = {
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o="; "stm32-eth-0.2.0" =
"sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
}; };
}; };
@ -77,10 +82,7 @@
inherit pname version; inherit pname version;
hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4="; hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
}; };
propagatedBuildInputs = with pkgs.python3Packages; [ propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
numpy
pyqt6
];
}; };
qtextras = pkgs.python3Packages.buildPythonPackage rec { qtextras = pkgs.python3Packages.buildPythonPackage rec {
@ -108,12 +110,22 @@
hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
}; };
buildInputs = [ pkgs.python3Packages.poetry-core ]; buildInputs = [ pkgs.python3Packages.poetry-core ];
propagatedBuildInputs = [ propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
pyqtgraph
pkgs.python3Packages.numpy
];
}; };
pytec-dev-wrappers = pkgs.runCommandNoCC "pytec-dev-wrappers" { } ''
mkdir -p $out/bin
for program in ${self}/pytec/*.py; do
if [ -x $program ]; then
progname=`basename -s .py $program`
outname=$out/bin/$progname
echo "#!${pkgs.bash}/bin/bash" >> $outname
echo "exec python3 -m pytec.$progname \"\$@\"" >> $outname
chmod 755 $outname
fi
done
'';
thermostat_gui = pkgs.python3Packages.buildPythonPackage { thermostat_gui = pkgs.python3Packages.buildPythonPackage {
pname = "thermostat_gui"; pname = "thermostat_gui";
version = "0.0.0"; version = "0.0.0";
@ -121,8 +133,7 @@
src = "${self}/pytec"; src = "${self}/pytec";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = propagatedBuildInputs = [ pkgs.qt6.qtbase ]
[ pkgs.qt6.qtbase ]
++ (with pkgs.python3Packages; [ ++ (with pkgs.python3Packages; [
pyqtgraph pyqtgraph
pyqt6 pyqt6
@ -136,31 +147,22 @@
wrapQtApp "$out/bin/tec_qt" wrapQtApp "$out/bin/tec_qt"
''; '';
}; };
in in {
{ packages.x86_64-linux = { inherit thermostat thermostat_gui; };
packages.x86_64-linux = {
inherit thermostat thermostat_gui; formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
default = thermostat;
};
apps.x86_64-linux.thermostat_gui = { apps.x86_64-linux.thermostat_gui = {
type = "app"; type = "app";
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt"; program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt";
}; };
hydraJobs = { hydraJobs = { inherit thermostat; };
inherit thermostat;
};
devShells.x86_64-linux.default = pkgs.mkShellNoCC { devShell.x86_64-linux = pkgs.mkShell {
name = "thermostat-dev-shell"; name = "thermostat-dev-shell";
packages = buildInputs = with pkgs;
with pkgs; [ rust openocd dfu-util pytec-dev-wrappers ]
[
rust
openocd
dfu-util
]
++ (with python3Packages; [ ++ (with python3Packages; [
numpy numpy
matplotlib matplotlib
@ -171,6 +173,10 @@
pglive pglive
qtextras qtextras
]); ]);
shellHook = ''
export PYTHONPATH=`pwd`/pytec:$PYTHONPATH
'';
}; };
defaultPackage.x86_64-linux = thermostat;
}; };
} }

0
pytec/autotune.py Normal file → Executable file
View File

View File

@ -4,7 +4,7 @@ from pytec.aioclient import AsyncioClient
async def main(): async def main():
tec = AsyncioClient() tec = AsyncioClient()
await tec.start_session() # (host="192.168.1.26", port=23) await tec.connect() # (host="192.168.1.26", port=23)
await tec.set_param("s-h", 1, "t0", 20) await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_pwm()) print(await tec.get_pwm())
print(await tec.get_pid()) print(await tec.get_pid())

0
pytec/plot.py Normal file → Executable file
View File

View File

@ -14,7 +14,7 @@ license = {text = "GPLv3"}
tec_qt = "tec_qt:main" tec_qt = "tec_qt:main"
[tool.setuptools] [tool.setuptools]
packages.find = {} # Use setuptools custom discovery, package directory structure isn't standard packages.find = {}
py-modules = ["autotune", "plot", "tec_qt"] py-modules = ["autotune", "plot", "tec_qt"]
[tool.pylint.format] [tool.pylint.format]

View File

@ -7,56 +7,29 @@ class CommandError(Exception):
pass pass
class StoppedConnecting(Exception):
pass
class AsyncioClient: class AsyncioClient:
def __init__(self): def __init__(self):
self._reader = None self._reader = None
self._writer = None self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock() self._command_lock = asyncio.Lock()
self._report_mode_on = False self._report_mode_on = False
self.timeout = None
async def start_session(self, host="192.168.1.26", port=23, timeout=None): async def connect(self, host="192.168.1.26", port=23):
"""Start session to Thermostat at specified host and port. """Connect to Thermostat at specified host and port.
Throws StoppedConnecting if disconnect was called while connecting.
Throws asyncio.TimeoutError if timeout was exceeded.
Example:: Example::
client = AsyncioClient() client = AsyncioClient()
try: await client.connect()
await client.start_session()
except StoppedConnecting:
print("Stopped connecting")
""" """
self._connecting_task = asyncio.create_task( self._reader, self._writer = await asyncio.open_connection(host, port)
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
)
self.timeout = timeout
try:
self._reader, self._writer = await self._connecting_task
except asyncio.CancelledError as exc:
raise StoppedConnecting from exc
finally:
self._connecting_task = None
await self._check_zero_limits() await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self): def connected(self):
"""Returns True if client is connected""" """Returns True if client is connected"""
return self._writer is not None return self._writer is not None
async def end_session(self): async def disconnect(self):
"""End session to Thermostat if connected, cancel connection if connecting""" """Disconnect from the Thermostat"""
if self._connecting_task is not None:
self._connecting_task.cancel()
if self._writer is None: if self._writer is None:
return return
@ -80,9 +53,7 @@ class AsyncioClient:
async def _read_line(self): async def _read_line(self):
# read 1 line # read 1 line
chunk = await asyncio.wait_for( chunk = await self._reader.readline()
self._reader.readline(), self.timeout
) # Only wait for response until timeout
return chunk.decode("utf-8", errors="ignore") return chunk.decode("utf-8", errors="ignore")
async def _read_write(self, command): async def _read_write(self, command):
@ -96,7 +67,7 @@ class AsyncioClient:
line = await self._read_write(command) line = await self._read_write(command)
response = json.loads(line) response = json.loads(line)
logging.debug(f"{command}: {response}") logging.debug("%s: %s", command, response)
if "error" in response: if "error" in response:
raise CommandError(response["error"]) raise CommandError(response["error"])
return response return response
@ -247,6 +218,8 @@ class AsyncioClient:
async def save_config(self, channel=""): async def save_config(self, channel=""):
"""Save current configuration to EEPROM""" """Save current configuration to EEPROM"""
await self._command("save", str(channel)) await self._command("save", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def load_config(self, channel=""): async def load_config(self, channel=""):
"""Load current configuration from EEPROM""" """Load current configuration from EEPROM"""
@ -267,7 +240,7 @@ class AsyncioClient:
self._writer.write("reset\n".encode("utf-8")) self._writer.write("reset\n".encode("utf-8"))
await self._writer.drain() await self._writer.drain()
await self.end_session() await self.disconnect()
async def dfu(self): async def dfu(self):
"""Put the Thermostat in DFU update mode """Put the Thermostat in DFU update mode
@ -280,7 +253,7 @@ class AsyncioClient:
self._writer.write("dfu\n".encode("utf-8")) self._writer.write("dfu\n".encode("utf-8"))
await self._writer.drain() await self._writer.drain()
await self.end_session() await self.disconnect()
async def ipv4(self): async def ipv4(self):
"""Get the IPv4 settings of the Thermostat""" """Get the IPv4 settings of the Thermostat"""

View File

@ -1,13 +1,18 @@
from PyQt6.QtCore import QObject, pyqtSlot from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
from qasync import asyncSlot from qasync import asyncSlot
from autotune import PIDAutotuneState, PIDAutotune from autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject): class PIDAutoTuner(QObject):
autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
def __init__(self, parent, thermostat, num_of_channel): def __init__(self, parent, thermostat, num_of_channel):
super().__init__(parent) super().__init__(parent)
self._thermostat = thermostat self._thermostat = thermostat
self._thermostat.report_update.connect(self.tick)
self._thermostat.interval_update.connect(self.update_sampling_interval)
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)] self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
self.target_temp = [20.0 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.test_current = [1.0 for _ in range(num_of_channel)]
@ -34,9 +39,11 @@ class PIDAutoTuner(QObject):
self.lookback[ch], self.lookback[ch],
) )
self.autotuners[ch].setReady() self.autotuners[ch].setReady()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
async def stop_pid_from_running(self, ch): async def stop_pid_from_running(self, ch):
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0) await self._thermostat.set_param("pwm", ch, "i_set", 0)
@asyncSlot(list) @asyncSlot(list)
@ -62,6 +69,7 @@ class PIDAutoTuner(QObject):
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_tec_pid() kp, ki, kd = self.autotuners[ch].get_tec_pid()
self.autotuners[ch].setOff() 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, "kp", kp)
await self._thermostat.set_param("pid", ch, "ki", ki) await self._thermostat.set_param("pid", ch, "ki", ki)
@ -73,4 +81,5 @@ class PIDAutoTuner(QObject):
) )
case PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_FAILED:
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0) await self._thermostat.set_param("pwm", ch, "i_set", 0)

View File

@ -3,9 +3,16 @@ from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta from pytec.gui.model.property import Property, PropertyMeta
import asyncio import asyncio
import logging import logging
from enum import Enum
from pytec.aioclient import AsyncioClient from pytec.aioclient import AsyncioClient
class ThermostatConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
class Thermostat(QObject, metaclass=PropertyMeta): class Thermostat(QObject, metaclass=PropertyMeta):
hw_rev = Property(dict) hw_rev = Property(dict)
fan = Property(dict) fan = Property(dict)
@ -17,6 +24,7 @@ class Thermostat(QObject, metaclass=PropertyMeta):
report = Property(list) report = Property(list)
info_box_trigger = pyqtSignal(str, str) info_box_trigger = pyqtSignal(str, str)
connection_error = pyqtSignal() connection_error = pyqtSignal()
connection_state_changed = pyqtSignal(ThermostatConnectionState)
def __init__(self, parent, update_s): def __init__(self, parent, update_s):
self._update_s = update_s self._update_s = update_s
@ -24,51 +32,63 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self._watch_task = None self._watch_task = None
self._report_mode_task = None self._report_mode_task = None
self._poll_for_report = True self._poll_for_report = True
self._update_params_task = None
self.connection_errored = False
super().__init__(parent) super().__init__(parent)
async def start_session(self, host, port): async def start_session(self, host, port):
await self._client.start_session(host, port, timeout=5) self.connection_state_changed.emit(ThermostatConnectionState.CONNECTING)
await self._client.connect(host, port)
await self.get_hw_rev()
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTED)
self.start_watching()
async def run(self): async def run(self):
self.task = asyncio.create_task(self.update_params()) self._update_params_task = asyncio.create_task(self.update_params())
while True: while True:
if self.task.done(): if self._update_params_task.done():
if self.task.exception() is not None:
try: try:
raise self.task.exception() self._update_params_task.result()
except asyncio.TimeoutError: except OSError:
logging.error( logging.error(
"Encountered an error while updating parameter tree.", "Encountered an error while polling for information from Thermostat.",
exc_info=True, exc_info=True,
) )
self.connection_error.emit() self.connection_error.emit()
return return
_ = self.task.result() self._update_params_task = asyncio.create_task(self.update_params())
self.task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s) await asyncio.sleep(self._update_s)
@pyqtSlot()
def timed_out(self):
self.connection_errored = True
async def get_hw_rev(self): async def get_hw_rev(self):
self.hw_rev = await self._client.hw_rev() self.hw_rev = await self._client.hw_rev()
return self.hw_rev return self.hw_rev
async def update_params(self): async def update_params(self):
self.fan = await self._client.get_fan() fan_task = asyncio.create_task(self._client.get_fan())
self.pwm = await self._client.get_pwm() pwm_task = asyncio.create_task(self._client.get_pwm())
pid_task = asyncio.create_task(self._client.get_pid())
report_task = asyncio.create_task(self._client.report())
thermistor_task = asyncio.create_task(self._client.get_steinhart_hart())
postfilter_task = asyncio.create_task(self._client.get_postfilter())
self.fan = await fan_task
self.pwm = await pwm_task
if self._poll_for_report: if self._poll_for_report:
self.report = await self._client.report() self.report = await report_task
self.interval = [ self.interval = [
self.report[i]["interval"] for i in range(len(self.report)) self.report[i]["interval"] for i in range(len(self.report))
] ]
self.pid = await self._client.get_pid() self.pid = await pid_task
self.thermistor = await self._client.get_steinhart_hart() self.thermistor = await thermistor_task
self.postfilter = await self._client.get_postfilter() self.postfilter = await postfilter_task
def connected(self): def connected(self):
return self._client.connected() return self._client.connected()
def connecting(self):
return self._client.connecting()
def start_watching(self): def start_watching(self):
self._watch_task = asyncio.create_task(self.run()) self._watch_task = asyncio.create_task(self.run())
@ -78,8 +98,8 @@ class Thermostat(QObject, metaclass=PropertyMeta):
await self.set_report_mode(False) await self.set_report_mode(False)
self._watch_task.cancel() self._watch_task.cancel()
self._watch_task = None self._watch_task = None
self.task.cancel() self._update_params_task.cancel()
self.task = None self._update_params_task = None
async def set_report_mode(self, enabled: bool): async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled self._poll_for_report = not enabled
@ -95,8 +115,13 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self.report[i]["interval"] for i in range(len(self.report)) self.report[i]["interval"] for i in range(len(self.report))
] ]
@asyncSlot()
async def end_session(self): async def end_session(self):
await self._client.end_session() await self.set_report_mode(False)
self.stop_watching()
await self._client.disconnect()
self.connection_state_changed.emit(ThermostatConnectionState.DISCONNECTED)
self.connection_errored = False
async def set_ipv4(self, ipv4): async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4) await self._client.set_param("ipv4", ipv4)
@ -105,18 +130,12 @@ class Thermostat(QObject, metaclass=PropertyMeta):
return await self._client.ipv4() return await self._client.ipv4()
@asyncSlot() @asyncSlot()
async def save_cfg(self, ch): async def save_cfg(self, ch=""):
await self._client.save_config(ch) await self._client.save_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been saved to flash."
)
@asyncSlot() @asyncSlot()
async def load_cfg(self, ch): async def load_cfg(self, ch=""):
await self._client.load_config(ch) await self._client.load_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been loaded from flash."
)
async def dfu(self): async def dfu(self):
await self._client.dfu() await self._client.dfu()

View File

@ -1,9 +1,12 @@
from functools import partial
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import ( from pyqtgraph.parametertree import (
Parameter, Parameter,
registerParameterType, registerParameterType,
) )
from qasync import asyncSlot
from autotune import PIDAutotuneState
class MutexParameter(pTypes.ListParameter): class MutexParameter(pTypes.ListParameter):
@ -42,26 +45,23 @@ class MutexParameter(pTypes.ListParameter):
registerParameterType("mutex", MutexParameter) registerParameterType("mutex", MutexParameter)
def set_tree_label_tips(tree):
for item in tree.listAllItems():
p = item.param
if "tip" in p.opts:
item.setToolTip(0, p.opts["tip"])
class CtrlPanel(QObject): class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list) set_zero_limits_warning_sig = pyqtSignal(list)
def __init__( def __init__(
self, self,
thermostat,
autotuners,
info_box,
trees_ui, trees_ui,
param_tree, param_tree,
sigTreeStateChanged_handle,
sigActivated_handles,
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self.thermostat = thermostat
self.autotuners = autotuners
self.info_box = info_box
self.trees_ui = trees_ui self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui) self.NUM_CHANNELS = len(trees_ui)
@ -84,12 +84,23 @@ class CtrlPanel(QObject):
tree.setHeaderHidden(True) tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False) tree.setParameters(self.params[i], showTop=False)
self.params[i].setValue = self._setValue self.params[i].setValue = self._setValue
self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle) self.params[i].sigTreeStateChanged.connect(self.send_command)
set_tree_label_tips(tree) 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))
for handle in sigActivated_handles[i]: self.thermostat.pid_update.connect(self.update_pid)
self.params[i].child(*handle[0]).sigActivated.connect(handle[1]) self.thermostat.report_update.connect(self.update_report)
self.thermostat.thermistor_update.connect(self.update_thermistor)
self.thermostat.pwm_update.connect(self.update_pwm)
self.thermostat.postfilter_update.connect(self.update_postfilter)
def _setValue(self, value, blockSignal=None): def _setValue(self, value, blockSignal=None):
""" """
@ -120,28 +131,66 @@ class CtrlPanel(QObject):
def change_params_title(self, channel, path, title): def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=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":
if inner_param.opts.get("param", None) is not None:
if inner_param.opts.get("suffix", None) == "mA":
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"]
if thermostat_param[1] == "ch":
thermostat_param[1] = ch
if inner_param.name() == "Postfilter Rate" and data is None:
set_param_args = (*thermostat_param[:2], "off")
else:
set_param_args = (*thermostat_param, data)
param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.thermostat.set_param(*set_param_args)
param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None:
auto_tuner_param = inner_param.opts["pid_autotune"][0]
if inner_param.opts["pid_autotune"][1] != "ch":
ch = inner_param.opts["pid_autotune"][1]
self.autotuners.set_params(auto_tuner_param, ch, data)
if inner_param.opts.get("activaters", None) is not None:
activater = inner_param.opts["activaters"][
inner_param.opts["limits"].index(data)
]
if activater is not None:
if activater[1] == "ch":
activater[1] = ch
await self.thermostat.set_param(*activater)
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
def update_pid(self, pid_settings): def update_pid(self, pid_settings):
for settings in pid_settings: for settings in pid_settings:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("pid", "kp").setValue( self.params[channel].child("PID Config", "Kp").setValue(
settings["parameters"]["kp"] settings["parameters"]["kp"]
) )
self.params[channel].child("pid", "ki").setValue( self.params[channel].child("PID Config", "Ki").setValue(
settings["parameters"]["ki"] settings["parameters"]["ki"]
) )
self.params[channel].child("pid", "kd").setValue( self.params[channel].child("PID Config", "Kd").setValue(
settings["parameters"]["kd"] settings["parameters"]["kd"]
) )
self.params[channel].child( self.params[channel].child(
"pid", "pid_output_clamping", "output_min" "PID Config", "PID Output Clamping", "Minimum"
).setValue(settings["parameters"]["output_min"] * 1000) ).setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child( self.params[channel].child(
"pid", "pid_output_clamping", "output_max" "PID Config", "PID Output Clamping", "Maximum"
).setValue(settings["parameters"]["output_max"] * 1000) ).setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child( self.params[channel].child(
"output", "control_method", "set_temperature" "Output Config", "Control Method", "Set Temperature"
).setValue(settings["target"]) ).setValue(settings["target"])
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
@ -149,19 +198,19 @@ class CtrlPanel(QObject):
for settings in report_data: for settings in report_data:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("output", "control_method").setValue( self.params[channel].child("Output Config", "Control Method").setValue(
"temperature_pid" if settings["pid_engaged"] else "constant_current" "Temperature PID" if settings["pid_engaged"] else "Constant Current"
) )
self.params[channel].child( self.params[channel].child(
"output", "control_method", "set_current" "Output Config", "Control Method", "Set Current"
).setValue(settings["i_set"] * 1000) ).setValue(settings["i_set"] * 1000)
if settings["temperature"] is not None: if settings["temperature"] is not None:
self.params[channel].child("temperature").setValue( self.params[channel].child("Temperature").setValue(
settings["temperature"] settings["temperature"]
) )
if settings["tec_i"] is not None: if settings["tec_i"] is not None:
self.params[channel].child("current").setValue( self.params[channel].child("Current through TEC").setValue(
settings["tec_i"] settings["tec_i"] * 1000
) )
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
@ -169,13 +218,13 @@ class CtrlPanel(QObject):
for sh_param in sh_data: for sh_param in sh_data:
channel = sh_param["channel"] channel = sh_param["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("thermistor", "t0").setValue( self.params[channel].child("Thermistor Config", "T₀").setValue(
sh_param["params"]["t0"] - 273.15 sh_param["params"]["t0"] - 273.15
) )
self.params[channel].child("thermistor", "r0").setValue( self.params[channel].child("Thermistor Config", "R₀").setValue(
sh_param["params"]["r0"] sh_param["params"]["r0"]
) )
self.params[channel].child("thermistor", "b").setValue( self.params[channel].child("Thermistor Config", "B").setValue(
sh_param["params"]["b"] sh_param["params"]["b"]
) )
@ -186,15 +235,15 @@ class CtrlPanel(QObject):
for pwm_params in pwm_data: for pwm_params in pwm_data:
channel = pwm_params["channel"] channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("output", "limits", "max_v").setValue( self.params[channel].child(
pwm_params["max_v"]["value"] "Output Config", "Limits", "Max Voltage Difference"
) ).setValue(pwm_params["max_v"]["value"])
self.params[channel].child("output", "limits", "max_i_pos").setValue( self.params[channel].child(
pwm_params["max_i_pos"]["value"] * 1000 "Output Config", "Limits", "Max Cooling Current"
) ).setValue(pwm_params["max_i_pos"]["value"] * 1000)
self.params[channel].child("output", "limits", "max_i_neg").setValue( self.params[channel].child(
pwm_params["max_i_neg"]["value"] * 1000 "Output Config", "Limits", "Max Heating Current"
) ).setValue(pwm_params["max_i_neg"]["value"] * 1000)
for limit in "max_i_pos", "max_i_neg", "max_v": for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0: if pwm_params[limit]["value"] == 0.0:
@ -206,6 +255,39 @@ class CtrlPanel(QObject):
for postfilter_params in postfilter_data: for postfilter_params in postfilter_data:
channel = postfilter_params["channel"] channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("thermistor", "postfilter_rate").setValue( self.params[channel].child(
postfilter_params["rate"] "Thermistor Config", "Postfilter Rate"
).setValue(postfilter_params["rate"])
@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)

View File

@ -10,8 +10,12 @@ pg.setConfigOptions(antialias=True)
class LiveDataPlotter(QObject): class LiveDataPlotter(QObject):
def __init__(self, live_plots): def __init__(self, thermostat, live_plots):
super().__init__() super().__init__()
self._thermostat = thermostat
self._thermostat.report_update.connect(self.update_report)
self._thermostat.pid_update.connect(self.update_pid)
self.NUM_CHANNELS = len(live_plots) self.NUM_CHANNELS = len(live_plots)
self.graphs = [] self.graphs = []

View File

@ -1,36 +1,31 @@
{ {
"ctrl_panel": [ "ctrl_panel":[
{ {
"name": "temperature", "name":"Temperature",
"title": "Temperature", "type":"float",
"type": "float", "format":"{value:.4f} °C",
"format": "{value:.4f} °C", "readonly":true
"readonly": true
}, },
{ {
"name": "current", "name":"Current through TEC",
"title": "Current through TEC", "type":"float",
"type": "float", "suffix":"mA",
"siPrefix": true, "decimals":6,
"suffix": "A", "readonly":true
"decimals": 6,
"readonly": true
}, },
{ {
"name": "output", "name":"Output Config",
"title": "Output Settings", "expanded":true,
"expanded": true, "type":"group",
"type": "group", "children":[
"children": [
{ {
"name": "control_method", "name":"Control Method",
"title": "Control Method", "type":"mutex",
"type": "mutex", "limits":[
"limits": { "Constant Current",
"Constant Current": "constant_current", "Temperature PID"
"Temperature PID": "temperature_pid" ],
}, "activaters":[
"activaters": [
null, null,
[ [
"pwm", "pwm",
@ -38,370 +33,333 @@
"pid" "pid"
] ]
], ],
"children": [ "children":[
{ {
"name": "set_current", "name":"Set Current",
"title": "Set Current (mA)", "type":"float",
"type": "float", "value":0,
"value": 0, "step":100,
"step": 100, "limits":[
"limits": [
-2000, -2000,
2000 2000
], ],
"triggerOnShow": true, "triggerOnShow":true,
"decimals": 6, "decimals":6,
"compactHeight": false, "suffix":"mA",
"param": [ "param":[
"pwm", "pwm",
"ch", "ch",
"i_set" "i_set"
], ],
"lock": false "lock":false
}, },
{ {
"name": "set_temperature", "name":"Set Temperature",
"title": "Set Temperature (°C)", "type":"float",
"type": "float", "value":25,
"value": 25, "step":0.1,
"step": 0.1, "limits":[
"limits": [
-273, -273,
300 300
], ],
"format": "{value:.4f}", "format":"{value:.4f} °C",
"compactHeight": false, "param":[
"param": [
"pid", "pid",
"ch", "ch",
"target" "target"
], ],
"lock": false "lock":false
} }
] ]
}, },
{ {
"name": "limits", "name":"Limits",
"title": "Limits", "expanded":true,
"expanded": true, "type":"group",
"type": "group", "children":[
"children": [
{ {
"name": "max_i_pos", "name":"Max Cooling Current",
"title": "Max Cooling Current (mA)", "type":"float",
"type": "float", "value":0,
"value": 0, "step":100,
"step": 100, "decimals":6,
"decimals": 6, "limits":[
"compactHeight": false,
"limits": [
0, 0,
2000 2000
], ],
"param": [ "suffix":"mA",
"param":[
"pwm", "pwm",
"ch", "ch",
"max_i_pos" "max_i_pos"
], ],
"lock": false "lock":false
}, },
{ {
"name": "max_i_neg", "name":"Max Heating Current",
"title": "Max Heating Current (mA)", "type":"float",
"type": "float", "value":0,
"value": 0, "step":100,
"step": 100, "decimals":6,
"decimals": 6, "limits":[
"compactHeight": false,
"limits": [
0, 0,
2000 2000
], ],
"param": [ "suffix":"mA",
"param":[
"pwm", "pwm",
"ch", "ch",
"max_i_neg" "max_i_neg"
], ],
"lock": false "lock":false
}, },
{ {
"name": "max_v", "name":"Max Voltage Difference",
"title": "Max Voltage Difference (V)", "type":"float",
"type": "float", "value":0,
"value": 0, "step":0.1,
"step": 0.1, "limits":[
"limits": [
0, 0,
5 5
], ],
"siPrefix": true, "siPrefix":true,
"compactHeight": false, "suffix":"V",
"param": [ "param":[
"pwm", "pwm",
"ch", "ch",
"max_v" "max_v"
], ],
"lock": false "lock":false
} }
] ]
} }
] ]
}, },
{ {
"name": "thermistor", "name":"Thermistor Config",
"title": "Thermistor Settings", "expanded":true,
"expanded": true, "type":"group",
"type": "group", "children":[
"tip": "Settings of the connected Thermistor",
"children": [
{ {
"name": "t0", "name":"T₀",
"title": "T₀ (°C)", "type":"float",
"type": "float", "value":25,
"value": 25, "step":0.1,
"step": 0.1, "limits":[
"limits": [
-100, -100,
100 100
], ],
"format": "{value:.4f}", "format":"{value:.4f} °C",
"compactHeight": false, "param":[
"param": [
"s-h", "s-h",
"ch", "ch",
"t0" "t0"
], ],
"tip": "The origin temperature for the B-Parameter Formula", "lock":false
"lock": false
}, },
{ {
"name": "r0", "name":"R₀",
"title": "R₀ (Ω)", "type":"float",
"type": "float", "value":10000,
"value": 10000, "step":1,
"step": 1, "siPrefix":true,
"siPrefix": true, "suffix":"Ω",
"compactHeight": false, "param":[
"param": [
"s-h", "s-h",
"ch", "ch",
"r0" "r0"
], ],
"tip": "The origin resistance for the B-Parameter Formula", "lock":false
"lock": false
}, },
{ {
"name": "b", "name":"B",
"title": "B (K)", "type":"float",
"type": "float", "value":3950,
"value": 3950, "step":1,
"step": 1, "suffix":"K",
"decimals": 4, "decimals":4,
"compactHeight": false, "param":[
"param": [
"s-h", "s-h",
"ch", "ch",
"b" "b"
], ],
"lock": false "lock":false
}, },
{ {
"name": "postfilter_rate", "name":"Postfilter Rate",
"title": "Postfilter Rate", "type":"list",
"type": "list", "value":16.67,
"value": 16.67, "param":[
"param": [
"postfilter", "postfilter",
"ch", "ch",
"rate" "rate"
], ],
"limits": { "limits":{
"Off": null, "Off":null,
"16.67 Hz": 16.67, "16.67 Hz":16.67,
"20 Hz": 20.0, "20 Hz":20.0,
"21.25 Hz": 21.25, "21.25 Hz":21.25,
"27 Hz": 27.0 "27 Hz":27.0
}, },
"lock": false "lock":false
} }
] ]
}, },
{ {
"name": "pid", "name":"PID Config",
"title": "PID Settings", "expanded":true,
"expanded": true, "type":"group",
"type": "group", "children":[
"children": [
{ {
"name": "kp", "name":"Kp",
"title": "Kp", "type":"float",
"type": "float", "step":0.1,
"step": 0.1, "suffix":"",
"suffix": "", "param":[
"compactHeight": false,
"param": [
"pid", "pid",
"ch", "ch",
"kp" "kp"
], ],
"lock": false "lock":false
}, },
{ {
"name": "ki", "name":"Ki",
"title": "Ki", "type":"float",
"type": "float", "step":0.1,
"step": 0.1, "suffix":"Hz",
"suffix": "Hz", "param":[
"compactHeight": false,
"param": [
"pid", "pid",
"ch", "ch",
"ki" "ki"
], ],
"lock": false "lock":false
}, },
{ {
"name": "kd", "name":"Kd",
"title": "Kd", "type":"float",
"type": "float", "step":0.1,
"step": 0.1, "suffix":"s",
"suffix": "s", "param":[
"compactHeight": false,
"param": [
"pid", "pid",
"ch", "ch",
"kd" "kd"
], ],
"lock": false "lock":false
}, },
{ {
"name": "pid_output_clamping", "name":"PID Output Clamping",
"title": "PID Output Clamping", "expanded":true,
"expanded": true, "type":"group",
"type": "group", "children":[
"children": [
{ {
"name": "output_min", "name":"Minimum",
"title": "Minimum (mA)", "type":"float",
"type": "float", "step":100,
"step": 100, "limits":[
"limits": [
-2000, -2000,
2000 2000
], ],
"decimals": 6, "decimals":6,
"compactHeight": false, "suffix":"mA",
"param": [ "param":[
"pid", "pid",
"ch", "ch",
"output_min" "output_min"
], ],
"lock": false "lock":false
}, },
{ {
"name": "output_max", "name":"Maximum",
"title": "Maximum (mA)", "type":"float",
"type": "float", "step":100,
"step": 100, "limits":[
"limits": [
-2000, -2000,
2000 2000
], ],
"decimals": 6, "decimals":6,
"compactHeight": false, "suffix":"mA",
"param": [ "param":[
"pid", "pid",
"ch", "ch",
"output_max" "output_max"
], ],
"lock": false "lock":false
} }
] ]
}, },
{ {
"name": "pid_autotune", "name":"PID Auto Tune",
"title": "PID Autotune", "expanded":false,
"expanded": false, "type":"group",
"type": "group", "children":[
"children": [
{ {
"name": "target_temp", "name":"Target Temperature",
"title": "Target Temperature (°C)", "type":"float",
"type": "float", "value":20,
"value": 20, "step":0.1,
"step": 0.1, "format":"{value:.4f} °C",
"format": "{value:.4f}", "pid_autotune":[
"compactHeight": false,
"pid_autotune": [
"target_temp", "target_temp",
"ch" "ch"
] ]
}, },
{ {
"name": "test_current", "name":"Test Current",
"title": "Test Current (mA)", "type":"float",
"type": "float", "value":0,
"value": 0, "decimals":6,
"decimals": 6, "step":100,
"compactHeight": false, "limits":[
"step": 100,
"limits": [
-2000, -2000,
2000 2000
], ],
"pid_autotune": [ "suffix":"mA",
"pid_autotune":[
"test_current", "test_current",
"ch" "ch"
] ]
}, },
{ {
"name": "temp_swing", "name":"Temperature Swing",
"title": "Temperature Swing (°C)", "type":"float",
"type": "float", "value":1.5,
"value": 1.5, "step":0.1,
"step": 0.1, "prefix":"±",
"prefix": "±", "format":"{value:.4f} °C",
"format": "{value:.4f}", "pid_autotune":[
"compactHeight": false,
"pid_autotune": [
"temp_swing", "temp_swing",
"ch" "ch"
] ]
}, },
{ {
"name": "lookback", "name":"Lookback",
"title": "Lookback (s)", "type":"float",
"type": "float", "value":3.0,
"value": 3.0, "step":0.1,
"step": 0.1, "format":"{value:.4f} s",
"format": "{value:.4f}", "pid_autotune":[
"compactHeight": false,
"pid_autotune": [
"lookback", "lookback",
"ch" "ch"
] ]
}, },
{ {
"name": "run_pid", "name":"Run",
"title": "Run", "type":"action",
"type": "action", "tip":"Run"
"tip": "Run PID Autotune with above settings"
} }
] ]
} }
] ]
}, },
{ {
"name": "save", "name":"Save to flash",
"title": "Save to flash", "type":"action",
"type": "action", "tip":"Save config to thermostat, applies on reset"
"tip": "Save settings to thermostat, applies on reset"
}, },
{ {
"name": "load", "name":"Load from flash",
"title": "Load from flash", "type":"action",
"type": "action", "tip":"Load config from flash"
"tip": "Load settings from flash"
} }
] ]
} }

View File

@ -2,18 +2,22 @@ from PyQt6 import QtWidgets, QtGui
class PlotOptionsMenu(QtWidgets.QMenu): class PlotOptionsMenu(QtWidgets.QMenu):
def __init__(self, max_samples=1000): def __init__(self, channel_graphs, max_samples=1000):
super().__init__() super().__init__()
self.channel_graphs = channel_graphs
self.setTitle("Plot Settings") self.setTitle("Plot Settings")
clear = QtGui.QAction("Clear graphs", self) clear = QtGui.QAction("Clear graphs", self)
self.addAction(clear) self.addAction(clear)
self.clear = clear self.clear = clear
self.clear.triggered.connect(self.channel_graphs.clear_graphs)
self.samples_spinbox = QtWidgets.QSpinBox() self.samples_spinbox = QtWidgets.QSpinBox()
self.samples_spinbox.setRange(2, 100000) self.samples_spinbox.setRange(2, 100000)
self.samples_spinbox.setSuffix(" samples") self.samples_spinbox.setSuffix(" samples")
self.samples_spinbox.setValue(max_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 = QtWidgets.QWidgetAction(self)
limit_samples.setDefaultWidget(self.samples_spinbox) limit_samples.setDefaultWidget(self.samples_spinbox)

View File

@ -1,24 +1,20 @@
import logging
from PyQt6 import QtWidgets, QtGui, QtCore from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, pyqtSlot from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker
from qasync import asyncSlot
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
class ThermostatCtrlMenu(QtWidgets.QMenu): class ThermostatCtrlMenu(QtWidgets.QMenu):
fan_set_act = pyqtSignal(int) def __init__(self, thermostat, info_box, style):
fan_auto_set_act = pyqtSignal(int)
connect_act = pyqtSignal()
reset_act = pyqtSignal(bool)
dfu_act = pyqtSignal(bool)
load_cfg_act = pyqtSignal(int)
save_cfg_act = pyqtSignal(int)
net_cfg_act = pyqtSignal(bool)
def __init__(self, style):
super().__init__() super().__init__()
self._thermostat = thermostat
self._info_box = info_box
self._style = style self._style = style
self.setTitle("Thermostat settings") self.setTitle("Thermostat settings")
self.hw_rev_data = dict() self.hw_rev_data = dict()
self._thermostat.hw_rev_update.connect(self.hw_rev)
self.fan_group = QtWidgets.QWidget() self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False) self.fan_group.setEnabled(False)
@ -45,8 +41,9 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning) self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set_act) self.fan_power_slider.valueChanged.connect(self.fan_set_request)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act) 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.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:") self.fan_lbl.setText("Fan:")
@ -58,40 +55,36 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan = fan self.fan = fan
self.actionReset = QtGui.QAction("Reset Thermostat", self) self.actionReset = QtGui.QAction("Reset Thermostat", self)
self.actionReset.triggered.connect(self.reset_act) self.actionReset.triggered.connect(self.reset_request)
self.addAction(self.actionReset) self.addAction(self.actionReset)
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self) self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act) self.actionEnter_DFU_Mode.triggered.connect(self.dfu_request)
self.addAction(self.actionEnter_DFU_Mode) self.addAction(self.actionEnter_DFU_Mode)
self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self) self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
self.actionnet_settings_input_diag.triggered.connect(self.net_cfg_act) self.actionnet_settings_input_diag.triggered.connect(self.net_settings_request)
self.addAction(self.actionnet_settings_input_diag) self.addAction(self.actionnet_settings_input_diag)
@pyqtSlot(bool) @asyncSlot(bool)
def load(_): async def load(_):
self.load_cfg_act.emit(0) await self._thermostat.load_cfg()
self.load_cfg_act.emit(1)
loaded = QtWidgets.QMessageBox(self) self._info_box.display_info_box(
loaded.setWindowTitle("Config loaded") "Config loaded", "All channel configs have been loaded from flash."
loaded.setText("All channel configs have been loaded from flash.") )
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
loaded.show()
self.actionLoad_all_configs = QtGui.QAction("Load Config", self) self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
self.actionLoad_all_configs.triggered.connect(load) self.actionLoad_all_configs.triggered.connect(load)
self.addAction(self.actionLoad_all_configs) self.addAction(self.actionLoad_all_configs)
@pyqtSlot(bool) @asyncSlot(bool)
def save(_): async def save(_):
self.save_cfg_act.emit(0) await self._thermostat.save_cfg()
self.save_cfg_act.emit(1)
saved = QtWidgets.QMessageBox(self) self._info_box.display_info_box(
saved.setWindowTitle("Config saved") "Config saved", "All channel configs have been saved to flash."
saved.setText("All channel configs have been saved to flash.") )
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
saved.show()
self.actionSave_all_configs = QtGui.QAction("Save Config", self) self.actionSave_all_configs = QtGui.QAction("Save Config", self)
self.actionSave_all_configs.triggered.connect(save) self.actionSave_all_configs.triggered.connect(save)
@ -127,6 +120,18 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.actionAbout_Thermostat.triggered.connect(about_thermostat) self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.addAction(self.actionAbout_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): def set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100: if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
@ -143,3 +148,55 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
def hw_rev(self, hw_rev): def hw_rev(self, hw_rev):
self.hw_rev_data = hw_rev self.hw_rev_data = hw_rev
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"]) 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()
@asyncSlot(bool)
async def dfu_request(self, _):
assert self._thermostat.connected()
await self._thermostat.dfu()
await self._thermostat.end_session()
@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()

18
pytec/setup.py Normal file
View File

@ -0,0 +1,18 @@
from setuptools import setup, find_packages
setup(
name="pytec",
version="0.0",
author="M-Labs",
url="https://git.m-labs.hk/M-Labs/thermostat",
description="Control TEC",
license="GPLv3",
install_requires=["setuptools"],
packages=find_packages(),
entry_points={
"gui_scripts": [
"tec_qt = tec_qt:main",
]
},
py_modules=['autotune', 'plot', 'tec_qt'],
)

321
pytec/tec_qt.py Normal file → Executable file
View File

@ -1,5 +1,4 @@
from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu
from pytec.gui.view.conn_menu import ConnMenu from pytec.gui.view.conn_menu import ConnMenu
from pytec.gui.view.plot_options_menu import PlotOptionsMenu from pytec.gui.view.plot_options_menu import PlotOptionsMenu
@ -7,12 +6,11 @@ from pytec.gui.view.live_plot_view import LiveDataPlotter
from pytec.gui.view.ctrl_panel import CtrlPanel from pytec.gui.view.ctrl_panel import CtrlPanel
from pytec.gui.view.info_box import InfoBox from pytec.gui.view.info_box import InfoBox
from pytec.gui.model.pid_autotuner import PIDAutoTuner from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.model.thermostat import Thermostat from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState
import json import json
from autotune import PIDAutotuneState from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose from qasync import asyncSlot, asyncClose
import qasync import qasync
from pytec.aioclient import StoppedConnecting
import asyncio import asyncio
import logging import logging
import argparse import argparse
@ -30,9 +28,9 @@ def get_argparser():
"--connect", "--connect",
default=None, default=None,
action="store_true", action="store_true",
help="Automatically connect to the specified Thermostat in IP:port format", help="Automatically connect to the specified Thermostat in host:port format",
) )
parser.add_argument("IP", metavar="ip", default=None, nargs="?") parser.add_argument("HOST", metavar="host", default=None, nargs="?")
parser.add_argument("PORT", metavar="port", default=None, nargs="?") parser.add_argument("PORT", metavar="port", default=None, nargs="?")
parser.add_argument( parser.add_argument(
"-l", "-l",
@ -60,47 +58,38 @@ class MainWindow(QtWidgets.QMainWindow):
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui") ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self) uic.loadUi(ui_file_path, self)
self.hw_rev_data = None
self.info_box = InfoBox() self.info_box = InfoBox()
self.thermostat = Thermostat( self.thermostat = Thermostat(
self, self.report_refresh_spin.value() self, self.report_refresh_spin.value()
) )
self._connecting_task = None
def handle_connection_error(): def handle_connection_error():
logging.error("Client connection error, disconnecting")
self.info_box.display_info_box( self.info_box.display_info_box(
"Connection Error", "Thermostat connection lost. Is it unplugged?" "Connection Error", "Thermostat connection lost. Is it unplugged?"
) )
self.bail()
self.thermostat.connection_error.connect(handle_connection_error) self.thermostat.connection_error.connect(handle_connection_error)
self.thermostat.connection_error.connect(self.thermostat.timed_out)
self.thermostat.connection_error.connect(self.thermostat.end_session)
self.thermostat.connection_state_changed.connect(self._on_connection_changed)
self.autotuners = PIDAutoTuner(self, self.thermostat, 2) self.autotuners = PIDAutoTuner(self, self.thermostat, 2)
self.autotuners.autotune_state_changed.connect(self.pid_autotune_handler)
def get_ctrl_panel_config(args): def get_ctrl_panel_config(args):
with open(args.param_tree, "r") as f: with open(args.param_tree, "r", encoding="utf-8") as f:
return json.load(f)["ctrl_panel"] return json.load(f)["ctrl_panel"]
param_tree_sigActivated_handles = [
[
[["save"], partial(self.thermostat.save_cfg, ch)],
[["load"], partial(self.thermostat.load_cfg, ch)],
[
["pid", "pid_autotune", "run_pid"],
partial(self.pid_auto_tune_request, ch),
],
]
for ch in range(self.NUM_CHANNELS)
]
self.thermostat.info_box_trigger.connect(self.info_box.display_info_box)
self.ctrl_panel_view = CtrlPanel( self.ctrl_panel_view = CtrlPanel(
self.thermostat,
self.autotuners,
self.info_box,
[self.ch0_tree, self.ch1_tree], [self.ch0_tree, self.ch1_tree],
get_ctrl_panel_config(args), get_ctrl_panel_config(args),
self.send_command,
param_tree_sigActivated_handles,
) )
self.zero_limits_warning = ZeroLimitsWarningView( self.zero_limits_warning = ZeroLimitsWarningView(
@ -110,55 +99,28 @@ class MainWindow(QtWidgets.QMainWindow):
self.zero_limits_warning.set_limits_warning self.zero_limits_warning.set_limits_warning
) )
self.thermostat.fan_update.connect(self.fan_update) self.thermostat.hw_rev_update.connect(self._status)
self.thermostat.report_update.connect(self.ctrl_panel_view.update_report)
self.thermostat.report_update.connect(self.autotuners.tick)
self.thermostat.report_update.connect(self.pid_autotune_handler)
self.thermostat.pid_update.connect(self.ctrl_panel_view.update_pid)
self.thermostat.pwm_update.connect(self.ctrl_panel_view.update_pwm)
self.thermostat.thermistor_update.connect(
self.ctrl_panel_view.update_thermistor
)
self.thermostat.postfilter_update.connect(
self.ctrl_panel_view.update_postfilter
)
self.thermostat.interval_update.connect(
self.autotuners.update_sampling_interval
)
self.report_apply_btn.clicked.connect( self.report_apply_btn.clicked.connect(
lambda: self.thermostat.set_update_s(self.report_refresh_spin.value()) lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
) )
self.channel_graphs = LiveDataPlotter( self.channel_graphs = LiveDataPlotter(
self.thermostat,
[ [
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")] [getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
for ch in range(self.NUM_CHANNELS) for ch in range(self.NUM_CHANNELS)
] ]
) )
self.thermostat.report_update.connect(self.channel_graphs.update_report) self.plot_options_menu = PlotOptionsMenu(self.channel_graphs)
self.thermostat.pid_update.connect(self.channel_graphs.update_pid)
self.plot_options_menu = PlotOptionsMenu()
self.plot_options_menu.clear.triggered.connect(self.clear_graphs)
self.plot_options_menu.samples_spinbox.valueChanged.connect(
self.channel_graphs.set_max_samples
)
self.plot_settings.setMenu(self.plot_options_menu) self.plot_settings.setMenu(self.plot_options_menu)
self.conn_menu = ConnMenu() self.conn_menu = ConnMenu()
self.connect_btn.setMenu(self.conn_menu) self.connect_btn.setMenu(self.conn_menu)
self.thermostat_ctrl_menu = ThermostatCtrlMenu(self.style()) self.thermostat_ctrl_menu = ThermostatCtrlMenu(
self.thermostat_ctrl_menu.fan_set_act.connect(self.fan_set_request) self.thermostat, self.info_box, self.style()
self.thermostat_ctrl_menu.fan_auto_set_act.connect(self.fan_auto_set_request) )
self.thermostat_ctrl_menu.reset_act.connect(self.reset_request)
self.thermostat_ctrl_menu.dfu_act.connect(self.dfu_request)
self.thermostat_ctrl_menu.save_cfg_act.connect(self.save_cfg_request)
self.thermostat_ctrl_menu.load_cfg_act.connect(self.load_cfg_request)
self.thermostat_ctrl_menu.net_cfg_act.connect(self.net_settings_request)
self.thermostat.hw_rev_update.connect(self.thermostat_ctrl_menu.hw_rev)
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu) self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.loading_spinner.hide() self.loading_spinner.hide()
@ -170,166 +132,97 @@ class MainWindow(QtWidgets.QMainWindow):
self.port_set_spin.setValue(int(args.PORT)) self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click() self.connect_btn.click()
def clear_graphs(self): @asyncSlot(ThermostatConnectionState)
self.channel_graphs.clear_graphs()
async def _on_connection_changed(self, result): async def _on_connection_changed(self, result):
self.graph_group.setEnabled(result) match result:
self.report_group.setEnabled(result) case ThermostatConnectionState.CONNECTED:
self.thermostat_settings.setEnabled(result) self.graph_group.setEnabled(True)
self.report_group.setEnabled(True)
self.thermostat_settings.setEnabled(True)
self.conn_menu.host_set_line.setEnabled(not result) self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(not result) self.conn_menu.port_set_spin.setEnabled(False)
self.connect_btn.setText("Disconnect" if result else "Connect") self.connect_btn.setText("Disconnect")
if result:
self.hw_rev_data = await self.thermostat.get_hw_rev() case ThermostatConnectionState.CONNECTING:
logging.debug(self.hw_rev_data) self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False)
case ThermostatConnectionState.DISCONNECTED:
self.graph_group.setEnabled(False)
self.report_group.setEnabled(False)
self.thermostat_settings.setEnabled(False)
self.conn_menu.host_set_line.setEnabled(True)
self.conn_menu.port_set_spin.setEnabled(True)
self.connect_btn.setText("Connect")
self._status(self.hw_rev_data)
self.thermostat.start_watching()
else:
self.status_lbl.setText("Disconnected") self.status_lbl.setText("Disconnected")
self.background_task_lbl.setText("Ready.") self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide() self.loading_spinner.hide()
self.loading_spinner.stop() self.loading_spinner.stop()
self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap()) self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("") self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.clear_graphs() self.channel_graphs.clear_graphs()
self.report_box.setChecked(False) self.report_box.setChecked(False)
if not Thermostat.connecting or Thermostat.connected:
for ch in range(self.NUM_CHANNELS): for ch in range(self.NUM_CHANNELS):
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF: if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
if self.thermostat.connection_errored:
# Don't send any commands, just reset local state
self.autotuners.autotuners[ch].setOff()
else:
await self.autotuners.stop_pid_from_running(ch) await self.autotuners.stop_pid_from_running(ch)
await self.thermostat.set_report_mode(False)
self.thermostat.stop_watching()
def _status(self, hw_rev_d: dict): def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText( self.status_lbl.setText(
f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}" f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}"
) )
@pyqtSlot("QVariantMap")
def fan_update(self, fan_settings):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.thermostat_ctrl_menu.fan_power_slider):
self.thermostat_ctrl_menu.fan_power_slider.setValue(
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
)
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(fan_settings["auto_mode"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int) @asyncSlot(int)
async def on_report_box_stateChanged(self, enabled): async def on_report_box_stateChanged(self, enabled):
await self.thermostat.set_report_mode(enabled) await self.thermostat.set_report_mode(enabled)
@asyncClose @asyncClose
async def closeEvent(self, event): async def closeEvent(self, _event):
try: try:
await self.bail() await self.thermostat.end_session()
except: except:
pass pass
@asyncSlot() @asyncSlot()
async def on_connect_btn_clicked(self): async def on_connect_btn_clicked(self):
host, port = ( if (self._connecting_task is None) and (not self.thermostat.connected()):
self.conn_menu.host_set_line.text(), self._connecting_task = asyncio.create_task(
self.conn_menu.port_set_spin.value(), self.thermostat.start_session(
host=self.conn_menu.host_set_line.text(),
port=self.conn_menu.port_set_spin.value(),
)
) )
try: try:
if not (self.thermostat.connecting() or self.thermostat.connected()): await self._connecting_task
self.status_lbl.setText("Connecting...") except (OSError, asyncio.CancelledError) as exc:
self.connect_btn.setText("Stop") await self.thermostat.end_session()
self.conn_menu.host_set_line.setEnabled(False) if isinstance(exc, asyncio.CancelledError):
self.conn_menu.port_set_spin.setEnabled(False)
try:
await self.thermostat.start_session(host=host, port=port)
except StoppedConnecting:
return return
await self._on_connection_changed(True) raise
finally:
self._connecting_task = None
elif self._connecting_task is not None:
self._connecting_task.cancel()
else: else:
await self.bail()
# TODO: Remove asyncio.TimeoutError in Python 3.11
except (OSError, asyncio.TimeoutError):
try:
await self.bail()
except ConnectionResetError:
pass
@asyncSlot()
async def bail(self):
await self._on_connection_changed(False)
await self.thermostat.end_session() await self.thermostat.end_session()
@asyncSlot(object, object) @asyncSlot(int, PIDAutotuneState)
async def send_command(self, param, changes): async def pid_autotune_handler(self, _ch, _state):
"""Translates parameter tree changes into thermostat set_param calls"""
ch = param.channel
for inner_param, change, data in changes:
if change == "value":
if inner_param.opts.get("param", None) is not None:
if inner_param.opts.get("suffix", None) == "mA":
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"]
if thermostat_param[1] == "ch":
thermostat_param[1] = ch
if inner_param.name() == "Postfilter Rate" and data is None:
set_param_args = (*thermostat_param[:2], "off")
else:
set_param_args = (*thermostat_param, data)
param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.thermostat.set_param(*set_param_args)
param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None:
auto_tuner_param = inner_param.opts["pid_autotune"][0]
if inner_param.opts["pid_autotune"][1] != "ch":
ch = inner_param.opts["pid_autotune"][1]
self.autotuners.set_params(auto_tuner_param, ch, data)
if inner_param.opts.get("activaters", None) is not None:
activater = inner_param.opts["activaters"][
inner_param.reverse[0].index(
data
) # ListParameter.reverse = list of codename values
]
if activater is not None:
if activater[1] == "ch":
activater[1] = ch
await self.thermostat.set_param(*activater)
@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)
# To Update the UI elements
self.pid_autotune_handler([])
@asyncSlot(list)
async def pid_autotune_handler(self, _):
ch_tuning = [] ch_tuning = []
for ch in range(self.NUM_CHANNELS): for ch in range(self.NUM_CHANNELS):
match self.autotuners.get_state(ch): match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF: case PIDAutotuneState.STATE_OFF:
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("pid", "pid_autotune", "run_pid"), "Run" ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
) )
case ( case (
PIDAutotuneState.STATE_READY PIDAutotuneState.STATE_READY
@ -337,21 +230,21 @@ class MainWindow(QtWidgets.QMainWindow):
| PIDAutotuneState.STATE_RELAY_STEP_DOWN | PIDAutotuneState.STATE_RELAY_STEP_DOWN
): ):
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("pid", "pid_autotune", "run_pid"), "Stop" ch, ("PID Config", "PID Auto Tune", "Run"), "Stop"
) )
ch_tuning.append(ch) ch_tuning.append(ch)
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
self.info_box.display_info_box( self.info_box.display_info_box(
"PID Autotune Success", "PID Autotune Success",
f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.", f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
) )
self.info_box.show() self.info_box.show()
case PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_FAILED:
self.info_box.display_info_box( self.info_box.display_info_box(
"PID Autotune Failed", "PID Autotune Failed",
f"Channel {ch} PID Autotune on channel has failed.", f"Channel {ch} PID Autotune has failed.",
) )
self.info_box.show() self.info_box.show()
@ -366,74 +259,6 @@ class MainWindow(QtWidgets.QMainWindow):
self.loading_spinner.start() self.loading_spinner.start()
self.loading_spinner.show() self.loading_spinner.show()
@asyncSlot(int)
async def fan_set_request(self, value):
assert self.thermostat.connected()
if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
await self.thermostat.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.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.thermostat_ctrl_menu.fan_power_slider.value()
)
@asyncSlot(int)
async def save_cfg_request(self, ch):
assert self.thermostat.connected()
await self.thermostat.save_cfg(str(ch))
@asyncSlot(int)
async def load_cfg_request(self, ch):
assert self.thermostat.connected()
await self.thermostat.load_cfg(str(ch))
@asyncSlot(bool)
async def dfu_request(self, _):
assert self.thermostat.connected()
await self._on_connection_changed(False)
await self.thermostat.dfu()
@asyncSlot(bool)
async def reset_request(self, _):
assert self.thermostat.connected()
await self._on_connection_changed(False)
await self.thermostat.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
@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()
await self._on_connection_changed(False)
async def coro_main(): async def coro_main():
args = get_argparser().parse_args() args = get_argparser().parse_args()

View File

@ -1,6 +1,5 @@
use core::{cmp::max_by, marker::PhantomData}; use core::cmp::max_by;
use heapless::{consts::U2, Vec}; use heapless::{consts::U2, Vec};
use num_traits::Zero;
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use smoltcp::time::Instant; use smoltcp::time::Instant;
use stm32f4xx_hal::hal; use stm32f4xx_hal::hal;
@ -33,24 +32,12 @@ pub enum PinsAdcReadTarget {
pub const CHANNELS: usize = 2; pub const CHANNELS: usize = 2;
pub const R_SENSE: f64 = 0.05; pub const R_SENSE: f64 = 0.05;
// From design specs // as stated in the MAX1968 datasheet
pub const MAX_TEC_I: ElectricCurrent = ElectricCurrent { pub const MAX_TEC_I: f64 = 3.0;
dimension: PhantomData,
units: PhantomData,
value: 2.0,
};
pub const MAX_TEC_V: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 4.0,
};
// DAC chip outputs 0-5v, which is then passed through a resistor dividor to provide 0-3v range // DAC chip outputs 0-5v, which is then passed through a resistor dividor to provide 0-3v range
const DAC_OUT_V_MAX: ElectricPotential = ElectricPotential { const DAC_OUT_V_MAX: f64 = 3.0;
dimension: PhantomData,
units: PhantomData,
value: 3.0,
};
// TODO: -pub // TODO: -pub
pub struct Channels { pub struct Channels {
channel0: Channel<Channel0>, channel0: Channel<Channel0>,
@ -141,7 +128,7 @@ impl Channels {
/// i_set DAC /// i_set DAC
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential { fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential {
let value = ((voltage / DAC_OUT_V_MAX).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ; let value = ((voltage / ElectricPotential::new::<volt>(DAC_OUT_V_MAX)).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ;
match channel { match channel {
0 => self.channel0.dac.set(value).unwrap(), 0 => self.channel0.dac.set(value).unwrap(),
1 => self.channel1.dac.set(value).unwrap(), 1 => self.channel1.dac.set(value).unwrap(),
@ -152,7 +139,11 @@ impl Channels {
} }
pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent { pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent {
let i_set = i_set.min(MAX_TEC_I).max(-MAX_TEC_I); // Silently clamp i_set
let i_ceiling = ElectricCurrent::new::<ampere>(MAX_TEC_I);
let i_floor = ElectricCurrent::new::<ampere>(-MAX_TEC_I);
let i_set = i_set.min(i_ceiling).max(i_floor);
let vref_meas = match channel.into() { let vref_meas = match channel.into() {
0 => self.channel0.vref_meas, 0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas, 1 => self.channel1.vref_meas,
@ -327,7 +318,7 @@ impl Channels {
best_error = error; best_error = error;
start_value = prev_value; start_value = prev_value;
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * DAC_OUT_V_MAX; let vref = (value as f64 / ad5680::MAX_VALUE as f64) * ElectricPotential::new::<volt>(DAC_OUT_V_MAX);
match channel { match channel {
0 => self.channel0.vref_meas = vref, 0 => self.channel0.vref_meas = vref,
1 => self.channel1.vref_meas = vref, 1 => self.channel1.vref_meas = vref,
@ -387,22 +378,22 @@ impl Channels {
} }
} }
pub fn get_max_v(&mut self, channel: usize) -> (ElectricPotential, ElectricPotential) { pub fn get_max_v(&mut self, channel: usize) -> ElectricPotential {
let max = 4.0 * ElectricPotential::new::<volt>(3.3); let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = self.get_pwm(channel, PwmPin::MaxV); let duty = self.get_pwm(channel, PwmPin::MaxV);
(duty * max, MAX_TEC_V) duty * max
} }
pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) { pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxIPos); let duty = self.get_pwm(channel, PwmPin::MaxIPos);
(duty * max, MAX_TEC_I) (duty * max, max)
} }
pub fn get_max_i_neg(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) { pub fn get_max_i_neg(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxINeg); let duty = self.get_pwm(channel, PwmPin::MaxINeg);
(duty * max, MAX_TEC_I) (duty * max, max)
} }
// Get current passing through TEC // Get current passing through TEC
@ -444,21 +435,21 @@ impl Channels {
pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) { pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3); let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = (max_v.min(MAX_TEC_V).max(ElectricPotential::zero()) / max).get::<ratio>(); let duty = (max_v / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxV, duty); let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
(duty * max, max) (duty * max, max)
} }
pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) { pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_pos.min(MAX_TEC_I).max(ElectricCurrent::zero()) / max).get::<ratio>(); let duty = (max_i_pos / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxIPos, duty); let duty = self.set_pwm(channel, PwmPin::MaxIPos, duty);
(duty * max, max) (duty * max, max)
} }
pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) { pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_neg.min(MAX_TEC_I).max(ElectricCurrent::zero()) / max).get::<ratio>(); let duty = (max_i_neg / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxINeg, duty); let duty = self.set_pwm(channel, PwmPin::MaxINeg, duty);
(duty * max, max) (duty * max, max)
} }
@ -518,8 +509,8 @@ impl Channels {
PwmSummary { PwmSummary {
channel, channel,
center: CenterPointJson(self.channel_state(channel).center.clone()), center: CenterPointJson(self.channel_state(channel).center.clone()),
i_set: (self.get_i(channel), MAX_TEC_I).into(), i_set: (self.get_i(channel), ElectricCurrent::new::<ampere>(3.0)).into(),
max_v: self.get_max_v(channel).into(), max_v: (self.get_max_v(channel), ElectricPotential::new::<volt>(5.0)).into(),
max_i_pos: self.get_max_i_pos(channel).into(), max_i_pos: self.get_max_i_pos(channel).into(),
max_i_neg: self.get_max_i_neg(channel).into(), max_i_neg: self.get_max_i_neg(channel).into(),
} }

View File

@ -1,4 +1,3 @@
use num_traits::Zero;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use uom::si::{ use uom::si::{
electric_potential::volt, electric_potential::volt,
@ -19,7 +18,6 @@ pub struct ChannelConfig {
pid: pid::Parameters, pid: pid::Parameters,
pid_target: f32, pid_target: f32,
pid_engaged: bool, pid_engaged: bool,
i_set: ElectricCurrent,
sh: steinhart_hart::Parameters, sh: steinhart_hart::Parameters,
pwm: PwmLimits, pwm: PwmLimits,
/// uses variant `PostFilter::Invalid` instead of `None` to save space /// uses variant `PostFilter::Invalid` instead of `None` to save space
@ -35,17 +33,11 @@ impl ChannelConfig {
.unwrap_or(PostFilter::Invalid); .unwrap_or(PostFilter::Invalid);
let state = channels.channel_state(channel); let state = channels.channel_state(channel);
let i_set = if state.pid_engaged {
ElectricCurrent::zero()
} else {
state.i_set
};
ChannelConfig { ChannelConfig {
center: state.center.clone(), center: state.center.clone(),
pid: state.pid.parameters.clone(), pid: state.pid.parameters.clone(),
pid_target: state.pid.target as f32, pid_target: state.pid.target as f32,
pid_engaged: state.pid_engaged, pid_engaged: state.pid_engaged,
i_set: i_set,
sh: state.sh.clone(), sh: state.sh.clone(),
pwm, pwm,
adc_postfilter, adc_postfilter,
@ -67,7 +59,6 @@ impl ChannelConfig {
adc_postfilter => Some(adc_postfilter), adc_postfilter => Some(adc_postfilter),
}; };
let _ = channels.adc.set_postfilter(channel as u8, adc_postfilter); let _ = channels.adc.set_postfilter(channel as u8, adc_postfilter);
let _ = channels.set_i(channel, self.i_set);
} }
} }
@ -80,7 +71,7 @@ struct PwmLimits {
impl PwmLimits { impl PwmLimits {
pub fn new(channels: &mut Channels, channel: usize) -> Self { pub fn new(channels: &mut Channels, channel: usize) -> Self {
let (max_v, _) = channels.get_max_v(channel); let max_v = channels.get_max_v(channel);
let (max_i_pos, _) = channels.get_max_i_pos(channel); let (max_i_pos, _) = channels.get_max_i_pos(channel);
let (max_i_neg, _) = channels.get_max_i_neg(channel); let (max_i_neg, _) = channels.get_max_i_neg(channel);
PwmLimits { PwmLimits {

View File

@ -54,7 +54,7 @@ impl FanCtrl {
pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) { pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) {
self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32; self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32;
if self.fan_auto && self.hw_settings.fan_available { if self.fan_auto && self.hw_settings.fan_available {
let scaled_current = self.abs_max_tec_i / MAX_TEC_I.get::<ampere>() as f32; let scaled_current = self.abs_max_tec_i / MAX_TEC_I as f32;
// do not limit upper bound, as it will be limited in the set_pwm() // do not limit upper bound, as it will be limited in the set_pwm()
let pwm = (MAX_USER_FAN_PWM * (scaled_current * (scaled_current * self.k_a + self.k_b) + self.k_c)) as u32; let pwm = (MAX_USER_FAN_PWM * (scaled_current * (scaled_current * self.k_a + self.k_b) + self.k_c)) as u32;
self.set_pwm(pwm); self.set_pwm(pwm);

View File

@ -54,13 +54,15 @@ impl Controller {
// + x0 * (kp + ki + kd) // + x0 * (kp + ki + kd)
// - x1 * (kp + 2kd) // - x1 * (kp + 2kd)
// + x2 * kd // + x2 * kd
// + kp * (u0 - u1)
// y0 = clip(y0', ymin, ymax) // y0 = clip(y0', ymin, ymax)
pub fn update(&mut self, input: f64) -> f64 { pub fn update(&mut self, input: f64) -> f64 {
let mut output: f64 = self.y1 - self.target * f64::from(self.parameters.ki) let mut output: f64 = self.y1 - self.target * f64::from(self.parameters.ki)
+ input * f64::from(self.parameters.kp + self.parameters.ki + self.parameters.kd) + input * f64::from(self.parameters.kp + self.parameters.ki + self.parameters.kd)
- self.x1 * f64::from(self.parameters.kp + 2.0 * self.parameters.kd) - self.x1 * f64::from(self.parameters.kp + 2.0 * self.parameters.kd)
+ self.x2 * f64::from(self.parameters.kd); + self.x2 * f64::from(self.parameters.kd)
+ f64::from(self.parameters.kp) * (self.target - self.u1);
if output < self.parameters.output_min.into() { if output < self.parameters.output_min.into() {
output = self.parameters.output_min.into(); output = self.parameters.output_min.into();
} }