Compare commits

..

250 Commits

Author SHA1 Message Date
9c4b514485 add swap status to the report
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-03-08 11:16:17 +08:00
89380b1716 Merge remote-tracking branch 'refs/remotes/atse/GUI' into zotino-tec
# Conflicts:
#	src/channels.rs
2024-03-08 11:13:03 +08:00
d7b65831a8 Add swap command 2024-03-06 15:54:57 +08:00
d9fc7a9e27 Put temperature on anyway 2024-03-06 15:19:56 +08:00
aac5172397 More detailed text for load/save on all channels 2024-02-14 16:00:57 +08:00
04017c0008 Change confusing PID limit terminology 2024-02-14 16:00:55 +08:00
e0e37cb6d2 Don't plot tec_i (for hwrev v2 and below) 2024-02-14 11:31:41 +08:00
0e5d7ee9fb asyncio.TimeoutError not needed 2024-02-14 11:31:41 +08:00
3b13881429 Move Postfilter Rate setting to Thermostat Config 2024-02-14 11:31:41 +08:00
4582a8818c Temperatures to 4 dp, not 6 sig. fig 2024-02-14 11:31:41 +08:00
4c89a05e46 Update nix repos and use repo qasync and pyqtgraph
Now that they are updated, no reason to use our own.
2024-02-14 11:31:41 +08:00
2deded5c1e String quotes 2024-02-14 11:31:41 +08:00
d4bf06e242 Fix setpoint line label to not display old values
Setpoint line still displays 0 °C sometimes!
2024-02-14 11:31:41 +08:00
9797364043 anti-aliasing 2024-02-14 11:31:41 +08:00
f3e5bb69bf Get rid of the setpoint line out-of-range problem 2024-02-14 11:31:41 +08:00
6be23451cb Comment change 2024-02-14 11:31:41 +08:00
3f7d8fdbf3 Fix unicode 2024-02-14 11:31:41 +08:00
938e3bd23f i_set in front of measured current 2024-02-14 11:31:41 +08:00
355cb8360a Move t_line setting to method, fixes 0°C bug
The 0 °C fix is an ugly one, ideally we should only update the label
when visibility returns.
2024-02-14 11:31:41 +08:00
0e02803b98 Adjust exit text 2024-02-14 11:31:41 +08:00
d119fd2275 Longer duration tooltip for zero limits warning 2024-02-14 11:31:41 +08:00
111742b809 Connect on enter press in the connection details 2024-02-14 11:31:41 +08:00
98d491203f Add exit option in connection menu 2024-02-14 11:31:41 +08:00
7c1293e3d2 Number of channels generalisation 2024-02-14 11:31:41 +08:00
8cd1a70f50 variable name change 2024-02-14 11:31:41 +08:00
91a2cfb73e Comment 2024-02-14 11:31:41 +08:00
da794080c6 More general activater 2024-02-14 11:31:41 +08:00
d8b1e7e964 Control Method simplifcation 2024-02-14 11:31:41 +08:00
c56a167824 Simplify postfilter stuff 2024-02-14 11:31:41 +08:00
bf0cb9ef05 Improve name 2024-02-14 11:31:41 +08:00
d60172d7eb Set Limits Warning Fix 2024-02-14 11:31:41 +08:00
cb2fa36e68 No need for list now that params are all singular 2024-02-14 11:31:41 +08:00
470ea9edb3 Put PID output min and max into its own section
The critical difference between this and the max_i_pos, max_i_neg pair
is that output_max and output_min can have the same sign, meaning that
it is possible that PID current can be limited to positive values only.
2024-02-14 11:31:41 +08:00
99b40360c7 Remove siPrefixes for temperature units 2024-02-14 11:31:41 +08:00
83c14fb2de Sync 2024-02-14 11:31:41 +08:00
2e8b26eb60 Show all current values in mA
Since the max and min is known as (-3A, 3A) on the thermostat TEC ports,
there is no need to use other SI prefix units.
2024-02-14 11:31:41 +08:00
5af14c26ea Limit test current 2024-02-14 11:31:41 +08:00
da73f537b7 Separate min and max current 2024-02-14 11:31:41 +08:00
b4504bcfaa Add docstring 2024-02-14 11:31:41 +08:00
fe6901d35f Patch to avoid floating point error of temperature
A more complete system of dealing with floating point imprecision on the
way.
2024-02-14 11:31:41 +08:00
6005caf8b7 Add info boxes when loading/saving configs 2024-02-14 11:31:41 +08:00
442450667b Status bar limits warning 2024-02-14 11:31:41 +08:00
e4d1f0133e More decimals for current too 2024-02-14 11:31:41 +08:00
8fcd00292e Add the rest of the modules 2024-02-14 11:31:41 +08:00
cfda30f795 Switch to pyproject.toml 2024-02-14 11:31:41 +08:00
ae70ce7e0b Remove old GUI and update docs 2024-02-14 11:31:41 +08:00
333a759d85 Increase icon resolution 2024-02-14 11:31:41 +08:00
7ea9752b0f Add window icon 2024-02-14 11:31:41 +08:00
9e96be30e9 Get rid of all translation things for now 2024-02-14 11:31:41 +08:00
933e1726c1 Correct units and stuff 2024-02-14 11:31:41 +08:00
1ff5a5d2b5 More decimals for temp 2024-02-14 11:31:41 +08:00
24006d17bf Only log the autotuned params 2024-02-14 11:31:41 +08:00
a2c7b0b97a Spinner 2024-02-14 11:31:41 +08:00
82dff9fc05 Remove bottom settings group tooltip 2024-02-14 11:31:41 +08:00
2231652cb2 Correct about thermostat var 2024-02-14 11:31:41 +08:00
37c0332c31 Legend names & order 2024-02-14 11:31:41 +08:00
4635e71ebf Autotune 2024-02-14 11:31:41 +08:00
c9e8c4f4a1 Don't set_param that much 2024-02-14 11:31:41 +08:00
72956e19ee No more _command 2024-02-14 11:31:41 +08:00
a213078c9a Shorten channel to ch 2024-02-14 11:31:41 +08:00
4df651902a Postfilter option revamped 2024-02-14 11:31:41 +08:00
1581aa4027 No private to slot 2024-02-14 11:31:41 +08:00
5c8ab769f3 Set param mroe 2024-02-14 11:31:41 +08:00
fe7bc5b7e5 Load config 2024-02-14 11:31:41 +08:00
5bb64e577f Don't use _command 2024-02-14 11:31:41 +08:00
4caaf44f74 Both command and param 2024-02-14 11:31:41 +08:00
fc4f69aec0 Change name 2024-02-14 11:31:41 +08:00
663c46525d Refactor into class 2024-02-14 11:31:41 +08:00
744a472566 Refactor a bit to update lines only via poll
Model-View-Controller thing, don't edit UI when UI value changed
2024-02-14 11:31:41 +08:00
6014dce158 Try triggering on show 2024-02-14 11:31:41 +08:00
6f0677bac6 Call it B not Beta 2024-02-14 11:31:41 +08:00
68ab3555cf Depend on temperature only 2024-02-14 11:31:41 +08:00
19ffc160e3 Legend 2024-02-14 11:31:41 +08:00
6bca8a2728 Improve conditionals 2024-02-14 11:31:41 +08:00
68124cd92b Only show either one or another, pid or not 2024-02-14 11:31:41 +08:00
7f7f749e84 Interface change 2024-02-14 11:31:41 +08:00
4c839f079b Absolute 2024-02-14 11:31:41 +08:00
ef87225339 Switch to using set_param if possible 2024-02-14 11:31:41 +08:00
db766d8707 CommandsParameter useless anyway 2024-02-14 11:31:41 +08:00
50aafa493f Put thermostat parameters constant into mainwindow 2024-02-14 11:31:41 +08:00
f1abab9bd6 Forget about sizePolicy 2024-02-14 11:31:41 +08:00
be340ce094 Fan layout misspelled 2024-02-14 11:31:41 +08:00
2db9e8fea3 Use setRange 2024-02-14 11:31:41 +08:00
180146bc34 Remove sizePolicy horizontal/vertical stretch 0
Default anyway
2024-02-14 11:31:41 +08:00
c10317bfdb Remove extra horizontal layout 2024-02-14 11:31:41 +08:00
59d26436f6 No text why set text 2024-02-14 11:31:41 +08:00
00b252a347 setValue 2024-02-14 11:31:41 +08:00
0d1cb074e1 Remove setObjectName 2024-02-14 11:31:41 +08:00
790e744822 Read extra load 2024-02-14 11:31:41 +08:00
c876c1ec0a Don't use dynamic properties 2024-02-14 11:31:41 +08:00
68503d19e5 Remove 2024-02-14 11:31:41 +08:00
aeecde09af Move report mode bookkeeping into ClientWatcher 2024-02-14 11:31:41 +08:00
898a6891cf Fix redundant code report mode 2024-02-14 11:31:41 +08:00
d7863e5dbd Privatise ClientWatcher member variables 2024-02-14 11:31:41 +08:00
f189b86e06 Current
Also plot iset
2024-02-14 11:31:41 +08:00
2db0936185 Better tooltip 2024-02-14 11:31:41 +08:00
4ca3b14877 Remove stuff that would update on polling anyway 2024-02-14 11:31:41 +08:00
01a3601c3b Clear warning 2024-02-14 11:31:41 +08:00
c3fdb105eb Add proper set fan curve coroutine method 2024-02-14 11:31:41 +08:00
a54773d3ae Add proper set_fan and get_fan coroutine methods 2024-02-14 11:31:41 +08:00
f6dc882d9b Handle timeout errors 2024-02-14 11:31:41 +08:00
c6815950d2 Use start and end session nomenclature
Helps when we also inherit from QObject, which already has connect and
disconnect methods.
2024-02-14 11:31:41 +08:00
05bc5d8809 Remove is_ prefix 2024-02-14 11:31:41 +08:00
bc4b5bb615 Bail
Disconnects everything, stops all polling
2024-02-14 11:31:41 +08:00
ae9c34f411 Proper report 2024-02-14 11:31:41 +08:00
3e20658107 Proper timeout implementation 2024-02-14 11:31:41 +08:00
e82437ca9f Move global params into window 2024-02-14 11:31:41 +08:00
3597fb4445 Fan group to be set based on hw_rev only 2024-02-14 11:31:41 +08:00
953e314abb Add optional channel selection for save/load 2024-02-14 11:31:41 +08:00
f3e13cbb0b List comprehension 2024-02-14 11:31:41 +08:00
980812de67 Full name of the parameter tree 2024-02-14 11:31:41 +08:00
cc60ceefa9 Unused import 2024-02-14 11:31:41 +08:00
82c3576603 Don't use payload to get channel
Use parent param instead
2024-02-14 11:31:41 +08:00
6f40adb19d Max current plus-or-minus better informs 2024-02-14 11:31:41 +08:00
02619f1338 Convinience to turn down fan on connect 2024-02-14 11:31:41 +08:00
7e89bf5337 Better send_command 2024-02-14 11:31:41 +08:00
98f2d70cf6 Match statement 2024-02-14 11:31:41 +08:00
fde4e42069 Set status first in _on_connection_changed 2024-02-14 11:31:41 +08:00
3c9541fea2 host 2024-02-14 11:31:41 +08:00
c1ae69f218 Enable axis linking functionality 2024-02-14 11:31:41 +08:00
7149fb6d85 Shield pending commands from cancellation 2024-02-14 11:31:41 +08:00
39a78b92c4 Implement IPv4 settings dialog 2024-02-14 11:31:41 +08:00
2a31cdb1af Add ipv4 config 2024-02-14 11:31:41 +08:00
169b89208d Use direct calling in report mode 2024-02-14 11:31:41 +08:00
967492642e Add load and save configs 2024-02-14 11:31:41 +08:00
bfec9efbec Implement DFU mode 2024-02-14 11:31:41 +08:00
9291160798 Change name of tec_client 2024-02-14 11:31:41 +08:00
26fdc951bc Move fan signal connection into menu setup 2024-02-14 11:31:41 +08:00
0443778456 Split menu setup method per menu 2024-02-14 11:31:41 +08:00
7a727cb011 Add about thermostat window 2024-02-14 11:31:41 +08:00
d7c0219456 Use graph emoji for plot settings 2024-02-14 11:31:41 +08:00
34ed3cf39a Add tooltip to settings buttons 2024-02-14 11:31:41 +08:00
1f0e74bf9f Don't enabled thermostat settings before connect 2024-02-14 11:31:41 +08:00
1ae44d6b82 Give proper names to settings buttons 2024-02-14 11:31:41 +08:00
fa8f1ebf10 No :port 2024-02-14 11:31:41 +08:00
0434b08abc Don't translate ip 2024-02-14 11:31:41 +08:00
8ff08c1539 Not just ip, can put domain name too, or "host" 2024-02-14 11:31:41 +08:00
b32062d855 More elegant connection stopping 2024-02-14 11:31:41 +08:00
d52aafd7f6 Add timeout to connect call 2024-02-14 11:31:41 +08:00
efa814a0d3 Add load and save config menu items 2024-02-14 11:31:41 +08:00
1be874f6a7 Add about thermostat menu item
Meant to display hardware rev stuff, does nothing right now
2024-02-14 11:31:41 +08:00
9364c9b187 Add network settings menu option
Also does nothing for now
2024-02-14 11:31:41 +08:00
a9c0106c46 Add DFU mode menu option
Does nothing for now
2024-02-14 11:31:41 +08:00
2d341df23c Use _on_connection_changed(False) 2024-02-14 11:31:41 +08:00
8f31380d52 Reset button 2024-02-14 11:31:41 +08:00
998d999b59 Save the entire hw_rev data
Not just fan_pwm_recommended
2024-02-14 11:31:41 +08:00
ca7c64c115 Put connection details in connection button menu 2024-02-14 11:31:41 +08:00
a1a94a9c99 Move host selection into menu too 2024-02-14 11:31:41 +08:00
9fc38d4614 Move fan throttling warning to the right 2024-02-14 11:31:41 +08:00
5732bc951f Split the settings and plotting menus 2024-02-14 11:31:41 +08:00
cbffb8d700 Crude relocation of port spinbox from .ui file 2024-02-14 11:31:41 +08:00
cc1fddddda Space out bottom bar properly 2024-02-14 11:31:41 +08:00
9aac571187 Remove leftover lines 2024-02-14 11:31:41 +08:00
a3d4bef68e Crude removal of fan group from .ui file 2024-02-14 11:31:41 +08:00
8e98b62cfb Add line at PID temp 2024-02-14 11:31:41 +08:00
64891231cd Report mode functionality 2024-02-14 11:31:41 +08:00
728bce38b6 Add crosshair for better read of values 2024-02-14 11:31:41 +08:00
5c081b0547 Add samples box in menu 2024-02-14 11:31:41 +08:00
001ce432e8 Add clear graphs context menu item 2024-02-14 11:31:41 +08:00
fdf4c4f0d6 Plot temperature and current graphs
- Have units

