17 Commits
master ... GUI

Author SHA1 Message Date
718ef99609 update docs 2022-06-30 14:25:56 +08:00
3f6419835f add autotune 2022-06-07 13:54:18 +08:00
8d3a7292e3 WIP: adding autotune 2022-06-06 23:18:44 +08:00
c52cdceec5 fix docs, fix i_set, fix GUI param ranges 2022-06-06 21:28:30 +08:00
1940367dc8 fix whitespace error 2022-06-06 15:25:37 +08:00
cdb78094ca bi-dir sync, minimum working prototype 2022-06-06 15:24:58 +08:00
24cc7dd2b7 sync tree param from TEC 2022-06-06 13:49:58 +08:00
d74e806de8 add sync from TEC 2022-06-06 12:38:44 +08:00
1083af1266 add param tree, param tree inactive 2022-06-05 18:59:05 +08:00
da415e6da6 add voltage monitoring 2022-06-05 17:21:50 +08:00
1d8bd99038 fix typo 2022-06-05 16:04:46 +08:00
09f58f4202 refactor with classes 2022-06-05 16:03:33 +08:00
06625d0716 add graph legends 2022-06-05 14:58:42 +08:00
da8948a166 add more graphs in 2x2 grid 2022-06-02 20:08:18 +08:00
81cc23a452 plot both channel temperatures 2022-06-01 17:47:31 +08:00
07f73bed41 fix pyqtgraph on nixos 2022-06-01 13:09:01 +08:00
7b15ee004d add pyqtgraph 2022-06-01 12:32:18 +08:00
62 changed files with 2065 additions and 5863 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,2 @@
target/
result
*.bin
__pycache__/

74
Cargo.lock generated
View File

@@ -99,15 +99,15 @@ dependencies = [
"aligned",
"bare-metal 0.2.5",
"bitfield",
"cortex-m 0.7.7",
"cortex-m 0.7.4",
"volatile-register",
]
[[package]]
name = "cortex-m"
version = "0.7.7"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
checksum = "37ff967e867ca14eba0c34ac25cd71ea98c678e741e3915d923999bb2fe7c826"
dependencies = [
"bare-metal 0.2.5",
"bitfield",
@@ -173,7 +173,7 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bffa6c1454368a6aa4811ae60964c38e6996d397ff8095a8b9211b1c1f749bc"
dependencies = [
"cortex-m 0.6.7",
"cortex-m 0.7.4",
]
[[package]]
@@ -306,28 +306,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "num-bigint"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
"serde",
]
[[package]]
name = "num-integer"
version = "0.1.44"
@@ -338,32 +316,21 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "panic-halt"
version = "1.0.0"
name = "panic-abort"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a513e167849a384b7f9b746e517604398518590a9142f4846a32e3c2a4de7b11"
checksum = "4e20e6499bbbc412f280b04a42346b356c6fa0753d5fd22b7bd752ff34c778ee"
[[package]]
name = "panic-semihosting"
@@ -371,7 +338,7 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d55dedd501dfd02514646e0af4d7016ce36bc12ae177ef52056989966a1eec"
dependencies = [
"cortex-m 0.6.7",
"cortex-m 0.7.4",
"cortex-m-semihosting",
]
@@ -520,7 +487,7 @@ version = "0.2.0"
source = "git+https://github.com/stm32-rs/stm32-eth.git?rev=3759c5c9#3759c5c99c0ab69bb71759030766bc0fba0b6cde"
dependencies = [
"aligned",
"cortex-m 0.7.7",
"cortex-m 0.7.4",
"smoltcp",
"stm32f4xx-hal",
"volatile-register",
@@ -533,7 +500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3d56009c8f32e4f208dbea17df72484154d1040a8969b75d8c73eb7b18fe8f"
dependencies = [
"bare-metal 0.2.5",
"cortex-m 0.6.7",
"cortex-m 0.7.4",
"cortex-m-rt 0.6.13",
"vcell",
]
@@ -546,7 +513,7 @@ checksum = "3a06fde2dd27c0ba934c9e69b62af66eb1c20dbb6d741b187a763912e9892d13"
dependencies = [
"bare-metal 1.0.0",
"cast",
"cortex-m 0.7.7",
"cortex-m 0.7.4",
"cortex-m-rt 0.7.1",
"embedded-dma",
"embedded-hal",
@@ -587,7 +554,7 @@ dependencies = [
"bare-metal 1.0.0",
"bit_field",
"byteorder",
"cortex-m 0.7.7",
"cortex-m 0.6.7",
"cortex-m-log",
"cortex-m-rt 0.6.13",
"eeprom24x",
@@ -596,7 +563,7 @@ dependencies = [
"nb 1.0.0",
"nom",
"num-traits",
"panic-halt",
"panic-abort",
"panic-semihosting",
"serde",
"serde-json-core",
@@ -611,9 +578,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.18.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33"
[[package]]
name = "unicode-xid"
@@ -623,13 +590,10 @@ checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "uom"
version = "0.36.0"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd36e5350a65d112584053ee91843955826bf9e56ec0d1351214e01f6d7cd9c"
checksum = "e76503e636584f1e10b9b3b9498538279561adcef5412927ba00c2b32c4ce5ed"
dependencies = [
"num-bigint",
"num-complex",
"num-rational",
"num-traits",
"serde",
"typenum",

View File

@@ -7,18 +7,18 @@ authors = ["Astro <astro@spaceboyz.net>"]
version = "0.0.0"
keywords = ["thermostat", "laser", "physics"]
repository = "https://git.m-labs.hk/M-Labs/thermostat"
edition = "2021"
edition = "2018"
[package.metadata.docs.rs]
features = []
default-target = "thumbv7em-none-eabihf"
[dependencies]
panic-halt = "1.0"
panic-abort = "0.3"
panic-semihosting = { version = "0.5", optional = true }
log = "0.4"
bare-metal = "1"
cortex-m = "0.7"
cortex-m = "0.6"
cortex-m-rt = { version = "0.6", features = ["device"] }
cortex-m-log = { version = "0.6", features = ["log-integration"] }
stm32f4xx-hal = { version = "=0.10.1", features = ["rt", "stm32f427", "usb_fs"] }
@@ -31,7 +31,7 @@ num-traits = { version = "0.2", default-features = false, features = ["libm"] }
usb-device = "0.2"
usbd-serial = "0.1"
nb = "1"
uom = { version = "0.36", default-features = false, features = ["autoconvert", "si", "f64", "serde"] }
uom = { version = "0.30", default-features = false, features = ["autoconvert", "si", "f64", "use_serde"] }
eeprom24x = "0.3"
serde = { version = "1.0", default-features = false, features = ["derive"] }
heapless = "0.5"

176
README.md
View File

@@ -23,13 +23,13 @@ cargo build --release
The resulting ELF file will be located under `target/thumbv7em-none-eabihf/release/thermostat`.
Alternatively, you can install the Rust toolchain without Nix using rustup; see the `rust` variable in `flake.nix` to determine which Rust version to use.
Alternatively, you can install the Rust toolchain without Nix using rustup; see the Rust manifest file pulled in `flake.nix` to determine which Rust version to use.
## Debugging
Connect SWDIO/SWCLK/RST/GND to a programmer such as ST-Link v2.1. Run OpenOCD:
```shell
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg
```
You may need to power up the programmer before powering the device.
@@ -45,7 +45,7 @@ There are several options for flashing Thermostat. DFU requires only a micro-USB
### dfu-util on Linux
* Install the DFU USB tool (dfu-util).
* Convert firmware from ELF to BIN: `llvm-objcopy -O binary target/thumbv7em-none-eabihf/release/thermostat thermostat.bin` (you can skip this step if using the BIN from Hydra)
* Convert firmware from ELF to BIN: `arm-none-eabi-objcopy -O binary thermostat thermostat.bin` (you can skip this step if using the BIN from Hydra)
* Connect to the Micro USB connector to Thermostat below the RJ45.
* Add jumper to Thermostat v2.0 across 2-pin jumper adjacent to JTAG connector.
* Cycle board power to put it in DFU update mode
@@ -64,26 +64,22 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware
### OpenOCD
```shell
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
```
## GUI Usage
The Thermostat Control Panel is available for easy configuration and plotting of key parameters.
It is included in the enclosed PyThermostat library, developed based on the Python libraries PyQt and PyQtGraph.
A GUI has been developed for easy configuration and plotting of key parameters.
Launch it by either running:
The Python GUI program is located at pytec/tecQT.py
```sh
nix run git+https://git.m-labs.hk/M-Labs/thermostat#control_panel
The GUI is developed based on the Python library pyqtgraph. The environment needed to run the GUI is configured automatically by running:
```shell
nix develop
```
Or, without Nix, run it after installing the PyThermostat library:
```sh
pip install git+https://git.m-labs.hk/M-Labs/thermostat#subdirectory=pythermostat
thermostat_control_panel
```
The GUI program assumes the default IP and port of 192.168.1.26 23 is used. If a different IP or port is used, the IP and port setting should be changed in the GUI code.
## Command Line Usage
@@ -102,7 +98,9 @@ invalidate the first line of input.
### Reading ADC input
ADC input data is provided in reports. Query for the latest report with the command `report`. See the *Reports* section below.
Set report mode to `on` for a continuous stream of input data.
The scope of this setting is per TCP session.
### TCP commands
@@ -110,41 +108,36 @@ ADC input data is provided in reports. Query for the latest report with the comm
Send commands as simple text string terminated by `\n`. Responses are
formatted as line-delimited JSON.
| Syntax | Function |
|-------------------------------------------|-------------------------------------------------------------------------------|
| `report` | Show latest report of channel parameters (see *Reports* section) |
| `output` | Show current output settings |
| `output <0/1> max_i_pos <amp>` | Set maximum positive output current, clamped to [0, 2] |
| `output <0/1> max_i_neg <amp>` | Set maximum negative output current, clamped to [0, 2] |
| `output <0/1> max_v <volt>` | Set maximum output voltage, clamped to [0, 4.3] |
| `output <0/1> i_set <amp>` | Disengage PID, set fixed output current, clamped to [-2, 2] |
| `output <0/1> polarity <normal/reversed>` | Set output current polarity, with 'normal' being the front panel polarity |
| `output <0/1> pid` | Let output current to be controlled by the PID |
| `center <0/1> <volt>` | Set the MAX1968 0A-centerpoint to the specified fixed voltage |
| `center <0/1> vref` | Set the MAX1968 0A-centerpoint to measure from VREF |
| `pid` | Show PID configuration |
| `pid <0/1> target <deg_celsius>` | Set the PID controller target temperature |
| `pid <0/1> kp <value>` | Set proportional gain |
| `pid <0/1> ki <value>` | Set integral gain |
| `pid <0/1> kd <value>` | Set differential gain |
| `pid <0/1> output_min <amp>` | Set lower limit of PID-regulated output current |
| `pid <0/1> output_max <amp>` | Set upper limit of PID-regulated output current |
| `b-p` | Show B-Parameter equation parameters |
| `b-p <0/1> <t0/b/r0> <value>` | Set B-Parameter for a channel |
| `postfilter` | Show postfilter settings |
| `postfilter <0/1> off` | Disable postfilter |
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
| `load [0/1]` | Restore configuration for channel all/0/1 from flash |
| `save [0/1]` | Save configuration for channel all/0/1 to flash |
| `reset` | Reset the device |
| `dfu` | Reset device and enters USB device firmware update (DFU) mode |
| `ipv4 <X.X.X.X/L> [Y.Y.Y.Y]` | Configure IPv4 address, netmask length, and optional default gateway |
| `fan` | Show current fan settings and sensors' measurements |
| `fan <value>` | Set fan power with values from 1 to 100 |
| `fan auto` | Enable automatic fan speed control |
| `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 |
| Syntax | Function |
| --- | --- |
| `report` | Show current input |
| `report mode` | Show current report mode |
| `report mode <off/on>` | Set report mode |
| `pwm` | Show current PWM settings |
| `pwm <0/1> max_i_pos <amp>` | Set maximum positive output current |
| `pwm <0/1> max_i_neg <amp>` | Set maximum negative output current |
| `pwm <0/1> max_v <volt>` | Set maximum output voltage |
| `pwm <0/1> i_set <amp>` | Disengage PID, set fixed output current |
| `pwm <0/1> pid` | Let output current to be controlled by the PID |
| `center <0/1> <volt>` | Set the MAX1968 0A-centerpoint to the specified fixed voltage |
| `center <0/1> vref` | Set the MAX1968 0A-centerpoint to measure from VREF |
| `pid` | Show PID configuration |
| `pid <0/1> target <deg_celsius>` | Set the PID controller target temperature |
| `pid <0/1> kp <value>` | Set proportional gain |
| `pid <0/1> ki <value>` | Set integral gain |
| `pid <0/1> kd <value>` | Set differential gain |
| `pid <0/1> output_min <amp>` | Set mininum output |
| `pid <0/1> output_max <amp>` | Set maximum output |
| `s-h` | Show Steinhart-Hart equation parameters |
| `s-h <0/1> <t0/b/r0> <value>` | Set Steinhart-Hart parameter for a channel |
| `postfilter` | Show postfilter settings |
| `postfilter <0/1> off` | Disable postfilter |
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
| `load [0/1]` | Restore configuration for channel all/0/1 from flash |
| `save [0/1]` | Save configuration for channel all/0/1 to flash |
| `reset` | Reset the device |
| `dfu` | Reset device and enters USB device firmware update (DFU) mode |
| `ipv4 <X.X.X.X/L> [Y.Y.Y.Y]` | Configure IPv4 address, netmask length, and optional default gateway |
## USB
@@ -162,22 +155,22 @@ output will be truncated when USB buffers are full.
Connect the thermistor with the SENS pins of the
device. Temperature-depending resistance is measured by the AD7172
ADC. To prepare conversion to a temperature, set the parameters
for the B-Parameter equation.
ADC. To prepare conversion to a temperature, set the Beta parameters
for the Steinhart-Hart equation.
Set the base temperature in degrees celsius for the channel 0 thermistor:
```
b-p 0 t0 20
s-h 0 t0 20
```
Set the resistance in Ohms measured at the base temperature t0:
```
b-p 0 r0 10000
s-h 0 r0 10000
```
Set the Beta parameter:
```
b-p 0 b 3800
s-h 0 b 3800
```
### 50/60 Hz filtering
@@ -188,10 +181,10 @@ postfilter rate can be tuned with the `postfilter` command.
| Postfilter rate | Rejection | Effective sampling rate |
| --- | :---: | --- |
| 16.667 Hz | 90 dB | 8.4 Hz |
| 20 Hz | 85 dB | 9.1 Hz |
| 25 Hz | 62 dB | 10 Hz |
| 27.27 Hz | 47 dB | 10.41 Hz |
| 16.67 Hz | 92 dB | 8.4 Hz |
| 20 Hz | 86 dB | 9.1 Hz |
| 21.25 Hz | 62 dB | 10 Hz |
| 27 Hz | 47 dB | 10.41 Hz |
## Thermo-Electric Cooling (TEC)
@@ -201,47 +194,46 @@ postfilter rate can be tuned with the `postfilter` command.
When using a TEC module with the Thermostat, the Thermostat expects the thermal load (where the thermistor is located) to cool down with a positive software current set point, and heat up with a negative current set point.
If the Thermostat is used for temperature control with the Sinara 5432 DAC "Zotino", and is connected via an IDC cable, the TEC polarity may need to be reversed with the `output <ch> polarity reversed` TCP command.
Testing heat flow direction with a low set current is recommended before installation of the TEC module.
### Limits
Each channel has maximum value settings, for setting
Each of the MAX1968 TEC driver has analog/PWM inputs for setting
output limits.
Use the `output` command to see them.
Use the `pwm` command to see current settings and maximum values.
| Limit | Unit | Description |
| --- | :---: | --- |
| `max_v` | Volts | Maximum voltage |
| `max_i_pos` | Amperes | Maximum positive current |
| `max_i_neg` | Amperes | Maximum negative current |
| `i_set` | Amperes | (Not a limit; Open-loop mode) |
Example: set the maximum voltage of channel 0 to 1.5 V.
```
output 0 max_v 1.5
pwm 0 max_v 1.5
```
Example: set the maximum negative current of channel 0 to -2 A.
Example: set the maximum negative current of channel 0 to -3 A.
```
output 0 max_i_neg 2
pwm 0 max_i_neg 3
```
Example: set the maximum positive current of channel 1 to 2 A.
Example: set the maximum positive current of channel 1 to 3 A.
```
output 1 max_i_pos 2
pwm 0 max_i_pos 3
```
### Open-loop mode
To manually control TEC output current, set a fixed output current with
the `output` command. Doing so will disengage the PID control for that
To manually control TEC output current, omit the limit parameter of
the `pwm` command. Doing so will disengage the PID control for that
channel.
Example: set output current of channel 0 to 0 A.
```
output 0 i_set 0
pwm 0 i_set 0
```
## PID-stabilized temperature control
@@ -254,23 +246,7 @@ pid 0 target 20
Enter closed-loop mode by switching control of the TEC output current
of channel 0 to the PID algorithm:
```
output 0 pid
```
### PID output clamping
It is possible to clamp the PID algorithm output independently of channel output limits. This is desirable when e.g. there is a need to keep the current value above a certain threshold in closed-loop mode.
Note that the actual output will still ultimately be limited by the `max_i_pos` and `max_i_neg` values.
Set PID maximum output of channel 0 to 1.5 A.
```
pid 0 output_max 1.5
```
Set PID minimum output of channel 0 to 0.1 A.
```
pid 0 output_min 0.1
pwm 0 pid
```
## LED indicators
@@ -283,19 +259,20 @@ pid 0 output_min 0.1
## Reports
Use the bare `report` command to obtain a single report. Reports are JSON objects
Use the bare `report` command to obtain a single report. Enable
continuous reporting with `report mode on`. Reports are JSON objects
with the following keys.
| Key | Unit | Description |
| --- | :---: | --- |
| `channel` | Integer | Channel `0`, or `1` |
| `time` | Seconds | Temperature measurement time |
| `interval` | Seconds | Time elapsed since last report update on channel |
| `time` | Milliseconds | Temperature measurement time |
| `adc` | Volts | AD7172 input |
| `sens` | Ohms | Thermistor resistance derived from `adc` |
| `temperature` | Degrees Celsius | B-Parameter conversion result derived from `sens` |
| `temperature` | Degrees Celsius | Steinhart-Hart conversion result derived from `sens` |
| `pid_engaged` | Boolean | `true` if in closed-loop mode |
| `i_set` | Amperes | TEC output current |
| `vref` | Volts | MAX1968 VREF (1.5 V) |
| `dac_value` | Volts | AD5680 output derived from `i_set` |
| `dac_feedback` | Volts | ADC measurement of the AD5680 output |
| `i_tec` | Volts | MAX1968 TEC current monitor |
@@ -303,19 +280,6 @@ with the following keys.
| `tec_u_meas` | Volts | Measurement of the voltage across the TEC |
| `pid_output` | Amperes | PID control output |
Note: Prior to Thermostat hardware revision v2.2.4, the voltage and current readouts `i_tec` and `tec_i` are noisy without the hardware fix shown in [this PR](https://git.m-labs.hk/M-Labs/thermostat/pulls/105).
## PID Tuning
The thermostat implements a PID control loop for each of the TEC channels, more details on setting up the PID control loop can be found [here](./doc/PID%20tuning.md).
## Fan control
Fan control commands are available for thermostat revisions with an integrated fan system:
1. `fan` - show fan stats: `fan_pwm`, `abs_max_tec_i`, `auto_mode`, `k_a`, `k_b`, `k_c`.
2. `fan auto` - enable auto speed controller mode, where fan speed is controlled by the fan curve `fcurve`.
3. `fan <value>` - set the fan power with the value from `1` to `100` and disable auto mode. There is no way to completely disable the fan.
Please note that power doesn't correlate with the actual speed linearly.
4. `fcurve <a> <b> <c>` - set coefficients of the controlling curve `a*x^2 + b*x + c`, where `x` is `abs_max_tec_i/MAX_TEC_I`, a normalized value in range [0,1],
i.e. the (linear) proportion of current output capacity used, on the channel with the largest current flow. The controlling curve is also clamped to [0,1].
5. `fcurve default` - restore fan curve coefficients to defaults: `a = 1.0, b = 0.0, c = 0.0`.

View File

@@ -13,7 +13,7 @@ When tuning Thermostat PID parameters, it is helpful to view the temperature, PI
To use the Python real-time plotting utility, run
```shell
python pythermostat/pythermostat/plot.py
python pytec/plot.py
```
![default view](./assets/default%20view.png)
@@ -44,12 +44,12 @@ Below are some general guidelines for manually tuning PID loops. Note that every
## Auto Tuning
A PID auto tuning utility is provided in the PyThermostat library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters.
A PID auto tuning utility is provided in the Pytec library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters.
To run the auto tuning utility, run
```shell
python pythermostat/pythermostat/autotune.py
python pytec/autotune.py
```
After some time, the auto tuning utility will output the auto tuning results, below is a sample output

48
flake.lock generated
View File

@@ -1,45 +1,41 @@
{
"nodes": {
"mozilla-overlay": {
"flake": false,
"locked": {
"lastModified": 1638887313,
"narHash": "sha256-FMYV6rVtvSIfthgC1sK1xugh3y7muoQcvduMdriz4ag=",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "7c1e8b1dd6ed0043fb4ee0b12b815256b0b9de6f",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1775811116,
"narHash": "sha256-t+HZK42pB6N+i5RGbuy7Xluez/VvWbembBdvzsc23Ss=",
"lastModified": 1641870998,
"narHash": "sha256-6HkxR2WZsm37VoQS7jgp6Omd71iw6t1kP8bDbaqCDuI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "54170c54449ea4d6725efd30d719c5e505f1c10e",
"rev": "386234e2a61e1e8acf94dfa3a3d3ca19a6776efb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"ref": "nixos-21.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1775877051,
"narHash": "sha256-wpSQm2PD/w4uRo2wb8utk0b5hOBkkg/CZ1xICY+qB7M=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "08b4f3633471874c8894632ade1b78d75dbda002",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"mozilla-overlay": "mozilla-overlay",
"nixpkgs": "nixpkgs"
}
}
},

189
flake.nix
View File

@@ -1,123 +1,84 @@
{
description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
inputs.rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-21.11;
inputs.mozilla-overlay = { url = github:mozilla/nixpkgs-mozilla; flake = false; };
outputs = {
self,
nixpkgs,
rust-overlay,
}: let
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [(import rust-overlay)];
};
rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = ["rust-src"];
targets = ["thumbv7em-none-eabihf"];
};
rustPlatform = pkgs.makeRustPlatform {
rustc = rust;
cargo = rust;
};
thermostat = rustPlatform.buildRustPackage {
name = "thermostat";
version = "0.0.0";
src = self;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
};
outputs = { self, nixpkgs, mozilla-overlay }:
let
pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ (import mozilla-overlay) ]; };
rustManifest = pkgs.fetchurl {
url = "https://static.rust-lang.org/dist/2021-10-26/channel-rust-nightly.toml";
sha256 = "sha256-1hLbypXA+nuH7o3AHCokzSBZAvQxvef4x9+XxO3aBao=";
};
nativeBuildInputs = [pkgs.llvm];
buildPhase = ''
cargo build --release --bin thermostat
'';
installPhase = ''
mkdir -p $out $out/nix-support
cp target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.elf
echo file binary-dist $out/thermostat.elf >> $out/nix-support/hydra-build-products
llvm-objcopy -O binary target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.bin
echo file binary-dist $out/thermostat.bin >> $out/nix-support/hydra-build-products
'';
dontFixup = true;
auditable = false;
};
pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pythermostat";
version = "0.0.0";
format = "pyproject";
src = "${self}/pythermostat";
nativeBuildInputs = [
pkgs.python3Packages.setuptools
pkgs.qt6.wrapQtAppsHook
targets = [
"thumbv7em-none-eabihf"
];
propagatedBuildInputs =
[pkgs.qt6.qtbase]
++ (with pkgs.python3Packages; [
numpy
matplotlib
pyqtgraph
pyqt6
qasync
pglive
]);
rustChannelOfTargets = _channel: _date: targets:
(pkgs.lib.rustLib.fromManifestFile rustManifest {
inherit (pkgs) stdenv lib fetchurl patchelf;
}).rust.override {
inherit targets;
extensions = ["rust-src"];
};
rust = rustChannelOfTargets "nightly" null targets;
rustPlatform = pkgs.recurseIntoAttrs (pkgs.makeRustPlatform {
rustc = rust;
cargo = rust;
});
thermostat = rustPlatform.buildRustPackage rec {
name = "thermostat";
version = "0.0.0";
dontWrapQtApps = true;
postFixup = ''
wrapQtApp "$out/bin/thermostat_control_panel"
'';
src = self;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
};
};
nativeBuildInputs = [ pkgs.llvm ];
buildPhase = ''
cargo build --release --bin thermostat
'';
installPhase = ''
mkdir -p $out $out/nix-support
cp target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.elf
echo file binary-dist $out/thermostat.elf >> $out/nix-support/hydra-build-products
llvm-objcopy -O binary target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.bin
echo file binary-dist $out/thermostat.bin >> $out/nix-support/hydra-build-products
'';
dontFixup = true;
};
in {
packages.x86_64-linux = {
inherit thermostat;
};
hydraJobs = {
inherit thermostat;
};
devShell.x86_64-linux = pkgs.mkShell {
name = "thermostat-dev-shell";
buildInputs = with pkgs; [
rustPlatform.rust.rustc
rustPlatform.rust.cargo
openocd dfu-util
] ++ (with python3Packages; [
numpy matplotlib pyqtgraph
]);
shellHook=
''
export QT_PLUGIN_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtPluginPrefix}
export QML2_IMPORT_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtQmlPrefix}
'';
};
defaultPackage.x86_64-linux = thermostat;
};
in {
packages.x86_64-linux = {
inherit thermostat pythermostat;
default = thermostat;
};
apps.x86_64-linux.control_panel = {
type = "app";
program = "${pythermostat}/bin/thermostat_control_panel";
};
hydraJobs = {
inherit thermostat;
};
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "thermostat-dev-shell";
packages = with pkgs;
[
rust
llvm
openocd
dfu-util
rlwrap
]
++ (with python3Packages; [
numpy
matplotlib
pyqtgraph
pyqt6
qasync
pglive
]);
};
formatter.x86_64-linux = pkgs.alejandra;
};
}
}