- Samples are limited

- pglive is used for better live graphs
-- Also fixes bug with constantly updating normal pyqtgraphs where it
will bug out if right-clicked on and context menu is brought up
--Since pglive requires pyqtgraph == 0.13.3, upgrade pyqtgraph to that
too.
2024-02-14 11:31:41 +08:00
90df3ae784 Plus or minus symbol on swing 2024-02-14 11:31:41 +08:00
ac51476d59 Add save to flash paramtree item 2024-02-14 11:31:41 +08:00
4be6d419f6 Hide paramtree headers 2024-02-14 11:31:41 +08:00
ae6f08247a Add postfilter config 2024-02-14 11:31:41 +08:00
863352d620 Add i_set 2024-02-14 11:31:41 +08:00
9803a2d12b Add pwm update 2024-02-14 11:31:41 +08:00
8291b2052f Add thermistor config & sync
Note: The formula is not actually Steinhart-Hart
2024-02-14 11:31:41 +08:00
49c5120600 Connect up pid 2024-02-14 11:31:41 +08:00
0e3a01d601 Connect up report update 2024-02-14 11:31:41 +08:00
4961b2adb2 Use proper symbols in units, and add units
PID parameters are not actually dimensionless, and their units can be
deduced from the input unit and the output (actuator) unit.
2024-02-14 11:31:41 +08:00
abf5d5f2bd Fix formatting 2024-02-14 11:31:41 +08:00
1b3f767d94 Might as well be a pass, doens't exec 2024-02-14 11:31:41 +08:00
137004e6b5 Loop through trees to set them up 2024-02-14 11:31:41 +08:00
5ba189d3ba Remove unused as clause 2024-02-14 11:31:41 +08:00
928db9963d Add paramtree view, without updates
Signed-off-by: Egor Savkin <es@m-labs.hk>

Fix signal blocker argument -atse
2024-02-14 11:31:41 +08:00
8383abec8c Add explanation of report rate 2024-02-14 11:31:41 +08:00
b8d0cdabd3 Swap order connected first 2024-02-14 11:31:41 +08:00
04a8f5ea56 Add tooltip to fan 2024-02-14 11:31:41 +08:00
6c11a0536c Arrange context menu items to be in order 2024-02-14 11:31:41 +08:00
e5b0583a91 Disable auto fan box if adjusted while auto 2024-02-14 11:31:41 +08:00
71076510a2 Steal fan group and port??? Somehow 2024-02-14 11:31:41 +08:00
7749459701 Max the label 2024-02-14 11:31:41 +08:00
463ee4105c Context menu by QToolButton 2024-02-14 11:31:41 +08:00
1d192f50c8 Remove redundant return 2024-02-14 11:31:41 +08:00
30f6c4f829 Correct order once the tec_client disconnects to
stop watching the client first
2024-02-14 11:31:41 +08:00
1226cca6e6 Only set connecting task to None once 2024-02-14 11:31:41 +08:00
8520dae93b Update and add docstrings to aioclient 2024-02-14 11:31:41 +08:00
981c28ac27 Conslidate connect & disconnect actions 2024-02-14 11:31:41 +08:00
5ced33594c Change name of button slot 2024-02-14 11:31:41 +08:00
b4a5e90f2e Turn on_connection_changed to coroutine
Further compresses the connect
2024-02-14 11:31:41 +08:00
82438ee4a5 Simplify stuff a bit 2024-02-14 11:31:41 +08:00
e727f8b06b Change statement order up a bit 2024-02-14 11:31:41 +08:00
27ce311111 Init client_watcher once 2024-02-14 11:31:41 +08:00
659d0d0835 Init client once
No none-ing
2024-02-14 11:31:41 +08:00
fa60439e39 Put the connecting task in aioclient 2024-02-14 11:31:41 +08:00
7e56f2d879 Rearrange bottom bar for new context menu 2024-02-14 11:31:41 +08:00
917a2546cc Remove is running loop variable
Just use Task.done()
2024-02-14 11:31:41 +08:00
fd83ee23e1 Catch a more specific exception 2024-02-14 11:31:41 +08:00
115c7eb800 Add stop connection button
Stuff to add to stop button
2024-02-14 11:31:41 +08:00
7e1b64b72c Set fan slider value minimum to 1, not 0 2024-02-14 11:31:41 +08:00
fca4b061ee Fix Slot decorators and types 2024-02-14 11:31:41 +08:00
9f839f4bd9 Handle UI when fan_pwm is 0 2024-02-14 11:31:41 +08:00
ac77c457ec Fix fan warning wording 2024-02-14 11:31:41 +08:00
54bb740a41 Only warn about fan pwm when not at full strength 2024-02-14 11:31:41 +08:00
47dbe95045 Replace fan group highlighting with warning icon
Highlighting is too confusing
2024-02-14 11:31:41 +08:00
8045d8c93d Grammar 2024-02-14 11:31:41 +08:00
3d801666fa Update fan slider value immediately after fan auto 2024-02-14 11:31:41 +08:00
5e105884d1 Use slider signal argument to set fan value 2024-02-14 11:31:41 +08:00
1849711c62 Set client to none if failed to connect
Fixes connect button behaviour after accidental disconnect
2024-02-14 11:31:41 +08:00
84018c3ebc Start running only when task is running 2024-02-14 11:31:41 +08:00
1fd49360d0 Lock connection details while connecting
Fix connect button behaviour
2024-02-14 11:31:41 +08:00
ad5e36beab Add unit to report spinbox 2024-02-14 11:31:41 +08:00
a55589415d Cancel task to stop watch 2024-02-14 11:31:41 +08:00
c261ca2447 Disconnect client too on close 2024-02-14 11:31:41 +08:00
c476ad9f7d Close client_watcher on closeEvent not aboutToQuit
Mirrors
2024-02-14 11:31:41 +08:00
d0d33f42da Rearrange client_watcher to hold its own task 2024-02-14 11:31:41 +08:00
608573c03c Update fan too 2024-02-14 11:31:41 +08:00
ec9ce6537c More helpful tooltip 2024-02-14 11:31:41 +08:00
6b4b576518 Fix hardware revision showing major.major 2024-02-14 11:31:41 +08:00
142fe1043c Remove unused 'as' clause 2024-02-14 11:31:41 +08:00
3544f1ebdf Get rid of global client 2024-02-14 11:31:41 +08:00
e33f8430f2 Remove client_watcher global 2024-02-14 11:31:41 +08:00
9cf33abe06 Gather client_watcher managment into connect 2024-02-14 11:31:41 +08:00
0252c7b0e4 Invert logic, connect first 2024-02-14 11:31:41 +08:00
c6ca2b3490 Make Ui_MainWindow a superclass of our main window
Gets rid of the global ui.
2024-02-14 11:31:41 +08:00
299ef7dcc3 Get rid of app global
QApplication is a singleton, no need for global
2024-02-14 11:31:41 +08:00
73887564a5 Change title 2024-02-14 11:31:41 +08:00
f469d8fee3 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-02-14 11:31:41 +08:00
f546a3c61b 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.

-Make the GUI `nix run`-able
2024-02-14 11:31:41 +08:00
0ad77047f1 Try move from Qthreads to qasync
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-02-14 11:31:41 +08:00
3de6f233f9 Create client watcher, that would poll Thermostat for config
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-02-14 11:31:41 +08:00
069280feb6 Create basic GUI, that would connect and control thermostat's fan
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-02-14 11:31:41 +08:00
6de0d41c23 Update nix repos
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-02-14 11:31:41 +08:00
7c1820872b update docs 2024-02-14 11:31:41 +08:00
059dd0bbb6 add autotune 2024-02-14 11:31:41 +08:00
be4383a447 WIP: adding autotune 2024-02-14 11:31:41 +08:00
f31c3be335 fix docs, fix i_set, fix GUI param ranges 2024-02-14 11:31:41 +08:00
e71453750c fix whitespace error 2024-02-14 11:31:41 +08:00
2796400d47 bi-dir sync, minimum working prototype 2024-02-14 11:31:41 +08:00
593ad9a133 sync tree param from TEC 2024-02-14 11:31:41 +08:00
563a32edf4 add sync from TEC 2024-02-14 11:31:41 +08:00
b097067afc add param tree, param tree inactive 2024-02-14 11:31:41 +08:00
d841cd2559 add voltage monitoring 2024-02-14 11:31:41 +08:00
d6a80c4f9b fix typo 2024-02-14 11:31:41 +08:00
4310a27085 refactor with classes 2024-02-14 11:31:41 +08:00
61a8af468a add graph legends 2024-02-14 11:31:40 +08:00
fe28ac98e5 add more graphs in 2x2 grid 2024-02-14 11:31:40 +08:00
6655581b6f plot both channel temperatures 2024-02-14 11:31:40 +08:00
8e45e98ee0 fix pyqtgraph on nixos 2024-02-14 11:31:40 +08:00
446c3ea1d6 add pyqtgraph 2024-02-14 11:31:40 +08:00
20 changed files with 2376 additions and 27 deletions

View File