View File

@@ -1,11 +1,9 @@
import argparse
import math
import logging
import time
from collections import deque, namedtuple
from enum import Enum, auto
from enum import Enum
from pythermostat.client import Client
from pytec.client import Client
# Based on hirshmann pid-autotune libiary
# See https://github.com/hirschmann/pid-autotune
@@ -13,62 +11,13 @@ from pythermostat.client import Client
# See https://github.com/t0mpr1c3/Arduino-PID-AutoTune-Library
def get_argparser():
parser = argparse.ArgumentParser(description="Thermostat PID Autotuning Utility")
parser.add_argument(
"-c",
"--channel",
default=0,
type=int,
help="Thermostat channel to autotune",
)
parser.add_argument(
"-t",
"--target",
default=20,
type=float,
help="Target temperature of the autotune routine, degrees Celcius",
)
parser.add_argument(
"-s",
"--step",
default=1,
type=float,
help="Value by which output will be increased/decreased from zero, amps",
)
parser.add_argument(
"-b",
"--lookback",
default=3,
type=float,
help="Reference period for local minima/maxima, seconds",
)
parser.add_argument(
"-n",
"--noiseband",
default=1.5,
type=float,
help="Determines by how much the input value must overshoot/undershoot the setpoint, degrees Celcius",
)
parser.add_argument(
"-l",
"--log",
dest="logLevel",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set the logging level",
)
return parser
class PIDAutotuneState(Enum):
OFF = auto()
RELAY_STEP_UP = auto()
RELAY_STEP_DOWN = auto()
SUCCEEDED = auto()
FAILED = auto()
READY = auto()
STATE_OFF = 'off'
STATE_RELAY_STEP_UP = 'relay step up'
STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune:
@@ -96,7 +45,7 @@ class PIDAutotune:
self._noiseband = noiseband
self._out_min = -out_step
self._out_max = out_step
self._state = PIDAutotuneState.OFF
self._state = PIDAutotuneState.STATE_OFF
self._peak_timestamps = deque(maxlen=5)
self._peaks = deque(maxlen=5)
self._output = 0
@@ -108,7 +57,7 @@ class PIDAutotune:
self._Ku = 0
self._Pu = 0
def set_param(self, target, step, noiseband, sampletime, lookback):
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
@@ -116,12 +65,11 @@ class PIDAutotune:
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def set_ready(self):
self._state = PIDAutotuneState.READY
self._peak_count = 0
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
def set_off(self):
self._state = PIDAutotuneState.OFF
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self):
"""Get the current state."""
@@ -148,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.
@@ -160,30 +115,30 @@ class PIDAutotune:
"""
now = time_input * 1000
if self._state not in {
PIDAutotuneState.RELAY_STEP_DOWN,
PIDAutotuneState.RELAY_STEP_UP,
}:
self._state = PIDAutotuneState.RELAY_STEP_UP
if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now
# check input and change relay state if necessary
if (self._state == PIDAutotuneState.RELAY_STEP_UP
if (self._state == PIDAutotuneState.STATE_RELAY_STEP_UP
and input_val > self._setpoint + self._noiseband):
self._state = PIDAutotuneState.RELAY_STEP_DOWN
self._state = PIDAutotuneState.STATE_RELAY_STEP_DOWN
logging.debug('switched state: {0}'.format(self._state))
logging.debug('input: {0}'.format(input_val))
elif (self._state == PIDAutotuneState.RELAY_STEP_DOWN
elif (self._state == PIDAutotuneState.STATE_RELAY_STEP_DOWN
and input_val < self._setpoint - self._noiseband):
self._state = PIDAutotuneState.RELAY_STEP_UP
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
logging.debug('switched state: {0}'.format(self._state))
logging.debug('input: {0}'.format(input_val))
# set output
if self._state == PIDAutotuneState.RELAY_STEP_UP:
if (self._state == PIDAutotuneState.STATE_RELAY_STEP_UP):
self._output = self._initial_output - self._outputstep
elif self._state == PIDAutotuneState.RELAY_STEP_DOWN:
elif self._state == PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self._output = self._initial_output + self._outputstep
# respect output limits
@@ -251,16 +206,16 @@ class PIDAutotune:
logging.debug('amplitude deviation: {0}'.format(amplitude_dev))
if amplitude_dev < PIDAutotune.PEAK_AMPLITUDE_TOLERANCE:
self._state = PIDAutotuneState.SUCCEEDED
self._state = PIDAutotuneState.STATE_SUCCEEDED
# if the autotune has not already converged
# terminate after 10 cycles
if self._peak_count >= 20:
self._output = 0
self._state = PIDAutotuneState.FAILED
self._state = PIDAutotuneState.STATE_FAILED
return True
if self._state == PIDAutotuneState.SUCCEEDED:
if self._state == PIDAutotuneState.STATE_SUCCEEDED:
self._output = 0
logging.debug('peak finding successful')
@@ -287,33 +242,30 @@ class PIDAutotune:
def main():
args = get_argparser().parse_args()
if args.logLevel:
logging.basicConfig(level=getattr(logging, args.logLevel))
# Auto tune parameters
# Thermostat channel
channel = args.channel
channel = 0
# Target temperature of the autotune routine, celcius
target_temperature = args.target
target_temperature = 20
# Value by which output will be increased/decreased from zero, amps
output_step = args.step
output_step = 1
# Reference period for local minima/maxima, seconds
lookback = args.lookback
lookback = 3
# Determines by how much the input value must
# overshoot/undershoot the setpoint, celcius
noiseband = args.noiseband
noiseband = 1.5
thermostat = Client()
# logging.basicConfig(level=logging.DEBUG)
data = thermostat.get_report()
tec = Client()
data = next(tec.report_mode())
ch = data[channel]
tuner = PIDAutotune(target_temperature, output_step,
lookback, noiseband, ch['interval'])
while True:
data = thermostat.get_report()
for data in tec.report_mode():
ch = data[channel]
@@ -324,11 +276,9 @@ def main():
tuner_out = tuner.output()
thermostat.set_param("output", channel, "i_set", tuner_out)
tec.set_param("pwm", channel, "i_set", tuner_out)
time.sleep(0.05)
thermostat.set_param("output", channel, "i_set", 0)
tec.set_param("pwm", channel, "i_set", 0)
if __name__ == "__main__":

11
pytec/example.py Normal file
View File

@@ -0,0 +1,11 @@
from pytec.client import Client
tec = Client() #(host="localhost", port=6667)
tec.set_param("s-h", 1, "t0", 20)
print(tec.get_pwm())
print(tec.get_pid())
print(tec.get_pwm())
print(tec.get_postfilter())
print(tec.get_steinhart_hart())
for data in tec.report_mode():
print(data)

128
pytec/plot.py Normal file
View File

@@ -0,0 +1,128 @@
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pytec.client import Client
TIME_WINDOW = 300.0
tec = Client()
target_temperature = tec.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(tec):
global last_packet_time
for data in tec.report_mode():
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
thread = Thread(target=recv_data, args=(tec,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
global legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()

View File

@@ -1,7 +1,5 @@
import socket
import json
import logging
class CommandError(Exception):
pass
@@ -10,18 +8,6 @@ class Client:
def __init__(self, host="192.168.1.26", port=23, timeout=None):
self._socket = socket.create_connection((host, port), timeout)
self._lines = [""]
self._check_zero_limits()
def disconnect(self):
self._socket.shutdown(socket.SHUT_RDWR)
self._socket.close()
def _check_zero_limits(self):
output_report = self.get_output()
for output_channel in output_report:
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
if output_channel[limit] == 0.0:
logging.warning("`{}` limit is set to zero on channel {}".format(limit, output_channel["channel"]))
def _read_line(self):
# read more lines
@@ -37,7 +23,7 @@ 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)
@@ -51,27 +37,25 @@ class Client:
result[int(item["channel"])] = item
return result
def get_output(self):
"""Retrieve output limits for the TEC
def get_pwm(self):
"""Retrieve PWM limits for the TEC
Example::
[{'channel': 0,
'center': 'vref',
'i_set': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': 3.988,
'max_i_pos': 2.0,
'polarity': 'normal',
'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': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': 3.988,
'max_i_pos': 2.0}
'polarity': 'normal',
'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 self._get_conf("output")
return self._get_conf("pwm")
def get_pid(self):
"""Retrieve PID control state
@@ -96,14 +80,14 @@ class Client:
"""
return self._get_conf("pid")
def get_b_parameter(self):
"""Retrieve B-Parameter equation parameters for resistance to temperature conversion
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 self._get_conf("b-p")
return self._get_conf("s-h")
def get_postfilter(self):
"""Retrieve DAC postfilter configuration
@@ -114,18 +98,18 @@ class Client:
"""
return self._get_conf("postfilter")
def get_report(self):
"""Obtain one-time report on measurement values
def report_mode(self):
"""Start reporting measurement values
Example of yielded data::
{'channel': 0,
'time': 2302524,
'interval': 0.12
'adc': 0.6199188965423515,
'sens': 6138.519310282602,
'temperature': 36.87032392655527,
'pid_engaged': True,
'i_set': 2.0635816680889123,
'vref': 1.494,
'dac_value': 2.527790834044456,
'dac_feedback': 2.523,
'i_tec': 2.331,
@@ -133,29 +117,26 @@ class Client:
'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247}
"""
return self._get_conf("report")
self._command("report mode", "on")
def get_ipv4(self):
"""Get the IPv4 settings of the Thermostat"""
return self._command("ipv4")
def get_fan(self):
"""Get Thermostat current fan settings"""
return self._command("fan")
def get_hwrev(self):
"""Get Thermostat hardware revision"""
return self._command("hwrev")
while True:
line = self._read_line()
if not line:
break
try:
yield json.loads(line)
except json.decoder.JSONDecodeError:
pass
def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters
Examples::
thermostat.set_param("output", 0, "max_v", 2.0)
thermostat.set_param("pid", 1, "output_max", 2.5)
thermostat.set_param("b-p", 0, "t0", 20.0)
thermostat.set_param("center", 0, "vref")
thermostat.set_param("postfilter", 1, 21)
tec.set_param("pwm", 0, "max_v", 2.0)
tec.set_param("pid", 1, "output_max", 2.5)
tec.set_param("s-h", 0, "t0", 20.0)
tec.set_param("center", 0, "vref")
tec.set_param("postfilter", 1, 21)
See the firmware's README.md for a full list.
"""
@@ -168,40 +149,12 @@ class Client:
def power_up(self, channel, target):
"""Start closed-loop mode"""
self.set_param("pid", channel, "target", value=target)
self.set_param("output", channel, "pid")
self.set_param("pwm", channel, "pid")
def save_config(self, channel=""):
def save_config(self):
"""Save current configuration to EEPROM"""
self._command("save", channel)
if channel != "":
self._read_line() # read the extra {}
self._command("save")
def load_config(self, channel=""):
def load_config(self):
"""Load current configuration from EEPROM"""
self._command("load", channel)
if channel != "":
self._read_line() # read the extra {}
def reset(self):
"""Reset the device"""
self._socket.sendall("reset".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def enter_dfu_mode(self):
"""Reset device and enters USB device firmware update (DFU) mode"""
self._socket.sendall("dfu".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def set_ipv4(self, address, netmask, gateway=""):
"""Configure IPv4 address, netmask length, and optional default gateway"""
self._command("ipv4", f"{address}/{netmask}", gateway)
def set_fan(self, power=None):
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
if power is None:
power = "auto"
self._command("fan", power)
def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan controller curve coefficients"""
self._command("fcurve", a, b, c)
self._command("load")

12
pytec/setup.py Normal file
View File

@@ -0,0 +1,12 @@
from setuptools import setup, find_packages
setup(
name="pytec",
version="0.0",
author="M-Labs",
url="https://git.m-labs.hk/M-Labs/thermostat",
description="Control TEC",
license="GPLv3",
install_requires=["setuptools"],
packages=find_packages(),
)

308
pytec/tecQT.py Normal file
View File

@@ -0,0 +1,308 @@
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
import numpy as np
import pyqtgraph as pg
from pytec.client import Client
from enum import Enum
from autotune import PIDAutotune, PIDAutotuneState
rec_len = 1000
refresh_period = 20
TECparams = [ [
{'tag': 'report', 'type': 'parent', 'children': [
{'tag': 'pid_engaged', 'type': 'bool', 'value': False},
]},
{'tag': 'pwm', 'type': 'parent', 'children': [
{'tag': 'max_i_pos', 'type': 'float', 'value': 0},
{'tag': 'max_i_neg', 'type': 'float', 'value': 0},
{'tag': 'max_v', 'type': 'float', 'value': 0},
{'tag': 'i_set', 'type': 'float', 'value': 0},
]},
{'tag': 'pid', 'type': 'parent', 'children': [
{'tag': 'kp', 'type': 'float', 'value': 0},
{'tag': 'ki', 'type': 'float', 'value': 0},
{'tag': 'kd', 'type': 'float', 'value': 0},
{'tag': 'output_min', 'type': 'float', 'value': 0},
{'tag': 'output_max', 'type': 'float', 'value': 0},
]},
{'tag': 's-h', 'type': 'parent', 'children': [
{'tag': 't0', 'type': 'float', 'value': 0},
{'tag': 'r0', 'type': 'float', 'value': 0},
{'tag': 'b', 'type': 'float', 'value': 0},
]},
{'tag': 'PIDtarget', 'type': 'parent', 'children': [
{'tag': 'target', 'type': 'float', 'value': 0},
]},
] for _ in range(2)]
GUIparams = [[
{'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'},
{'name': 'Constant Current', 'type': 'group', 'children': [
{'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A'},
]},
{'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': 'C'},
]},
{'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'suffix': 'A'},
{'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V'},
]},
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, 'suffix': 'C'},
{'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'},
{'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1},
]},
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
{'name': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'},
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
]},
]},
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save to flash'},
] for _ in range(2)]
autoTuner = [PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000),
PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000)]
## If anything changes in the tree, print a message
def change(param, changes, ch):
print("tree changes:")
for param, change, data in changes:
path = paramList[ch].childPath(param)
if path is not None:
childName = '.'.join(path)
else:
childName = param.name()
print(' parameter: %s'% childName)
print(' change: %s'% change)
print(' data: %s'% str(data))
print(' ----------')
if (childName == 'Disable Output'):
tec.set_param('pwm', ch, 'i_set', 0)
paramList[ch].child('Constant Current').child('Set Current').setValue(0)
paramList[ch].child('Temperature PID').setValue(False)
autoTuner[ch].setOff()
if (childName == 'Temperature PID'):
if (data):
tec.set_param("pwm", ch, "pid")
else:
tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value())
if (childName == 'Constant Current.Set Current'):
tec.set_param('pwm', ch, 'i_set', data)
paramList[ch].child('Temperature PID').setValue(False)
if (childName == 'Temperature PID.Set Temperature'):
tec.set_param('pid', ch, 'target', data)
if (childName == 'Output Config.Max Current'):
tec.set_param('pwm', ch, 'max_i_pos', data)
tec.set_param('pwm', ch, 'max_i_neg', data)
tec.set_param('pid', ch, 'output_min', -data)
tec.set_param('pid', ch, 'output_max', data)
if (childName == 'Output Config.Max Voltage'):
tec.set_param('pwm', ch, 'max_v', data)
if (childName == 'Thermistor Config.T0'):
tec.set_param('s-h', ch, 't0', data)
if (childName == 'Thermistor Config.R0'):
tec.set_param('s-h', ch, 'r0', data)
if (childName == 'Thermistor Config.Beta'):
tec.set_param('s-h', ch, 'b', data)
if (childName == 'PID Config.kP'):
tec.set_param('pid', ch, 'kp', data)
if (childName == 'PID Config.kI'):
tec.set_param('pid', ch, 'ki', data)
if (childName == 'PID Config.kD'):
tec.set_param('pid', ch, 'kd', data)
if (childName == 'PID Config.PID Auto Tune.Run'):
autoTuner[ch].setParam(paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(),
paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(),
paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(),
refresh_period / 1000,
1)
autoTuner[ch].setReady()
paramList[ch].child('Temperature PID').setValue(False)
if (childName == 'Save to flash'):
tec.save_config()
def change0(param, changes):
change(param, changes, 0)
def change1(param, changes):
change(param, changes, 1)
class Curves:
def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int):
self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1}))
self.legendStr = legend
self.keyStr = key
self.channel = channel
self.data_buf = np.zeros(buffer_len)
self.time_stamp = np.zeros(buffer_len)
self.buffLen = buffer_len
self.period = period
def update(self, tec_data, cnt):
if cnt == 0:
np.copyto(self.data_buf, np.full(self.buffLen, tec_data[self.channel][self.keyStr]))
else:
self.data_buf[:-1] = self.data_buf[1:]
self.data_buf[-1] = tec_data[self.channel][self.keyStr]
self.time_stamp[:-1] = self.time_stamp[1:]
self.time_stamp[-1] = cnt * self.period / 1000
self.curveItem.setData(x = self.time_stamp, y = self.data_buf)
class Graph:
def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]):
self.plotItem = pg.PlotWidget(title=title)
self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50,50,200,150))
self.legendItem.setParentItem(self.plotItem.getPlotItem())
parent.addWidget(self.plotItem, row, col)
self.curves = curves
for curve in self.curves:
self.plotItem.addItem(curve.curveItem)
self.legendItem.addItem(curve.curveItem, curve.legendStr)
def update(self, tec_data, cnt):
for curve in self.curves:
curve.update(tec_data, cnt)
self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000])
def TECsync():
global TECparams
for channel in range(2):
for parents in TECparams[channel]:
if parents['tag'] == 'report':
for data in tec.report_mode():
for children in parents['children']:
print(data)
children['value'] = data[channel][children['tag']]
if quit:
break
if parents['tag'] == 'pwm':
for children in parents['children']:
children['value'] = tec.get_pwm()[channel][children['tag']]['value']
if parents['tag'] == 'pid':
for children in parents['children']:
children['value'] = tec.get_pid()[channel]['parameters'][children['tag']]
if parents['tag'] == 's-h':
for children in parents['children']:
children['value'] = tec.get_steinhart_hart()[channel]['params'][children['tag']]
if parents['tag'] == 'PIDtarget':
for children in parents['children']:
children['value'] = tec.get_pid()[channel]['target']
def refreshTreeParam(tempTree:dict, channel:int) -> dict:
tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value']
tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value']
tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value']
tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0]['value']
tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2]['value']
tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0]['value'] - 273.15
tempTree['children']['Thermistor Config']['children']['R0']['value'] = TECparams[channel][3]['children'][1]['value']
tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2]['value']
tempTree['children']['PID Config']['children']['kP']['value'] = TECparams[channel][2]['children'][0]['value']
tempTree['children']['PID Config']['children']['kI']['value'] = TECparams[channel][2]['children'][1]['value']
tempTree['children']['PID Config']['children']['kD']['value'] = TECparams[channel][2]['children'][2]['value']
return tempTree
cnt = 0
def updateData():
global cnt
for data in tec.report_mode():
ch0tempGraph.update(data, cnt)
ch1tempGraph.update(data, cnt)
ch0currentGraph.update(data, cnt)
ch1currentGraph.update(data, cnt)
for channel in range (2):
if (autoTuner[channel].state() == PIDAutotuneState.STATE_READY or
autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or
autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN):
autoTuner[channel].run(data[channel]['temperature'], data[channel]['time'])
tec.set_param('pwm', channel, 'i_set', autoTuner[channel].output())
elif (autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED):
kp, ki, kd = autoTuner[channel].get_tec_pid()
autoTuner[channel].setOff()
paramList[channel].child('PID Config').child('kP').setValue(kp)
paramList[channel].child('PID Config').child('kI').setValue(ki)
paramList[channel].child('PID Config').child('kD').setValue(kd)
tec.set_param('pid', channel, 'kp', kp)
tec.set_param('pid', channel, 'ki', ki)
tec.set_param('pid', channel, 'kd', kd)
elif (autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED):
tec.set_param('pwm', channel, 'i_set', 0)
autoTuner[channel].setOff()
if quit:
break
cnt += 1
if __name__ == '__main__':
tec = Client(host="192.168.1.26", port=23, timeout=None)
app = pg.mkQApp()
pg.setConfigOptions(antialias=True)
mw = QtGui.QMainWindow()
mw.setWindowTitle('Thermostat Control Panel')
mw.resize(1920,1200)
cw = QtGui.QWidget()
mw.setCentralWidget(cw)
l = QtGui.QVBoxLayout()
layout = pg.LayoutWidget()
l.addWidget(layout)
cw.setLayout(l)
## Create tree of Parameter objects
paramList = [Parameter.create(name='GUIparams', type='group', children=GUIparams[0]),
Parameter.create(name='GUIparams', type='group', children=GUIparams[1])]
ch0Tree = ParameterTree()
ch0Tree.setParameters(paramList[0], showTop=False)
ch1Tree = ParameterTree()
ch1Tree.setParameters(paramList[1], showTop=False)
TECsync()
paramList[0].restoreState(refreshTreeParam(paramList[0].saveState(), 0))
paramList[1].restoreState(refreshTreeParam(paramList[1].saveState(), 1))
paramList[0].sigTreeStateChanged.connect(change0)
paramList[1].sigTreeStateChanged.connect(change1)
layout.addWidget(ch0Tree, 1, 1, 1, 1)
layout.addWidget(ch1Tree, 2, 1, 1, 1)
ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)])
ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)])
ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period),
Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)])
ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period),
Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)])
t = QtCore.QTimer()
t.timeout.connect(updateData)
t.start(refresh_period)
mw.show()
pg.exec()

View File

@@ -1,4 +0,0 @@
graft examples
include pythermostat/gui/resources/artiq.svg
include pythermostat/gui/view/param_tree.json
include pythermostat/gui/view/MainWindow.ui

View File

@@ -1,27 +0,0 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pythermostat"
version = "0.0"
authors = [{name = "M-Labs"}]
description = "Python utilities for the Sinara 8451 Thermostat"
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
license = {text = "GPLv3"}
dependencies = [
"numpy >= 1.26.4",
"matplotlib >= 3.8.4",
"pyqtgraph >= 0.13.7",
"pyqt6 >= 6.7.0",
"qasync >= 0.27.1",
"pglive >= 0.7.2",
]
[project.gui-scripts]
thermostat_plot = "pythermostat.plot:main"
thermostat_control_panel = "pythermostat.control_panel:main"
[project.scripts]
thermostat_autotune = "pythermostat.autotune:main"
thermostat_test = "pythermostat.test:main"

View File

@@ -1,240 +0,0 @@
import asyncio
import json
import logging
class CommandError(Exception):
pass
class AsyncioClient:
def __init__(self):
self._reader = None
self._writer = None
self._read_lock = asyncio.Lock()
async def connect(self, host="192.168.1.26", port=23):
"""Connect to Thermostat at specified host and port.
Example::
thermostat = AsyncioClient()
await client.connect()
"""
self._reader, self._writer = await asyncio.open_connection(host, port)
await self._check_zero_limits()
def connected(self):
"""Returns True if client is connected"""
return self._writer is not None
async def disconnect(self):
"""Disconnect from the Thermostat"""
if self._writer is None:
return
# Reader needn't be closed
self._writer.close()
await self._writer.wait_closed()
self._reader = None
self._writer = None
async def _check_zero_limits(self):
output_report = await self.get_output()
for output_channel in output_report:
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
if output_channel[limit] == 0.0:
logging.warning(
"`%s` limit is set to zero on channel %d",
limit,
output_channel["channel"],
)
async def _read_line(self):
# read 1 line
async with self._read_lock:
chunk = await self._reader.readline()
return chunk.decode("utf-8", errors="ignore")
async def _read_write(self, command):
self._writer.write(((" ".join(command)).strip() + "\n").encode("utf-8"))
await self._writer.drain()
return await self._read_line()
async def _command(self, *command):
line = await self._read_write(command)
response = json.loads(line)
if "error" in response:
raise CommandError(response["error"])
return response
async def _get_conf(self, topic):
result = [None, None]
for item in await self._command(topic):
result[int(item["channel"])] = item
return result
async def get_output(self):
"""Retrieve output limits for the TEC
Example::
[{'channel': 0,
'center': 'vref',
'i_set': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': : 3.988,
'max_i_pos': 2.0,
'polarity': 'normal'},
{'channel': 1,
'center': 'vref',
'i_set': -0.02002179650216762,
'max_i_neg': 2.0,
'max_v': : 3.988,
'max_i_pos': 2.0,
'polarity': 'normal'},
]
"""
return await self._get_conf("output")
async def get_pid(self):
"""Retrieve PID control state
Example::
[{'channel': 0,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 37.0},
{'channel': 1,
'parameters': {
'kp': 10.0,
'ki': 0.02,
'kd': 0.0,
'output_min': 0.0,
'output_max': 3.0},
'target': 36.5}]
"""
return await self._get_conf("pid")
async def get_b_parameter(self):
"""
Retrieve B-Parameter equation parameters for resistance to
temperature conversion
Example::
[{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0},
{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}]
"""
return await self._get_conf("b-p")
async def get_postfilter(self):
"""Retrieve DAC postfilter configuration
Example::
[{'rate': None, 'channel': 0},
{'rate': 21.25, 'channel': 1}]
"""
return await self._get_conf("postfilter")
async def get_report(self):
"""Obtain one-time report on measurement values
Example of yielded data:
{'channel': 0,
'time': 2302524,
'interval': 0.12
'adc': 0.6199188965423515,
'sens': 6138.519310282602,
'temperature': 36.87032392655527,
'pid_engaged': True,
'i_set': 2.0635816680889123,
'dac_value': 2.527790834044456,
'dac_feedback': 2.523,
'i_tec': 2.331,
'tec_i': 2.0925,
'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247}
"""
return await self._command("report")
async def get_ipv4(self):
"""Get the IPv4 settings of the Thermostat"""
return await self._command("ipv4")
async def get_fan(self):
"""Get Thermostat current fan settings"""
return await self._command("fan")
async def get_hwrev(self):
"""Get Thermostat hardware revision"""
return await self._command("hwrev")
async def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters
Examples::
await thermostat.set_param("output", 0, "max_v", 2.0)
await thermostat.set_param("pid", 1, "output_max", 2.5)
await thermostat.set_param("b-p", 0, "t0", 20.0)
await thermostat.set_param("center", 0, "vref")
await thermostat.set_param("postfilter", 1, 21)
See the firmware's README.md for a full list.
"""
if isinstance(value, float):
value = f"{value:f}"
if not isinstance(value, str):
value = str(value)
await self._command(topic, str(channel), field, value)
async def power_up(self, channel, target):
"""Start closed-loop mode"""
await self.set_param("pid", channel, "target", value=target)
await self.set_param("output", channel, "pid")
async def save_config(self, channel=""):
"""Save current configuration to EEPROM"""
await self._command("save", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def load_config(self, channel=""):
"""Load current configuration from EEPROM"""
await self._command("load", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def reset(self):
"""Reset the Thermostat
The client is disconnected as the TCP session is terminated.
"""
self._writer.write("reset\n".encode("utf-8"))
await self._writer.drain()
await self.disconnect()
async def enter_dfu_mode(self):
"""Put the Thermostat in DFU mode
The client is disconnected as the Thermostat stops responding to
TCP commands in DFU mode. To exit it, submit a DFU leave request
or power-cycle the Thermostat.
"""
self._writer.write("dfu\n".encode("utf-8"))
await self._writer.drain()
await self.disconnect()
async def set_fan(self, power="auto"):
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
await self._command("fan", str(power))
async def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan controller curve coefficients"""
await self._command("fcurve", str(a), str(b), str(c))

View File

@@ -1,252 +0,0 @@
"""GUI Control Panel for the Sinara 8451 Thermostat"""
import asyncio
import logging
import argparse
import importlib.resources
import json
from PyQt6 import QtWidgets, QtGui, uic
from PyQt6.QtCore import pyqtSlot
import qasync
from qasync import asyncSlot, asyncClose
from pythermostat.autotune import PIDAutotuneState
from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState
from pythermostat.gui.model.pid_autotuner import PIDAutoTuner
from pythermostat.gui.view.settings_tree_view import SettingsTreeView
from pythermostat.gui.view.info_box import InfoBox
from pythermostat.gui.view.menus import PlotOptionsMenu, ThermostatSettingsMenu, ConnectionDetailsMenu
from pythermostat.gui.view.live_plot_view import LiveDataPlotter
from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
def get_argparser():
parser = argparse.ArgumentParser(description="Thermostat Control Panel")
parser.add_argument(
"--connect",
default=None,
action="store_true",
help="Automatically connect to the specified Thermostat in host:port format",
)
parser.add_argument("host", metavar="HOST", default=None, nargs="?")
parser.add_argument("port", metavar="PORT", default=None, nargs="?")
parser.add_argument(
"-l",
"--log",
dest="logLevel",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set the logging level",
)
parser.add_argument(
"-p",
"--param_tree",
default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"),
help="Param Tree Description JSON File",
)
return parser
class MainWindow(QtWidgets.QMainWindow):
NUM_CHANNELS = 2
def __init__(self, args):
super().__init__()
ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.ui")
uic.loadUi(ui_file_path, self)
self._info_box = InfoBox()
# Models
self._thermostat = Thermostat(self, self.report_refresh_spin.value())
self._connecting_task = None
self._thermostat.connection_state_update.connect(
self._on_connection_state_changed
)
self._autotuners = PIDAutoTuner(self, self._thermostat, 2)
self._autotuners.autotune_state_changed.connect(
self._on_pid_autotune_state_changed
)
# Handlers for disconnections
async def autotune_disconnect():
for ch in range(self.NUM_CHANNELS):
if self._autotuners.get_state(ch) != PIDAutotuneState.OFF:
await self._autotuners.stop_pid_from_running(ch)
self._thermostat.disconnect_cb = autotune_disconnect
@pyqtSlot()
def handle_connection_error():
self._info_box.display_info_box(
"Connection Error", "Thermostat connection lost. Is it unplugged?"
)
self._thermostat.connection_error.connect(handle_connection_error)
# Settings tree view
def get_settings_tree_view_config(args):
with open(args.param_tree, "r", encoding="utf-8") as f:
return json.load(f)["settings_tree"]
self._settings_tree_view = SettingsTreeView(
self._thermostat,
self._autotuners,
self._info_box,
[self.ch0_tree, self.ch1_tree],
get_settings_tree_view_config(args),
)
# Graphs
self._channel_graphs = LiveDataPlotter(
self._thermostat,
[
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
for ch in range(self.NUM_CHANNELS)
],
)
# Bottom bar menus
self.connection_details_menu = ConnectionDetailsMenu(
self._thermostat, self.connect_btn
)
self.connect_btn.setMenu(self.connection_details_menu)
self._thermostat_settings_menu = ThermostatSettingsMenu(
self._thermostat, self._info_box, self.style()
)
self.thermostat_settings.setMenu(self._thermostat_settings_menu)
self._plot_options_menu = PlotOptionsMenu(self._channel_graphs)
self.plot_settings.setMenu(self._plot_options_menu)
# Status line
self._zero_limits_warning_view = ZeroLimitsWarningView(
self._thermostat, self.style(), self.limits_warning
)
self.loading_spinner.hide()
self.report_apply_btn.clicked.connect(
lambda: self._thermostat.set_update_s(self.report_refresh_spin.value())
)
@asyncClose
async def closeEvent(self, _event):
try:
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
except:
pass
@pyqtSlot(ThermostatConnectionState)
def _on_connection_state_changed(self, state):
self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
self.thermostat_settings.setEnabled(
state == ThermostatConnectionState.CONNECTED
)
self.report_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
match state:
case ThermostatConnectionState.CONNECTED:
self.connect_btn.setText("Disconnect")
self.status_lbl.setText(
"Connected to Thermostat v"
f"{self._thermostat.hw_rev['rev']['major']}."
f"{self._thermostat.hw_rev['rev']['minor']}"
)
case ThermostatConnectionState.CONNECTING:
self.connect_btn.setText("Stop")
self.status_lbl.setText("Connecting...")
case ThermostatConnectionState.DISCONNECTED:
self.connect_btn.setText("Connect")
self.status_lbl.setText("Disconnected")
@pyqtSlot(int, PIDAutotuneState)
def _on_pid_autotune_state_changed(self, _ch, _state):
autotuning_channels = []
for ch in range(self.NUM_CHANNELS):
if self._autotuners.get_state(ch) in {
PIDAutotuneState.READY,
PIDAutotuneState.RELAY_STEP_UP,
PIDAutotuneState.RELAY_STEP_DOWN,
}:
autotuning_channels.append(ch)
if len(autotuning_channels) == 0:
self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
self.loading_spinner.stop()
else:
self.background_task_lbl.setText(
f"Autotuning channel {autotuning_channels}..."
)
self.loading_spinner.start()
self.loading_spinner.show()
@asyncSlot()
async def on_connect_btn_clicked(self):
match self._thermostat.connection_state:
case ThermostatConnectionState.DISCONNECTED:
self._connecting_task = asyncio.current_task()
self._thermostat.connection_state = ThermostatConnectionState.CONNECTING
await self._thermostat.start_session(
host=self.connection_details_menu.host_set_line.text(),
port=self.connection_details_menu.port_set_spin.value(),
)
self._connecting_task = None
self._thermostat.connection_state = ThermostatConnectionState.CONNECTED
self._thermostat.start_watching()
case ThermostatConnectionState.CONNECTING:
self._connecting_task.cancel()
self._connecting_task = None
await self._thermostat.end_session()
self._thermostat.connection_state = (
ThermostatConnectionState.DISCONNECTED
)
case ThermostatConnectionState.CONNECTED:
await self._thermostat.end_session()
self._thermostat.connection_state = (
ThermostatConnectionState.DISCONNECTED
)
async def coro_main():
args = get_argparser().parse_args()
if args.logLevel:
logging.basicConfig(level=getattr(logging, args.logLevel))
app_quit_event = asyncio.Event()
app = QtWidgets.QApplication.instance()
app.aboutToQuit.connect(app_quit_event.set)
app.setWindowIcon(
QtGui.QIcon(
str(importlib.resources.files("pythermostat.gui.resources").joinpath("artiq.svg"))
)
)
main_window = MainWindow(args)
main_window.show()
if args.connect:
if args.host:
main_window.connection_details_menu.host_set_line.setText(args.host)
if args.port:
main_window.connection_details_menu.port_set_spin.setValue(int(args.port))
main_window.connect_btn.click()
await app_quit_event.wait()
def main():
qasync.run(coro_main())
if __name__ == "__main__":
main()

View File

@@ -1,37 +0,0 @@
import asyncio
from pythermostat.aioclient import AsyncioClient
async def poll_for_reports(thermostat_aio):
while True:
print(await thermostat_aio.get_report())
await asyncio.sleep(0.05)
async def poll_for_settings(thermostat_aio):
while True:
await asyncio.sleep(1)
print(await thermostat_aio.get_output())
print(await thermostat_aio.get_pid())
print(await thermostat_aio.get_fan())
print(await thermostat_aio.get_postfilter())
print(await thermostat_aio.get_b_parameter())
async def main():
thermostat_aio = AsyncioClient()
await thermostat_aio.connect()
await thermostat_aio.set_param("b-p", 1, "t0", 20)
print(await thermostat_aio.get_output())
print(await thermostat_aio.get_pid())
print(await thermostat_aio.get_fan())
print(await thermostat_aio.get_postfilter())
print(await thermostat_aio.get_b_parameter())
# Poll both reports and settings, at different rates
async with asyncio.TaskGroup() as tg:
tg.create_task(poll_for_reports(thermostat_aio))
tg.create_task(poll_for_settings(thermostat_aio))
asyncio.run(main())

View File

@@ -1,13 +0,0 @@
import time
from pythermostat.client import Client
thermostat = Client() #(host="localhost", port=6667)
thermostat.set_param("b-p", 1, "t0", 20)
print(thermostat.get_output())
print(thermostat.get_pid())
print(thermostat.get_output())
print(thermostat.get_postfilter())
print(thermostat.get_b_parameter())
while True:
print(thermostat.get_report())
time.sleep(0.05)

View File

@@ -1,84 +0,0 @@
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
from qasync import asyncSlot
from pythermostat.autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject):
autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
def __init__(self, parent, thermostat, num_of_channel):
super().__init__(parent)
self._thermostat = thermostat
self._thermostat.report_update.connect(self.tick)
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
self.target_temp = [20.0 for _ in range(num_of_channel)]
self.test_current = [1.0 for _ in range(num_of_channel)]
self.temp_swing = [1.5 for _ in range(num_of_channel)]
self.lookback = [3.0 for _ in range(num_of_channel)]
self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)]
def set_params(self, params_name, ch, val):
getattr(self, params_name)[ch] = val
def get_state(self, ch):
return self.autotuners[ch].state()
def load_params_and_set_ready(self, ch):
self.autotuners[ch].set_param(
self.target_temp[ch],
self.test_current[ch] / 1000,
self.temp_swing[ch],
1 / self.sampling_interval[ch],
self.lookback[ch],
)
self.autotuners[ch].set_ready()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
async def stop_pid_from_running(self, ch):
self.autotuners[ch].set_off()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
if self._thermostat.connected():
await self._thermostat.set_param("output", ch, "i_set", 0)
@asyncSlot(list)
async def tick(self, report):
for channel_report in report:
ch = channel_report["channel"]
self.sampling_interval[ch] = channel_report["interval"]
# TODO: Skip when PID Autotune or emit error message if NTC is not connected
if channel_report["temperature"] is None:
continue
match self.autotuners[ch].state():
case (
PIDAutotuneState.READY
| PIDAutotuneState.RELAY_STEP_UP
| PIDAutotuneState.RELAY_STEP_DOWN
):
self.autotuners[ch].run(
channel_report["temperature"], channel_report["time"]
)
await self._thermostat.set_param(
"output", ch, "i_set", self.autotuners[ch].output()
)
case PIDAutotuneState.SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_pid_parameters("tyreus-luyben")
self.autotuners[ch].set_off()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pid", ch, "kp", kp)
await self._thermostat.set_param("pid", ch, "ki", ki)
await self._thermostat.set_param("pid", ch, "kd", kd)
await self._thermostat.set_param("output", ch, "pid")
await self._thermostat.set_param(
"pid", ch, "target", self.target_temp[ch]
)
case PIDAutotuneState.FAILED:
self.autotuners[ch].set_off()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("output", ch, "i_set", 0)

View File

@@ -1,126 +0,0 @@
# A Custom Class that allows defining a QObject Property Dynamically
# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically
from functools import wraps
from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
class PropertyMeta(type(QObject)):
"""Lets a class succinctly define Qt properties."""
def __new__(cls, name, bases, attrs):
for key in list(attrs.keys()):
attr = attrs[key]
if not isinstance(attr, Property):
continue
types = {list: "QVariantList", dict: "QVariantMap"}
type_ = types.get(attr.type_, attr.type_)
notifier = pyqtSignal(type_)
attrs[f"{key}_update"] = notifier
attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
return super().__new__(cls, name, bases, attrs)
class Property:
"""Property definition.
Instances of this class will be replaced with their full
implementation by the PropertyMeta metaclass.
"""
def __init__(self, type_):
self.type_ = type_
class PropertyImpl(pyqtProperty):
"""Property implementation: gets, sets, and notifies of change."""
def __init__(self, type_, name, notify):
super().__init__(type_, self.getter, self.setter, notify=notify)
self.name = name
def getter(self, instance):
return getattr(instance, f"_{self.name}")
def setter(self, instance, value):
signal = getattr(instance, f"{self.name}_update")
if type(value) in {list, dict}:
value = make_notified(value, signal)
setattr(instance, f"_{self.name}", value)
signal.emit(value)
class MakeNotified:
"""Adds notifying signals to lists and dictionaries.
Creates the modified classes just once, on initialization.
"""
change_methods = {
list: [
"__delitem__",
"__iadd__",
"__imul__",
"__setitem__",
"append",
"extend",
"insert",
"pop",
"remove",
"reverse",
"sort",
],
dict: [
"__delitem__",
"__ior__",
"__setitem__",
"clear",
"pop",
"popitem",
"setdefault",
"update",
],
}
def __init__(self):
if not hasattr(dict, "__ior__"):
# Dictionaries don't have | operator in Python < 3.9.
self.change_methods[dict].remove("__ior__")
self.notified_class = {
type_: self.make_notified_class(type_) for type_ in [list, dict]
}
def __call__(self, seq, signal):
"""Returns a notifying version of the supplied list or dict."""
notified_class = self.notified_class[type(seq)]
notified_seq = notified_class(seq)
notified_seq.signal = signal
return notified_seq
@classmethod
def make_notified_class(cls, parent):
notified_class = type(f"notified_{parent.__name__}", (parent,), {})
for method_name in cls.change_methods[parent]:
original = getattr(notified_class, method_name)
notified_method = cls.make_notified_method(original, parent)
setattr(notified_class, method_name, notified_method)
return notified_class
@staticmethod
def make_notified_method(method, parent):
@wraps(method)
def notified_method(self, *args, **kwargs):
result = getattr(parent, method.__name__)(self, *args, **kwargs)
self.signal.emit(self)
return result
return notified_method
make_notified = MakeNotified()

View File

@@ -1,135 +0,0 @@
import asyncio
import logging
from enum import Enum
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot
from pythermostat.aioclient import AsyncioClient
from pythermostat.gui.model.property import Property, PropertyMeta
class ThermostatConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
class Thermostat(QObject, metaclass=PropertyMeta):
connection_state = Property(ThermostatConnectionState)
hw_rev = Property(dict)
fan = Property(dict)
thermistor = Property(list)
pid = Property(list)
output = Property(list)
postfilter = Property(list)
report = Property(list)
connection_error = pyqtSignal()
NUM_CHANNELS = 2
def __init__(self, parent, update_s, disconnect_cb=None):
super().__init__(parent)
self._update_s = update_s
self._client = AsyncioClient()
self._watch_task = None
self._update_params_task = None
self.disconnect_cb = disconnect_cb
self.connection_state = ThermostatConnectionState.DISCONNECTED
async def start_session(self, host, port):
await self._client.connect(host, port)
self.hw_rev = await self._client.get_hwrev()
@asyncSlot()
async def end_session(self):
self.stop_watching()
if self.disconnect_cb is not None:
if asyncio.iscoroutinefunction(self.disconnect_cb):
await self.disconnect_cb()
else:
self.disconnect_cb()
await self._client.disconnect()
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
def stop_watching(self):
if self._watch_task is not None:
self._watch_task.cancel()
self._watch_task = None
self._update_params_task.cancel()
self._update_params_task = None
async def run(self):
self._update_params_task = asyncio.create_task(self.update_params())
while True:
if self._update_params_task.done():
try:
self._update_params_task.result()
except OSError:
logging.error(
"Encountered an error while polling for information from Thermostat.",
exc_info=True,
)
await self.end_session()
self.connection_state = ThermostatConnectionState.DISCONNECTED
self.connection_error.emit()
return
self._update_params_task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s)
async def update_params(self):
(
self.fan,
self.output,
self.report,
self.pid,
self.thermistor,
self.postfilter,
) = await asyncio.gather(
self._client.get_fan(),
self._client.get_output(),
self._client.get_report(),
self._client.get_pid(),
self._client.get_b_parameter(),
self._client.get_postfilter(),
)
def connected(self):
return self._client.connected()
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s
async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4)
async def get_ipv4(self):
return await self._client.get_ipv4()
@asyncSlot()
async def save_cfg(self, ch=""):
await self._client.save_config(ch)
@asyncSlot()
async def load_cfg(self, ch=""):
await self._client.load_config(ch)
async def dfu(self):
await self._client.enter_dfu_mode()
async def reset(self):
await self._client.reset()
async def set_fan(self, power="auto"):
await self._client.set_fan(power)
async def get_fan(self):
return await self._client.get_fan()
async def set_param(self, topic, channel, field="", value=""):
await self._client.set_param(topic, channel, field, value)

View File

@@ -1,134 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:i="&amp;#38;#38;ns_ai;"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
x="0px"
y="0px"
width="360"
height="360"
viewBox="0 0 360 360"
enable-background="new 0 0 800 800"
xml:space="preserve"
id="svg2"
inkscape:version="0.91 r13725"
sodipodi:docname="logo.svg"><metadata
id="metadata548"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs546" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1156"
id="namedview544"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="0.417193"
inkscape:cx="287.46503"
inkscape:cy="-196.56401"
inkscape:window-x="0"
inkscape:window-y="44"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path381"
d="m 306.69585,335.11213 c 5.09,-0.035 9.227,-4.208 9.217,-9.303 -0.01,-5.062 -4.225,-9.248 -9.291,-9.229 -5.066,0.021 -9.246,4.237 -9.217,9.302 0.027,5.085 4.236,9.264 9.291,9.23 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path383"
d="m 89.214854,155.25113 10.609,9.812 c 0.214996,-0.162 0.460996,-0.348 0.688996,-0.549 20.543,-18.001 43.98,-33.619 69.662,-46.423 32.912,-16.40399 60.969,-25.001991 88.295,-27.057991 2.504,-0.188 4.811,-0.279 7.051,-0.279 9.105,0 16.873,1.591 23.744,4.864 7.635,3.636 11.473,9.740991 11.404,18.145991 -0.053,6.609 -1.955,13.229 -5.812,20.239 -2.68,4.868 -5.713,9.68 -8.646,14.332 -1.248,1.979 -2.502,3.969 -3.744,5.982 l 10.135,9.65 c 8.092,-10.235 16.82,-22.731 20.846,-38.001 0.467,-1.765 0.861,-3.586 1.244,-5.348 0.174,-0.804 0.348,-1.606 0.529,-2.408 l 0,-8.887 c -0.049,-0.148 -0.102,-0.297 -0.154,-0.444 -0.141,-0.387 -0.285,-0.787 -0.357,-1.216 -2.037,-12.212991 -8.967,-20.777991 -21.184,-26.185991 -7.824,-3.462 -16.289,-4.355 -23.535,-4.772 -2.264,-0.13 -4.576,-0.196 -6.877,-0.196 -11.945,0 -24.328,1.727 -37.859,5.278 -46.736,12.272 -90.896,35.553991 -131.251996,69.196991 -1.098,0.917 -2.182,1.903 -3.332,2.947 -0.465,0.425 -0.948,0.863 -1.456,1.32 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path385"
d="m 118.26585,121.01513 c 2.992,-2.037 5.816,-3.961 8.797,-5.561 3.58,-1.923 4.771,-4.586 5.459,-7.993 4.053,-20.110991 9.557,-35.939991 17.318,-49.815991 4.494,-8.033 9.088,-13.791 14.455,-18.119 9.002,-7.259 18.375,-7.266 27.412,-0.017 5.564,4.462 10.137,10.14 13.98,17.356 2.211,4.151 4.197,8.308 6.303,12.707 0.855,1.791 1.719,3.595 2.602,5.408 l 14.334,-3.655 c -1.174,-2.378 -2.311,-4.763 -3.412,-7.074 -2.658,-5.585 -5.172,-10.859 -8.139,-15.979 -9.824,-16.947 -20.699,-25.812 -35.26,-28.744 l -8.322,0.01 c -12.096,2.398 -22.07,9.437 -30.395,21.507 -3.602,5.219 -6.787,10.571 -9.471,15.906 -7.41,14.732 -12.738,31.635 -16.773,53.191991 -0.568,3.039 -1.053,6.101 -1.566,9.342 -0.193,1.218 -0.391,2.462 -0.598,3.74 1.132,-0.75 2.218,-1.49 3.276,-2.21 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path387"
d="m 105.42785,254.22313 c -3.225,0.31 -6.270996,0.602 -9.351996,0.754 -1.867,0.093 -3.594,0.139 -5.277,0.139 -7.129,0 -13.34,-0.867 -18.904,-2.646 -0.795,-0.254 -1.576,-0.526 -2.346,-0.817 -11.328,-4.29 -16.076,-12.875 -13.732,-24.827 2.135,-10.872 7.631,-19.988 13.254,-28.221 1.115,-1.634 2.314,-3.259 3.473,-4.83 0.453,-0.616 0.91,-1.233 1.365,-1.857 l -10.357,-10.004 c -7.527,9.307 -16.645,21.933 -20.824,37.338 -3.191,11.767 -2.23,22.453 2.783,30.906 5.008,8.446 13.908,14.409 25.738,17.245 6.105,1.465 12.57,2.177 19.76,2.177 3.754,-10e-4 7.688,-0.192 12.022996,-0.588 2.494,-0.227 4.928,-0.557 7.504,-0.906 0.973,-0.132 1.951,-0.265 2.936,-0.392 l -3.898,-13.857 c -1.415,0.124 -2.792,0.256 -4.146,0.386 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path389"
d="m 90.798854,255.11513 c 1.684,0 3.41,-0.046 5.277,-0.139 -1.866,0.093 -3.593,0.139 -5.277,0.139 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path391"
d="m 71.894854,252.47013 c 5.564,1.778 11.775,2.646 18.904,2.646 -7.127,-0.001 -13.339,-0.868 -18.904,-2.646 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path393"
d="m 91.007854,269.57913 c -7.189,0 -13.654,-0.712 -19.76,-2.177 6.106,1.465 12.571,2.177 19.76,2.177 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path395"
d="m 103.03185,268.99113 c -4.335996,0.396 -8.269996,0.587 -12.022996,0.588 3.753,0 7.685,-0.192 12.022996,-0.588 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path397"
d="m 216.19185,131.16013 c -0.625,-4.189 -1.227,-8.218 -1.867,-12.238 -0.326,-2.036 -5.861,-6.224 -8.229,-6.224 -0.156,0 -0.291,0.02 -0.402,0.058 -4.172,1.46 -8.242,3.096 -12.551,4.827 -1.42,0.57 -2.855,1.146 -4.316,1.727 l 28,16.088 -0.635,-4.238 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path399"
d="m 125.60785,156.07313 c -0.338,0.264 -0.668,0.525 -1,0.788 -0.463,0.366 -0.936,0.736 -1.393,1.099 -2.838,2.248 -5.516,4.371 -8.346,6.353 -2.75,1.927 -3.779,4.095 -3.336,7.03 0.102,0.675 0.096,1.436 0.09,2.17 -0.01,1.219 -0.02,2.479 0.488,2.946 3.336,3.059 6.891,5.851 10.654,8.807 0.605,0.477 1.227,0.968 1.842,1.452 0.334,0.264 0.664,0.523 1,0.789 0.188,0.148 0.369,0.29 0.557,0.439 l 0,-32.312 c -0.189,0.148 -0.369,0.291 -0.556,0.439 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path401"
d="m 151.98285,100.84014 0.104,-0.053 c 3.387,-1.754001 6.783,-3.483001 10.385,-5.316001 l 4.047,-2.062 -17.232,-6.35 -3.984,13.866001 c 1.803,0.81 2.684,1.17 3.451,1.17 0.584,0 1.174,-0.223 2.061,-0.658 0.34,-0.169 0.721,-0.365 1.168,-0.597 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path403"
d="m 150.81285,101.43614 c 0.342,-0.168 0.723,-0.364 1.172,-0.597 l 0.102,-0.053 -0.104,0.053 c -0.447,0.233 -0.828,0.429 -1.17,0.597 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path405"
d="m 266.77785,190.88113 -10.314,-9.723 c -0.9,2.513 -2.059,14.3 -1.457,19.737 l 11.771,-10.014 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path407"
d="m 146.40085,244.68813 c -0.344,0 -0.562,0.08 -0.627,0.134 -0.129,0.123 -0.217,0.812 -0.078,1.328 0.848,3.195 1.752,6.407 2.709,9.809 l 0.814,2.899 14.725,-5.297 -2.984,-1.559 c -4.799,-2.507 -9.33,-4.874 -13.859,-7.181 -0.163,-0.082 -0.431,-0.133 -0.7,-0.133 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path409"
d="m 178.01785,182.25313 c 5.09,-0.035 9.227,-4.207 9.217,-9.303 -0.008,-5.061 -4.223,-9.248 -9.291,-9.229 -5.066,0.021 -9.244,4.238 -9.217,9.303 0.029,5.084 4.236,9.264 9.291,9.229 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path411"
d="m 178.66685,137.26513 c 10.389,0 20.699,4.453 27.85,13.074 6.838,8.24 9.393,18.624 7.93,28.444 0.682,4.709 4.068,13.639 16.732,28.898 0,0 39.695,50.833 67.607,86.683 1.363,1.533 6.5,6.911 11.957,8.765 4.92,0.979 9.547,3.578 13.004,7.74 7.998,9.641 6.668,23.926 -2.971,31.917 -4.232,3.516 -9.361,5.229 -14.461,5.229 -6.51,0 -12.973,-2.793 -17.459,-8.197 -3.123,-3.762 -4.801,-8.235 -5.139,-12.764 l -0.014,0.019 c 0.004,-0.188 -0.045,-0.956 -0.047,-1.136 -0.498,-5.215 -5.215,-11.978 -7.074,-14.461 l -73.916,-80.137 c -12.717,-15.323 -21.002,-20.271 -25.605,-21.787 -9.816,-0.444 -19.428,-4.869 -26.199,-13.034 -12.762,-15.37 -10.639,-38.165 4.734,-50.921 6.752,-5.6 14.934,-8.332 23.071,-8.332 m -8.434,57.243 22.721,-3.874 8.002,-21.613 -14.719,-17.732 -22.719,3.875 -7.996,21.609 14.711,17.735 m 131.273,145.051 14.541,-2.529 5.084,-13.854 -9.451,-11.331 -14.543,2.523 -5.082,13.857 9.451,11.334" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path431"
d="m 200.61585,241.28413 0.006,-0.025 c -24.418,-9.209 -48.256,-21.711 -70.979,-37.256 -24.553,-16.797 -42.628996,-33.192 -56.884996,-51.596 -8.715,-11.247 -13.768,-20.717 -16.381,-30.705 -3.068,-11.729 0.105,-20.53999 9.178,-25.481991 2.277,-1.241 4.834,-2.269 7.596,-3.054 7.576,-2.153 15.721,-2.812 25.201,-2.015 1.244,0.104 2.519996,0.217 3.804996,0.332 1.402,0.123 2.803,0.242 4.209,0.368 l 3.176,0.281 3.846,-13.919 c -0.947,-0.121 -1.893,-0.245 -2.83,-0.37 -2.537,-0.337 -4.934,-0.656 -7.25,-0.857 -4.688996,-0.406 -8.802996,-0.604 -12.577996,-0.604 -8.74,0 -16.342,1.076 -23.24,3.29 -14.58,4.68 -23.049,13.281 -25.893,26.296991 -1.943,8.9 -0.568,18.38 4.328,29.833 6.098,14.267 15.623,27.692 29.977,42.251 31.706996,32.162 69.878996,56.911 116.698996,75.662 3.182,1.274 6.383,2.416 9.771,3.624 1.434,0.511 2.889,1.029 4.369,1.568 l 2.396,-8.365 -8.521,-9.258 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path433"
d="m 315.10985,227.63013 c -0.146,-0.262 -0.314,-0.56 -0.359,-0.905 -0.99,-8.005 -3.834,-16.142 -8.688,-24.875 -7.945,-14.297 -18.83,-27.683 -34.252,-42.126 -3.812,-3.572 -7.723,-6.949 -11.863,-10.523 -1.678,-1.448 -3.377,-2.915 -5.096,-4.419 -0.006,0.032 -0.012,0.062 -0.018,0.092 -0.062,0.355 -0.096,0.551 -0.09,0.713 l 0.148,3.794 c 0.176,4.559 0.359,9.272 0.67,13.896 0.047,0.706 0.615,1.672 1.52,2.583 2.135,2.144 4.346,4.286 6.484,6.358 3.807,3.687 7.742,7.5 11.389,11.467 11.611,12.634 19.076,24.245 23.488,36.543 2.049,5.705 2.707,10.802 2.012,15.581 -1.146,7.896 -6.145,13.235 -15.281,16.322 -2.455,0.829 -5.002,1.474 -7.656,1.956 l 9.738,12.6 c 1.551,-0.468 3.08,-0.975 4.574,-1.562 12.387,-4.858 19.754,-12.956 22.521,-24.758 l 0.869,-3.686 0,-8.847 c -0.034,-0.068 -0.071,-0.136 -0.11,-0.204 z" /><g
style="fill:#ffffff"
id="g435"
transform="translate(-215.39315,-165.25587)"><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path437"
d="m 439.345,274.729 c 0.58,4.945 1.223,9.971 1.846,14.831 1.416,11.057 2.879,22.489 3.713,33.785 0.807,10.944 0.859,22.254 0.164,34.1 l 13,16.818 c 0.334,-3.384 0.643,-6.817 0.902,-10.349 1.854,-25.214 1.066,-50.093 -2.342,-73.945 -0.709,-4.964 -1.549,-9.816 -2.439,-14.955 -0.377,-2.185 -0.758,-4.387 -1.133,-6.617 l -14.16,3.555 c 0.043,0.257 0.086,0.5 0.127,0.734 0.13,0.742 0.244,1.39 0.322,2.043 z" /><path
inkscape:connector-curvature="0"
style="fill:#ffffff"
id="path439"
d="m 433.437,425.474 c -2.322,7.348 -4.98,14.184 -8.043,20.678 -3.967,8.416 -9.191,17.993 -17.877,25.219 -9.297,7.733 -19.082,7.701 -28.365,-0.092 -5.934,-4.982 -10.92,-11.633 -15.691,-20.929 -6.629,-12.926 -11.459,-27.311 -15.66,-46.642 l -0.072,-0.342 c -0.174,-0.828 -0.412,-1.962 -0.893,-2.284 -4.152,-2.786 -8.357,-5.448 -12.807,-8.267 -1.068,-0.677 -2.146,-1.359 -3.238,-2.054 0.164,0.969 0.32,1.911 0.475,2.834 0.434,2.596 0.842,5.047 1.303,7.478 4.703,24.702 10.705,42.76 19.463,58.551 7.541,13.604 17.859,28.05 37.209,32.08 l 8.318,0 c 17.949,-3.632 27.887,-16.568 35.24,-28.748 1.953,-3.234 3.717,-6.507 5.244,-9.726 2.389,-5.035 4.557,-10.249 6.533,-15.655 l -11.139,-12.101 z" /></g><i:pgf
id="adobe_illustrator_pgf" /></svg>

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,506 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>720</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1280</width>
<height>720</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>3840</width>
<height>2160</height>
</size>
</property>
<property name="windowTitle">
<string>Thermostat Control Panel</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>../resources/artiq.ico</normaloff>../resources/artiq.ico</iconset>
</property>
<widget class="QWidget" name="main_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<property name="spacing">
<number>3</number>
</property>
<item row="0" column="1">
<layout class="QVBoxLayout" name="main_layout">
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="graph_group">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
<property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<property name="spacing">
<number>2</number>
</property>
<item row="1" column="1">
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
</item>
<item row="0" column="1">
<widget class="LivePlotWidget" name="ch0_t_graph" native="true"/>
</item>
<item row="0" column="2">
<widget class="LivePlotWidget" name="ch0_i_graph" native="true"/>
</item>
<item row="1" column="2">
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
</item>
<item row="0" column="0" rowspan="2">
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="ch0_tab">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<attribute name="title">
<string>Channel 0</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="ParameterTree" name="ch0_tree" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="ch1_tab">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<attribute name="title">
<string>Channel 1</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="ParameterTree" name="ch1_tree" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="bottom_settings_group">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>40</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>3</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="settings_layout">
<item>
<widget class="QToolButton" name="connect_btn">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Connect</string>
</property>
<property name="popupMode">
<enum>QToolButton::ToolButtonPopupMode::MenuButtonPopup</enum>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonStyle::ToolButtonFollowStyle</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="status_lbl">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>240</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>120</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>120</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>Disconnected</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="thermostat_settings">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string notr="true">âš™</string>
</property>
<property name="popupMode">
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="plot_settings">
<property name="toolTip">
<string>Plot Settings</string>
</property>
<property name="text">
<string>📉</string>
</property>
<property name="popupMode">
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="limits_warning">
<property name="toolTipDuration">
<number>1000000000</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="background_task_lbl">
<property name="text">
<string>Ready.</string>
</property>
</widget>
</item>
<item>
<widget class="QtWaitingSpinner" name="loading_spinner" native="true"/>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QWidget" name="report_group" native="true">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1">
<property name="spacing">
<number>6</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="report_lbl">
<property name="text">
<string>Poll every: </string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QDoubleSpinBox" name="report_refresh_spin">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>70</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>70</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>70</width>
<height>0</height>
</size>
</property>
<property name="suffix">
<string> s</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>0.100000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="stepType">
<enum>QAbstractSpinBox::StepType::AdaptiveDecimalStepType</enum>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="report_apply_btn">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>ParameterTree</class>
<extends>QWidget</extends>
<header>pyqtgraph.parametertree</header>
<container>1</container>
</customwidget>
<customwidget>
<class>LivePlotWidget</class>
<extends>QWidget</extends>
<header>pglive.sources.live_plot_widget</header>
<container>1</container>
</customwidget>
<customwidget>
<class>QtWaitingSpinner</class>
<extends>QWidget</extends>
<header>pythermostat.gui.view.waitingspinnerwidget</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,69 +0,0 @@
from PyQt6 import QtWidgets, QtCore
from PyQt6.QtCore import pyqtSlot
from pythermostat.gui.model.thermostat import ThermostatConnectionState
class ConnectionDetailsMenu(QtWidgets.QMenu):
def __init__(self, thermostat, connect_btn):
super().__init__()
self._thermostat = thermostat
self._connect_btn = connect_btn
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)
self._setup_menu_items()
@pyqtSlot(ThermostatConnectionState)
def thermostat_state_change_handler(self, state):
self.host_set_line.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
self.port_set_spin.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
def _setup_menu_items(self):
# Sets Thermostat host/IP
self.host_set_line = QtWidgets.QLineEdit()
self.host_set_line.setMinimumWidth(160)
self.host_set_line.setMaximumWidth(160)
self.host_set_line.setMaxLength(15)
self.host_set_line.setClearButtonEnabled(True)
self.host_set_line.setText("192.168.1.26")
self.host_set_line.setPlaceholderText("IP for the Thermostat")
def connect_on_enter_press():
self._connect_btn.click()
self.hide()
self.host_set_line.returnPressed.connect(connect_on_enter_press)
host = QtWidgets.QWidgetAction(self)
host.setDefaultWidget(self.host_set_line)
self.addAction(host)
# Sets Thermostat port
self.port_set_spin = QtWidgets.QSpinBox()
self.port_set_spin.setMinimumWidth(70)
self.port_set_spin.setMaximumWidth(70)
self.port_set_spin.setMaximum(65535)
self.port_set_spin.setValue(23)
def connect_only_if_enter_pressed():
if (
not self.port_set_spin.hasFocus()
): # Don't connect if the spinbox only lost focus
return
connect_on_enter_press()
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
port = QtWidgets.QWidgetAction(self)
port.setDefaultWidget(self.port_set_spin)
self.addAction(port)
# Exits GUI
exit_button = QtWidgets.QPushButton()
exit_button.setText("Exit GUI")
exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
exit_action = QtWidgets.QWidgetAction(exit_button)
exit_action.setDefaultWidget(exit_button)
self.addAction(exit_action)

View File

@@ -1,14 +0,0 @@
from PyQt6 import QtWidgets
from PyQt6.QtCore import pyqtSlot
class InfoBox(QtWidgets.QMessageBox):
def __init__(self):
super().__init__()
self.setIcon(QtWidgets.QMessageBox.Icon.Information)
@pyqtSlot(str, str)
def display_info_box(self, title, text):
self.setWindowTitle(title)
self.setText(text)
self.show()

View File

@@ -1,181 +0,0 @@
from collections import deque
from PyQt6.QtCore import QObject, pyqtSlot
from pglive.sources.data_connector import DataConnector
from pglive.kwargs import Axis
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_axis import LiveAxis
import pyqtgraph as pg
from pythermostat.gui.model.thermostat import ThermostatConnectionState
pg.setConfigOptions(antialias=True)
class LiveDataPlotter(QObject):
def __init__(self, thermostat, live_plots):
super().__init__()
self._thermostat = thermostat
self._thermostat.report_update.connect(self.update_report)
self._thermostat.pid_update.connect(self.update_pid)
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)
self.NUM_CHANNELS = len(live_plots)
self.graphs = []
for i, live_plot in enumerate(live_plots):
live_plot[0].setTitle(f"Channel {i} Temperature")
live_plot[1].setTitle(f"Channel {i} Current")
self.graphs.append(_TecGraphs(live_plot[0], live_plot[1]))
@pyqtSlot(ThermostatConnectionState)
def thermostat_state_change_handler(self, state):
if state == ThermostatConnectionState.DISCONNECTED:
self.clear_graphs()
def _config_connector_max_pts(self, connector, samples):
connector.max_points = samples
connector.x = deque(maxlen=int(connector.max_points))
connector.y = deque(maxlen=int(connector.max_points))
@pyqtSlot(int)
def set_max_samples(self, samples: int):
for graph in self.graphs:
self._config_connector_max_pts(graph.t_connector, samples)
self._config_connector_max_pts(graph.i_connector, samples)
self._config_connector_max_pts(graph.iset_connector, samples)
@pyqtSlot()
def clear_graphs(self):
for graph in self.graphs:
graph.clear()
@pyqtSlot(list)
def update_pid(self, pid_settings):
for settings in pid_settings:
channel = settings["channel"]
self.graphs[channel].update_pid(settings)
@pyqtSlot(list)
def update_report(self, report_data):
for settings in report_data:
channel = settings["channel"]
self.graphs[channel].update_report(settings)
class _TecGraphs:
"""The maximum number of sample points to store."""
DEFAULT_MAX_SAMPLES = 1000
def __init__(self, t_widget, i_widget):
self._t_widget = t_widget
self._i_widget = i_widget
self._t_plot = LiveLinePlot()
self._i_plot = LiveLinePlot(name="Measured")
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen("r"))
self._t_line = self._t_widget.getPlotItem().addLine(label="{value} °C")
self._t_line.setVisible(False)
# Hack for keeping setpoint line in plot range
self._t_setpoint_plot = LiveLinePlot()
for graph in t_widget, i_widget:
time_axis = LiveAxis(
"bottom",
text="Time since Thermostat reset",
**{Axis.TICK_FORMAT: Axis.DURATION},
)
time_axis.showLabel()
graph.setAxisItems({"bottom": time_axis})
graph.add_crosshair(pg.mkPen(color="red", width=1), {"color": "green"})
# Enable linking of axes in the graph widget's context menu
graph.register(
graph.getPlotItem().titleLabel.text # Slight hack getting the title
)
temperature_axis = LiveAxis("left", text="Temperature", units="°C")
temperature_axis.showLabel()
t_widget.setAxisItems({"left": temperature_axis})
current_axis = LiveAxis("left", text="Current", units="A")
current_axis.showLabel()
i_widget.setAxisItems({"left": current_axis})
i_widget.addLegend(brush=(50, 50, 200, 150))
t_widget.addItem(self._t_plot)
t_widget.addItem(self._t_setpoint_plot)
i_widget.addItem(self._i_plot)
i_widget.addItem(self._iset_plot)
self.t_connector = DataConnector(
self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES
)
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
self.i_connector = DataConnector(
self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES
)
self.iset_connector = DataConnector(
self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES
)
self.max_samples = self.DEFAULT_MAX_SAMPLES
def plot_append(self, report):
temperature = report["temperature"]
current = report["tec_i"]
iset = report["i_set"]
time = report["time"]
if temperature is not None:
self.t_connector.cb_append_data_point(temperature, time)
if self._t_line.isVisible():
self.t_setpoint_connector.cb_append_data_point(
self._t_line.value(), time
)
else:
self.t_setpoint_connector.cb_append_data_point(temperature, time)
if current is not None:
self.i_connector.cb_append_data_point(current, time)
self.iset_connector.cb_append_data_point(iset, time)
def set_max_sample(self, samples: int):
for connector in self.t_connector, self.i_connector, self.iset_connector:
connector.max_points(samples)
def clear(self):
for connector in self.t_connector, self.i_connector, self.iset_connector:
connector.clear()
def set_t_line(self, temp=None, visible=None):
if visible is not None:
self._t_line.setVisible(visible)
if temp is not None:
self._t_line.setValue(temp)
# PyQtGraph normally does not update this text when the line
# is not visible, so make sure that the temperature label
# gets updated always, and doesn't stay at an old value.
self._t_line.label.setText(f"{temp} °C")
def set_max_samples(self, samples: int):
for graph in self.graphs:
graph.t_connector.max_points = samples
graph.i_connector.max_points = samples
graph.iset_connector.max_points = samples
def clear_graphs(self):
for graph in self.graphs:
graph.clear()
def update_pid(self, pid_settings):
self.set_t_line(temp=round(pid_settings["target"], 6))
def update_report(self, report_data):
self.plot_append(report_data)
self.set_t_line(visible=report_data["pid_engaged"])

View File

@@ -1,307 +0,0 @@
from PyQt6 import QtWidgets, QtCore, QtGui
from PyQt6.QtCore import pyqtSlot, QSignalBlocker
from qasync import asyncSlot
from pythermostat.gui.model.thermostat import ThermostatConnectionState
from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag
class ConnectionDetailsMenu(QtWidgets.QMenu):
def __init__(self, thermostat, connect_btn):
super().__init__()
self._thermostat = thermostat
self._connect_btn = connect_btn
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)
self._setup_menu_items()
@pyqtSlot(ThermostatConnectionState)
def thermostat_state_change_handler(self, state):
self.host_set_line.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
self.port_set_spin.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
def _setup_menu_items(self):
# Sets Thermostat host/IP
self.host_set_line = QtWidgets.QLineEdit()
self.host_set_line.setMinimumWidth(160)
self.host_set_line.setMaximumWidth(160)
self.host_set_line.setMaxLength(15)
self.host_set_line.setClearButtonEnabled(True)
self.host_set_line.setText("192.168.1.26")
self.host_set_line.setPlaceholderText("IP for the Thermostat")
def connect_on_enter_press():
self._connect_btn.click()
self.hide()
self.host_set_line.returnPressed.connect(connect_on_enter_press)
host = QtWidgets.QWidgetAction(self)
host.setDefaultWidget(self.host_set_line)
self.addAction(host)
# Sets Thermostat port
self.port_set_spin = QtWidgets.QSpinBox()
self.port_set_spin.setMinimumWidth(70)
self.port_set_spin.setMaximumWidth(70)
self.port_set_spin.setMaximum(65535)
self.port_set_spin.setValue(23)
def connect_only_if_enter_pressed():
if (
not self.port_set_spin.hasFocus()
): # Don't connect if the spinbox only lost focus
return
connect_on_enter_press()
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
port = QtWidgets.QWidgetAction(self)
port.setDefaultWidget(self.port_set_spin)
self.addAction(port)
# Exits GUI
exit_button = QtWidgets.QPushButton()
exit_button.setText("Exit GUI")
exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
exit_action = QtWidgets.QWidgetAction(exit_button)
exit_action.setDefaultWidget(exit_button)
self.addAction(exit_action)
class PlotOptionsMenu(QtWidgets.QMenu):
def __init__(self, channel_graphs, max_samples=1000):
super().__init__()
# Clears plots for both graphs in all channels
clear_graphs = QtGui.QAction("Clear graphs", self)
clear_graphs.triggered.connect(channel_graphs.clear_graphs)
self.addAction(clear_graphs)
# Set maximum samples in graphs
samples_spinbox = QtWidgets.QSpinBox()
samples_spinbox.setRange(2, 100000)
samples_spinbox.setSuffix(" samples")
samples_spinbox.setValue(max_samples)
samples_spinbox.valueChanged.connect(channel_graphs.set_max_samples)
limit_samples = QtWidgets.QWidgetAction(self)
limit_samples.setDefaultWidget(samples_spinbox)
self.addAction(limit_samples)
class ThermostatSettingsMenu(QtWidgets.QMenu):
def __init__(self, thermostat, info_box, style):
super().__init__()
self._thermostat = thermostat
self._info_box = info_box
self._style = style
self.hw_rev_data = {}
self._thermostat.hw_rev_update.connect(self.hw_rev)
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)
self._setup_menu_items()
@pyqtSlot("QVariantMap")
def fan_update(self, fan_settings):
if fan_settings is None:
return
with QSignalBlocker(self.fan_power_slider):
self.fan_power_slider.setValue(
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
)
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(fan_settings["auto_mode"])
def set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self._style.standardIcon(pixmapi)
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
self.fan_pwm_warning.setToolTip(
"Throttling the fan (not recommended on this hardware rev)"
)
else:
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
@pyqtSlot(ThermostatConnectionState)
def thermostat_state_change_handler(self, state):
if state == ThermostatConnectionState.DISCONNECTED:
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.fan_pwm_warning.setToolTip("")
@pyqtSlot("QVariantMap")
def hw_rev(self, hw_rev):
self.hw_rev_data = hw_rev
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
@asyncSlot(int)
async def fan_set_request(self, value):
assert self._thermostat.connected()
if self.fan_auto_box.isChecked():
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(False)
await self._thermostat.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.set_fan_pwm_warning()
@asyncSlot(int)
async def fan_auto_set_request(self, enabled):
assert self._thermostat.connected()
if enabled:
await self._thermostat.set_fan("auto")
self.fan_update(await self._thermostat.get_fan())
else:
await self.thermostat.set_fan(self.fan_power_slider.value())
@asyncSlot(bool)
async def reset_request(self, _):
assert self._thermostat.connected()
await self._thermostat.reset()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
@asyncSlot(bool)
async def dfu_request(self, _):
assert self._thermostat.connected()
await self._thermostat.dfu()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
@asyncSlot(bool)
async def net_settings_request(self, _):
assert self._thermostat.connected()
ipv4 = await self._thermostat.get_ipv4()
net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
@asyncSlot(str)
async def set_net_settings_request(self, ipv4_settings):
assert self._thermostat.connected()
await self._thermostat.set_ipv4(ipv4_settings)
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
def _setup_menu_items(self):
self.addAction(self._setup_fan_group())
self.reset_action = QtGui.QAction("Reset Thermostat", self)
self.reset_action.triggered.connect(self.reset_request)
self.addAction(self.reset_action)
self.dfu_action = QtGui.QAction("Enter DFU Mode", self)
self.dfu_action.triggered.connect(self.dfu_request)
self.addAction(self.dfu_action)
self.ipv4_action = QtGui.QAction("Set IPv4 Settings", self)
self.ipv4_action.triggered.connect(self.net_settings_request)
self.addAction(self.ipv4_action)
@asyncSlot(bool)
async def load(_):
await self._thermostat.load_cfg()
self._info_box.display_info_box(
"Settings loaded", "All channel settings have been loaded from flash."
)
self.load_config_action = QtGui.QAction("Load Settings", self)
self.load_config_action.triggered.connect(load)
self.addAction(self.load_config_action)
@asyncSlot(bool)
async def save(_):
await self._thermostat.save_cfg()
self._info_box.display_info_box(
"Settings saved", "All channel settings have been saved to flash."
)
self.save_config_action = QtGui.QAction("Save Settings", self)
self.save_config_action.triggered.connect(save)
self.addAction(self.save_config_action)
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.about_action = QtGui.QAction("About Thermostat", self)
self.about_action.triggered.connect(about_thermostat)
self.addAction(self.about_action)
def _setup_fan_group(self):
# Fan settings
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
self.fan_group.setMinimumWidth(40)
fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
fan_layout.setSpacing(9)
fan_label = QtWidgets.QLabel(parent=self.fan_group)
fan_label.setMinimumWidth(40)
fan_label.setMaximumWidth(40)
fan_label.setBaseSize(QtCore.QSize(40, 0))
fan_layout.addWidget(fan_label)
self.fan_power_slider = QtWidgets.QSlider(
QtCore.Qt.Orientation.Horizontal, parent=self.fan_group
)
self.fan_power_slider.setMinimumWidth(200)
self.fan_power_slider.setMaximumWidth(200)
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
self.fan_power_slider.setRange(1, 100)
fan_layout.addWidget(self.fan_power_slider)
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
self.fan_auto_box.setMinimumWidth(70)
self.fan_auto_box.setMaximumWidth(70)
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))
fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set_request)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request)
self._thermostat.fan_update.connect(self.fan_update)
fan_label.setToolTip("Adjust the fan")
fan_label.setText("Fan:")
self.fan_auto_box.setText("Auto")
fan = QtWidgets.QWidgetAction(self)
fan.setDefaultWidget(self.fan_group)
return fan

View File

@@ -1,36 +0,0 @@
from PyQt6 import QtWidgets
from PyQt6.QtWidgets import QAbstractButton
from PyQt6.QtCore import pyqtSignal, pyqtSlot
class NetSettingsInputDiag(QtWidgets.QInputDialog):
set_ipv4_act = pyqtSignal(str)
def __init__(self, current_ipv4_settings):
super().__init__()
self.setWindowTitle("Network Settings")
self.setLabelText(
"Set the Thermostat's IPv4 address, netmask and gateway (optional)"
)
self.setTextValue(current_ipv4_settings)
self._new_ipv4 = ""
@pyqtSlot(str)
def set_ipv4(ipv4_settings):
self._new_ipv4 = ipv4_settings
sure = QtWidgets.QMessageBox(self)
sure.setWindowTitle("Set network?")
sure.setText(
f"Setting this as network and disconnecting:<br>{ipv4_settings}"
)
sure.buttonClicked.connect(self._emit_sig)
sure.show()
self.textValueSelected.connect(set_ipv4)
self.show()
@pyqtSlot(QAbstractButton)
def _emit_sig(self, _):
self.set_ipv4_act.emit(self._new_ipv4)

View File

@@ -1,391 +0,0 @@
{
"settings_tree": [
{
"name": "readings",
"title": "Readings",
"type": "group",
"children": [
{
"name": "temperature",
"title": "Temperature",
"type": "float",
"format": "{value:.4f} °C",
"readonly": true
},
{
"name": "tec_i",
"title": "Current through TEC",
"type": "float",
"suffix": "mA",
"decimals": 6,
"readonly": true
}
]
},
{
"name": "output",
"title": "Output Settings",
"expanded": true,
"type": "group",
"children": [
{
"name": "control_method",
"title": "Control Method",
"type": "mutex",
"limits": {
"Constant Current": "constant_current",
"Temperature PID": "temperature_pid"
},
"value": "constant_current",
"thermostat:set_param": {
"topic": "output",
"field": "pid"
},
"children": [
{
"name": "i_set",
"title": "Set Current",
"type": "float",
"value": 0,
"step": 100,
"limits": [
-2000,
2000
],
"triggerOnShow": true,
"decimals": 6,
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
"field": "i_set"
},
"lock": false
},
{
"name": "target",
"title": "Set Temperature",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-273,
300
],
"format": "{value:.4f} °C",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "target"
},
"lock": false
}
]
},
{
"name": "limits",
"title": "Limits",
"expanded": true,
"type": "group",
"children": [
{
"name": "max_i_pos",
"title": "Max Cooling Current",
"type": "float",
"value": 0,
"step": 100,
"decimals": 6,
"limits": [
0,
2000
],
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
"field": "max_i_pos"
},
"lock": false
},
{
"name": "max_i_neg",
"title": "Max Heating Current",
"type": "float",
"value": 0,
"step": 100,
"decimals": 6,
"limits": [
0,
2000
],
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
"field": "max_i_neg"
},
"lock": false
},
{
"name": "max_v",
"title": "Max Voltage Difference",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 3,
"limits": [
0,
4.3
],
"suffix": "V",
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
"field": "max_v"
},
"lock": false
}
]
}
]
},
{
"name": "thermistor",
"title": "Thermistor Settings",
"expanded": true,
"type": "group",
"tip": "Settings of the connected Thermistor",
"children": [
{
"name": "t0",
"title": "Tâ‚€",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-100,
100
],
"format": "{value:.4f} °C",
"compactHeight": false,
"thermostat:set_param": {
"topic": "b-p",
"field": "t0"
},
"lock": false
},
{
"name": "r0",
"title": "Râ‚€",
"type": "float",
"value": 10000,
"step": 100,
"min": 0,
"siPrefix": true,
"suffix": "Ω",
"compactHeight": false,
"thermostat:set_param": {
"topic": "b-p",
"field": "r0"
},
"lock": false
},
{
"name": "b",
"title": "B",
"type": "float",
"value": 3950,
"step": 10,
"suffix": "K",
"decimals": 4,
"compactHeight": false,
"thermostat:set_param": {
"topic": "b-p",
"field": "b"
},
"lock": false
},
{
"name": "rate",
"title": "Postfilter Rate",
"type": "list",
"value": 16.67,
"thermostat:set_param": {
"topic": "postfilter",
"field": "rate"
},
"limits": {
"Off": null,
"16.667 Hz": 16.667,
"20 Hz": 20.0,
"25 Hz": 25,
"27.27 Hz": 27.27
},
"lock": false
}
]
},
{
"name": "pid",
"title": "PID Settings",
"expanded": true,
"type": "group",
"children": [
{
"name": "kp",
"title": "Kp",
"type": "float",
"step": 0.1,
"suffix": "",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kp"
},
"lock": false
},
{
"name": "ki",
"title": "Ki",
"type": "float",
"step": 0.1,
"suffix": "Hz",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "ki"
},
"lock": false
},
{
"name": "kd",
"title": "Kd",
"type": "float",
"step": 0.1,
"suffix": "s",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kd"
},
"lock": false
},
{
"name": "pid_output_clamping",
"title": "PID Output Clamping",
"expanded": true,
"type": "group",
"children": [
{
"name": "output_min",
"title": "Minimum",
"type": "float",
"step": 100,
"limits": [
-2000,
2000
],
"decimals": 6,
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_min"
},
"lock": false
},
{
"name": "output_max",
"title": "Maximum",
"type": "float",
"step": 100,
"limits": [
-2000,
2000
],
"decimals": 6,
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_max"
},
"lock": false
}
]
},
{
"name": "pid_autotune",
"title": "PID Auto Tune",
"expanded": false,
"type": "group",
"children": [
{
"name": "target_temp",
"title": "Target Temperature",
"type": "float",
"value": 20,
"step": 0.1,
"format": "{value:.4f} °C",
"compactHeight": false,
"pid_autotune": "target_temp"
},
{
"name": "test_current",
"title": "Test Current",
"type": "float",
"value": 0,
"decimals": 6,
"step": 100,
"limits": [
0,
2000
],
"suffix": "mA",
"compactHeight": false,
"pid_autotune": "test_current"
},
{
"name": "temp_swing",
"title": "Temperature Swing",
"type": "float",
"value": 1.5,
"step": 0.1,
"format": "± {value:.4f} °C",
"compactHeight": false,
"pid_autotune": "temp_swing"
},
{
"name": "lookback",
"title": "Lookback",
"type": "float",
"value": 3.0,
"step": 0.1,
"format": "{value:.4f} s",
"compactHeight": false,
"pid_autotune": "lookback"
},
{
"name": "run_pid",
"title": "Run",
"type": "action",
"tip": "Run"
}
]
}
]
},
{
"name": "save",
"title": "Save to flash",
"type": "action",
"tip": "Save settings to thermostat, applies on reset"
},
{
"name": "load",
"title": "Load from flash",
"type": "action",
"tip": "Load settings from flash"
}
]
}

View File

@@ -1,302 +0,0 @@
from functools import partial
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import (
Parameter,
registerParameterType,
)
from qasync import asyncSlot
from pythermostat.autotune import PIDAutotuneState
class MutexParameter(pTypes.ListParameter):
"""
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
The ordering of the list items determines which children will be visible.
"""
def __init__(self, **opts):
super().__init__(**opts)
self.sigValueChanged.connect(self.show_chosen_child)
self.sigValueChanged.emit(self, self.opts["value"])
def _get_param_from_value(self, value):
if isinstance(self.opts["limits"], dict):
values_list = list(self.opts["limits"].values())
else:
values_list = self.opts["limits"]
return self.children()[values_list.index(value)]
@pyqtSlot(object, object)
def show_chosen_child(self, value):
for param in self.children():
param.hide()
child_to_show = self._get_param_from_value(value.value())
child_to_show.show()
if child_to_show.opts.get("triggerOnShow", None):
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
registerParameterType("mutex", MutexParameter)
class SettingsTreeView(QObject):
def __init__(
self,
thermostat,
autotuners,
info_box,
trees_ui,
param_tree,
parent=None,
):
super().__init__(parent)
self.thermostat = thermostat
self.autotuners = autotuners
self.info_box = info_box
self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui)
self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
self.params = [
Parameter.create(
name=f"Thermostat Channel {ch} Parameters",
type="group",
value=ch,
children=self.THERMOSTAT_PARAMETERS[ch],
)
for ch in range(self.NUM_CHANNELS)
]
for i, param in enumerate(self.params):
param.channel = i
for i, tree in enumerate(self.trees_ui):
tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False)
self.params[i].setValue = self._setValue
self.params[i].sigTreeStateChanged.connect(self.send_command)
self.params[i].child("save").sigActivated.connect(
partial(self.save_settings, i)
)
self.params[i].child("load").sigActivated.connect(
partial(self.load_settings, i)
)
self.params[i].child("pid", "pid_autotune", "run_pid").sigActivated.connect(
partial(self.pid_auto_tune_request, i)
)
self.thermostat.pid_update.connect(self.update_pid)
self.thermostat.report_update.connect(self.update_report)
self.thermostat.thermistor_update.connect(self.update_thermistor)
self.thermostat.output_update.connect(self.update_output)
self.thermostat.postfilter_update.connect(self.update_postfilter)
self.autotuners.autotune_state_changed.connect(self.update_pid_autotune)
def _setValue(self, value, blockSignal=None):
"""
Implement 'lock' mechanism for Parameter Type
Modified from the source
"""
try:
if blockSignal is not None:
self.sigValueChanged.disconnect(blockSignal)
value = self._interpretValue(value)
if fn.eq(self.opts["value"], value):
return value
if "lock" in self.opts.keys():
if self.opts["lock"]:
return value
self.opts["value"] = value
self.sigValueChanged.emit(
self, value
) # value might change after signal is received by tree item
finally:
if blockSignal is not None:
self.sigValueChanged.connect(blockSignal)
return self.opts["value"]
def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=title)
@asyncSlot(object, object)
async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls"""
ch = param.channel
for inner_param, change, data in changes:
if change == "value":
new_value = data
if "thermostat:set_param" in inner_param.opts:
if inner_param.opts.get("suffix", None) == "mA":
new_value /= 1000 # Given in mA
thermostat_param = inner_param.opts["thermostat:set_param"]
# Handle thermostat command irregularities
match inner_param.name(), new_value:
case "rate", None:
thermostat_param = thermostat_param.copy()
thermostat_param["field"] = "off"
new_value = ""
case "control_method", "constant_current":
return
case "control_method", "temperature_pid":
new_value = ""
inner_param.setOpts(lock=True)
await self.thermostat.set_param(
channel=ch, value=new_value, **thermostat_param
)
inner_param.setOpts(lock=False)
if "pid_autotune" in inner_param.opts:
auto_tuner_param = inner_param.opts["pid_autotune"]
self.autotuners.set_params(auto_tuner_param, ch, new_value)
@pyqtSlot(list)
def update_pid(self, pid_settings):
for settings in pid_settings:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("pid", "kp").setValue(
settings["parameters"]["kp"]
)
self.params[channel].child("pid", "ki").setValue(
settings["parameters"]["ki"]
)
self.params[channel].child("pid", "kd").setValue(
settings["parameters"]["kd"]
)
self.params[channel].child(
"pid", "pid_output_clamping", "output_min"
).setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child(
"pid", "pid_output_clamping", "output_max"
).setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child(
"output", "control_method", "target"
).setValue(settings["target"])
@pyqtSlot(list)
def update_report(self, report_data):
for settings in report_data:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("output", "control_method").setValue(
"temperature_pid" if settings["pid_engaged"] else "constant_current"
)
self.params[channel].child(
"output", "control_method", "i_set"
).setValue(settings["i_set"] * 1000)
if settings["temperature"] is not None:
self.params[channel].child("readings", "temperature").setValue(
settings["temperature"]
)
if settings["tec_i"] is not None:
self.params[channel].child("readings", "tec_i").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", "t0").setValue(
sh_param["params"]["t0"] - 273.15
)
self.params[channel].child("thermistor", "r0").setValue(
sh_param["params"]["r0"]
)
self.params[channel].child("thermistor", "b").setValue(
sh_param["params"]["b"]
)
@pyqtSlot(list)
def update_output(self, output_data):
for output_params in output_data:
channel = output_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("output", "limits", "max_v").setValue(
output_params["max_v"]
)
self.params[channel].child("output", "limits", "max_i_pos").setValue(
output_params["max_i_pos"] * 1000
)
self.params[channel].child("output", "limits", "max_i_neg").setValue(
output_params["max_i_neg"] * 1000
)
@pyqtSlot(list)
def update_postfilter(self, postfilter_data):
for postfilter_params in postfilter_data:
channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("thermistor", "rate").setValue(
postfilter_params["rate"]
)
def update_pid_autotune(self, ch, state):
match state:
case PIDAutotuneState.OFF:
self.change_params_title(ch, ("pid", "pid_autotune", "run_pid"), "Run")
case (
PIDAutotuneState.READY
| PIDAutotuneState.RELAY_STEP_UP
| PIDAutotuneState.RELAY_STEP_DOWN
):
self.change_params_title(ch, ("pid", "pid_autotune", "run_pid"), "Stop")
case PIDAutotuneState.SUCCEEDED:
self.info_box.display_info_box(
"PID Autotune Success",
f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.",
)
case PIDAutotuneState.FAILED:
self.info_box.display_info_box(
"PID Autotune Failed",
f"Channel {ch} PID Autotune has failed.",
)
@asyncSlot(int)
async def load_settings(self, ch):
await self.thermostat.load_cfg(ch)
self.info_box.display_info_box(
f"Channel {ch} settings loaded",
f"Channel {ch} settings has been loaded from flash.",
)
@asyncSlot(int)
async def save_settings(self, ch):
await self.thermostat.save_cfg(ch)
self.info_box.display_info_box(
f"Channel {ch} settings saved",
f"Channel {ch} settings has been saved to flash.\n"
"It will be loaded on Thermostat reset, or when settings are explicitly loaded.",
)
@asyncSlot()
async def pid_auto_tune_request(self, ch=0):
match self.autotuners.get_state(ch):
case PIDAutotuneState.OFF | PIDAutotuneState.FAILED:
self.autotuners.load_params_and_set_ready(ch)
case (
PIDAutotuneState.READY
| PIDAutotuneState.RELAY_STEP_UP
| PIDAutotuneState.RELAY_STEP_DOWN
):
await self.autotuners.stop_pid_from_running(ch)

View File

@@ -1,212 +0,0 @@
"""
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

@@ -1,50 +0,0 @@
from PyQt6.QtCore import pyqtSlot, QObject
from PyQt6 import QtWidgets, QtGui
class ZeroLimitsWarningView(QObject):
def __init__(self, thermostat, style, limit_warning):
super().__init__()
self._thermostat = thermostat
self._thermostat.output_update.connect(self.set_limits_warning)
self._lbl = limit_warning
self._style = style
@pyqtSlot(list)
def set_limits_warning(self, output_data: list):
channels_zeroed_limits = [set() for i in range(self._thermostat.NUM_CHANNELS)]
for output_params in output_data:
channel = output_params["channel"]
for limit in "max_i_pos", "max_i_neg", "max_v":
if output_params[limit] == 0.0:
channels_zeroed_limits[channel].add(limit)
channel_disabled = [False, False]
report_str = "The following output limit(s) are set to zero:\n"
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits):
report_str += "Max Cooling Current, Max Heating Current"
channel_disabled[ch] = True
if "max_v" in zeroed_limits:
if channel_disabled[ch]:
report_str += ", "
report_str += "Max Voltage Difference"
channel_disabled[ch] = True
if channel_disabled[ch]:
report_str += f" for Channel {ch}\n"
report_str += (
"\nThese limit(s) are restricting the channel(s) from producing current."
)
if True in channel_disabled:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
icon = self._style.standardIcon(pixmapi)
self._lbl.setPixmap(icon.pixmap(16, 16))
self._lbl.setToolTip(report_str)
else:
self._lbl.setPixmap(QtGui.QPixmap())
self._lbl.setToolTip(None)

View File

@@ -1,137 +0,0 @@
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pythermostat.client import Client
def main():
TIME_WINDOW = 300.0
thermostat = Client()
target_temperature = thermostat.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(thermostat):
global last_packet_time
while True:
data = thermostat.get_report()
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
time.sleep(0.05)
thread = Thread(target=recv_data, args=(thermostat,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
nonlocal legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()
if __name__ == "__main__":
main()

View File

@@ -1,81 +0,0 @@
import argparse
from contextlib import contextmanager
from pythermostat.client import Client
CHANNELS = 2
def get_argparser():
parser = argparse.ArgumentParser(description="Thermostat hardware testing script")
parser.add_argument("host", metavar="HOST", default="192.168.1.26", nargs="?")
parser.add_argument("port", metavar="PORT", default=23, nargs="?")
parser.add_argument(
"-r",
"--testing_resistance",
default=10_000,
help="Testing resistance value through SENS pin in Ohms",
)
parser.add_argument(
"-d",
"--deviation",
default=1,
help="Allowed deviation of resistance in percentage",
)
return parser
def main():
args = get_argparser().parse_args()
min_allowed_resistance = args.testing_resistance * (1 - args.deviation / 100)
max_allowed_resistance = args.testing_resistance * (1 + args.deviation / 100)
print(min_allowed_resistance, max_allowed_resistance)
thermostat = Client(args.host, args.port)
for channel in range(CHANNELS):
print(f"Channel {channel} is active")
print("Checking resistance through SENS input ....", end=" ")
sens_resistance = thermostat.get_report()[channel]["sens"]
if sens_resistance is not None:
print(sens_resistance, "Ω")
if min_allowed_resistance <= sens_resistance <= max_allowed_resistance:
print("PASSED")
else:
print("FAILED")
else:
print("Floating SENS input! Is the channel connected?")
with preserve_thermostat_output_settings(thermostat, channel):
test_output_settings = {
"max_i_pos": 2,
"max_i_neg": 2,
"max_v": 4,
"i_set": 0.1,
"polarity": "normal",
}
for field, value in test_output_settings.items():
thermostat.set_param("output", channel, field, value)
input(f"Check if channel {channel} current = 0.1 A, and press ENTER...")
input(f"Channel {channel} testing done, press ENTER to continue.")
print()
print("Testing complete.")
@contextmanager
def preserve_thermostat_output_settings(client, channel):
original_output_settings = client.get_output()[channel]
yield original_output_settings
for setting in "max_i_pos", "max_i_neg", "max_v", "i_set", "polarity":
client.set_param("output", channel, setting, original_output_settings[setting])
if __name__ == "__main__":
main()

View File

@@ -1,9 +1,12 @@
use crate::timer::sleep;
use stm32f4xx_hal::{
hal::{blocking::spi::Transfer, digital::v2::OutputPin},
spi,
hal::{
blocking::spi::Transfer,
digital::v2::OutputPin,
},
time::MegaHertz,
spi,
};
use crate::timer::sleep;
/// SPI Mode 1
pub const SPI_MODE: spi::Mode = spi::Mode {
@@ -24,8 +27,11 @@ pub struct Dac<SPI: Transfer<u8>, S: OutputPin> {
impl<SPI: Transfer<u8>, S: OutputPin> Dac<SPI, S> {
pub fn new(spi: SPI, mut sync: S) -> Self {
let _ = sync.set_low();
Dac { spi, sync }
Dac {
spi,
sync,
}
}
fn write(&mut self, buf: &mut [u8]) -> Result<(), SPI::Error> {
@@ -41,7 +47,11 @@ impl<SPI: Transfer<u8>, S: OutputPin> Dac<SPI, S> {
pub fn set(&mut self, value: u32) -> Result<u32, SPI::Error> {
let value = value.min(MAX_VALUE);
let mut buf = [(value >> 14) as u8, (value >> 6) as u8, (value << 2) as u8];
let mut buf = [
(value >> 14) as u8,
(value >> 6) as u8,
(value << 2) as u8,
];
self.write(&mut buf)?;
Ok(value)
}

View File

@@ -1,12 +1,18 @@
use super::{
checksum::{Checksum, ChecksumMode},
regs::{self, Register, RegisterData},
DigitalFilterOrder, Input, Mode, PostFilter, RefSource,
};
use core::{fmt, marker::PhantomData};
use core::fmt;
use log::{info, warn};
use stm32f4xx_hal::hal::{blocking::spi::Transfer, digital::v2::OutputPin};
use uom::si::f64::ElectricPotential;
use stm32f4xx_hal::hal::{
blocking::spi::Transfer,
digital::v2::OutputPin,
};
use uom::si::{
f64::ElectricPotential,
electric_potential::volt,
};
use super::{
regs::{self, Register, RegisterData},
checksum::{ChecksumMode, Checksum},
Mode, Input, RefSource, PostFilter, DigitalFilterOrder,
};
/// AD7172-2 implementation
///
@@ -21,8 +27,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
pub fn new(spi: SPI, mut nss: NSS) -> Result<Self, SPI::Error> {
let _ = nss.set_high();
let mut adc = Adc {
spi,
nss,
spi, nss,
checksum_mode: ChecksumMode::Off,
};
adc.reset()?;
@@ -50,7 +55,8 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
/// `0x00DX` for AD7172-2
pub fn identify(&mut self) -> Result<u16, SPI::Error> {
self.read_reg(&regs::Id).map(|id| id.id())
self.read_reg(&regs::Id)
.map(|id| id.id())
}
pub fn set_checksum_mode(&mut self, mode: ChecksumMode) -> Result<(), SPI::Error> {
@@ -70,10 +76,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
}
pub fn setup_channel(
&mut self,
index: u8,
in_pos: Input,
in_neg: Input,
&mut self, index: u8, in_pos: Input, in_neg: Input
) -> Result<(), SPI::Error> {
self.update_reg(&regs::SetupCon { index }, |data| {
data.set_bipolar(false);
@@ -103,11 +106,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
let offset = self.read_reg(&regs::Offset { index })?.offset();
let gain = self.read_reg(&regs::Gain { index })?.gain();
let bipolar = self.read_reg(&regs::SetupCon { index })?.bipolar();
Ok(ChannelCalibration {
offset,
gain,
bipolar,
})
Ok(ChannelCalibration { offset, gain, bipolar })
}
pub fn start_continuous_conversion(&mut self) -> Result<(), SPI::Error> {
@@ -120,43 +119,44 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
}
pub fn get_postfilter(&mut self, index: u8) -> Result<Option<PostFilter>, SPI::Error> {
self.read_reg(&regs::FiltCon { index }).map(|data| {
if data.enh_filt_en() {
Some(data.enh_filt())
} else {
None
}
})
self.read_reg(&regs::FiltCon { index })
.map(|data| {
if data.enh_filt_en() {
Some(data.enh_filt())
} else {
None
}
})
}
pub fn set_postfilter(
&mut self,
index: u8,
filter: Option<PostFilter>,
) -> Result<(), SPI::Error> {
self.update_reg(&regs::FiltCon { index }, |data| match filter {
None => data.set_enh_filt_en(false),
Some(filter) => {
data.set_enh_filt_en(true);
data.set_enh_filt(filter);
pub fn set_postfilter(&mut self, index: u8, filter: Option<PostFilter>) -> Result<(), SPI::Error> {
self.update_reg(&regs::FiltCon { index }, |data| {
match filter {
None => data.set_enh_filt_en(false),
Some(filter) => {
data.set_enh_filt_en(true);
data.set_enh_filt(filter);
}
}
})
}
/// Returns the channel the data is from
pub fn data_ready(&mut self) -> Result<Option<u8>, SPI::Error> {
self.read_reg(&regs::Status).map(|status| {
if status.ready() {
Some(status.channel())
} else {
None
}
})
self.read_reg(&regs::Status)
.map(|status| {
if status.ready() {
Some(status.channel())
} else {
None
}
})
}
/// Get data
pub fn read_data(&mut self) -> Result<u32, SPI::Error> {
self.read_reg(&regs::Data).map(|data| data.data())
self.read_reg(&regs::Data)
.map(|data| data.data())
}
fn read_reg<R: regs::Register>(&mut self, reg: &R) -> Result<R::Data, SPI::Error> {
@@ -175,21 +175,12 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
break;
}
// Retry
warn!(
"read_reg {:02X}: checksum error: {:?}!={:?}, retrying",
reg.address(),
checksum_expected,
checksum_in
);
warn!("read_reg {:02X}: checksum error: {:?}!={:?}, retrying", reg.address(), checksum_expected, checksum_in);
}
Ok(reg_data)
}
fn write_reg<R: regs::Register>(
&mut self,
reg: &R,
reg_data: &mut R::Data,
) -> Result<(), SPI::Error> {
fn write_reg<R: regs::Register>(&mut self, reg: &R, reg_data: &mut R::Data) -> Result<(), SPI::Error> {
loop {
let address = reg.address();
let mut checksum = Checksum::new(match self.checksum_mode {
@@ -199,7 +190,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
ChecksumMode::Crc => ChecksumMode::Crc,
});
checksum.feed(&[address]);
checksum.feed(reg_data);
checksum.feed(&reg_data);
let checksum_out = checksum.result();
let mut data = reg_data.clone();
@@ -210,10 +201,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
if *readback_data == **reg_data {
return Ok(());
}
warn!(
"write_reg {:02X}: readback error, {:?}!={:?}, retrying",
address, &*readback_data, &**reg_data
);
warn!("write_reg {:02X}: readback error, {:?}!={:?}, retrying", address, &*readback_data, &**reg_data);
}
}
@@ -237,12 +225,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
Ok(())
}
fn transfer(
&mut self,
addr: u8,
reg_data: &mut [u8],
checksum: Option<u8>,
) -> Result<Option<u8>, SPI::Error> {
fn transfer<'w>(&mut self, addr: u8, reg_data: &'w mut [u8], checksum: Option<u8>) -> Result<Option<u8>, SPI::Error> {
let mut addr_buf = [addr];
let _ = self.nss.set_low();
@@ -251,7 +234,8 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
Err(e) => Err(e),
};
let result = match (result, checksum) {
(Ok(_), None) => Ok(None),
(Ok(_), None) =>
Ok(None),
(Ok(_), Some(checksum_out)) => {
let mut checksum_buf = [checksum_out; 1];
match self.spi.transfer(&mut checksum_buf) {
@@ -259,7 +243,8 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
Err(e) => Err(e),
}
}
(Err(e), _) => Err(e),
(Err(e), _) =>
Err(e),
};
let _ = self.nss.set_high();
@@ -283,13 +268,9 @@ impl ChannelCalibration {
};
let data = data / (self.gain as f64 / (0x40_0000 as f64));
let data = data + (self.offset as i32 - 0x80_0000) as f64;
let data = data / (1 << 23) as f64;
let data = data / (2 << 23) as f64;
const V_REF: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 3.3,
};
data * V_REF / 0.75
const V_REF: f64 = 3.3;
ElectricPotential::new::<volt>(data * V_REF / 0.75)
}
}

View File

@@ -29,13 +29,13 @@ impl Checksum {
fn feed_byte(&mut self, input: u8) {
match self.mode {
ChecksumMode::Off => {}
ChecksumMode::Off => {},
ChecksumMode::Xor => self.state ^= input,
ChecksumMode::Crc => {
for i in 0..8 {
let input_mask = 0x80 >> i;
self.state = (self.state << 1)
^ if ((self.state & 0x80) != 0) != ((input & input_mask) != 0) {
self.state = (self.state << 1) ^
if ((self.state & 0x80) != 0) != ((input & input_mask) != 0) {
0x07 /* x8 + x2 + x + 1 */
} else {
0
@@ -54,7 +54,7 @@ impl Checksum {
pub fn result(&self) -> Option<u8> {
match self.mode {
ChecksumMode::Off => None,
_ => Some(self.state),
_ => Some(self.state)
}
}
}

View File

@@ -1,10 +1,13 @@
use core::fmt;
use num_traits::float::Float;
use serde::{Deserialize, Serialize};
use stm32f4xx_hal::{spi, time::MegaHertz};
use serde::{Serialize, Deserialize};
use stm32f4xx_hal::{
time::MegaHertz,
spi,
};
mod checksum;
pub mod regs;
mod checksum;
pub use checksum::ChecksumMode;
mod adc;
pub use adc::*;
@@ -19,6 +22,7 @@ pub const SPI_CLOCK: MegaHertz = MegaHertz(2);
pub const MAX_VALUE: u32 = 0xFF_FFFF;
#[derive(Clone, Copy, Debug)]
#[repr(u8)]
pub enum Mode {
@@ -101,8 +105,7 @@ impl fmt::Display for Input {
RefPos => "ref+",
RefNeg => "ref-",
_ => "<INVALID>",
}
.fmt(fmt)
}.fmt(fmt)
}
}
@@ -121,9 +124,9 @@ pub enum RefSource {
impl From<u8> for RefSource {
fn from(x: u8) -> Self {
match x {
0b00 => RefSource::External,
0b10 => RefSource::Internal,
0b11 => RefSource::Avdd1MinusAvss,
0 => RefSource::External,
1 => RefSource::Internal,
2 => RefSource::Avdd1MinusAvss,
_ => RefSource::Invalid,
}
}
@@ -138,42 +141,28 @@ impl fmt::Display for RefSource {
Internal => "internal",
Avdd1MinusAvss => "avdd1-avss",
_ => "<INVALID>",
}
.fmt(fmt)
}.fmt(fmt)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[repr(u8)]
/// Simultaneous Rejection of 50 Hz +/- 1 Hz and 60 Hz +/- 1 Hz
pub enum PostFilter {
/// Output Data Rate: 27.27 SPS,
/// Settling Time: 36.67 ms,
/// Rejection: 47 dB
/// 27 SPS, 47 dB rejection, 36.7 ms settling
F27SPS = 0b010,
/// Output Data Rate: 25 SPS,
/// Settling Time: 40.0 ms,
/// Rejection: 62 dB
F25SPS = 0b011,
/// Output Data Rate: 20 SPS,
/// Settling Time: 50.0 ms,
/// Rejection: 85 dB
/// 21.25 SPS, 62 dB rejection, 40 ms settling
F21SPS = 0b011,
/// 20 SPS, 86 dB rejection, 50 ms settling
F20SPS = 0b101,
/// Output Data Rate: 16.667 SPS,
/// Settling Time: 60.0 ms,
/// Rejection: 90 dB
/// 16.67 SPS, 92 dB rejection, 60 ms settling
F16SPS = 0b110,
Invalid = 0b111,
}
impl PostFilter {
pub const VALID_VALUES: &'static [Self] = &[
PostFilter::F27SPS,
PostFilter::F25SPS,
PostFilter::F21SPS,
PostFilter::F20SPS,
PostFilter::F16SPS,
];
@@ -195,10 +184,10 @@ impl PostFilter {
/// Samples per Second
pub fn output_rate(&self) -> Option<f32> {
match self {
PostFilter::F27SPS => Some(27.27),
PostFilter::F25SPS => Some(25.0),
PostFilter::F27SPS => Some(27.0),
PostFilter::F21SPS => Some(21.25),
PostFilter::F20SPS => Some(20.0),
PostFilter::F16SPS => Some(16.667),
PostFilter::F16SPS => Some(16.67),
PostFilter::Invalid => None,
}
}
@@ -208,7 +197,7 @@ impl From<u8> for PostFilter {
fn from(x: u8) -> Self {
match x {
0b010 => PostFilter::F27SPS,
0b011 => PostFilter::F25SPS,
0b011 => PostFilter::F21SPS,
0b101 => PostFilter::F20SPS,
0b110 => PostFilter::F16SPS,
_ => PostFilter::Invalid,

View File

@@ -1,6 +1,6 @@
use bit_field::BitField;
use byteorder::{BigEndian, ByteOrder};
use core::ops::{Deref, DerefMut};
use byteorder::{BigEndian, ByteOrder};
use bit_field::BitField;
use super::*;
@@ -9,7 +9,7 @@ pub trait Register {
fn address(&self) -> u8;
}
pub trait RegisterData: Clone + Deref<Target = [u8]> + DerefMut {
pub trait RegisterData: Clone + Deref<Target=[u8]> + DerefMut {
fn empty() -> Self;
}
@@ -49,9 +49,7 @@ macro_rules! def_reg {
}
};
($Reg: ident, u8, $reg: ident, $addr: expr, $size: expr) => {
pub struct $Reg {
pub index: u8,
}
pub struct $Reg { pub index: u8, }
impl Register for $Reg {
type Data = $reg::Data;
fn address(&self) -> u8 {
@@ -78,7 +76,7 @@ macro_rules! def_reg {
}
}
}
};
}
}
macro_rules! reg_bit {
@@ -148,7 +146,7 @@ def_reg!(Status, status, 0x00, 1);
impl status::Data {
/// Is there new data to read?
pub fn ready(&self) -> bool {
!self.not_ready()
! self.not_ready()
}
reg_bit!(not_ready, 0, 7, "No data ready indicator");
@@ -161,21 +159,9 @@ impl status::Data {
def_reg!(AdcMode, adc_mode, 0x01, 2);
impl adc_mode::Data {
reg_bits!(delay, set_delay, 0, 0..=2, "Delay after channel switch");
reg_bit!(
sing_cyc,
set_sing_cyc,
0,
5,
"Can only used with single channel"
);
reg_bit!(sing_cyc, set_sing_cyc, 0, 5, "Can only used with single channel");
reg_bit!(hide_delay, set_hide_delay, 0, 6, "Hide delay");
reg_bit!(
ref_en,
set_ref_en,
0,
7,
"Enable internal reference, output buffered 2.5 V to REFOUT"
);
reg_bit!(ref_en, set_ref_en, 0, 7, "Enable internal reference, output buffered 2.5 V to REFOUT");
reg_bits!(clockset, set_clocksel, 1, 2..=3, "Clock source");
reg_bits!(mode, set_mode, 1, 4..=6, Mode, "Operating mode");
}
@@ -188,19 +174,15 @@ impl if_mode::Data {
def_reg!(Data, data, 0x04, 3);
impl data::Data {
pub fn data(&self) -> u32 {
(u32::from(self.0[0]) << 16) | (u32::from(self.0[1]) << 8) | u32::from(self.0[2])
(u32::from(self.0[0]) << 16) |
(u32::from(self.0[1]) << 8) |
u32::from(self.0[2])
}
}
def_reg!(GpioCon, gpio_con, 0x06, 2);
impl gpio_con::Data {
reg_bit!(
sync_en,
set_sync_en,
0,
3,
"Enables the SYNC/ERROR pin as a sync input"
);
reg_bit!(sync_en, set_sync_en, 0, 3, "Enables the SYNC/ERROR pin as a sync input");
}
def_reg!(Id, id, 0x07, 2);
@@ -218,7 +200,8 @@ impl channel::Data {
/// Which input is connected to positive input of this channel
#[allow(unused)]
pub fn a_in_pos(&self) -> Input {
((self.0[0].get_bits(0..=1) << 3) | self.0[1].get_bits(5..=7)).into()
((self.0[0].get_bits(0..=1) << 3) |
self.0[1].get_bits(5..=7)).into()
}
/// Set which input is connected to positive input of this channel
#[allow(unused)]
@@ -227,66 +210,27 @@ impl channel::Data {
self.0[0].set_bits(0..=1, value >> 3);
self.0[1].set_bits(5..=7, value & 0x7);
}
reg_bits!(
a_in_neg,
set_a_in_neg,
1,
0..=4,
Input,
"Which input is connected to negative input of this channel"
);
reg_bits!(a_in_neg, set_a_in_neg, 1, 0..=4, Input,
"Which input is connected to negative input of this channel");
}
def_reg!(SetupCon, u8, setup_con, 0x20, 2);
impl setup_con::Data {
reg_bit!(
bipolar,
set_bipolar,
0,
4,
"Unipolar (`false`) or bipolar (`true`) coded output"
);
reg_bit!(bipolar, set_bipolar, 0, 4, "Unipolar (`false`) or bipolar (`true`) coded output");
reg_bit!(refbuf_pos, set_refbuf_pos, 0, 3, "Enable REF+ input buffer");
reg_bit!(refbuf_neg, set_refbuf_neg, 0, 2, "Enable REF- input buffer");
reg_bit!(ainbuf_pos, set_ainbuf_pos, 0, 1, "Enable AIN+ input buffer");
reg_bit!(ainbuf_neg, set_ainbuf_neg, 0, 0, "Enable AIN- input buffer");
reg_bit!(burnout_en, 1, 7, "enables a 10 µA current source on the positive analog input selected and a 10 µA current sink on the negative analog input selected");
reg_bits!(
ref_sel,
set_ref_sel,
1,
4..=5,
RefSource,
"Select reference source for conversion"
);
reg_bits!(ref_sel, set_ref_sel, 1, 4..=5, RefSource, "Select reference source for conversion");
}
def_reg!(FiltCon, u8, filt_con, 0x28, 2);
impl filt_con::Data {
reg_bit!(sinc3_map, 0, 7, "If set, mapping of filter register changes to directly program the decimation rate of the sinc3 filter");
reg_bit!(
enh_filt_en,
set_enh_filt_en,
0,
3,
"Enable postfilters for enhanced 50Hz and 60Hz rejection"
);
reg_bits!(
enh_filt,
set_enh_filt,
0,
0..=2,
PostFilter,
"Select postfilters for enhanced 50Hz and 60Hz rejection"
);
reg_bits!(
order,
set_order,
1,
5..=6,
DigitalFilterOrder,
"order of the digital filter that processes the modulator data"
);
reg_bit!(enh_filt_en, set_enh_filt_en, 0, 3, "Enable postfilters for enhanced 50Hz and 60Hz rejection");
reg_bits!(enh_filt, set_enh_filt, 0, 0..=2, PostFilter, "Select postfilters for enhanced 50Hz and 60Hz rejection");
reg_bits!(order, set_order, 1, 5..=6, DigitalFilterOrder, "order of the digital filter that processes the modulator data");
reg_bits!(odr, set_odr, 1, 0..=4, "Output data rate");
}
@@ -294,7 +238,9 @@ def_reg!(Offset, u8, offset, 0x30, 3);
impl offset::Data {
#[allow(unused)]
pub fn offset(&self) -> u32 {
(u32::from(self.0[0]) << 16) | (u32::from(self.0[1]) << 8) | u32::from(self.0[2])
(u32::from(self.0[0]) << 16) |
(u32::from(self.0[1]) << 8) |
u32::from(self.0[2])
}
#[allow(unused)]
pub fn set_offset(&mut self, value: u32) {
@@ -308,7 +254,9 @@ def_reg!(Gain, u8, gain, 0x38, 3);
impl gain::Data {
#[allow(unused)]
pub fn gain(&self) -> u32 {
(u32::from(self.0[0]) << 16) | (u32::from(self.0[1]) << 8) | u32::from(self.0[2])
(u32::from(self.0[0]) << 16) |
(u32::from(self.0[1]) << 8) |
u32::from(self.0[2])
}
#[allow(unused)]
pub fn set_gain(&mut self, value: u32) {

View File

@@ -1,10 +1,14 @@
use crate::{
ad5680, ad7172,
channel_state::ChannelState,
pins::{ChannelPinSet, ChannelPins},
};
use stm32f4xx_hal::hal::digital::v2::OutputPin;
use uom::si::{electric_potential::volt, f64::ElectricPotential};
use uom::si::{
f64::ElectricPotential,
electric_potential::volt,
};
use crate::{
ad5680,
ad7172,
channel_state::ChannelState,
pins::{ChannelPins, ChannelPinSet},
};
/// Marker type for the first channel
pub struct Channel0;
@@ -20,7 +24,7 @@ pub struct Channel<C: ChannelPins> {
pub vref_meas: ElectricPotential,
pub shdn: C::Shdn,
pub vref_pin: C::VRefPin,
pub itec_pin: C::ITecPin,
pub itec_pin: C::ItecPin,
/// feedback from `dac` output
pub dac_feedback_pin: C::DacFeedbackPin,
pub tec_u_meas_pin: C::TecUMeasPin,
@@ -36,8 +40,7 @@ impl<C: ChannelPins> Channel<C> {
Channel {
state,
dac,
vref_meas,
dac, vref_meas,
shdn: pins.shdn,
vref_pin: pins.vref_pin,
itec_pin: pins.itec_pin,

View File

@@ -1,48 +1,39 @@
use crate::{
ad7172, b_parameter as bp,
command_parser::{CenterPoint, Polarity},
config::OutputLimits,
pid,
};
use core::marker::PhantomData;
use smoltcp::time::{Duration, Instant};
use uom::{
si::{
f64::{
ElectricCurrent, ElectricPotential, ElectricalResistance, ThermodynamicTemperature,
Time,
},
thermodynamic_temperature::degree_celsius,
time::millisecond,
use uom::si::{
f64::{
ElectricPotential,
ElectricalResistance,
ThermodynamicTemperature,
Time,
},
ConstZero,
electric_potential::volt,
electrical_resistance::ohm,
thermodynamic_temperature::degree_celsius,
time::millisecond,
};
use crate::{
ad7172,
pid,
steinhart_hart as sh,
command_parser::CenterPoint,
};
const R_INNER: ElectricalResistance = ElectricalResistance {
dimension: PhantomData,
units: PhantomData,
value: 2.0 * 5100.0,
};
const VREF_SENS: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 3.3,
};
const R_INNER: f64 = 2.0 * 5100.0;
const VREF_SENS: f64 = 3.3 / 2.0;
pub struct ChannelState {
pub adc_data: Option<u32>,
pub adc_calibration: ad7172::ChannelCalibration,
pub adc_time: Instant,
pub adc_interval: Duration,
/// VREF for the TEC (1.5V)
pub vref: ElectricPotential,
/// i_set 0A center point
pub center: CenterPoint,
pub dac_value: ElectricPotential,
pub i_set: ElectricCurrent,
pub output_limits: OutputLimits,
pub pid_engaged: bool,
pub pid: pid::Controller,
pub bp: bp::Parameters,
pub polarity: Polarity,
pub sh: sh::Parameters,
}
impl ChannelState {
@@ -53,18 +44,13 @@ impl ChannelState {
adc_time: Instant::from_secs(0),
// default: 10 Hz
adc_interval: Duration::from_millis(100),
center: CenterPoint::VRef,
dac_value: ElectricPotential::ZERO,
i_set: ElectricCurrent::ZERO,
output_limits: OutputLimits {
max_v: ElectricPotential::ZERO,
max_i_pos: ElectricCurrent::ZERO,
max_i_neg: ElectricCurrent::ZERO,
},
// updated later with Channels.read_vref()
vref: ElectricPotential::new::<volt>(1.5),
center: CenterPoint::Vref,
dac_value: ElectricPotential::new::<volt>(0.0),
pid_engaged: false,
pid: pid::Controller::new(pid::Parameters::default()),
bp: bp::Parameters::default(),
polarity: Polarity::Normal,
sh: sh::Parameters::default(),
}
}
@@ -81,7 +67,8 @@ impl ChannelState {
/// Update PID state on ADC input, calculate new DAC output
pub fn update_pid(&mut self) -> Option<f64> {
let temperature = self.get_temperature()?.get::<degree_celsius>();
let temperature = self.get_temperature()?
.get::<degree_celsius>();
let pid_output = self.pid.update(temperature);
Some(pid_output)
}
@@ -100,14 +87,16 @@ impl ChannelState {
/// Get `SENS[01]` input resistance
pub fn get_sens(&self) -> Option<ElectricalResistance> {
let r_inner = ElectricalResistance::new::<ohm>(R_INNER);
let vref = ElectricPotential::new::<volt>(VREF_SENS);
let adc_input = self.get_adc()?;
let r = R_INNER * adc_input / (VREF_SENS - adc_input);
let r = r_inner * adc_input / (vref - adc_input);
Some(r)
}
pub fn get_temperature(&self) -> Option<ThermodynamicTemperature> {
let r = self.get_sens()?;
let temperature = self.bp.get_temperature(r);
let temperature = self.sh.get_temperature(r);
Some(temperature)
}
}

View File

@@ -1,76 +1,38 @@
use crate::timer::sleep;
use crate::{
ad5680,
ad7172::{self, PostFilter},
b_parameter,
channel::{Channel, Channel0, Channel1},
channel_state::ChannelState,
command_handler::JsonBuffer,
command_parser::{CenterPoint, Polarity, PwmPin},
pins::{self, Channel0VRef, Channel1VRef},
};
use core::marker::PhantomData;
use heapless::{consts::U2, Vec};
use heapless::{consts::{U2, U1024}, Vec};
use serde::{Serialize, Serializer};
use smoltcp::time::Instant;
use stm32f4xx_hal::hal;
use uom::{
si::{
electric_current::ampere,
electric_potential::{millivolt, volt},
electrical_resistance::ohm,
f64::{ElectricCurrent, ElectricPotential, ElectricalResistance, Time},
ratio::ratio,
thermodynamic_temperature::degree_celsius,
},
ConstZero,
use uom::si::{
f64::{ElectricCurrent, ElectricPotential, ElectricalResistance, Time},
electric_potential::{millivolt, volt},
electric_current::ampere,
electrical_resistance::ohm,
ratio::ratio,
thermodynamic_temperature::degree_celsius,
};
use crate::{
ad5680,
ad7172,
channel::{Channel, Channel0, Channel1},
channel_state::ChannelState,
command_parser::{CenterPoint, PwmPin},
pins,
steinhart_hart,
};
pub enum PinsAdcReadTarget {
VRef,
DacVfb,
ITec,
VTec,
}
pub const CHANNELS: usize = 2;
const R_SENSE: ElectricalResistance = ElectricalResistance {
dimension: PhantomData,
units: PhantomData,
value: 0.05,
};
const CPU_ADC_VREF: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 3.3,
};
// From design specs
pub const MAX_TEC_I: ElectricCurrent = ElectricCurrent {
dimension: PhantomData,
units: PhantomData,
value: 2.0,
};
pub const MAX_TEC_V: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 4.3,
};
pub const R_SENSE: f64 = 0.05;
// DAC chip outputs 0-5v, which is then passed through a resistor dividor to provide 0-3v range
const DAC_OUT_V_MAX: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 3.0,
};
const DAC_OUT_V_MAX: f64 = 3.0;
// TODO: -pub
pub struct Channels {
channel0: Channel<Channel0>,
channel1: Channel<Channel1>,
adc: ad7172::Adc<pins::AdcSpi, pins::AdcNss>,
pub adc: ad7172::Adc<pins::AdcSpi, pins::AdcNss>,
/// stm32f4 integrated adc
pins_adc: pins::PinsAdc,
pwm: pins::PwmPins,
pub pwm: pins::PwmPins,
}
impl Channels {
@@ -80,28 +42,23 @@ impl Channels {
adc.set_sync_enable(false).unwrap();
// Setup channels and start ADC
adc.setup_channel(0, ad7172::Input::Ain2, ad7172::Input::Ain3)
.unwrap();
let adc_calibration0 = adc.get_calibration(0).expect("adc_calibration0");
adc.setup_channel(1, ad7172::Input::Ain0, ad7172::Input::Ain1)
.unwrap();
let adc_calibration1 = adc.get_calibration(1).expect("adc_calibration1");
adc.setup_channel(0, ad7172::Input::Ain2, ad7172::Input::Ain3).unwrap();
let adc_calibration0 = adc.get_calibration(0)
.expect("adc_calibration0");
adc.setup_channel(1, ad7172::Input::Ain0, ad7172::Input::Ain1).unwrap();
let adc_calibration1 = adc.get_calibration(1)
.expect("adc_calibration1");
adc.start_continuous_conversion().unwrap();
let channel0 = Channel::new(pins.channel0, adc_calibration0);
let channel1 = Channel::new(pins.channel1, adc_calibration1);
let pins_adc = pins.pins_adc;
let pwm = pins.pwm;
let mut channels = Channels {
channel0,
channel1,
adc,
pins_adc,
pwm,
};
let mut channels = Channels { channel0, channel1, adc, pins_adc, pwm };
for channel in 0..CHANNELS {
channels.channel_state(channel).vref = channels.read_vref(channel);
channels.calibrate_dac_value(channel);
channels.set_i(channel, ElectricCurrent::ZERO);
channels.set_i(channel, ElectricCurrent::new::<ampere>(0.0));
}
channels
}
@@ -139,10 +96,13 @@ impl Channels {
/// calculate the TEC i_set centerpoint
pub fn get_center(&mut self, channel: usize) -> ElectricPotential {
match self.channel_state(channel).center {
CenterPoint::VRef => self.adc_read(channel, PinsAdcReadTarget::VRef, 8),
CenterPoint::Override(center_point) => {
ElectricPotential::new::<volt>(center_point.into())
}
CenterPoint::Vref => {
let vref = self.read_vref(channel);
self.channel_state(channel).vref = vref;
vref
},
CenterPoint::Override(center_point) =>
ElectricPotential::new::<volt>(center_point.into()),
}
}
@@ -152,14 +112,22 @@ impl Channels {
voltage
}
pub fn get_i_set(&mut self, channel: usize) -> ElectricCurrent {
let i_set = self.channel_state(channel).i_set;
i_set
pub fn get_i(&mut self, channel: usize) -> ElectricCurrent {
let center_point = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
// let center_point = self.get_center(channel);
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = self.get_dac(channel);
let i_tec = (voltage - center_point) / (10.0 * r_sense);
i_tec
}
/// i_set DAC
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential {
let value = ((voltage / DAC_OUT_V_MAX).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32;
let value = ((voltage / ElectricPotential::new::<volt>(DAC_OUT_V_MAX)).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ;
match channel {
0 => self.channel0.dac.set(value).unwrap(),
1 => self.channel1.dac.set(value).unwrap(),
@@ -169,122 +137,114 @@ impl Channels {
voltage
}
pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent {
let i_set = i_set.min(MAX_TEC_I).max(-MAX_TEC_I);
self.channel_state(channel).i_set = i_set;
let negate = match self.channel_state(channel).polarity {
Polarity::Normal => 1.0,
Polarity::Reversed => -1.0,
};
let vref_meas = match channel {
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 center_point = vref_meas;
let voltage = negate * i_set * 10.0 * R_SENSE + center_point;
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = i_tec * 10.0 * r_sense + center_point;
let voltage = self.set_dac(channel, voltage);
negate * (voltage - center_point) / (10.0 * R_SENSE)
let i_tec = (voltage - center_point) / (10.0 * r_sense);
i_tec
}
/// AN4073: ADC Reading Dispersion can be reduced through Averaging
pub fn adc_read(
&mut self,
channel: usize,
adc_read_target: PinsAdcReadTarget,
avg_pt: u16,
) -> ElectricPotential {
let mut sample: u32 = 0;
pub fn read_dac_feedback(&mut self, channel: usize) -> ElectricPotential {
match channel {
0 => {
sample = match adc_read_target {
PinsAdcReadTarget::VRef => match &self.channel0.vref_pin {
Channel0VRef::Analog(vref_pin) => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
vref_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
Channel0VRef::Disabled(_) => 2048_u32,
},
PinsAdcReadTarget::DacVfb => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel0.dac_feedback_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
PinsAdcReadTarget::ITec => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel0.itec_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
PinsAdcReadTarget::VTec => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel0.tec_u_meas_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
};
let mv = self.pins_adc.sample_to_millivolts(sample as u16);
let sample = self.pins_adc.convert(
&self.channel0.dac_feedback_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
1 => {
sample = match adc_read_target {
PinsAdcReadTarget::VRef => match &self.channel1.vref_pin {
Channel1VRef::Analog(vref_pin) => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
vref_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
Channel1VRef::Disabled(_) => 2048_u32,
},
PinsAdcReadTarget::DacVfb => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel1.dac_feedback_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
PinsAdcReadTarget::ITec => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel1.itec_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
PinsAdcReadTarget::VTec => {
for _ in (0..avg_pt).rev() {
sample += self.pins_adc.convert(
&self.channel1.tec_u_meas_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480,
) as u32;
}
sample / avg_pt as u32
}
};
let mv = self.pins_adc.sample_to_millivolts(sample as u16);
let sample = self.pins_adc.convert(
&self.channel1.dac_feedback_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
_ => unreachable!(),
}
}
pub fn read_dac_feedback_until_stable(&mut self, channel: usize, tolerance: ElectricPotential) -> ElectricPotential {
let mut prev = self.read_dac_feedback(channel);
loop {
let current = self.read_dac_feedback(channel);
if (current - prev).abs() < tolerance {
return current;
}
prev = current;
}
}
pub fn read_itec(&mut self, channel: usize) -> ElectricPotential {
match channel {
0 => {
let sample = self.pins_adc.convert(
&self.channel0.itec_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
1 => {
let sample = self.pins_adc.convert(
&self.channel1.itec_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
_ => unreachable!(),
}
}
/// should be 1.5V
pub fn read_vref(&mut self, channel: usize) -> ElectricPotential {
match channel {
0 => {
let sample = self.pins_adc.convert(
&self.channel0.vref_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
1 => {
let sample = self.pins_adc.convert(
&self.channel1.vref_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
_ => unreachable!(),
}
}
pub fn read_tec_u_meas(&mut self, channel: usize) -> ElectricPotential {
match channel {
0 => {
let sample = self.pins_adc.convert(
&self.channel0.tec_u_meas_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
1 => {
let sample = self.pins_adc.convert(
&self.channel1.tec_u_meas_pin,
stm32f4xx_hal::adc::config::SampleTime::Cycles_480
);
let mv = self.pins_adc.sample_to_millivolts(sample);
ElectricPotential::new::<millivolt>(mv as f64)
}
_ => unreachable!(),
@@ -295,29 +255,30 @@ impl Channels {
///
/// The thermostat DAC applies a control voltage signal to the CTLI pin of MAX driver chip to control its output current.
/// The CTLI input signal is centered around VREF of the MAX chip. Applying VREF to CTLI sets the output current to 0.
///
///
/// This calibration routine measures the VREF voltage and the DAC output with the STM32 ADC, and uses a breadth-first
/// search to find the DAC setting that will produce a DAC output voltage closest to VREF. This DAC output voltage will
/// be stored and used in subsequent i_set routines to bias the current control signal to the measured VREF, reducing
/// search to find the DAC setting that will produce a DAC output voltage closest to VREF. This DAC output voltage will
/// be stored and used in subsequent i_set routines to bias the current control signal to the measured VREF, reducing
/// the offset error of the current control signal.
///
/// The input offset of the STM32 ADC is eliminated by using the same ADC for the measurements, and by only using the
/// difference in VREF and DAC output for the calibration.
///
/// This routine should be called only once after boot, repeated reading of the vref signal and changing of the stored
///
/// This routine should be called only once after boot, repeated reading of the vref signal and changing of the stored
/// VREF measurement can introduce significant noise at the current output, degrading the stabilily performance of the
/// thermostat.
/// thermostat.
pub fn calibrate_dac_value(&mut self, channel: usize) {
let samples = 50;
let mut target_voltage = ElectricPotential::ZERO;
let mut target_voltage = ElectricPotential::new::<volt>(0.0);
for _ in 0..samples {
target_voltage += self.get_center(channel);
target_voltage = target_voltage + self.get_center(channel);
}
target_voltage /= samples as f64;
target_voltage = target_voltage / samples as f64;
let mut start_value = 1;
let mut best_error = ElectricPotential::new::<volt>(100.0);
for step in (5..18).rev() {
for step in (0..18).rev() {
let mut prev_value = start_value;
for value in (start_value..=ad5680::MAX_VALUE).step_by(1 << step) {
match channel {
0 => {
@@ -328,28 +289,29 @@ impl Channels {
}
_ => unreachable!(),
}
sleep(10);
let dac_feedback = self.adc_read(channel, PinsAdcReadTarget::DacVfb, 64);
let dac_feedback = self.read_dac_feedback_until_stable(channel, ElectricPotential::new::<volt>(0.001));
let error = target_voltage - dac_feedback;
if error < ElectricPotential::ZERO {
if error < ElectricPotential::new::<volt>(0.0) {
break;
} else if error < best_error {
best_error = error;
start_value = value;
start_value = prev_value;
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * DAC_OUT_V_MAX;
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * ElectricPotential::new::<volt>(DAC_OUT_V_MAX);
match channel {
0 => self.channel0.vref_meas = vref,
1 => self.channel1.vref_meas = vref,
_ => unreachable!(),
}
}
prev_value = value;
}
}
// Reset
self.set_dac(channel, ElectricPotential::ZERO);
self.set_dac(channel, ElectricPotential::new::<volt>(0.0));
}
// power up TEC
@@ -370,139 +332,112 @@ impl Channels {
}
}
fn get_pwm(&self, channel: usize, pin: PwmPin) -> f64 {
fn get<P: hal::PwmPin<Duty=u16>>(pin: &P) -> f64 {
let duty = pin.get_duty();
let max = pin.get_max_duty();
duty as f64 / (max as f64)
}
match (channel, pin) {
(_, PwmPin::ISet) =>
panic!("i_set is no pwm pin"),
(0, PwmPin::MaxIPos) =>
get(&self.pwm.max_i_pos0),
(0, PwmPin::MaxINeg) =>
get(&self.pwm.max_i_neg0),
(0, PwmPin::MaxV) =>
get(&self.pwm.max_v0),
(1, PwmPin::MaxIPos) =>
get(&self.pwm.max_i_pos1),
(1, PwmPin::MaxINeg) =>
get(&self.pwm.max_i_neg1),
(1, PwmPin::MaxV) =>
get(&self.pwm.max_v1),
_ =>
unreachable!(),
}
}
pub fn get_max_v(&mut self, channel: usize) -> ElectricPotential {
self.channel_state(channel).output_limits.max_v
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = self.get_pwm(channel, PwmPin::MaxV);
duty * max
}
pub fn get_max_i_pos(&mut self, channel: usize) -> ElectricCurrent {
self.channel_state(channel).output_limits.max_i_pos
pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxIPos);
(duty * max, max)
}
pub fn get_max_i_neg(&mut self, channel: usize) -> ElectricCurrent {
self.channel_state(channel).output_limits.max_i_neg
}
pub fn get_postfilter(&mut self, index: u8) -> Option<PostFilter> {
self.adc.get_postfilter(index).unwrap()
pub fn get_max_i_neg(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxINeg);
(duty * max, max)
}
// Get current passing through TEC
pub fn get_tec_i(&mut self, channel: usize) -> ElectricCurrent {
let tec_i = (self.adc_read(channel, PinsAdcReadTarget::ITec, 16)
- self.adc_read(channel, PinsAdcReadTarget::VRef, 16))
/ ElectricalResistance::new::<ohm>(0.4);
match self.channel_state(channel).polarity {
Polarity::Normal => tec_i,
Polarity::Reversed => -tec_i,
}
(self.read_itec(channel) - self.read_vref(channel)) / ElectricalResistance::new::<ohm>(0.4)
}
// Get voltage across TEC
pub fn get_tec_v(&mut self, channel: usize) -> ElectricPotential {
(self.adc_read(channel, PinsAdcReadTarget::VTec, 16) - ElectricPotential::new::<volt>(1.5))
* 4.0
(self.read_tec_u_meas(channel) - ElectricPotential::new::<volt>(1.5)) * 4.0
}
fn set_pwm(&mut self, channel: usize, pin: PwmPin, duty: f64) -> f64 {
fn set<P: hal::PwmPin<Duty = u16>>(pin: &mut P, duty: f64) -> f64 {
fn set<P: hal::PwmPin<Duty=u16>>(pin: &mut P, duty: f64) -> f64 {
let max = pin.get_max_duty();
let value = ((duty * (max as f64)) as u16).min(max);
pin.set_duty(value);
value as f64 / (max as f64)
}
match (channel, pin) {
(_, PwmPin::ISet) => panic!("i_set is no pwm pin"),
(0, PwmPin::MaxIPos) => set(&mut self.pwm.max_i_pos0, duty),
(0, PwmPin::MaxINeg) => set(&mut self.pwm.max_i_neg0, duty),
(0, PwmPin::MaxV) => set(&mut self.pwm.max_v0, duty),
(1, PwmPin::MaxIPos) => set(&mut self.pwm.max_i_pos1, duty),
(1, PwmPin::MaxINeg) => set(&mut self.pwm.max_i_neg1, duty),
(1, PwmPin::MaxV) => set(&mut self.pwm.max_v1, duty),
_ => unreachable!(),
(_, PwmPin::ISet) =>
panic!("i_set is no pwm pin"),
(0, PwmPin::MaxIPos) =>
set(&mut self.pwm.max_i_pos0, duty),
(0, PwmPin::MaxINeg) =>
set(&mut self.pwm.max_i_neg0, duty),
(0, PwmPin::MaxV) =>
set(&mut self.pwm.max_v0, duty),
(1, PwmPin::MaxIPos) =>
set(&mut self.pwm.max_i_pos1, duty),
(1, PwmPin::MaxINeg) =>
set(&mut self.pwm.max_i_neg1, duty),
(1, PwmPin::MaxV) =>
set(&mut self.pwm.max_v1, duty),
_ =>
unreachable!(),
}
}
pub fn set_max_v(
&mut self,
channel: usize,
max_v: ElectricPotential,
) -> (ElectricPotential, ElectricPotential) {
let max_v = max_v.min(MAX_TEC_V).max(ElectricPotential::ZERO);
self.channel_state(channel).output_limits.max_v = max_v;
let v_maxv = max_v / 4.0;
let duty = (v_maxv / CPU_ADC_VREF).get::<ratio>();
pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = (max_v / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
let v_maxv = duty * CPU_ADC_VREF;
let max_v = 4.0 * v_maxv;
(max_v, MAX_TEC_V)
(duty * max, max)
}
pub fn set_max_i_pos(
&mut self,
channel: usize,
max_i_pos: ElectricCurrent,
) -> (ElectricCurrent, ElectricCurrent) {
let pin = match self.channel_state(channel).polarity {
Polarity::Normal => PwmPin::MaxIPos,
Polarity::Reversed => PwmPin::MaxINeg,
};
let max_i_pos = max_i_pos.min(MAX_TEC_I).max(ElectricCurrent::ZERO);
self.channel_state(channel).output_limits.max_i_pos = max_i_pos;
let v_maxip = 10.0 * (max_i_pos * R_SENSE);
let duty = (v_maxip / CPU_ADC_VREF).get::<ratio>();
let duty = self.set_pwm(channel, pin, duty);
let v_maxip = duty * CPU_ADC_VREF;
let max_i_pos = v_maxip / 10.0 / R_SENSE;
(max_i_pos, MAX_TEC_I)
pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_pos / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxIPos, duty);
(duty * max, max)
}
pub fn set_max_i_neg(
&mut self,
channel: usize,
max_i_neg: ElectricCurrent,
) -> (ElectricCurrent, ElectricCurrent) {
let pin = match self.channel_state(channel).polarity {
Polarity::Normal => PwmPin::MaxINeg,
Polarity::Reversed => PwmPin::MaxIPos,
};
let max_i_neg = max_i_neg.min(MAX_TEC_I).max(ElectricCurrent::ZERO);
self.channel_state(channel).output_limits.max_i_neg = max_i_neg;
let v_maxin = 10.0 * (max_i_neg * R_SENSE);
let duty = (v_maxin / CPU_ADC_VREF).get::<ratio>();
let duty = self.set_pwm(channel, pin, duty);
let v_maxin = duty * CPU_ADC_VREF;
let max_i_neg = v_maxin / 10.0 / R_SENSE;
(max_i_neg, MAX_TEC_I)
}
pub fn set_postfilter(&mut self, index: u8, filter: Option<PostFilter>) {
self.adc.set_postfilter(index, filter).unwrap()
}
pub fn set_polarity(&mut self, channel: usize, polarity: Polarity) {
if self.channel_state(channel).polarity != polarity {
let i_set = self.channel_state(channel).i_set;
let max_i_pos = self.get_max_i_pos(channel);
let max_i_neg = self.get_max_i_neg(channel);
self.channel_state(channel).polarity = polarity;
self.set_i(channel, i_set);
self.set_max_i_pos(channel, max_i_pos);
self.set_max_i_neg(channel, max_i_neg);
}
pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_neg / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxINeg, duty);
(duty * max, max)
}
fn report(&mut self, channel: usize) -> Report {
let i_set = self.get_i_set(channel);
let i_tec = self.adc_read(channel, PinsAdcReadTarget::ITec, 16);
let vref = self.channel_state(channel).vref;
let i_set = self.get_i(channel);
let i_tec = self.read_itec(channel);
let tec_i = self.get_tec_i(channel);
let dac_value = self.get_dac(channel);
let state = self.channel_state(channel);
@@ -513,13 +448,13 @@ impl Channels {
interval: state.get_adc_interval(),
adc: state.get_adc(),
sens: state.get_sens(),
temperature: state
.get_temperature()
temperature: state.get_temperature()
.map(|temperature| temperature.get::<degree_celsius>()),
pid_engaged: state.pid_engaged,
i_set,
vref,
dac_value,
dac_feedback: self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1),
dac_feedback: self.read_dac_feedback(channel),
i_tec,
tec_i,
tec_u_meas: self.get_tec_v(channel),
@@ -543,38 +478,27 @@ impl Channels {
serde_json_core::to_vec(&summaries)
}
pub fn pid_engaged(&mut self) -> bool {
for channel in 0..CHANNELS {
if self.channel_state(channel).pid_engaged {
return true;
}
}
false
}
fn output_summary(&mut self, channel: usize) -> OutputSummary {
OutputSummary {
fn pwm_summary(&mut self, channel: usize) -> PwmSummary {
PwmSummary {
channel,
center: CenterPointJson(self.channel_state(channel).center.clone()),
i_set: self.get_i_set(channel),
max_v: self.get_max_v(channel),
max_i_pos: self.get_max_i_pos(channel),
max_i_neg: self.get_max_i_neg(channel),
polarity: PolarityJson(self.channel_state(channel).polarity.clone()),
i_set: (self.get_i(channel), ElectricCurrent::new::<ampere>(3.0)).into(),
max_v: (self.get_max_v(channel), ElectricPotential::new::<volt>(5.0)).into(),
max_i_pos: self.get_max_i_pos(channel).into(),
max_i_neg: self.get_max_i_neg(channel).into(),
}
}
pub fn output_summaries_json(&mut self) -> Result<JsonBuffer, serde_json_core::ser::Error> {
pub fn pwm_summaries_json(&mut self) -> Result<JsonBuffer, serde_json_core::ser::Error> {
let mut summaries = Vec::<_, U2>::new();
for channel in 0..CHANNELS {
let _ = summaries.push(self.output_summary(channel));
let _ = summaries.push(self.pwm_summary(channel));
}
serde_json_core::to_vec(&summaries)
}
fn postfilter_summary(&mut self, channel: usize) -> PostFilterSummary {
let rate = self
.get_postfilter(channel as u8)
let rate = self.adc.get_postfilter(channel as u8).unwrap()
.and_then(|filter| filter.output_rate());
PostFilterSummary { channel, rate }
}
@@ -587,29 +511,22 @@ impl Channels {
serde_json_core::to_vec(&summaries)
}
fn b_parameter_summary(&mut self, channel: usize) -> BParameterSummary {
let params = self.channel_state(channel).bp.clone();
BParameterSummary { channel, params }
fn steinhart_hart_summary(&mut self, channel: usize) -> SteinhartHartSummary {
let params = self.channel_state(channel).sh.clone();
SteinhartHartSummary { channel, params }
}
pub fn b_parameter_summaries_json(
&mut self,
) -> Result<JsonBuffer, serde_json_core::ser::Error> {
pub fn steinhart_hart_summaries_json(&mut self) -> Result<JsonBuffer, serde_json_core::ser::Error> {
let mut summaries = Vec::<_, U2>::new();
for channel in 0..CHANNELS {
let _ = summaries.push(self.b_parameter_summary(channel));
let _ = summaries.push(self.steinhart_hart_summary(channel));
}
serde_json_core::to_vec(&summaries)
}
pub fn current_abs_max_tec_i(&mut self) -> ElectricCurrent {
(0..CHANNELS)
.map(|channel| self.get_tec_i(channel).abs())
.max_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal))
.unwrap()
}
}
type JsonBuffer = Vec<u8, U1024>;
#[derive(Serialize)]
pub struct Report {
channel: usize,
@@ -620,6 +537,7 @@ pub struct Report {
temperature: Option<f64>,
pid_engaged: bool,
i_set: ElectricCurrent,
vref: ElectricPotential,
dac_value: ElectricPotential,
dac_feedback: ElectricPotential,
i_tec: ElectricPotential,
@@ -637,36 +555,34 @@ impl Serialize for CenterPointJson {
S: Serializer,
{
match self.0 {
CenterPoint::VRef => serializer.serialize_str("vref"),
CenterPoint::Override(vref) => serializer.serialize_f32(vref),
CenterPoint::Vref =>
serializer.serialize_str("vref"),
CenterPoint::Override(vref) =>
serializer.serialize_f32(vref),
}
}
}
pub struct PolarityJson(Polarity);
#[derive(Serialize)]
pub struct PwmSummaryField<T: Serialize> {
value: T,
max: T,
}
// used in JSON encoding, not for config
impl Serialize for PolarityJson {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(match self.0 {
Polarity::Normal => "normal",
Polarity::Reversed => "reversed",
})