@ -67,7 +67,19 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
```
## Network
## GUI Usage
A GUI has been developed for easy configuration and plotting of key parameters.
The Python GUI program is located at pytec/tec_qt.py.
The GUI is developed based on the Python library pyqtgraph. The GUI can be configured and launched automatically by running:
```
nix run .#thermostat_gui
```
## Command Line Usage
### Connecting
@ -130,6 +142,7 @@ formatted as line-delimited JSON.
| `fcurve <a> <b> <c>` | Set fan controller curve coefficients (see *Fan control* section) |
| `fcurve default` | Set fan controller curve coefficients to defaults (see *Fan control* section) |
| `hwrev` | Show hardware revision, and settings related to it |
| `swap` | Swap TEC polarities for all channels (For use with Zotino) |
## USB

8
flake.lock generated
View File

@ -18,16 +18,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1691421349,
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
"lastModified": 1701156937,
"narHash": "sha256-jpMJOFvOTejx211D8z/gz0ErRtQPy6RXxgD2ZB86mso=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
"rev": "7c4c20509c4363195841faa6c911777a134acdf3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.05",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}

View File

@ -1,7 +1,7 @@
{
description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.05;
inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.11;
inputs.mozilla-overlay = { url = github:mozilla/nixpkgs-mozilla; flake = false; };
outputs = { self, nixpkgs, mozilla-overlay }:
@ -55,9 +55,41 @@
dontFixup = true;
};
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 ];
};
thermostat_gui = pkgs.python3Packages.buildPythonPackage {
pname = "thermostat_gui";
version = "0.0.0";
format = "pyproject";
src = "${self}/pytec";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]);
dontWrapQtApps = true;
postFixup = ''
wrapQtApp "$out/bin/tec_qt"
'';
};
in {
packages.x86_64-linux = {
inherit thermostat;
inherit thermostat thermostat_gui;
};
apps.x86_64-linux.thermostat_gui = {
type = "app";
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt";
};
hydraJobs = {
@ -69,7 +101,7 @@
buildInputs = with pkgs; [
rust openocd dfu-util
] ++ (with python3Packages; [
numpy matplotlib
numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive
]);
};
defaultPackage.x86_64-linux = thermostat;

16
pytec/aioexample.py Normal file
View File

@ -0,0 +1,16 @@
import asyncio
from pytec.aioclient import Client
async def main():
tec = Client()
await tec.start_session() #(host="192.168.1.26", port=23)
await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_pwm())
print(await tec.get_pid())
print(await tec.get_pwm())
print(await tec.get_postfilter())
print(await tec.get_steinhart_hart())
async for data in tec.report_mode():
print(data)
asyncio.run(main())

View File

@ -17,6 +17,7 @@ class PIDAutotuneState(Enum):
STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune:
@ -56,6 +57,20 @@ class PIDAutotune:
self._Ku = 0
self._Pu = 0
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
self._out_min = -step
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self):
"""Get the current state."""
return self._state
@ -81,6 +96,13 @@ class PIDAutotune:
kd = divisors[2] * self._Ku * self._Pu
return PIDAutotune.PIDParams(kp, ki, kd)
def get_tec_pid (self):
divisors = self._tuning_rules["tyreus-luyben"]
kp = self._Ku * divisors[0]
ki = divisors[1] * self._Ku / self._Pu
kd = divisors[2] * self._Ku * self._Pu
return kp, ki, kd
def run(self, input_val, time_input):
"""To autotune a system, this method must be called periodically.
@ -95,7 +117,8 @@ class PIDAutotune:
if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED):
or self._state == PIDAutotuneState.STATE_FAILED
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now
@ -199,20 +222,20 @@ class PIDAutotune:
# calculate ultimate gain
self._Ku = 4.0 * self._outputstep / \
(self._induced_amplitude * math.pi)
print('Ku: {0}'.format(self._Ku))
logging.debug('Ku: {0}'.format(self._Ku))
# calculate ultimate period in seconds
period1 = self._peak_timestamps[3] - self._peak_timestamps[1]
period2 = self._peak_timestamps[4] - self._peak_timestamps[2]
self._Pu = 0.5 * (period1 + period2) / 1000.0
print('Pu: {0}'.format(self._Pu))
logging.debug('Pu: {0}'.format(self._Pu))
for rule in self._tuning_rules:
params = self.get_pid_parameters(rule)
print('rule: {0}'.format(rule))
print('Kp: {0}'.format(params.Kp))
print('Ki: {0}'.format(params.Ki))
print('Kd: {0}'.format(params.Kd))
logging.debug('rule: {0}'.format(rule))
logging.debug('Kp: {0}'.format(params.Kp))
logging.debug('Ki: {0}'.format(params.Ki))
logging.debug('Kd: {0}'.format(params.Kd))
return True
return False

View File

@ -30,15 +30,15 @@ class Series:
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'temperature': Series(),
'i_set': Series(),
# 'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()

18
pytec/pyproject.toml Normal file
View File

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pytec"
version = "0.0"
authors = [{name = "M-Labs"}]
description = "Control TEC"
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
license = {text = "GPLv3"}
[project.gui-scripts]
tec_qt = "tec_qt:main"
[tool.setuptools]
packages.find = {}
py-modules = ["aioexample", "autotune", "example", "plot", "tec_qt", "ui_tec_qt", "waitingspinnerwidget"]

279
pytec/pytec/aioclient.py Normal file
View File

@ -0,0 +1,279 @@
import asyncio
import json
import logging
class CommandError(Exception):
pass
class StoppedConnecting(Exception):
pass
class Client:
def __init__(self):
self._reader = None
self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock()
self._report_mode_on = False
self.timeout = None
async def start_session(self, host='192.168.1.26', port=23, timeout=None):
"""Start session to Thermostat at specified host and port.
Throws StoppedConnecting if disconnect was called while connecting.
Throws asyncio.TimeoutError if timeout was exceeded.
Example::
client = Client()
try:
await client.start_session()
except StoppedConnecting:
print("Stopped connecting")
"""
self._connecting_task = asyncio.create_task(
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
)
self.timeout = timeout
try:
self._reader, self._writer = await self._connecting_task
except asyncio.CancelledError:
raise StoppedConnecting
finally:
self._connecting_task = None
await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self):
"""Returns True if client is connected"""
return self._writer is not None
async def end_session(self):
"""End session to Thermostat if connected, cancel connection if connecting"""
if self._connecting_task is not None:
self._connecting_task.cancel()
if self._writer is None:
return
# Reader needn't be closed
self._writer.close()
await self._writer.wait_closed()
self._reader = None
self._writer = None
async def _check_zero_limits(self):
pwm_report = await self.get_pwm()
for pwm_channel in pwm_report:
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
if pwm_channel[limit]["value"] == 0.0:
logging.warning("`{}` limit is set to zero on channel {}".format(limit, pwm_channel["channel"]))
async def _read_line(self):
# read 1 line
chunk = await asyncio.wait_for(self._reader.readline(), self.timeout) # Only wait for response until timeout
return chunk.decode('utf-8', errors='ignore')
async def _read_write(self, command):
self._writer.write(((" ".join(command)).strip() + "\n").encode('utf-8'))
await self._writer.drain()
return await self._read_line()
async def _command(self, *command):
async with self._command_lock:
# protect the read-write process from being cancelled midway
line = await asyncio.shield(self._read_write(command))
response = json.loads(line)
logging.debug(f"{command}: {response}")
if "error" in response:
raise CommandError(response["error"])
return response
async def _get_conf(self, topic):
result = [None, None]
for item in await self._command(topic):
result[int(item["channel"])] = item
return result
async def get_pwm(self):
"""Retrieve PWM limits for the TEC
Example::
[{'channel': 0,
'center': 'vref',
'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762},
'max_i_neg': {'max': 3.0, 'value': 3.0},
'max_v': {'max': 5.988, 'value': 5.988},
'max_i_pos': {'max': 3.0, 'value': 3.0}},
{'channel': 1,
'center': 'vref',
'i_set': {'max': 2.9802790335151985, 'value': -0.02002179650216762},
'max_i_neg': {'max': 3.0, 'value': 3.0},
'max_v': {'max': 5.988, 'value': 5.988},
'max_i_pos': {'max': 3.0, 'value': 3.0}}
]
"""
return await self._get_conf("pwm")
async def get_pid(self):
"""Retrieve PID control state
Example::
[{'channel': 0,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 37.0},
{'channel': 1,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 36.5}]
"""
return await self._get_conf("pid")
async def get_steinhart_hart(self):
"""Retrieve Steinhart-Hart parameters for resistance to temperature conversion
Example::
[{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0},
{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}]
"""
return await self._get_conf("s-h")
async def get_postfilter(self):
"""Retrieve DAC postfilter configuration
Example::
[{'rate': None, 'channel': 0},
{'rate': 21.25, 'channel': 1}]
"""
return await self._get_conf("postfilter")
async def get_fan(self):
"""Get Thermostat current fan settings"""
return await self._command("fan")
async def report(self):
"""Obtain one-time report on measurement values"""
return await self._command("report")
async def report_mode(self):
"""Start reporting measurement values
Example of yielded data::
{'channel': 0,
'time': 2302524,
'adc': 0.6199188965423515,
'sens': 6138.519310282602,
'temperature': 36.87032392655527,
'pid_engaged': True,
'i_set': 2.0635816680889123,
'vref': 1.494,
'dac_value': 2.527790834044456,
'dac_feedback': 2.523,
'i_tec': 2.331,
'tec_i': 2.0925,
'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247}
"""
await self._command("report mode", "on")
self._report_mode_on = True
while self._report_mode_on:
async with self._command_lock:
line = await self._read_line()
if not line:
break
try:
yield json.loads(line)
except json.decoder.JSONDecodeError:
pass
await self._command("report mode", "off")
def stop_report_mode(self):
self._report_mode_on = False
async def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters
Examples::
await tec.set_param("pwm", 0, "max_v", 2.0)
await tec.set_param("pid", 1, "output_max", 2.5)
await tec.set_param("s-h", 0, "t0", 20.0)
await tec.set_param("center", 0, "vref")
await tec.set_param("postfilter", 1, 21)
See the firmware's README.md for a full list.
"""
if type(value) is float:
value = "{:f}".format(value)
if type(value) is not str:
value = str(value)
await self._command(topic, str(channel), field, value)
async def set_fan(self, power="auto"):
"""Set fan power"""
await self._command("fan", str(power))
async def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan curve"""
await self._command("fcurve", str(a), str(b), str(c))
async def power_up(self, channel, target):
"""Start closed-loop mode"""
await self.set_param("pid", channel, "target", value=target)
await self.set_param("pwm", channel, "pid")
async def save_config(self, channel=""):
"""Save current configuration to EEPROM"""
await self._command("save", str(channel))
async def load_config(self, channel=""):
"""Load current configuration from EEPROM"""
await self._command("load", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def hw_rev(self):
"""Get Thermostat hardware revision"""
return await self._command("hwrev")
async def reset(self):
"""Reset the Thermostat
The client is disconnected as the TCP session is terminated.
"""
async with self._command_lock:
self._writer.write("reset\n".encode('utf-8'))
await self._writer.drain()
await self.end_session()
async def dfu(self):
"""Put the Thermostat in DFU update mode
The client is disconnected as the Thermostat stops responding to
TCP commands in DFU update mode. The only way to exit it is by
power-cycling.
"""
async with self._command_lock:
self._writer.write("dfu\n".encode('utf-8'))
await self._writer.drain()
await self.end_session()
async def ipv4(self):
"""Get the IPv4 settings of the Thermostat"""
return await self._command('ipv4')

View File

@ -11,6 +11,10 @@ class Client:
self._lines = [""]
self._check_zero_limits()
def disconnect(self):
self._socket.shutdown(socket.SHUT_RDWR)
self._socket.close()
def _check_zero_limits(self):
pwm_report = self.get_pwm()
for pwm_channel in pwm_report:
@ -32,10 +36,11 @@ class Client:
return line
def _command(self, *command):
self._socket.sendall((" ".join(command) + "\n").encode('utf-8'))
self._socket.sendall(((" ".join(command)).strip() + "\n").encode('utf-8'))
line = self._read_line()
response = json.loads(line)
logging.debug(f"{command}: {response}")
if "error" in response:
raise CommandError(response["error"])
return response
@ -167,3 +172,11 @@ class Client:
def load_config(self):
"""Load current configuration from EEPROM"""
self._command("load")
def hw_rev(self):
"""Get Thermostat hardware revision"""
return self._command("hwrev")
def fan(self):
"""Get Thermostat current fan settings"""
return self._command("fan")

View File

@ -9,4 +9,10 @@ setup(
license="GPLv3",
install_requires=["setuptools"],
packages=find_packages(),
entry_points={
"gui_scripts": [
"tec_qt = tec_qt:main",
]
},
py_modules=['tec_qt', 'ui_tec_qt', 'autotune', 'waitingspinnerwidget'],
)

903
pytec/tec_qt.py Normal file
View File

@ -0,0 +1,903 @@
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
import pyqtgraph as pg
pg.setConfigOptions(antialias=True)
from pglive.sources.data_connector import DataConnector
from pglive.kwargs import Axis
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_plot_widget import LivePlotWidget
from pglive.sources.live_axis import LiveAxis
import sys
import argparse
import logging
import asyncio
from pytec.aioclient import Client, StoppedConnecting
import qasync
from qasync import asyncSlot, asyncClose
from autotune import PIDAutotune, PIDAutotuneState
# pyuic6 -x tec_qt.ui -o ui_tec_qt.py
from ui_tec_qt import Ui_MainWindow
"""Number of channels provided by the Thermostat"""
NUM_CHANNELS: int = 2
def get_argparser():
parser = argparse.ArgumentParser(description="ARTIQ master")
parser.add_argument("--connect", default=None, action="store_true",
help="Automatically connect to the specified Thermostat in IP:port format")
parser.add_argument('IP', metavar="ip", 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 MutexParameter(pTypes.ListParameter):
"""
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
The ordering of the list items determines which children will be visible.
"""
def __init__(self, **opts):
super().__init__(**opts)
self.sigValueChanged.connect(self.show_chosen_child)
self.sigValueChanged.emit(self, self.opts['value'])
def _get_param_from_value(self, value):
if isinstance(self.opts['limits'], dict):
values_list = list(self.opts['limits'].values())
else:
values_list = self.opts['limits']
return self.children()[values_list.index(value)]
@pyqtSlot(object, object)
def show_chosen_child(self, value):
for param in self.children():
param.hide()
child_to_show = self._get_param_from_value(value.value())
child_to_show.show()
if child_to_show.opts.get('triggerOnShow', None):
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
registerParameterType('mutex', MutexParameter)
class WrappedClient(QObject, Client):
connection_error = pyqtSignal()
async def _read_line(self):
try:
return await super()._read_line()
except (OSError, TimeoutError) as e:
logging.error("Client connection error, disconnecting", exc_info=True)
self.connection_error.emit()
class ClientWatcher(QObject):
fan_update = pyqtSignal(dict)
pwm_update = pyqtSignal(list)
report_update = pyqtSignal(list)
pid_update = pyqtSignal(list)
thermistor_update = pyqtSignal(list)
postfilter_update = pyqtSignal(list)
def __init__(self, parent, client, update_s):
self._update_s = update_s
self._client = client
self._watch_task = None
self._report_mode_task = None
self._poll_for_report = True
super().__init__(parent)
async def run(self):
loop = asyncio.get_running_loop()
while True:
time = loop.time()
await self.update_params()
await asyncio.sleep(self._update_s - (loop.time() - time))
async def update_params(self):
self.fan_update.emit(await self._client.get_fan())
self.pwm_update.emit(await self._client.get_pwm())
if self._poll_for_report:
self.report_update.emit(await self._client.report())
self.pid_update.emit(await self._client.get_pid())
self.thermistor_update.emit(await self._client.get_steinhart_hart())
self.postfilter_update.emit(await self._client.get_postfilter())
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@pyqtSlot()
def stop_watching(self):
if self._watch_task is not None:
self._watch_task.cancel()
self._watch_task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
if enabled:
self._report_mode_task = asyncio.create_task(self.report_mode())
else:
self._client.stop_report_mode()
if self._report_mode_task is not None:
await self._report_mode_task
self._report_mode_task = None
async def report_mode(self):
async for report in self._client.report_mode():
self.report_update.emit(report)
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s
class ChannelGraphs:
"""Manager of a channel's two graphs and their elements."""
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
def __init__(self, t_widget, i_widget):
self._t_widget = t_widget
self._i_widget = i_widget
self._t_plot = LiveLinePlot()
self._i_plot = LiveLinePlot(name="Measured")
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen('r'))
self._t_line = self._t_widget.getPlotItem().addLine(label='{value} °C')
self._t_line.setVisible(False)
self._t_setpoint_plot = LiveLinePlot() # Hack for keeping setpoint line in plot range
for graph in t_widget, i_widget:
time_axis = LiveAxis('bottom', text="Time since Thermostat reset", **{Axis.TICK_FORMAT: Axis.DURATION})
time_axis.showLabel()
graph.setAxisItems({'bottom': time_axis})
graph.add_crosshair(pg.mkPen(color='red', width=1), {'color': 'green'})
# Enable linking of axes in the graph widget's context menu
graph.register(graph.getPlotItem().titleLabel.text) # Slight hack getting the title
temperature_axis = LiveAxis('left', text="Temperature", units="°C")
temperature_axis.showLabel()
t_widget.setAxisItems({'left': temperature_axis})
current_axis = LiveAxis('left', text="Current", units="A")
current_axis.showLabel()
i_widget.setAxisItems({'left': current_axis})
i_widget.addLegend(brush=(50, 50, 200, 150))
t_widget.addItem(self._t_plot)
t_widget.addItem(self._t_setpoint_plot)
i_widget.addItem(self._i_plot)
i_widget.addItem(self._iset_plot)
self.t_connector = DataConnector(self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
self.i_connector = DataConnector(self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.iset_connector = DataConnector(self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES)
self.max_samples = self.DEFAULT_MAX_SAMPLES
def plot_append(self, report):
temperature = report['temperature']
current = report['tec_i']
iset = report['i_set']
time = report['time']
if temperature is not None:
self.t_connector.cb_append_data_point(temperature, time)
if self._t_line.isVisible():
self.t_setpoint_connector.cb_append_data_point(self._t_line.value(), time)
else:
self.t_setpoint_connector.cb_append_data_point(temperature, time)
if current is not None:
self.i_connector.cb_append_data_point(current, time)
self.iset_connector.cb_append_data_point(iset, time)
def clear(self):
for connector in self.t_connector, self.i_connector, self.iset_connector:
connector.clear()
def set_t_line(self, temp=None, visible=None):
if visible is not None:
self._t_line.setVisible(visible)
if temp is not None:
self._t_line.setValue(temp)
# PyQtGraph normally does not update this text when the line
# is not visible, so make sure that the temperature label
# gets updated always, and doesn't stay at an old value.
self._t_line.label.setText(f"{temp} °C")
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
"""Thermostat parameters that are particular to a channel"""
THERMOSTAT_PARAMETERS = [[
{'name': 'Temperature', 'type': 'float', 'format': '{value:.4f} °C', 'readonly': True},
{'name': 'Current through TEC', 'type': 'float', 'suffix': 'mA', 'decimals': 6, 'readonly': True},
{'name': 'Output Config', 'expanded': True, 'type': 'group', 'children': [
{'name': 'Control Method', 'type': 'mutex', 'limits': ['Constant Current', 'Temperature PID'],
'activaters': [None, ('pwm', ch, 'pid')], 'children': [
{'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 100, 'limits': (-3000, 3000), 'triggerOnShow': True,
'decimals': 6, 'suffix': 'mA', 'param': ('pwm', ch, 'i_set')},
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300),
'format': '{value:.4f} °C', 'param': ('pid', ch, 'target')},
]},
{'name': 'Limits', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Max Cooling Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_pos')},
{'name': 'Max Heating Current', 'type': 'float', 'value': 0, 'step': 100, 'decimals': 6, 'limits': (0, 3000),
'suffix': 'mA', 'param': ('pwm', ch, 'max_i_neg')},
{'name': 'Max Voltage Difference', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True,
'suffix': 'V', 'param': ('pwm', ch, 'max_v')},
]}
]},
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'T₀', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100),
'format': '{value:.4f} °C', 'param': ('s-h', ch, 't0')},
{'name': 'R₀', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ω',
'param': ('s-h', ch, 'r0')},
{'name': 'B', 'type': 'float', 'value': 3950, 'step': 1, 'suffix': 'K', 'decimals': 4, 'param': ('s-h', ch, 'b')},
{'name': 'Postfilter Rate', 'type': 'list', 'value': 16.67, 'param': ('postfilter', ch, 'rate'),
'limits': {'Off': None, '16.67 Hz': 16.67, '20 Hz': 20.0, '21.25 Hz': 21.25, '27 Hz': 27.0}},
]},
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Kp', 'type': 'float', 'step': 0.1, 'suffix': '', 'param': ('pid', ch, 'kp')},
{'name': 'Ki', 'type': 'float', 'step': 0.1, 'suffix': 'Hz', 'param': ('pid', ch, 'ki')},
{'name': 'Kd', 'type': 'float', 'step': 0.1, 'suffix': 's', 'param': ('pid', ch, 'kd')},
{'name': "PID Output Clamping", 'expanded': True, 'type': 'group', 'children': [
{'name': 'Minimum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_min')},
{'name': 'Maximum', 'type': 'float', 'step': 100, 'limits': (-3000, 3000), 'decimals': 6, 'suffix': 'mA', 'param': ('pid', ch, 'output_max')},
]},
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'format': '{value:.4f} °C'},
{'name': 'Test Current', 'type': 'float', 'value': 1000, 'decimals': 6, 'step': 100, 'limits': (-3000, 3000), 'suffix': 'mA'},
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'prefix': '±', 'format': '{value:.4f} °C'},
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
]},
]},
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save config to thermostat, applies on reset'},
{'name': 'Load from flash', 'type': 'action', 'tip': 'Load config from flash'}
] for ch in range(NUM_CHANNELS)]
def __init__(self, args):
super().__init__()
self.setupUi(self)
self.ch0_t_graph.setTitle("Channel 0 Temperature")
self.ch0_i_graph.setTitle("Channel 0 Current")
self.ch1_t_graph.setTitle("Channel 1 Temperature")
self.ch1_i_graph.setTitle("Channel 1 Current")
self.max_samples = self.DEFAULT_MAX_SAMPLES
self._set_up_connection_menu()
self._set_up_thermostat_menu()
self._set_up_plot_menu()
self.client = WrappedClient(self)
self.client.connection_error.connect(self.bail)
self.client_watcher = ClientWatcher(self, self.client, self.report_refresh_spin.value())
self.client_watcher.fan_update.connect(self.fan_update)
self.client_watcher.report_update.connect(self.update_report)
self.client_watcher.pid_update.connect(self.update_pid)
self.client_watcher.pwm_update.connect(self.update_pwm)
self.client_watcher.thermistor_update.connect(self.update_thermistor)
self.client_watcher.postfilter_update.connect(self.update_postfilter)
self.report_apply_btn.clicked.connect(
lambda: self.client_watcher.set_update_s(self.report_refresh_spin.value())
)
self.params = [
Parameter.create(name=f"Thermostat Channel {ch} Parameters", type='group', value=ch, children=self.THERMOSTAT_PARAMETERS[ch])
for ch in range(NUM_CHANNELS)
]
self._set_param_tree()
self.channel_graphs = [
ChannelGraphs(getattr(self, f'ch{ch}_t_graph'), getattr(self, f'ch{ch}_i_graph'))
for ch in range(NUM_CHANNELS)
]
self.autotuners = [
PIDAutotune(25)
for _ in range(NUM_CHANNELS)
]
self.loading_spinner.hide()
self.hw_rev_data = None
if args.connect:
if args.IP:
self.host_set_line.setText(args.IP)
if args.PORT:
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
def _set_up_connection_menu(self):
self.connection_menu = QtWidgets.QMenu()
self.connection_menu.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.connection_menu.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.connection_menu)
host.setDefaultWidget(self.host_set_line)
self.connection_menu.addAction(host)
self.connection_menu.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.connection_menu)
port.setDefaultWidget(self.port_set_spin)
self.connection_menu.addAction(port)
self.connection_menu.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.connection_menu.addAction(exit_action)
self.connection_menu.exit_action = exit_action
self.connect_btn.setMenu(self.connection_menu)
def _set_up_thermostat_menu(self):
self.thermostat_menu = QtWidgets.QMenu()
self.thermostat_menu.setTitle('Thermostat settings')
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
self.fan_layout.setSpacing(9)
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
self.fan_layout.addWidget(self.fan_lbl)
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
self.fan_power_slider.setRange(1, 100)
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.fan_layout.addWidget(self.fan_power_slider)
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
self.fan_layout.addWidget(self.fan_auto_box)
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set)
self.fan_lbl.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:")
self.fan_auto_box.setText("Auto")
fan = QtWidgets.QWidgetAction(self.thermostat_menu)
fan.setDefaultWidget(self.fan_group)
self.thermostat_menu.addAction(fan)
self.thermostat_menu.fan = fan
@asyncSlot(bool)
async def reset_thermostat(_):
await self._on_connection_changed(False)
await self.client.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
self.actionReset.triggered.connect(reset_thermostat)
self.thermostat_menu.addAction(self.actionReset)
@asyncSlot(bool)
async def dfu_mode(_):
await self._on_connection_changed(False)
await self.client.dfu()
# TODO: add a firmware flashing GUI?
self.actionEnter_DFU_Mode.triggered.connect(dfu_mode)
self.thermostat_menu.addAction(self.actionEnter_DFU_Mode)
@asyncSlot(bool)
async def network_settings(_):
ask_network = QtWidgets.QInputDialog(self)
ask_network.setWindowTitle("Network Settings")
ask_network.setLabelText("Set the Thermostat's IPv4 address, netmask and gateway (optional)")
ask_network.setTextValue((await self.client.ipv4())['addr'])
@pyqtSlot(str)
def set_ipv4(ipv4_settings):
sure = QtWidgets.QMessageBox(self)
sure.setWindowTitle("Set network?")
sure.setText(f"Setting this as network and disconnecting:<br>{ipv4_settings}")
@asyncSlot(object)
async def really_set(button):
await self.client.set_param("ipv4", ipv4_settings)
await self.client.disconnect()
await self._on_connection_changed(False)
sure.buttonClicked.connect(really_set)
sure.show()
ask_network.textValueSelected.connect(set_ipv4)
ask_network.show()
self.actionNetwork_Settings.triggered.connect(network_settings)
self.thermostat_menu.addAction(self.actionNetwork_Settings)
@asyncSlot(bool)
async def load(_):
await self.client.load_config()
loaded = QtWidgets.QMessageBox(self)
loaded.setWindowTitle("Config loaded")
loaded.setText(f"All channel configs have been loaded from flash.")
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
loaded.show()
self.actionLoad_all_configs.triggered.connect(load)
self.thermostat_menu.addAction(self.actionLoad_all_configs)
@asyncSlot(bool)
async def save(_):
await self.client.save_config()
saved = QtWidgets.QMessageBox(self)
saved.setWindowTitle("Config saved")
saved.setText(f"All channel configs have been saved to flash.")
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
saved.show()
self.actionSave_all_configs.triggered.connect(save)
self.thermostat_menu.addAction(self.actionSave_all_configs)
def about_thermostat():
QtWidgets.QMessageBox.about(
self,
"About Thermostat",
f"""
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
<br>
<h2>Settings:</h2>
Default fan curve:
a = {self.hw_rev_data['settings']['fan_k_a']},
b = {self.hw_rev_data['settings']['fan_k_b']},
c = {self.hw_rev_data['settings']['fan_k_c']}
<br>
Fan PWM range:
{self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
<br>
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
<br>
Fan available: {self.hw_rev_data['settings']['fan_available']}
<br>
Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
"""
)
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.thermostat_menu.addAction(self.actionAbout_Thermostat)
self.thermostat_settings.setMenu(self.thermostat_menu)
def _set_up_plot_menu(self):
self.plot_menu = QtWidgets.QMenu()
self.plot_menu.setTitle("Plot Settings")
clear = QtGui.QAction("Clear graphs", self.plot_menu)
clear.triggered.connect(self.clear_graphs)
self.plot_menu.addAction(clear)
self.plot_menu.clear = clear
self.samples_spinbox = QtWidgets.QSpinBox()
self.samples_spinbox.setRange(2, 100000)
self.samples_spinbox.setSuffix(' samples')
self.samples_spinbox.setValue(self.max_samples)
self.samples_spinbox.valueChanged.connect(self.set_max_samples)
limit_samples = QtWidgets.QWidgetAction(self.plot_menu)
limit_samples.setDefaultWidget(self.samples_spinbox)
self.plot_menu.addAction(limit_samples)
self.plot_menu.limit_samples = limit_samples
self.plot_settings.setMenu(self.plot_menu)
@pyqtSlot(list)
def set_limits_warning(self, channels_zeroed_limits: list):
channel_disabled = [False, False]
report_str = "The following output limit(s) are set to zero:\n"
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
if {'max_i_pos', 'max_i_neg'}.issubset(zeroed_limits):
report_str += "Max Cooling Current, Max Heating Current"
channel_disabled[ch] = True
if 'max_v' in zeroed_limits:
if channel_disabled[ch]:
report_str += ", "
report_str += "Max Voltage Difference"
channel_disabled[ch] = True
if channel_disabled[ch]:
report_str += f" for Channel {ch}\n"
report_str += "\nThese limit(s) are restricting the channel(s) from producing current."
if True in channel_disabled:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self.style().standardIcon(pixmapi)
self.limits_warning.setPixmap(icon.pixmap(16, 16))
self.limits_warning.setToolTip(report_str)
else:
self.limits_warning.setPixmap(QtGui.QPixmap())
self.limits_warning.setToolTip(None)
@pyqtSlot(int)
def set_max_samples(self, samples: int):
for channel_graph in self.channel_graphs:
channel_graph.t_connector.max_points = samples
channel_graph.i_connector.max_points = samples
channel_graph.iset_connector.max_points = samples
def clear_graphs(self):
for channel_graph in self.channel_graphs:
channel_graph.clear()
async def _on_connection_changed(self, result):
self.graph_group.setEnabled(result)
self.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(result)
self.host_set_line.setEnabled(not result)
self.port_set_spin.setEnabled(not result)
self.connect_btn.setText("Disconnect" if result else "Connect")
if result:
self.hw_rev_data = await self.client.hw_rev()
self._status(self.hw_rev_data)
self.client_watcher.start_watching()
# await self.client.set_param("fan", 1)
else:
self.status_lbl.setText("Disconnected")
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
self.clear_graphs()
self.report_box.setChecked(False)
await self.client_watcher.set_report_mode(False)
self.client_watcher.stop_watching()
self.status_lbl.setText("Disconnected")
def _set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self.style().standardIcon(pixmapi)
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
self.fan_pwm_warning.setToolTip("Throttling the fan (not recommended on this hardware rev)")
else:
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText(f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}")
self.fan_group.setEnabled(hw_rev_d["settings"]["fan_available"])
@pyqtSlot(dict)
def fan_update(self, fan_settings: dict):
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"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self._set_fan_pwm_warning()
@asyncSlot(int)
async def fan_set(self, value):
if not self.client.connected():
return
if self.fan_auto_box.isChecked():
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(False)
await self.client.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(self, enabled):
if not self.client.connected():
return
if enabled:
await self.client.set_fan("auto")
self.fan_update(await self.client.get_fan())
else:
await self.client.set_fan(self.fan_power_slider.value())
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
await self.client_watcher.set_report_mode(enabled)
@asyncClose
async def closeEvent(self, event):
await self.bail()
@asyncSlot()
async def on_connect_btn_clicked(self):
host, port = self.host_set_line.text(), self.port_set_spin.value()
try:
if not (self.client.connecting() or self.client.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.host_set_line.setEnabled(False)
self.port_set_spin.setEnabled(False)
try:
await self.client.start_session(host=host, port=port, timeout=30)
except StoppedConnecting:
return
await self._on_connection_changed(True)
else:
await self.bail()
except (OSError, TimeoutError) as e:
logging.error(f"Failed communicating to {host}:{port}: {e}")
await self.bail()
@asyncSlot()
async def bail(self):
await self._on_connection_changed(False)
await self.client.end_session()
@asyncSlot(object, object)
async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls"""
for inner_param, change, data in changes:
if change == 'value':
if inner_param.opts.get("param", None) is not None:
if 'Current' in inner_param.name():
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"]
if inner_param.name() == 'Postfilter Rate' and data == None:
set_param_args = (*thermostat_param[:2], 'off')
else:
set_param_args = (*thermostat_param, data)
await self.client.set_param(*set_param_args)
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:
await self.client.set_param(*activater)
def _set_param_tree(self):
for i, tree in enumerate((self.ch0_tree, self.ch1_tree)):
tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False)
self.params[i].sigTreeStateChanged.connect(self.send_command)
@asyncSlot()
async def save(_, ch=i):
await self.client.save_config(ch)
saved = QtWidgets.QMessageBox(self)
saved.setWindowTitle("Config saved")
saved.setText(f"Channel {ch} Config has been saved to flash.")
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
saved.show()
self.params[i].child('Save to flash').sigActivated.connect(save)
@asyncSlot()
async def load(_, ch=i):
await self.client.load_config(ch)
loaded = QtWidgets.QMessageBox(self)
loaded.setWindowTitle("Config loaded")
loaded.setText(f"Channel {ch} Config has been loaded from flash.")
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
loaded.show()
self.params[i].child('Load from flash').sigActivated.connect(load)
@asyncSlot()
async def autotune(param, ch=i):
match self.autotuners[ch].state():
case PIDAutotuneState.STATE_OFF:
self.autotuners[ch].setParam(
param.parent().child('Target Temperature').value(),
param.parent().child('Test Current').value() / 1000,
param.parent().child('Temperature Swing').value(),
self.report_refresh_spin.value(),
3)
self.autotuners[ch].setReady()
param.setOpts(title="Stop")
self.client_watcher.report_update.connect(self.autotune_tick)
self.loading_spinner.show()
self.loading_spinner.start()
if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF:
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=ch))
else:
self.background_task_lbl.setText("Autotuning channel 0 and 1...")
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self.autotuners[ch].setOff()
param.setOpts(title="Run")
await self.client.set_param('pwm', ch, 'i_set', 0)
self.client_watcher.report_update.disconnect(self.autotune_tick)
if self.autotuners[1 - ch].state() == PIDAutotuneState.STATE_OFF:
self.background_task_lbl.setText("Ready.")
self.loading_spinner.stop()
self.loading_spinner.hide()
else:
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
self.params[i].child('PID Config', 'PID Auto Tune', 'Run').sigActivated.connect(autotune)
@asyncSlot(list)
async def autotune_tick(self, report):
for channel_report in report:
channel = channel_report['channel']
match self.autotuners[channel].state():
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self.autotuners[channel].run(channel_report['temperature'], channel_report['time'])
await self.client.set_param('pwm', channel, 'i_set', self.autotuners[channel].output())
case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[channel].get_tec_pid()
self.autotuners[channel].setOff()
self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run")
await self.client.set_param('pid', channel, 'kp', kp)
await self.client.set_param('pid', channel, 'ki', ki)
await self.client.set_param('pid', channel, 'kd', kd)
await self.client.set_param('pwm', channel, 'pid')
await self.client.set_param('pid', channel, 'target', self.params[channel].child("PID Config", "PID Auto Tune", "Target Temperature").value())
self.client_watcher.report_update.disconnect(self.autotune_tick)
if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF:
self.background_task_lbl.setText("Ready.")
self.loading_spinner.stop()
self.loading_spinner.hide()
else:
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
case PIDAutotuneState.STATE_FAILED:
self.autotuners[channel].setOff()
self.params[channel].child('PID Config', 'PID Auto Tune', 'Run').setOpts(title="Run")
await self.client.set_param('pwm', channel, 'i_set', 0)
self.client_watcher.report_update.disconnect(self.autotune_tick)
if self.autotuners[1 - channel].state() == PIDAutotuneState.STATE_OFF:
self.background_task_lbl.setText("Ready.")
self.loading_spinner.stop()
self.loading_spinner.hide()
else:
self.background_task_lbl.setText("Autotuning channel {ch}...".format(ch=1-ch))
@pyqtSlot(list)
def update_pid(self, pid_settings):
for settings in pid_settings:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("PID Config", "Kp").setValue(settings["parameters"]["kp"])
self.params[channel].child("PID Config", "Ki").setValue(settings["parameters"]["ki"])
self.params[channel].child("PID Config", "Kd").setValue(settings["parameters"]["kd"])
self.params[channel].child("PID Config", "PID Output Clamping", "Minimum").setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child("PID Config", "PID Output Clamping", "Maximum").setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child("Output Config", "Control Method", "Set Temperature").setValue(settings["target"])
self.channel_graphs[channel].set_t_line(temp=round(settings["target"], 6))
@pyqtSlot(list)
def update_report(self, report_data):
for settings in report_data:
channel = settings["channel"]
self.channel_graphs[channel].plot_append(settings)
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Control Method").setValue("Temperature PID" if settings["pid_engaged"] else "Constant Current")
self.channel_graphs[channel].set_t_line(visible=settings['pid_engaged'])
self.params[channel].child("Output Config", "Control Method", "Set Current").setValue(settings["i_set"] * 1000)
if settings['temperature'] is not None:
self.params[channel].child("Temperature").setValue(settings['temperature'])
if settings['tec_i'] is not None:
self.params[channel].child("Current through TEC").setValue(settings['tec_i'] * 1000)
@pyqtSlot(list)
def update_thermistor(self, sh_data):
for sh_param in sh_data:
channel = sh_param["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Thermistor Config", "T₀").setValue(sh_param["params"]["t0"] - 273.15)
self.params[channel].child("Thermistor Config", "R₀").setValue(sh_param["params"]["r0"])
self.params[channel].child("Thermistor Config", "B").setValue(sh_param["params"]["b"])
@pyqtSlot(list)
def update_pwm(self, pwm_data):
channels_zeroed_limits = [set() for i in range(NUM_CHANNELS)]
for pwm_params in pwm_data:
channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Limits", "Max Voltage Difference").setValue(pwm_params["max_v"]["value"])
self.params[channel].child("Output Config", "Limits", "Max Cooling Current").setValue(pwm_params["max_i_pos"]["value"] * 1000)
self.params[channel].child("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":
if pwm_params[limit]["value"] == 0.0:
channels_zeroed_limits[channel].add(limit)
self.set_limits_warning(channels_zeroed_limits)
@pyqtSlot(list)
def update_postfilter(self, postfilter_data):
for postfilter_params in postfilter_data:
channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Thermistor Config", "Postfilter Rate").setValue(postfilter_params["rate"])
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)
main_window = MainWindow(args)
main_window.show()
await app_quit_event.wait()
def main():
qasync.run(coro_main())
if __name__ == '__main__':
main()

544
pytec/tec_qt.ui Normal file
View File

@ -0,0 +1,544 @@
<?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>thermostat-icon-640x640.png</normaloff>thermostat-icon-640x640.png</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::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::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::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="0">
<widget class="ParameterTree" name="ch1_tree" native="true"/>
</item>
<item row="0" column="0">
<widget class="ParameterTree" name="ch0_tree" native="true"/>
</item>
<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>
</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::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::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::MenuButtonPopup</enum>
</property>
<property name="toolButtonStyle">
<enum>Qt::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::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::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::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,1">
<property name="spacing">
<number>6</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::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::AlignRight|Qt::AlignTrailing|Qt::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::AdaptiveDecimalStepType</enum>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="report_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</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>Report</string>
</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::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::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::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::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::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::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>waitingspinnerwidget</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

268
pytec/ui_tec_qt.py Normal file
View File

@ -0,0 +1,268 @@
# Form implementation generated from reading ui file 'tec_qt.ui'
#
# Created by: PyQt6 UI code generator 6.5.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1280, 720)
MainWindow.setMinimumSize(QtCore.QSize(1280, 720))
MainWindow.setMaximumSize(QtCore.QSize(3840, 2160))
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap("thermostat-icon-640x640.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
MainWindow.setWindowIcon(icon)
self.main_widget = QtWidgets.QWidget(parent=MainWindow)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(self.main_widget.sizePolicy().hasHeightForWidth())
self.main_widget.setSizePolicy(sizePolicy)
self.main_widget.setObjectName("main_widget")
self.gridLayout_2 = QtWidgets.QGridLayout(self.main_widget)
self.gridLayout_2.setContentsMargins(3, 3, 3, 3)
self.gridLayout_2.setSpacing(3)
self.gridLayout_2.setObjectName("gridLayout_2")
self.main_layout = QtWidgets.QVBoxLayout()
self.main_layout.setSpacing(0)
self.main_layout.setObjectName("main_layout")
self.graph_group = QtWidgets.QFrame(parent=self.main_widget)
self.graph_group.setEnabled(False)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(self.graph_group.sizePolicy().hasHeightForWidth())
self.graph_group.setSizePolicy(sizePolicy)
self.graph_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.graph_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.graph_group.setObjectName("graph_group")
self.graphs_layout = QtWidgets.QGridLayout(self.graph_group)
self.graphs_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
self.graphs_layout.setContentsMargins(3, 3, 3, 3)
self.graphs_layout.setSpacing(2)
self.graphs_layout.setObjectName("graphs_layout")
self.ch1_tree = ParameterTree(parent=self.graph_group)
self.ch1_tree.setObjectName("ch1_tree")
self.graphs_layout.addWidget(self.ch1_tree, 1, 0, 1, 1)
self.ch0_tree = ParameterTree(parent=self.graph_group)
self.ch0_tree.setObjectName("ch0_tree")
self.graphs_layout.addWidget(self.ch0_tree, 0, 0, 1, 1)
self.ch1_t_graph = LivePlotWidget(parent=self.graph_group)
self.ch1_t_graph.setObjectName("ch1_t_graph")
self.graphs_layout.addWidget(self.ch1_t_graph, 1, 1, 1, 1)
self.ch0_t_graph = LivePlotWidget(parent=self.graph_group)
self.ch0_t_graph.setObjectName("ch0_t_graph")
self.graphs_layout.addWidget(self.ch0_t_graph, 0, 1, 1, 1)
self.ch0_i_graph = LivePlotWidget(parent=self.graph_group)
self.ch0_i_graph.setObjectName("ch0_i_graph")
self.graphs_layout.addWidget(self.ch0_i_graph, 0, 2, 1, 1)
self.ch1_i_graph = LivePlotWidget(parent=self.graph_group)
self.ch1_i_graph.setObjectName("ch1_i_graph")
self.graphs_layout.addWidget(self.ch1_i_graph, 1, 2, 1, 1)
self.graphs_layout.setColumnMinimumWidth(0, 100)
self.graphs_layout.setColumnMinimumWidth(1, 100)
self.graphs_layout.setColumnMinimumWidth(2, 100)
self.graphs_layout.setRowMinimumHeight(0, 100)
self.graphs_layout.setRowMinimumHeight(1, 100)
self.graphs_layout.setColumnStretch(0, 1)
self.graphs_layout.setColumnStretch(1, 1)
self.graphs_layout.setColumnStretch(2, 1)
self.graphs_layout.setRowStretch(0, 1)
self.graphs_layout.setRowStretch(1, 1)
self.main_layout.addWidget(self.graph_group)
self.bottom_settings_group = QtWidgets.QFrame(parent=self.main_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.bottom_settings_group.sizePolicy().hasHeightForWidth())
self.bottom_settings_group.setSizePolicy(sizePolicy)
self.bottom_settings_group.setMinimumSize(QtCore.QSize(0, 40))
self.bottom_settings_group.setMaximumSize(QtCore.QSize(16777215, 40))
self.bottom_settings_group.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.bottom_settings_group.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
self.bottom_settings_group.setObjectName("bottom_settings_group")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.bottom_settings_group)
self.horizontalLayout_2.setContentsMargins(3, 3, 3, 3)
self.horizontalLayout_2.setSpacing(3)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.settings_layout = QtWidgets.QHBoxLayout()
self.settings_layout.setObjectName("settings_layout")
self.connect_btn = QtWidgets.QToolButton(parent=self.bottom_settings_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.connect_btn.sizePolicy().hasHeightForWidth())
self.connect_btn.setSizePolicy(sizePolicy)
self.connect_btn.setMinimumSize(QtCore.QSize(100, 0))
self.connect_btn.setMaximumSize(QtCore.QSize(100, 16777215))
self.connect_btn.setBaseSize(QtCore.QSize(100, 0))
self.connect_btn.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self.connect_btn.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonFollowStyle)
self.connect_btn.setObjectName("connect_btn")
self.settings_layout.addWidget(self.connect_btn)
self.status_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.status_lbl.sizePolicy().hasHeightForWidth())
self.status_lbl.setSizePolicy(sizePolicy)
self.status_lbl.setMinimumSize(QtCore.QSize(240, 0))
self.status_lbl.setMaximumSize(QtCore.QSize(120, 16777215))
self.status_lbl.setBaseSize(QtCore.QSize(120, 50))
self.status_lbl.setObjectName("status_lbl")
self.settings_layout.addWidget(self.status_lbl)
self.thermostat_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
self.thermostat_settings.setEnabled(False)
self.thermostat_settings.setText("")
self.thermostat_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
self.thermostat_settings.setObjectName("thermostat_settings")
self.settings_layout.addWidget(self.thermostat_settings)
self.plot_settings = QtWidgets.QToolButton(parent=self.bottom_settings_group)
self.plot_settings.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
self.plot_settings.setObjectName("plot_settings")
self.settings_layout.addWidget(self.plot_settings)
self.limits_warning = QtWidgets.QLabel(parent=self.bottom_settings_group)
self.limits_warning.setToolTipDuration(1000000000)
self.limits_warning.setObjectName("limits_warning")
self.settings_layout.addWidget(self.limits_warning)
self.background_task_lbl = QtWidgets.QLabel(parent=self.bottom_settings_group)
self.background_task_lbl.setObjectName("background_task_lbl")
self.settings_layout.addWidget(self.background_task_lbl)
self.loading_spinner = QtWaitingSpinner(parent=self.bottom_settings_group)
self.loading_spinner.setObjectName("loading_spinner")
self.settings_layout.addWidget(self.loading_spinner)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.settings_layout.addItem(spacerItem)
self.report_group = QtWidgets.QWidget(parent=self.bottom_settings_group)
self.report_group.setEnabled(False)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_group.sizePolicy().hasHeightForWidth())
self.report_group.setSizePolicy(sizePolicy)
self.report_group.setMinimumSize(QtCore.QSize(40, 0))
self.report_group.setObjectName("report_group")
self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.report_group)
self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout_4.setSpacing(0)
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.report_layout = QtWidgets.QHBoxLayout()
self.report_layout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetDefaultConstraint)
self.report_layout.setContentsMargins(0, -1, -1, -1)
self.report_layout.setSpacing(6)
self.report_layout.setObjectName("report_layout")
self.report_lbl = QtWidgets.QLabel(parent=self.report_group)
self.report_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.report_lbl.setObjectName("report_lbl")
self.report_layout.addWidget(self.report_lbl)
self.report_refresh_spin = QtWidgets.QDoubleSpinBox(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_refresh_spin.sizePolicy().hasHeightForWidth())
self.report_refresh_spin.setSizePolicy(sizePolicy)
self.report_refresh_spin.setMinimumSize(QtCore.QSize(70, 0))
self.report_refresh_spin.setMaximumSize(QtCore.QSize(70, 16777215))
self.report_refresh_spin.setBaseSize(QtCore.QSize(70, 0))
self.report_refresh_spin.setDecimals(1)
self.report_refresh_spin.setMinimum(0.1)
self.report_refresh_spin.setSingleStep(0.1)
self.report_refresh_spin.setStepType(QtWidgets.QAbstractSpinBox.StepType.AdaptiveDecimalStepType)
self.report_refresh_spin.setProperty("value", 1.0)
self.report_refresh_spin.setObjectName("report_refresh_spin")
self.report_layout.addWidget(self.report_refresh_spin)
self.report_box = QtWidgets.QCheckBox(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_box.sizePolicy().hasHeightForWidth())
self.report_box.setSizePolicy(sizePolicy)
self.report_box.setMaximumSize(QtCore.QSize(80, 16777215))
self.report_box.setBaseSize(QtCore.QSize(80, 0))
self.report_box.setObjectName("report_box")
self.report_layout.addWidget(self.report_box)
self.report_apply_btn = QtWidgets.QPushButton(parent=self.report_group)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.report_apply_btn.sizePolicy().hasHeightForWidth())
self.report_apply_btn.setSizePolicy(sizePolicy)
self.report_apply_btn.setMinimumSize(QtCore.QSize(80, 0))
self.report_apply_btn.setMaximumSize(QtCore.QSize(80, 16777215))
self.report_apply_btn.setBaseSize(QtCore.QSize(80, 0))
self.report_apply_btn.setObjectName("report_apply_btn")
self.report_layout.addWidget(self.report_apply_btn)
self.report_layout.setStretch(1, 1)
self.report_layout.setStretch(2, 1)
self.report_layout.setStretch(3, 1)
self.horizontalLayout_4.addLayout(self.report_layout)
self.settings_layout.addWidget(self.report_group)
self.horizontalLayout_2.addLayout(self.settings_layout)
self.main_layout.addWidget(self.bottom_settings_group)
self.gridLayout_2.addLayout(self.main_layout, 0, 1, 1, 1)
MainWindow.setCentralWidget(self.main_widget)
self.actionReset = QtGui.QAction(parent=MainWindow)
self.actionReset.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionReset.setObjectName("actionReset")
self.actionEnter_DFU_Mode = QtGui.QAction(parent=MainWindow)
self.actionEnter_DFU_Mode.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionEnter_DFU_Mode.setObjectName("actionEnter_DFU_Mode")
self.actionNetwork_Settings = QtGui.QAction(parent=MainWindow)
self.actionNetwork_Settings.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionNetwork_Settings.setObjectName("actionNetwork_Settings")
self.actionAbout_Thermostat = QtGui.QAction(parent=MainWindow)
self.actionAbout_Thermostat.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionAbout_Thermostat.setObjectName("actionAbout_Thermostat")
self.actionLoad_all_configs = QtGui.QAction(parent=MainWindow)
self.actionLoad_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionLoad_all_configs.setObjectName("actionLoad_all_configs")
self.actionSave_all_configs = QtGui.QAction(parent=MainWindow)
self.actionSave_all_configs.setMenuRole(QtGui.QAction.MenuRole.NoRole)
self.actionSave_all_configs.setObjectName("actionSave_all_configs")
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Thermostat Control Panel"))
self.connect_btn.setText(_translate("MainWindow", "Connect"))
self.status_lbl.setText(_translate("MainWindow", "Disconnected"))
self.plot_settings.setToolTip(_translate("MainWindow", "Plot Settings"))
self.plot_settings.setText(_translate("MainWindow", "📉"))
self.background_task_lbl.setText(_translate("MainWindow", "Ready."))
self.report_lbl.setText(_translate("MainWindow", "Poll every: "))
self.report_refresh_spin.setSuffix(_translate("MainWindow", " s"))
self.report_box.setText(_translate("MainWindow", "Report"))
self.report_apply_btn.setText(_translate("MainWindow", "Apply"))
self.actionReset.setText(_translate("MainWindow", "Reset"))
self.actionReset.setToolTip(_translate("MainWindow", "Reset the Thermostat"))
self.actionEnter_DFU_Mode.setText(_translate("MainWindow", "Enter DFU Mode"))
self.actionEnter_DFU_Mode.setToolTip(_translate("MainWindow", "Reset thermostat and enter USB device firmware update (DFU) mode"))
self.actionNetwork_Settings.setText(_translate("MainWindow", "Network Settings"))
self.actionNetwork_Settings.setToolTip(_translate("MainWindow", "Configure IPv4 address, netmask length, and optional default gateway"))
self.actionAbout_Thermostat.setText(_translate("MainWindow", "About Thermostat"))
self.actionAbout_Thermostat.setToolTip(_translate("MainWindow", "Show Thermostat hardware revision, and settings related to i"))
self.actionLoad_all_configs.setText(_translate("MainWindow", "Load all channel configs from flash"))
self.actionLoad_all_configs.setToolTip(_translate("MainWindow", "Restore configuration for all channels from flash"))
self.actionSave_all_configs.setText(_translate("MainWindow", "Save all channel configs to flash"))
self.actionSave_all_configs.setToolTip(_translate("MainWindow", "Save configuration for all channels to flash"))
from pglive.sources.live_plot_widget import LivePlotWidget
from pyqtgraph.parametertree import ParameterTree
from waitingspinnerwidget import QtWaitingSpinner
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec())

View File

@ -0,0 +1,194 @@
"""
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()

View File

@ -35,6 +35,7 @@ pub struct ChannelState {
pub pid_engaged: bool,
pub pid: pid::Controller,
pub sh: sh::Parameters,
pub swap_tec_polarity: bool,
}
impl ChannelState {
@ -51,6 +52,7 @@ impl ChannelState {
pid_engaged: false,
pid: pid::Controller::new(pid::Parameters::default()),
sh: sh::Parameters::default(),
swap_tec_polarity: false,
}
}

View File

@ -113,8 +113,20 @@ impl<'a> Channels<'a> {
}
pub fn get_i(&mut self, channel: usize) -> ElectricCurrent {
let i_set = self.channel_state(channel).i_set;
i_set
let center_point = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
// let i_set = self.channel_state(channel).i_set;
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = self.get_dac(channel);
let i_tec = (voltage - center_point) / (10.0 * r_sense);
if self.channel_state(channel).swap_tec_polarity {
-i_tec
} else {
i_tec
}
}
/// i_set DAC
@ -129,19 +141,24 @@ impl<'a> Channels<'a> {
voltage
}
pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent {
pub fn set_i(&mut self, channel: usize, i_tec: ElectricCurrent) -> ElectricCurrent {
let vref_meas = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
let i_set = if self.channel_state(channel).swap_tec_polarity {
-i_tec
} else {
i_tec
};
let center_point = vref_meas;
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = i_set * 10.0 * r_sense + center_point;
let voltage = self.set_dac(channel, voltage);
let i_set = (voltage - center_point) / (10.0 * r_sense);
let i_tec = (voltage - center_point) / (10.0 * r_sense);
self.channel_state(channel).i_set = i_set;
i_set
i_tec
}
pub fn read_dac_feedback(&mut self, channel: usize) -> ElectricPotential {
@ -371,7 +388,12 @@ impl<'a> Channels<'a> {
// Get current passing through TEC
pub fn get_tec_i(&mut self, channel: usize) -> ElectricCurrent {
(self.read_itec(channel) - self.read_vref(channel)) / ElectricalResistance::new::<ohm>(0.4)
let tec_i = (self.read_itec(channel) - self.read_vref(channel)) / ElectricalResistance::new::<ohm>(0.4);
if self.channel_state(channel).swap_tec_polarity {
-tec_i
} else {
tec_i
}
}
// Get voltage across TEC
@ -443,6 +465,7 @@ impl<'a> Channels<'a> {
temperature: state.get_temperature()
.map(|temperature| temperature.get::<degree_celsius>()),
pid_engaged: state.pid_engaged,
current_swapped: state.swap_tec_polarity,
i_set,
dac_value,
dac_feedback: self.read_dac_feedback(channel),
@ -540,6 +563,7 @@ pub struct Report {
sens: Option<ElectricalResistance>,
temperature: Option<f64>,
pid_engaged: bool,
current_swapped: bool,
i_set: ElectricCurrent,
dac_value: ElectricPotential,
dac_feedback: ElectricPotential,

View File

@ -412,6 +412,14 @@ impl Handler {
}
}
fn swap_tec_polarity (socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
for c in 0..CHANNELS {
channels.channel_state(c).swap_tec_polarity = !channels.channel_state(c).swap_tec_polarity;
}
send_line(socket, b"{}");
Ok(Handler::Handled)
}
pub fn handle_command(command: Command, socket: &mut TcpSocket, channels: &mut Channels, session: &Session, store: &mut FlashStore, ipv4_config: &mut Ipv4Config, fan_ctrl: &mut FanCtrl, hwrev: HWRev) -> Result<Self, Error> {
match command {
Command::Quit => Ok(Handler::CloseSocket),
@ -441,6 +449,7 @@ impl Handler {
Command::FanCurve { k_a, k_b, k_c } => Handler::fan_curve(socket, fan_ctrl, k_a, k_b, k_c),
Command::FanCurveDefaults => Handler::fan_defaults(socket, fan_ctrl),
Command::ShowHWRev => Handler::show_hwrev(socket, hwrev),
Command::SwapTECPolarity => Handler::swap_tec_polarity(socket, channels),
}
}
}

View File

@ -191,6 +191,7 @@ pub enum Command {
},
FanCurveDefaults,
ShowHWRev,
SwapTECPolarity,
}
fn end(input: &[u8]) -> IResult<&[u8], ()> {
@ -600,6 +601,7 @@ fn command(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
fan,
fan_curve,
value(Ok(Command::ShowHWRev), tag("hwrev")),
value(Ok(Command::SwapTECPolarity), tag("swap")),
))(input)
}

View File

@ -22,6 +22,7 @@ pub struct ChannelConfig {
pwm: PwmLimits,
/// uses variant `PostFilter::Invalid` instead of `None` to save space
adc_postfilter: PostFilter,
swap_tec_polarity: bool,
}
impl ChannelConfig {
@ -41,6 +42,7 @@ impl ChannelConfig {
sh: state.sh.clone(),
pwm,
adc_postfilter,
swap_tec_polarity: state.swap_tec_polarity,
}
}
@ -51,6 +53,7 @@ impl ChannelConfig {
state.pid.target = self.pid_target.into();
state.pid_engaged = self.pid_engaged;
state.sh = self.sh.clone();
state.swap_tec_polarity = self.swap_tec_polarity;
self.pwm.apply(channels, channel);