Compare commits

..

55 Commits

Author SHA1 Message Date
atse c9aa0eaab8 ctrl_panel: Reformat SpinBox text always if valid
The parameter SpinBoxes previously would only update if the interpreted
value was changed, missing cases where the text would have changed but
the value stays the same, e.g. removing trailing decimal zeros.
2024-10-07 16:13:56 +08:00
atse ed6cee4394 ctrl_panel: Move postfilter into its own group 2024-10-07 16:13:56 +08:00
atse e1bd960900 ctrl_panel: Use new locking mechanism from Kirdy 2024-10-07 16:13:56 +08:00
atse aed0c484dd ctrl_panel: Fix editing fields with unit "°C"
A faulty regular expression within PyQtGraph causes any Parameter with a
suffix that doesn't begin with an alphanumeric character (as matched
with \w) to act abnormally. For instance, entering "100 °C" into the
input boxes gets interpreted as 10 °C.

Patch the FLOAT_REGEX in PyQtGraph to simply match for any character in
the suffix group.
2024-10-07 16:13:56 +08:00
atse 3fe343435d ctrl_panel: More appropriate steps and fixes 2024-10-07 16:13:56 +08:00
atse eddfc40e07 ctrl_panel: Put plotted values into readings group
For more intuitiveness to first-time users
2024-10-07 16:13:56 +08:00
atse 3ae89760ab ctrl_panel: Fix max_v to only have unit "V"
As most users do not need to limit TEC voltage with accuracy of less
than 1mV.
2024-10-07 16:13:56 +08:00
atse 2f9b95f04b ctrl_panel: Bold "Control Method" text 2024-10-07 16:13:56 +08:00
atse 9926c00494 ctrl_panel: Indicate active parameter of control
Instead of hiding the inactive control parameter, underline and bold the
active control parameter title, e.g. "Set Current" when control method
is constant current, and "Setpoint" when it is temperature PID.
2024-10-07 16:13:54 +08:00
atse fde1e6b3e4 ctrl_panel: Limits fixes
* PID Autotune test current should be positive

* max_v should be 4 V not 5 V

* r0 should not be negative
2024-10-07 16:03:00 +08:00
atse 873f16a675 ctrl_panel: Code cleanup
* Remove unnecessary duplication of `THERMOSTAT_PARAMETERS`

* i -> ch

* Separate ParameterTree and Parameter initiation

* Remove extra "channel" option to root parameters, as the "value"
option is already the channel number
2024-10-07 16:03:00 +08:00
atse 85c8c23f2c ctrl_panel: PID Auto Tune -> PID Autotune 2024-10-07 16:02:58 +08:00
atse ac10859f70 ctrl_panel: Stop crushing spinboxes
It might not be the case on some themes, but on the default Qt theme the
spinbox are a bit too short for the containing numbers. See
https://github.com/pyqtgraph/pyqtgraph/issues/701.
2024-10-07 16:02:04 +08:00
atse 4bda62ab41 ctrl_panel: Pin down units for editable fields
User input always has the same order of magnitude, so allowing multiple
siPrefixes would be unwanted complexity. Don't allow them to be changed.

The Parameter option "noUnitEditing" is added to do so by the following
measures:

1. Don't validate for changed siPrefix (if pinned) and suffix, which
avoids their removal.

2. Avoid getting the cursor embedded within the unit.
2024-10-07 16:01:50 +08:00
atse 6df37e31aa ctrl_panel: Remove need for "mA" hack
Remove all instances of mA scaling scattered all around the code and
specify it in the parameter tree with a single source of truth.

Done by adding the option "pinSiPrefix" for all Parameters of type `int`
or `float`, and using it for current Parameters with unit "mA".
2024-10-07 16:01:41 +08:00
atse 2df4c03c2d ctrl_panel: Appropriate units for measured current
Allow the readonly display of current to vary its SI prefix in the unit,
since as a display entry it won't have the unit adjustment problem.
2024-10-07 16:01:31 +08:00
atse 2b9bc020ce ctrl_panel: Improve postfilter description 2024-10-07 16:01:31 +08:00
atse dafde57e23 ctrl_panel: Add and improve tooltips and titles
For users' better understanding of what the parameters do
2024-10-07 16:01:29 +08:00
atse 6a38f9b5a6 ctrl_panel: Refer to Parameters by concise `name`s
For displayed string representations, use the `title` key, or for
`ListParameter`s, use the dictionary mapping method instead.
2024-10-07 16:01:21 +08:00
atse 9af4ffd125 ctrl_panel: Config -> Settings 2024-10-07 16:01:13 +08:00
atse 55a7583867 Format JSON 2024-10-07 16:01:11 +08:00
linuswck 19c3c7a8f2 Merge pull request 'GUI: Refactor `send_command`' (#4) from gui_dev-refactor-send_command into gui_dev
Reviewed-on: linuswck/thermostat#4
2024-10-07 13:03:38 +08:00
atse 41abad7aa3 send_command: Remove "activater"
Interpret commands anomalies directly in send_command instead
2024-08-14 16:07:15 +08:00
atse 5c8d9c7cce send_command: Simplify "pid_autotune" parameters 2024-08-14 16:07:15 +08:00
atse 278898fad2 send_command: Switch to thermostat:set_param
Use a `dict` to map values to thermostat parameters, which correspond to
the `set_param` parameters in the pytec client. New tag
"thermostat:set_param" used in JSON.
2024-08-14 16:07:14 +08:00
atse dd83daa5d9 send_command: Remove indirect path to parameter
The child at inner_param's childpath to the root parameter... is just
inner_param itself.
2024-08-14 16:06:31 +08:00
atse d57cc9ef2a send_command: Alias `data` as `new_value` 2024-08-14 16:06:24 +08:00
atse be77a6f205 send_command: Use `in` syntax 2024-08-14 16:06:17 +08:00
linuswck b768d61e39 Merge pull request 'GUI: Text changes' (#3) from gui_dev-str_changes into gui_dev
Reviewed-on: linuswck/thermostat#3
2024-07-19 15:36:01 +08:00
atse d244ba392a Fix typos 2024-07-19 15:34:31 +08:00
linuswck 93d6df5e92 Merge pull request 'GUI: Some repo organisation' (#1) from gui_dev-repo_org into gui_dev
Reviewed-on: linuswck/thermostat#1
2024-07-12 10:34:23 +08:00
atse 44bea87f03 Thermostat.disconnect -> Thermostat.end_session
QObject already has a disconnect method, avoid overriding it.
2024-07-10 15:56:43 +08:00
atse e6f62e9e19 flake: sha256 -> hash 2024-07-10 15:56:43 +08:00
atse 271fe449ba Remove duplicated show call
MainWindow.show() already called in coro_main
2024-07-10 15:56:43 +08:00
atse 70db0a39eb Remove duplicated antialias config option
Already set in live_plot_view.py
2024-07-10 15:56:43 +08:00
atse 26c7382b1e Move GUI components and examples into folder
For better organisation
2024-07-10 15:56:43 +08:00
atse c415d9de8a Use MANIFEST.in
Allows for more accurate control over included files in pytec package
2024-07-10 15:56:43 +08:00
atse 7069111e21 Expose frontend scripts exclusively in pytec 2024-07-10 15:56:43 +08:00
atse 1707728c3c thermostat_data_model.py -> thermostat.py 2024-07-10 15:56:34 +08:00
atse a16d2e9a9e Follow CapWords convention for class names
Re: PEP8
2024-07-10 15:45:03 +08:00
atse bc4ac43e0b Put comments in right place 2024-07-10 13:07:31 +08:00
linuswck 9acff86547 Restructure GUI Code, Improve and Fix Bugs
- Bugs fix:
1. Params Tree user input will not get overwritten
    by incoming report thermostat_data_model.
2. PID Autotune Sampling Period is now set according to Thermostat sampling interval
3. PID Autotune won't get stuck in Fail State
4. Various types disconnection related Bugs
5. Number of Samples stored in the plot cannot be set
6. Limit the max settable output current to be 2000mA

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

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

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

* Add aioclient

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

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

4
.gitignore vendored
View File

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

View File

@ -23,7 +23,7 @@ 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
@ -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
@ -67,7 +67,19 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
```
## Network
## GUI Usage
A GUI has been developed for easy configuration and plotting of key parameters.
The Python GUI program is located at pytec/tec_qt.py.
The GUI is developed based on the Python library pyqtgraph. The GUI can be configured and launched automatically by running:
```
nix run .#thermostat_gui
```
## Command Line Usage
### Connecting
@ -84,7 +96,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
@ -93,15 +107,16 @@ 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] |
| `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 |
|----------------------------------|-------------------------------------------------------------------------------|
| `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 |
@ -111,8 +126,8 @@ formatted as line-delimited JSON.
| `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 |
| `b-p` | Show B-Parameter equation parameters |
| `b-p <0/1> <t0/b/r0> <value>` | Set B-Parameter for a channel |
| `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 |
@ -144,22 +159,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
@ -183,47 +198,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
@ -236,7 +250,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
pwm 0 pid
```
## LED indicators
@ -249,17 +263,17 @@ output 0 pid
## 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 |
| `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 |
| `dac_value` | Volts | AD5680 output derived from `i_set` |
@ -269,7 +283,7 @@ 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).
Note: With Thermostat v2 and below, 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

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/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/autotune.py
python pytec/autotune.py
```
After some time, the auto tuning utility will output the auto tuning results, below is a sample output

View File

@ -1,45 +1,41 @@
{
"nodes": {
"mozilla-overlay": {
"flake": false,
"locked": {
"lastModified": 1690536331,
"narHash": "sha256-aRIf2FB2GTdfF7gl13WyETmiV/J7EhBGkSWXfZvlxcA=",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "db89c8707edcffefcd8e738459d511543a339ff5",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1722791413,
"narHash": "sha256-rCTrlCWvHzMCNcKxPE3Z/mMK2gDZ+BvvpEVyRM4tKmU=",
"lastModified": 1691421349,
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8b5b6723aca5a51edf075936439d9cd3947b7b2c",
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"ref": "nixos-23.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1719281921,
"narHash": "sha256-LIBMfhM9pMOlEvBI757GOK5l0R58SRi6YpwfYMbf4yc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b6032d3a404d8a52ecfc8571ff0c26dfbe221d07",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"mozilla-overlay": "mozilla-overlay",
"nixpkgs": "nixpkgs"
}
}
},

126
flake.nix
View File

@ -1,33 +1,32 @@
{
description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
inputs.rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
inputs.nixpkgs.url = github:NixOS/nixpkgs/nixos-23.05;
inputs.mozilla-overlay = { url = github:mozilla/nixpkgs-mozilla; flake = false; };
outputs =
{
self,
nixpkgs,
rust-overlay,
}:
outputs = { self, nixpkgs, mozilla-overlay }:
let
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ (import mozilla-overlay) ]; };
rustManifest = pkgs.fetchurl {
url = "https://static.rust-lang.org/dist/2022-12-15/channel-rust-stable.toml";
hash = "sha256-S7epLlflwt0d1GZP44u5Xosgf6dRrmr8xxC+Ml2Pq7c=";
};
rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = [ "rust-src" ];
targets = [ "thumbv7em-none-eabihf" ];
targets = [
"thumbv7em-none-eabihf"
];
rustChannelOfTargets = _channel: _date: targets:
(pkgs.lib.rustLib.fromManifestFile rustManifest {
inherit (pkgs) stdenv lib fetchurl patchelf;
}).rust.override {
inherit targets;
extensions = ["rust-src"];
};
rustPlatform = pkgs.makeRustPlatform {
rust = rustChannelOfTargets "stable" null targets;
rustPlatform = pkgs.recurseIntoAttrs (pkgs.makeRustPlatform {
rustc = rust;
cargo = rust;
};
});
thermostat = rustPlatform.buildRustPackage {
name = "thermostat";
version = "0.0.0";
@ -55,48 +54,79 @@
'';
dontFixup = true;
auditable = false;
};
pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pythermostat";
qasync = pkgs.python3Packages.buildPythonPackage rec {
pname = "qasync";
version = "0.27.1";
format = "pyproject";
src = pkgs.fetchPypi {
inherit pname version;
hash = "sha256-jcdo/R7l3hBEx8MF7M8tOdJNh4A+pxGJ1AJPtHX0mF8=";
};
buildInputs = [ pkgs.python3Packages.poetry-core ];
propagatedBuildInputs = [ pkgs.python3Packages.pyqt6 ];
};
pyqtgraph = pkgs.python3Packages.buildPythonPackage rec {
pname = "pyqtgraph";
version = "0.13.3";
format = "pyproject";
src = pkgs.fetchPypi {
inherit pname version;
hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
};
propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
};
pglive = pkgs.python3Packages.buildPythonPackage rec {
pname = "pglive";
version = "0.7.2";
format = "pyproject";
src = pkgs.fetchPypi {
inherit pname version;
hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
};
buildInputs = [ pkgs.python3Packages.poetry-core ];
propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
};
thermostat_gui = pkgs.python3Packages.buildPythonPackage {
pname = "thermostat_gui";
version = "0.0.0";
src = "${self}/pythermostat";
format = "pyproject";
src = "${self}/pytec";
propagatedBuildInputs =
with pkgs.python3Packages; [
numpy
matplotlib
];
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = [ pkgs.qt6.qtbase ] ++ (with pkgs.python3Packages; [ pyqtgraph pyqt6 qasync pglive ]);
dontWrapQtApps = true;
postFixup = ''
wrapQtApp "$out/bin/tec_qt"
'';
};
in
{
in {
packages.x86_64-linux = {
inherit thermostat pythermostat;
default = thermostat;
inherit thermostat thermostat_gui;
};
apps.x86_64-linux.thermostat_gui = {
type = "app";
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt";
};
hydraJobs = {
inherit thermostat;
};
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
devShell.x86_64-linux = pkgs.mkShell {
name = "thermostat-dev-shell";
packages =
with pkgs;
[
rust
llvm
openocd
dfu-util
rlwrap
]
++ (with python3Packages; [
numpy
matplotlib
buildInputs = with pkgs; [
rust openocd dfu-util
] ++ (with python3Packages; [
numpy matplotlib pyqtgraph setuptools pyqt6 qasync pglive
]);
};
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
defaultPackage.x86_64-linux = thermostat;
};
}

4
pytec/MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
graft examples
include pytec/gui/resources/artiq.ico
include pytec/gui/view/param_tree.json
include pytec/gui/view/tec_qt.ui

View File

@ -1,10 +1,9 @@
import math
import logging
import time
from collections import deque, namedtuple
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
@ -18,6 +17,7 @@ class PIDAutotuneState(Enum):
STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune:
@ -57,6 +57,21 @@ class PIDAutotune:
self._Ku = 0
self._Pu = 0
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
self._out_min = -step
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
self._peak_count = 0
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self):
"""Get the current state."""
return self._state
@ -82,6 +97,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.
@ -96,7 +118,8 @@ class PIDAutotune:
if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED):
or self._state == PIDAutotuneState.STATE_FAILED
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now
@ -200,20 +223,20 @@ class PIDAutotune:
# calculate ultimate gain
self._Ku = 4.0 * self._outputstep / \
(self._induced_amplitude * math.pi)
print('Ku: {0}'.format(self._Ku))
logging.debug('Ku: {0}'.format(self._Ku))
# calculate ultimate period in seconds
period1 = self._peak_timestamps[3] - self._peak_timestamps[1]
period2 = self._peak_timestamps[4] - self._peak_timestamps[2]
self._Pu = 0.5 * (period1 + period2) / 1000.0
print('Pu: {0}'.format(self._Pu))
logging.debug('Pu: {0}'.format(self._Pu))
for rule in self._tuning_rules:
params = self.get_pid_parameters(rule)
print('rule: {0}'.format(rule))
print('Kp: {0}'.format(params.Kp))
print('Ki: {0}'.format(params.Ki))
print('Kd: {0}'.format(params.Kd))
logging.debug('rule: {0}'.format(rule))
logging.debug('Kp: {0}'.format(params.Kp))
logging.debug('Ki: {0}'.format(params.Ki))
logging.debug('Kd: {0}'.format(params.Kd))
return True
return False
@ -237,14 +260,13 @@ def main():
tec = Client()
data = tec.get_report()
data = next(tec.report_mode())
ch = data[channel]
tuner = PIDAutotune(target_temperature, output_step,
lookback, noiseband, ch['interval'])
while True:
data = tec.get_report()
for data in tec.report_mode():
ch = data[channel]
@ -255,11 +277,9 @@ def main():
tuner_out = tuner.output()
tec.set_param("output", channel, "i_set", tuner_out)
tec.set_param("pwm", channel, "i_set", tuner_out)
time.sleep(0.05)
tec.set_param("output", channel, "i_set", 0)
tec.set_param("pwm", channel, "i_set", 0)
if __name__ == "__main__":

View File

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

11
pytec/examples/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)

View File

@ -1,9 +1,8 @@
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
from pytec.client import Client
TIME_WINDOW = 300.0
@ -48,8 +47,7 @@ quit = False
def recv_data(tec):
global last_packet_time
while True:
data = tec.get_report()
for data in tec.report_mode():
ch0 = data[0]
series_lock.acquire()
try:
@ -63,7 +61,6 @@ def recv_data(tec):
if quit:
break
time.sleep(0.05)
thread = Thread(target=recv_data, args=(tec,))
thread.start()

18
pytec/pyproject.toml Normal file
View File

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

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

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

View File

@ -2,7 +2,6 @@ import socket
import json
import logging
class CommandError(Exception):
pass
@ -17,11 +16,11 @@ class Client:
self._socket.close()
def _check_zero_limits(self):
output_report = self.get_output()
for output_channel in output_report:
pwm_report = self.get_pwm()
for pwm_channel in pwm_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"]))
if pwm_channel[limit]["value"] == 0.0:
logging.warning("`{}` limit is set to zero on channel {}".format(limit, pwm_channel["channel"]))
def _read_line(self):
# read more lines
@ -41,6 +40,7 @@ class Client:
line = self._read_line()
response = json.loads(line)
logging.debug(f"{command}: {response}")
if "error" in response:
raise CommandError(response["error"])
return response
@ -51,27 +51,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 +94,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 +112,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,27 +131,24 @@ 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::
tec.set_param("output", 0, "max_v", 2.0)
tec.set_param("pwm", 0, "max_v", 2.0)
tec.set_param("pid", 1, "output_max", 2.5)
tec.set_param("b-p", 0, "t0", 20.0)
tec.set_param("s-h", 0, "t0", 20.0)
tec.set_param("center", 0, "vref")
tec.set_param("postfilter", 1, 21)
@ -168,40 +163,20 @@ 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 {}
self._command("load")
def reset(self):
"""Reset the device"""
self._socket.sendall("reset".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def hw_rev(self):
"""Get Thermostat hardware revision"""
return self._command("hwrev")
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)
def fan(self):
"""Get Thermostat current fan settings"""
return self._command("fan")

View File

@ -0,0 +1,72 @@
from PyQt6.QtCore import QObject, pyqtSlot
from qasync import asyncSlot
from autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject):
def __init__(self, parent, client, num_of_channel):
super().__init__()
self._client = client
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)]
@pyqtSlot(list)
def update_sampling_interval(self, interval):
self.sampling_interval = interval
def set_params(self, params_name, ch, val):
getattr(self, params_name)[ch] = val
def get_state(self, ch):
return self.autotuners[ch].state()
def load_params_and_set_ready(self, ch):
self.autotuners[ch].setParam(
self.target_temp[ch],
self.test_current[ch],
self.temp_swing[ch],
1 / self.sampling_interval[ch],
self.lookback[ch],
)
self.autotuners[ch].setReady()
async def stop_pid_from_running(self, ch):
self.autotuners[ch].setOff()
await self._client.set_param("pwm", ch, "i_set", 0)
@asyncSlot(list)
async def tick(self, report):
for channel_report in report:
# TODO: Skip when PID Autotune or emit error message if NTC is not connected
if channel_report["temperature"] is None:
continue
ch = channel_report["channel"]
match self.autotuners[ch].state():
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self.autotuners[ch].run(
channel_report["temperature"], channel_report["time"]
)
await self._client.set_param(
"pwm", ch, "i_set", self.autotuners[ch].output()
)
case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_tec_pid()
self.autotuners[ch].setOff()
await self._client.set_param("pid", ch, "kp", kp)
await self._client.set_param("pid", ch, "ki", ki)
await self._client.set_param("pid", ch, "kd", kd)
await self._client.set_param("pwm", ch, "pid")
await self._client.set_param(
"pid", ch, "target", self.target_temp[ch]
)
case PIDAutotuneState.STATE_FAILED:
self.autotuners[ch].setOff()
await self._client.set_param("pwm", ch, "i_set", 0)

View File

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

View File

@ -0,0 +1,138 @@
from pytec.aioclient import Client
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta
import asyncio
import logging
class WrappedClient(QObject, Client):
connection_error = pyqtSignal()
async def _read_line(self):
try:
return await super()._read_line()
except (Exception, TimeoutError, asyncio.exceptions.TimeoutError):
logging.error("Client connection error, disconnecting", exc_info=True)
self.connection_error.emit()
class Thermostat(QObject, metaclass=PropertyMeta):
hw_rev = Property(dict)
fan = Property(dict)
thermistor = Property(list)
pid = Property(list)
pwm = Property(list)
postfilter = Property(list)
interval = Property(list)
report = Property(list)
info_box_trigger = pyqtSignal(str, str)
def __init__(self, parent, client, update_s):
self._update_s = update_s
self._client = client
self._watch_task = None
self._report_mode_task = None
self._poll_for_report = True
super().__init__(parent)
async def run(self):
self.task = asyncio.create_task(self.update_params())
while True:
if self.task.done():
if self.task.exception() is not None:
try:
raise self.task.exception()
except (
Exception,
TimeoutError,
asyncio.exceptions.TimeoutError,
):
logging.error(
"Encountered an error while updating parameter tree.",
exc_info=True,
)
_ = self.task.result()
self.task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s)
async def get_hw_rev(self):
self.hw_rev = await self._client.hw_rev()
return self.hw_rev
async def update_params(self):
self.fan = await self._client.get_fan()
self.pwm = await self._client.get_pwm()
if self._poll_for_report:
self.report = await self._client.report()
self.interval = [
self.report[i]["interval"] for i in range(len(self.report))
]
self.pid = await self._client.get_pid()
self.thermistor = await self._client.get_steinhart_hart()
self.postfilter = await self._client.get_postfilter()
def connected(self):
return self._client.connected
def connecting(self):
return self._client.connecting
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@asyncSlot()
async def stop_watching(self):
if self._watch_task is not None:
await self.set_report_mode(False)
self._watch_task.cancel()
self._watch_task = None
self.task.cancel()
self.task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
if enabled:
self._report_mode_task = asyncio.create_task(self.report_mode())
else:
self._client.stop_report_mode()
async def report_mode(self):
async for report in self._client.report_mode():
self.report_update.emit(report)
self.interval = [
self.report[i]["interval"] for i in range(len(self.report))
]
async def end_session(self):
await self._client.end_session()
async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4)
async def get_ipv4(self):
return await self._client.ipv4()
@asyncSlot()
async def save_cfg(self, ch):
await self._client.save_config(ch)
self.info_box_trigger.emit(
"Settings saved", f"Channel {ch} Settings has been saved to flash."
)
@asyncSlot()
async def load_cfg(self, ch):
await self._client.load_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been loaded from flash."
)
async def dfu(self):
await self._client.dfu()
async def reset(self):
await self._client.reset()
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -0,0 +1,56 @@
from PyQt6 import QtWidgets, QtCore
class ConnMenu(QtWidgets.QMenu):
def __init__(self):
super().__init__()
self.setTitle("Connection Settings")
self.host_set_line = QtWidgets.QLineEdit()
self.host_set_line.setMinimumSize(QtCore.QSize(160, 0))
self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215))
self.host_set_line.setMaxLength(15)
self.host_set_line.setClearButtonEnabled(True)
def connect_on_enter_press():
self.connect_btn.click()
self.hide()
self.host_set_line.returnPressed.connect(connect_on_enter_press)
self.host_set_line.setText("192.168.1.26")
self.host_set_line.setPlaceholderText("IP for the Thermostat")
host = QtWidgets.QWidgetAction(self)
host.setDefaultWidget(self.host_set_line)
self.addAction(host)
self.host = host
self.port_set_spin = QtWidgets.QSpinBox()
self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0))
self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215))
self.port_set_spin.setMaximum(65535)
self.port_set_spin.setValue(23)
def connect_only_if_enter_pressed():
if (
not self.port_set_spin.hasFocus()
): # Don't connect if the spinbox only lost focus
return
connect_on_enter_press()
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
port = QtWidgets.QWidgetAction(self)
port.setDefaultWidget(self.port_set_spin)
self.addAction(port)
self.port = port
self.exit_button = QtWidgets.QPushButton()
self.exit_button.setText("Exit GUI")
self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
exit_action = QtWidgets.QWidgetAction(self.exit_button)
exit_action.setDefaultWidget(self.exit_button)
self.addAction(exit_action)
self.exit_action = exit_action

View File

@ -0,0 +1,177 @@
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import (
Parameter,
registerParameterType,
)
import pytec.gui.view.lockable_unit
def set_tree_label_tips(tree):
for item in tree.listAllItems():
p = item.param
if "tip" in p.opts:
item.setToolTip(0, p.opts["tip"])
class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list)
def __init__(
self,
trees_ui,
param_tree,
sigTreeStateChanged_handle,
sigActivated_handles,
parent=None,
):
super().__init__(parent)
self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui)
def _set_value_with_lock(self, value):
if not self.opts.get("lock"):
self.setValue(value)
Parameter.set_value_with_lock = _set_value_with_lock
self.params = [
Parameter.create(
name=f"Thermostat Channel {ch} Parameters",
type="group",
value=ch,
children=param_tree,
)
for ch in range(self.NUM_CHANNELS)
]
for ch, tree in enumerate(self.trees_ui):
tree.setHeaderHidden(True)
tree.setParameters(self.params[ch], showTop=False)
set_tree_label_tips(tree)
for ch, param in enumerate(self.params):
param.sigTreeStateChanged.connect(sigTreeStateChanged_handle)
for handle in sigActivated_handles[ch]:
param.child(*handle[0]).sigActivated.connect(handle[1])
def _indicate_usage(param, control_method="constant_current"):
for item in param.child("i_set").items:
is_constant_current = control_method == "constant_current"
font = item.font(0)
font.setUnderline(is_constant_current)
font.setBold(is_constant_current)
item.setFont(0, font)
for item in param.child("target").items:
is_temperature_pid = control_method == "temperature_pid"
font = item.font(0)
font.setUnderline(is_temperature_pid)
font.setBold(is_temperature_pid)
item.setFont(0, font)
param.child("output", "control_method").sigValueChanged.connect(
_indicate_usage
)
_indicate_usage(param.child("output", "control_method"))
for item in param.child("output", "control_method").items:
font = item.font(0)
font.setBold(True)
item.setFont(0, font)
def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=title)
@pyqtSlot("QVariantList")
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").set_value_with_lock(
settings["parameters"]["kp"]
)
self.params[channel].child("pid", "ki").set_value_with_lock(
settings["parameters"]["ki"]
)
self.params[channel].child("pid", "kd").set_value_with_lock(
settings["parameters"]["kd"]
)
self.params[channel].child(
"pid", "pid_output_clamping", "output_min"
).set_value_with_lock(settings["parameters"]["output_min"])
self.params[channel].child(
"pid", "pid_output_clamping", "output_max"
).set_value_with_lock(settings["parameters"]["output_max"])
self.params[channel].child(
"output", "control_method", "target"
).set_value_with_lock(settings["target"])
@pyqtSlot("QVariantList")
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"
).set_value_with_lock(
"temperature_pid" if settings["pid_engaged"] else "constant_current"
)
self.params[channel].child(
"output", "control_method", "i_set"
).set_value_with_lock(settings["i_set"])
if settings["temperature"] is not None:
self.params[channel].child(
"readings", "temperature"
).set_value_with_lock(settings["temperature"])
if settings["tec_i"] is not None:
self.params[channel].child(
"readings", "tec_i"
).set_value_with_lock(settings["tec_i"])
@pyqtSlot("QVariantList")
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").set_value_with_lock(
sh_param["params"]["t0"] - 273.15
)
self.params[channel].child("thermistor", "r0").set_value_with_lock(
sh_param["params"]["r0"]
)
self.params[channel].child("thermistor", "b").set_value_with_lock(
sh_param["params"]["b"]
)
@pyqtSlot("QVariantList")
def update_pwm(self, pwm_data):
channels_zeroed_limits = [set() for i in range(self.NUM_CHANNELS)]
for pwm_params in pwm_data:
channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child(
"output", "limits", "max_v"
).set_value_with_lock(pwm_params["max_v"]["value"])
self.params[channel].child(
"output", "limits", "max_i_pos"
).set_value_with_lock(pwm_params["max_i_pos"]["value"])
self.params[channel].child(
"output", "limits", "max_i_neg"
).set_value_with_lock(pwm_params["max_i_neg"]["value"])
for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0:
channels_zeroed_limits[channel].add(limit)
self.set_zero_limits_warning_sig.emit(channels_zeroed_limits)
@pyqtSlot("QVariantList")
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("postfilter", "rate").set_value_with_lock(
postfilter_params["rate"]
)

View File

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

View File

@ -0,0 +1,169 @@
from PyQt6.QtCore import QObject, pyqtSlot
from pglive.sources.data_connector import DataConnector
from pglive.kwargs import Axis
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_axis import LiveAxis
from collections import deque
import pyqtgraph as pg
pg.setConfigOptions(antialias=True)
class LiveDataPlotter(QObject):
def __init__(self, live_plots):
super().__init__()
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]))
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

@ -0,0 +1,185 @@
import re
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtGui import QValidator
from pyqtgraph import SpinBox
import pyqtgraph.functions as fn
from pyqtgraph.parametertree import registerParameterItemType
from pyqtgraph.parametertree.parameterTypes import SimpleParameter, NumericParameterItem
# See https://github.com/pyqtgraph/pyqtgraph/issues/3115
fn.FLOAT_REGEX = re.compile(
r"(?P<number>[+-]?((((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)|((?i:nan)|(inf))))\s*"
+ r"((?P<siPrefix>[u" + fn.SI_PREFIXES + r"]?)(?P<suffix>.*))?$"
)
class LockableUnitSpinBox(SpinBox):
"""
Extension of PyQtGraph's SpinBox widget.
Adds:
* The "pinSiPrefix" option, where the siPrefix could be fixed to a
particular scale instead of as determined by its value.
* The "noUnitEditing" option, where the suffix and pinned siPrefix
of the SpinBox text is fixed and uneditable.
"""
def __init__(self, parent=None, value=0.0, **kwargs):
super().__init__(parent, value, **kwargs)
self.lineEdit().cursorPositionChanged.connect(
self._editor_cursor_position_changed
)
def validate(self, strn, pos):
ret, strn, pos = super().validate(strn, pos)
if self.opts.get("noUnitEditing") is True:
suffix = self.opts["suffix"]
pinned_si_prefix = self.opts.get("pinSiPrefix")
suffix_edited = not strn.endswith(suffix)
pinned_si_prefix_edited = (
pinned_si_prefix is not None
and not strn.removesuffix(suffix).endswith(pinned_si_prefix)
)
if suffix_edited or pinned_si_prefix_edited:
ret = QValidator.State.Invalid
return ret, strn, pos
def _editor_cursor_position_changed(self, oldpos, newpos):
# Called on cursor position change
# Skips over the suffix and pinned SI prefix on cursor navigation if option
# noUnitEditing is enabled.
# Modified from the original Qt C++ source,
# QAbstractSpinBox::editorCursorPositionChanged.
# Their suffix is different than our suffix; there's no obvious way to set
# theirs here in the derived class since it is private.
if self.opts.get("noUnitEditing") is True:
edit = self.lineEdit()
if edit.hasSelectedText():
return # Allow for selecting units, for copy-and-paste
pinned_si_prefix = self.opts.get("pinSiPrefix") or ""
unit_len = len(pinned_si_prefix) + len(self.opts["suffix"])
text_len = len(edit.text())
pos = -1
# Cursor in unit
if text_len - unit_len < newpos < text_len:
if oldpos == text_len:
pos = text_len - unit_len
else:
pos = text_len
if pos != -1:
with QSignalBlocker(edit):
edit.setCursorPosition(pos)
def setOpts(self, **opts):
if "pinSiPrefix" in opts:
self.opts["pinSiPrefix"] = opts.pop("pinSiPrefix")
if "noUnitEditing" in opts:
self.opts["noUnitEditing"] = opts.pop("noUnitEditing")
super().setOpts(**opts)
def editingFinishedEvent(self):
# Modified from pyqtgraph.SpinBox.editingFinishedEvent source
new_text = self.lineEdit().text()
if new_text == self.lastText:
return
try:
val = self.interpret()
except Exception:
return
if val is False:
return
if val == self.val:
self.updateText() # still update text so that values are reformatted pretty-like
return
self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like
def formatText(self, prev=None):
"""
In addition to pyqtgraph.SpinBox's formatting, incorporate the
'pinSiPrefix' mechanism, where SI prefixes could be fixed.
"""
# Code modified from the PyQtGraph source
# get the number of decimal places to print
decimals = self.opts['decimals']
suffix = self.opts['suffix']
prefix = self.opts['prefix']
pin_si_prefix = self.opts.get("pinSiPrefix")
# format the string
val = self.value()
if self.opts['siPrefix'] is True:
# SI prefix was requested, so scale the value accordingly
if pin_si_prefix is not None and pin_si_prefix in fn.SI_PREFIX_EXPONENTS:
# fixed scale
s = 10**-fn.SI_PREFIX_EXPONENTS[pin_si_prefix]
p = pin_si_prefix
elif self.val == 0 and prev is not None:
# special case: if it's zero use the previous prefix
(s, p) = fn.siScale(prev)
else:
(s, p) = fn.siScale(val)
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val, 'prefix':prefix}
else:
# no SI prefix /suffix requested; scale is 1
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val, 'prefix':prefix}
parts['prefixGap'] = '' if parts['prefix'] == '' else ' '
parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' '
return self.opts['format'].format(**parts)
class UnitfulNumericParameterItem(NumericParameterItem):
"""
Subclasses PyQtGraph's `NumericParameterItem` and uses
UnitfulSpinBox for editing.
"""
def makeWidget(self):
opts = self.param.opts
t = opts['type']
defs = {
'value': 0, 'min': None, 'max': None,
'step': 1.0, 'dec': False,
'siPrefix': False, 'suffix': '', 'decimals': 3,
'pinSiPrefix': None, 'noUnitEditing': False,
}
if t == 'int':
defs['int'] = True
defs['minStep'] = 1.0
for k in defs:
if k in opts:
defs[k] = opts[k]
if 'limits' in opts:
defs['min'], defs['max'] = opts['limits']
w = LockableUnitSpinBox()
w.setOpts(**defs)
w.sigChanged = w.sigValueChanged
w.sigChanging = w.sigValueChanging
return w
registerParameterItemType(
"float", UnitfulNumericParameterItem, SimpleParameter, override=True
)
registerParameterItemType(
"int", UnitfulNumericParameterItem, SimpleParameter, override=True
)

View File

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

@ -0,0 +1,459 @@
{
"ctrl_panel": [
{
"name": "readings",
"title": "Readings",
"type": "group",
"tip": "Thermostat readings",
"children": [
{
"name": "temperature",
"title": "Temperature",
"type": "float",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"readonly": true,
"tip": "The measured temperature at the thermistor"
},
{
"name": "tec_i",
"title": "Current through TEC",
"type": "float",
"siPrefix": true,
"suffix": "A",
"decimals": 6,
"readonly": true,
"tip": "The measured current through the TEC"
}
]
},
{
"name": "output",
"title": "Output Settings",
"expanded": true,
"type": "group",
"tip": "Settings of the output to the TEC",
"children": [
{
"name": "control_method",
"title": "Control Method",
"type": "list",
"limits": {
"Constant Current": "constant_current",
"Temperature PID": "temperature_pid"
},
"thermostat:set_param": {
"topic": "pwm",
"field": "pid"
},
"tip": "Select control method of output",
"children": [
{
"name": "i_set",
"title": "Set Current",
"type": "float",
"value": 0,
"step": 0.1,
"limits": [
-2,
2
],
"decimals": 6,
"pinSiPrefix": "m",
"suffix": "A",
"siPrefix": true,
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "i_set"
},
"tip": "The set current through TEC",
"lock": false
},
{
"name": "target",
"title": "Setpoint",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-273,
300
],
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "target"
},
"tip": "The temperature setpoint of the TEC",
"lock": false
}
]
},
{
"name": "limits",
"title": "Limits",
"expanded": true,
"type": "group",
"tip": "The limits of output, with the polarity at the front panel as reference",
"children": [
{
"name": "max_i_pos",
"title": "Max Cooling Current",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 6,
"limits": [
0,
2
],
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_i_pos"
},
"tip": "The maximum cooling (+ve) current through the output pins",
"lock": false
},
{
"name": "max_i_neg",
"title": "Max Heating Current",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"limits": [
0,
2
],
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_i_neg"
},
"tip": "The maximum heating (-ve) current through the output pins",
"lock": false
},
{
"name": "max_v",
"title": "Max Absolute Voltage",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 3,
"limits": [
0,
4
],
"suffix": "V",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_v"
},
"tip": "The maximum voltage (in both directions) across the output pins",
"lock": false
}
]
}
]
},
{
"name": "thermistor",
"title": "Thermistor Settings",
"expanded": true,
"type": "group",
"tip": "Parameters for the resistance to temperature conversion with the B-Parameter equation",
"children": [
{
"name": "t0",
"title": "T₀",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-100,
100
],
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "t0"
},
"tip": "The base temperature",
"lock": false
},
{
"name": "r0",
"title": "R₀",
"type": "float",
"value": 10000,
"step": 100,
"min": 0,
"siPrefix": true,
"pinSiPrefix": "k",
"suffix": "Ω",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "r0"
},
"tip": "The resistance of the thermistor at base temperature T₀",
"lock": false
},
{
"name": "b",
"title": "B",
"type": "float",
"value": 3950,
"step": 10,
"suffix": "K",
"noUnitEditing": true,
"decimals": 4,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "b"
},
"tip": "The Beta Parameter",
"lock": false
}
]
},
{
"name": "postfilter",
"title": "ADC Settings",
"type": "group",
"tip": "Settings of the ADC on the SENS input",
"children": [
{
"name": "rate",
"title": "50/60 Hz Rejection Filter",
"type": "list",
"value": 16.67,
"thermostat:set_param": {
"topic": "postfilter",
"field": "rate"
},
"limits": {
"16.67 SPS": 16.67,
"20 SPS": 20.0,
"21.25 SPS": 21.25,
"27 SPS": 27.0,
"Off": null
},
"tip": "Adjust the output data rate of the enhanced 50 Hz & 60 Hz rejection filter\n(Helps avoid mains interference)",
"lock": false
}
]
},
{
"name": "pid",
"title": "PID Settings",
"expanded": true,
"type": "group",
"tip": "Settings of PID parameters and clamping",
"children": [
{
"name": "kp",
"title": "Kp",
"type": "float",
"step": 0.1,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kp"
},
"tip": "Proportional gain",
"lock": false
},
{
"name": "ki",
"title": "Ki",
"type": "float",
"step": 0.1,
"suffix": "Hz",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "ki"
},
"tip": "Integral gain",
"lock": false
},
{
"name": "kd",
"title": "Kd",
"type": "float",
"step": 0.1,
"suffix": "s",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kd"
},
"tip": "Differential gain",
"lock": false
},
{
"name": "pid_output_clamping",
"title": "PID Output Clamping",
"expanded": true,
"type": "group",
"tip": "Clamps PID outputs to specified range\nCould be different than output limits",
"children": [
{
"name": "output_min",
"title": "Minimum",
"type": "float",
"step": 0.1,
"limits": [
-2,
2
],
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_min"
},
"tip": "Minimum PID output",
"lock": false
},
{
"name": "output_max",
"title": "Maximum",
"type": "float",
"step": 0.1,
"limits": [
-2,
2
],
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_max"
},
"tip": "Maximum PID output",
"lock": false
}
]
},
{
"name": "pid_autotune",
"title": "PID Autotune",
"expanded": false,
"type": "group",
"tip": "Automatically tune PID parameters",
"children": [
{
"name": "target_temp",
"title": "Target Temperature",
"type": "float",
"value": 20,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "target_temp",
"tip": "The target temperature to autotune for"
},
{
"name": "test_current",
"title": "Test Current",
"type": "float",
"value": 0,
"decimals": 6,
"step": 0.1,
"limits": [
0,
2
],
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "test_current",
"tip": "The testing current when autotuning"
},
{
"name": "temp_swing",
"title": "Temperature Swing",
"type": "float",
"value": 1.5,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"suffix": "K",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "temp_swing",
"tip": "The temperature swing around the target"
},
{
"name": "lookback",
"title": "Lookback",
"type": "float",
"value": 3.0,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"noUnitEditing": true,
"suffix": "s",
"compactHeight": false,
"pid_autotune": "lookback",
"tip": "Amount of time referenced for tuning"
},
{
"name": "run_pid",
"title": "Run",
"type": "action",
"tip": "Run PID Autotune with above settings"
}
]
}
]
},
{
"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 thermostat"
}
]
}

View File

@ -0,0 +1,21 @@
from PyQt6 import QtWidgets, QtGui
class PlotOptionsMenu(QtWidgets.QMenu):
def __init__(self, max_samples=1000):
super().__init__()
self.setTitle("Plot Settings")
clear = QtGui.QAction("Clear graphs", self)
self.addAction(clear)
self.clear = clear
self.samples_spinbox = QtWidgets.QSpinBox()
self.samples_spinbox.setRange(2, 100000)
self.samples_spinbox.setSuffix(" samples")
self.samples_spinbox.setValue(max_samples)
limit_samples = QtWidgets.QWidgetAction(self)
limit_samples.setDefaultWidget(self.samples_spinbox)
self.addAction(limit_samples)
self.limit_samples = limit_samples

View File

@ -0,0 +1,597 @@
<?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,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="QCheckBox" name="report_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Report</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="report_apply_btn">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<action name="actionReset">
<property name="text">
<string>Reset</string>
</property>
<property name="toolTip">
<string>Reset the Thermostat</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionEnter_DFU_Mode">
<property name="text">
<string>Enter DFU Mode</string>
</property>
<property name="toolTip">
<string>Reset thermostat and enter USB device firmware update (DFU) mode</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionNetwork_Settings">
<property name="text">
<string>Network Settings</string>
</property>
<property name="toolTip">
<string>Configure IPv4 address, netmask length, and optional default gateway</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAbout_Thermostat">
<property name="text">
<string>About Thermostat</string>
</property>
<property name="toolTip">
<string>Show Thermostat hardware revision, and settings related to i</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionLoad_all_configs">
<property name="text">
<string>Load all channel configs from flash</string>
</property>
<property name="toolTip">
<string>Restore configuration for all channels from flash</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSave_all_configs">
<property name="text">
<string>Save all channel configs to flash</string>
</property>
<property name="toolTip">
<string>Save configuration for all channels to flash</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>ParameterTree</class>
<extends>QWidget</extends>
<header>pyqtgraph.parametertree</header>
<container>1</container>
</customwidget>
<customwidget>
<class>LivePlotWidget</class>
<extends>QWidget</extends>
<header>pglive.sources.live_plot_widget</header>
<container>1</container>
</customwidget>
<customwidget>
<class>QtWaitingSpinner</class>
<extends>QWidget</extends>
<header>pytec.gui.view.waitingspinnerwidget</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,145 @@
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, pyqtSlot
class ThermostatCtrlMenu(QtWidgets.QMenu):
fan_set_act = pyqtSignal(int)
fan_auto_set_act = pyqtSignal(int)
connect_act = pyqtSignal()
reset_act = pyqtSignal(bool)
dfu_act = pyqtSignal(bool)
load_cfg_act = pyqtSignal(int)
save_cfg_act = pyqtSignal(int)
net_cfg_act = pyqtSignal(bool)
def __init__(self, style):
super().__init__()
self._style = style
self.setTitle("Thermostat settings")
self.hw_rev_data = dict()
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
self.fan_group.setMinimumSize(QtCore.QSize(40, 0))
self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
self.fan_layout.setSpacing(9)
self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group)
self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0))
self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215))
self.fan_lbl.setBaseSize(QtCore.QSize(40, 0))
self.fan_layout.addWidget(self.fan_lbl)
self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group)
self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0))
self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215))
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
self.fan_power_slider.setRange(1, 100)
self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
self.fan_layout.addWidget(self.fan_power_slider)
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0))
self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215))
self.fan_layout.addWidget(self.fan_auto_box)
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set_act)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
self.fan_lbl.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:")
self.fan_auto_box.setText("Auto")
fan = QtWidgets.QWidgetAction(self)
fan.setDefaultWidget(self.fan_group)
self.addAction(fan)
self.fan = fan
self.actionReset = QtGui.QAction("Reset Thermostat", self)
self.actionReset.triggered.connect(self.reset_act)
self.addAction(self.actionReset)
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act)
self.addAction(self.actionEnter_DFU_Mode)
self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
self.actionnet_settings_input_diag.triggered.connect(self.net_cfg_act)
self.addAction(self.actionnet_settings_input_diag)
@pyqtSlot(bool)
def load(_):
self.load_cfg_act.emit(0)
self.load_cfg_act.emit(1)
loaded = QtWidgets.QMessageBox(self)
loaded.setWindowTitle("Config loaded")
loaded.setText("All channel configs have been loaded from flash.")
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
loaded.show()
self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
self.actionLoad_all_configs.triggered.connect(load)
self.addAction(self.actionLoad_all_configs)
@pyqtSlot(bool)
def save(_):
self.save_cfg_act.emit(0)
self.save_cfg_act.emit(1)
saved = QtWidgets.QMessageBox(self)
saved.setWindowTitle("Config saved")
saved.setText("All channel configs have been saved to flash.")
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
saved.show()
self.actionSave_all_configs = QtGui.QAction("Save Config", self)
self.actionSave_all_configs.triggered.connect(save)
self.addAction(self.actionSave_all_configs)
def about_thermostat():
QtWidgets.QMessageBox.about(
self,
"About Thermostat",
f"""
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
<br>
<h2>Settings:</h2>
Default fan curve:
a = {self.hw_rev_data['settings']['fan_k_a']},
b = {self.hw_rev_data['settings']['fan_k_b']},
c = {self.hw_rev_data['settings']['fan_k_c']}
<br>
Fan PWM range:
{self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
<br>
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
<br>
Fan available: {self.hw_rev_data['settings']['fan_available']}
<br>
Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
""",
)
self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self)
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.addAction(self.actionAbout_Thermostat)
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("QVariantMap")
def hw_rev(self, hw_rev):
self.hw_rev_data = hw_rev
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])

View File

@ -0,0 +1,194 @@
"""
The MIT License (MIT)
Copyright (c) 2012-2014 Alexander Turkin
Copyright (c) 2014 William Hallatt
Copyright (c) 2015 Jacob Dawid
Copyright (c) 2016 Luca Weiss
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import math
from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *
class QtWaitingSpinner(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# WAS IN initialize()
self._color = QColor(Qt.GlobalColor.black)
self._roundness = 100.0
self._minimumTrailOpacity = 3.14159265358979323846
self._trailFadePercentage = 80.0
self._revolutionsPerSecond = 1.57079632679489661923
self._numberOfLines = 20
self._lineLength = 5
self._lineWidth = 2
self._innerRadius = 5
self._currentCounter = 0
self._timer = QTimer(self)
self._timer.timeout.connect(self.rotate)
self.updateSize()
self.updateTimer()
# END initialize()
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def paintEvent(self, QPaintEvent):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
if self._currentCounter >= self._numberOfLines:
self._currentCounter = 0
painter.setPen(Qt.PenStyle.NoPen)
for i in range(0, self._numberOfLines):
painter.save()
painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength)
rotateAngle = float(360 * i) / float(self._numberOfLines)
painter.rotate(rotateAngle)
painter.translate(self._innerRadius, 0)
distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines)
color = self.currentLineColor(distance, self._numberOfLines, self._trailFadePercentage,
self._minimumTrailOpacity, self._color)
painter.setBrush(color)
painter.drawRoundedRect(QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), self._roundness,
self._roundness, Qt.SizeMode.RelativeSize)
painter.restore()
def start(self):
if not self._timer.isActive():
self._timer.start()
self._currentCounter = 0
def stop(self):
if self._timer.isActive():
self._timer.stop()
self._currentCounter = 0
def setNumberOfLines(self, lines):
self._numberOfLines = lines
self._currentCounter = 0
self.updateTimer()
def setLineLength(self, length):
self._lineLength = length
self.updateSize()
def setLineWidth(self, width):
self._lineWidth = width
self.updateSize()
def setInnerRadius(self, radius):
self._innerRadius = radius
self.updateSize()
def color(self):
return self._color
def roundness(self):
return self._roundness
def minimumTrailOpacity(self):
return self._minimumTrailOpacity
def trailFadePercentage(self):
return self._trailFadePercentage
def revolutionsPersSecond(self):
return self._revolutionsPerSecond
def numberOfLines(self):
return self._numberOfLines
def lineLength(self):
return self._lineLength
def lineWidth(self):
return self._lineWidth
def innerRadius(self):
return self._innerRadius
def setRoundness(self, roundness):
self._roundness = max(0.0, min(100.0, roundness))
def setColor(self, color=Qt.GlobalColor.black):
self._color = QColor(color)
def setRevolutionsPerSecond(self, revolutionsPerSecond):
self._revolutionsPerSecond = revolutionsPerSecond
self.updateTimer()
def setTrailFadePercentage(self, trail):
self._trailFadePercentage = trail
def setMinimumTrailOpacity(self, minimumTrailOpacity):
self._minimumTrailOpacity = minimumTrailOpacity
def rotate(self):
self._currentCounter += 1
if self._currentCounter >= self._numberOfLines:
self._currentCounter = 0
self.update()
def updateSize(self):
self.size = (self._innerRadius + self._lineLength) * 2
self.setFixedSize(self.size, self.size)
def updateTimer(self):
self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond)))
def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines):
distance = primary - current
if distance < 0:
distance += totalNrOfLines
return distance
def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput):
color = QColor(colorinput)
if countDistance == 0:
return color
minAlphaF = minOpacity / 100.0
distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0))
if countDistance > distanceThreshold:
color.setAlphaF(minAlphaF)
else:
alphaDiff = color.alphaF() - minAlphaF
gradient = alphaDiff / float(distanceThreshold + 1)
resultAlpha = color.alphaF() - gradient * countDistance
# If alpha is out of bounds, clip it.
resultAlpha = min(1.0, max(0.0, resultAlpha))
color.setAlphaF(resultAlpha)
return color
if __name__ == '__main__':
app = QApplication([])
waiting_spinner = QtWaitingSpinner()
waiting_spinner.show()
waiting_spinner.start()
app.exec()

View File

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

View File

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

427
pytec/tec_qt.py Normal file
View File

@ -0,0 +1,427 @@
from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu
from pytec.gui.view.conn_menu import ConnMenu
from pytec.gui.view.plot_options_menu import PlotOptionsMenu
from pytec.gui.view.live_plot_view import LiveDataPlotter
from pytec.gui.view.ctrl_panel import CtrlPanel
from pytec.gui.view.info_box import InfoBox
from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.model.thermostat import WrappedClient, Thermostat
import json
from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose
import qasync
from pytec.aioclient import StoppedConnecting
import asyncio
import logging
import argparse
from PyQt6 import QtWidgets, QtGui, uic
from PyQt6.QtCore import QSignalBlocker, pyqtSlot
import pyqtgraph as pg
from functools import partial
import importlib.resources
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 IP:port format",
)
parser.add_argument("IP", metavar="ip", default=None, nargs="?")
parser.add_argument("PORT", metavar="port", default=None, nargs="?")
parser.add_argument(
"-l",
"--log",
dest="logLevel",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set the logging level",
)
parser.add_argument(
"-p",
"--param_tree",
default=importlib.resources.files("pytec.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(MainWindow, self).__init__()
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self)
self.hw_rev_data = None
self.info_box = InfoBox()
self.client = WrappedClient(self)
self.client.connection_error.connect(self.bail)
self.thermostat = Thermostat(
self, self.client, self.report_refresh_spin.value()
)
self.autotuners = PIDAutoTuner(self, self.client, 2)
def get_ctrl_panel_config(args):
with open(args.param_tree, "r") as f:
return json.load(f)["ctrl_panel"]
param_tree_sigActivated_handles = [
[
[["save"], partial(self.thermostat.save_cfg, ch)],
[["load"], partial(self.thermostat.load_cfg, ch)],
[
["pid", "pid_autotune", "run_pid"],
partial(self.pid_autotune_request, ch),
],
]
for ch in range(self.NUM_CHANNELS)
]
self.thermostat.info_box_trigger.connect(self.info_box.display_info_box)
self.zero_limits_warning = ZeroLimitsWarningView(
self.style(), self.limits_warning
)
self.ctrl_panel_view = CtrlPanel(
[self.ch0_tree, self.ch1_tree],
get_ctrl_panel_config(args),
self.send_command,
param_tree_sigActivated_handles,
)
self.ctrl_panel_view.set_zero_limits_warning_sig.connect(
self.zero_limits_warning.set_limits_warning
)
self.thermostat.fan_update.connect(self.fan_update)
self.thermostat.report_update.connect(self.ctrl_panel_view.update_report)
self.thermostat.report_update.connect(self.autotuners.tick)
self.thermostat.report_update.connect(self.pid_autotune_handler)
self.thermostat.pid_update.connect(self.ctrl_panel_view.update_pid)
self.thermostat.pwm_update.connect(self.ctrl_panel_view.update_pwm)
self.thermostat.thermistor_update.connect(
self.ctrl_panel_view.update_thermistor
)
self.thermostat.postfilter_update.connect(
self.ctrl_panel_view.update_postfilter
)
self.thermostat.interval_update.connect(
self.autotuners.update_sampling_interval
)
self.report_apply_btn.clicked.connect(
lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
)
self.channel_graphs = LiveDataPlotter(
[
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
for ch in range(self.NUM_CHANNELS)
]
)
self.thermostat.report_update.connect(self.channel_graphs.update_report)
self.thermostat.pid_update.connect(self.channel_graphs.update_pid)
self.plot_options_menu = PlotOptionsMenu()
self.plot_options_menu.clear.triggered.connect(self.clear_graphs)
self.plot_options_menu.samples_spinbox.valueChanged.connect(
self.channel_graphs.set_max_samples
)
self.plot_settings.setMenu(self.plot_options_menu)
self.conn_menu = ConnMenu()
self.connect_btn.setMenu(self.conn_menu)
self.thermostat_ctrl_menu = ThermostatCtrlMenu(self.style())
self.thermostat_ctrl_menu.fan_set_act.connect(self.fan_set_request)
self.thermostat_ctrl_menu.fan_auto_set_act.connect(self.fan_auto_set_request)
self.thermostat_ctrl_menu.reset_act.connect(self.reset_request)
self.thermostat_ctrl_menu.dfu_act.connect(self.dfu_request)
self.thermostat_ctrl_menu.save_cfg_act.connect(self.save_cfg_request)
self.thermostat_ctrl_menu.load_cfg_act.connect(self.load_cfg_request)
self.thermostat_ctrl_menu.net_cfg_act.connect(self.net_settings_request)
self.thermostat.hw_rev_update.connect(self.thermostat_ctrl_menu.hw_rev)
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.loading_spinner.hide()
if args.connect:
if args.IP:
self.host_set_line.setText(args.IP)
if args.PORT:
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
def clear_graphs(self):
self.channel_graphs.clear_graphs()
async def _on_connection_changed(self, result):
self.graph_group.setEnabled(result)
self.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(result)
self.conn_menu.host_set_line.setEnabled(not result)
self.conn_menu.port_set_spin.setEnabled(not result)
self.connect_btn.setText("Disconnect" if result else "Connect")
if result:
self.hw_rev_data = await self.thermostat.get_hw_rev()
logging.debug(self.hw_rev_data)
self._status(self.hw_rev_data)
self.thermostat.start_watching()
else:
self.status_lbl.setText("Disconnected")
self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
self.loading_spinner.stop()
self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.clear_graphs()
self.report_box.setChecked(False)
if not Thermostat.connecting or Thermostat.connected:
for ch in range(self.NUM_CHANNELS):
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
await self.autotuners.stop_pid_from_running(ch)
await self.thermostat.set_report_mode(False)
self.thermostat.stop_watching()
def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText(
f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}"
)
@pyqtSlot("QVariantMap")
def fan_update(self, fan_settings):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.thermostat_ctrl_menu.fan_power_slider):
self.thermostat_ctrl_menu.fan_power_slider.setValue(
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
)
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(fan_settings["auto_mode"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
await self.thermostat.set_report_mode(enabled)
@asyncClose
async def closeEvent(self, event):
try:
await self.bail()
except:
pass
@asyncSlot()
async def on_connect_btn_clicked(self):
host, port = (
self.conn_menu.host_set_line.text(),
self.conn_menu.port_set_spin.value(),
)
try:
if not (self.client.connecting() or self.client.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False)
try:
await self.client.start_session(host=host, port=port, timeout=5)
except StoppedConnecting:
return
await self._on_connection_changed(True)
else:
await self.bail()
# TODO: Remove asyncio.TimeoutError in Python 3.11
except (OSError, TimeoutError, asyncio.TimeoutError):
try:
await self.bail()
except ConnectionResetError:
pass
@asyncSlot()
async def bail(self):
await self._on_connection_changed(False)
await self.client.end_session()
@asyncSlot(object, object)
async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls"""
ch = param.value()
for inner_param, change, data in changes:
if change == "value":
new_value = data
if "thermostat:set_param" in inner_param.opts:
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":
thermostat_param = thermostat_param.copy()
thermostat_param["field"] = "i_set"
new_value = inner_param.child("i_set").value()
case "control_method", "temperature_pid":
new_value = ""
inner_param.setOpts(lock=True)
await self.client.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)
@asyncSlot()
async def pid_autotune_request(self, ch=0):
match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
self.autotuners.load_params_and_set_ready(ch)
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
await self.autotuners.stop_pid_from_running(ch)
# To Update the UI elements
self.pid_autotune_handler([])
@asyncSlot(list)
async def pid_autotune_handler(self, _):
ch_tuning = []
for ch in range(self.NUM_CHANNELS):
match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF:
self.ctrl_panel_view.change_params_title(
ch, ("pid", "pid_autotune", "run_pid"), "Run"
)
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self.ctrl_panel_view.change_params_title(
ch, ("pid", "pid_autotune", "run_pid"), "Stop"
)
ch_tuning.append(ch)
case PIDAutotuneState.STATE_SUCCEEDED:
self.info_box.display_info_box(
"PID Autotune Success",
f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.",
)
self.info_box.show()
case PIDAutotuneState.STATE_FAILED:
self.info_box.display_info_box(
"PID Autotune Failed", f"Channel {ch} PID Autotune has failed."
)
self.info_box.show()
if len(ch_tuning) == 0:
self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
self.loading_spinner.stop()
else:
self.background_task_lbl.setText(
"Autotuning channel {ch}...".format(ch=ch_tuning)
)
self.loading_spinner.start()
self.loading_spinner.show()
@asyncSlot(int)
async def fan_set_request(self, value):
if not self.client.connected():
return
if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
await self.client.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int)
async def fan_auto_set_request(self, enabled):
if not self.client.connected():
return
if enabled:
await self.client.set_fan("auto")
self.fan_update(await self.client.get_fan())
else:
await self.client.set_fan(
self.thermostat_ctrl_menu.fan_power_slider.value()
)
@asyncSlot(int)
async def save_cfg_request(self, ch):
await self.thermostat.save_cfg(str(ch))
@asyncSlot(int)
async def load_cfg_request(self, ch):
await self.thermostat.load_cfg(str(ch))
@asyncSlot(bool)
async def dfu_request(self, _):
await self._on_connection_changed(False)
await self.thermostat.dfu()
@asyncSlot(bool)
async def reset_request(self, _):
await self._on_connection_changed(False)
await self.thermostat.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
@asyncSlot(bool)
async def net_settings_request(self, _):
ipv4 = await self.thermostat.get_ipv4()
self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
@asyncSlot(str)
async def set_net_settings_request(self, ipv4_settings):
await self.thermostat.set_ipv4(ipv4_settings)
await self.thermostat._client.end_session()
await self._on_connection_changed(False)
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("pytec.gui.resources").joinpath("artiq.ico")))
)
main_window = MainWindow(args)
main_window.show()
await app_quit_event.wait()
def main():
qasync.run(coro_main())
if __name__ == "__main__":
main()

View File

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

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 {
@ -25,7 +28,10 @@ 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;
use log::{info, warn};
use stm32f4xx_hal::hal::{blocking::spi::Transfer, digital::v2::OutputPin};
use uom::si::{electric_potential::volt, 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,7 +119,8 @@ 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| {
self.read_reg(&regs::FiltCon { index })
.map(|data| {
if data.enh_filt_en() {
Some(data.enh_filt())
} else {
@ -129,23 +129,22 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
})
}
pub fn set_postfilter(
&mut self,
index: u8,
filter: Option<PostFilter>,
) -> Result<(), SPI::Error> {
self.update_reg(&regs::FiltCon { index }, |data| match 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| {
self.read_reg(&regs::Status)
.map(|status| {
if status.ready() {
Some(status.channel())
} else {
@ -156,7 +155,8 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
/// 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();

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)
}
}
@ -138,8 +141,7 @@ impl fmt::Display for RefSource {
Internal => "internal",
Avdd1MinusAvss => "avdd1-avss",
_ => "<INVALID>",
}
.fmt(fmt)
}.fmt(fmt)
}
}

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,21 +1,24 @@
use crate::{
ad7172, b_parameter as bp,
command_parser::{CenterPoint, Polarity},
config::PwmLimits,
pid,
};
use num_traits::Zero;
use smoltcp::time::{Duration, Instant};
use uom::si::{
electric_current::ampere,
electric_potential::volt,
electrical_resistance::ohm,
f64::{
ElectricCurrent, ElectricPotential, ElectricalResistance, ThermodynamicTemperature, Time,
ElectricPotential,
ElectricCurrent,
ElectricalResistance,
ThermodynamicTemperature,
Time,
},
electric_potential::volt,
electric_current::ampere,
electrical_resistance::ohm,
thermodynamic_temperature::degree_celsius,
time::millisecond,
};
use crate::{
ad7172,
pid,
steinhart_hart as sh,
command_parser::CenterPoint,
};
const R_INNER: f64 = 2.0 * 5100.0;
const VREF_SENS: f64 = 3.3 / 2.0;
@ -29,11 +32,9 @@ pub struct ChannelState {
pub center: CenterPoint,
pub dac_value: ElectricPotential,
pub i_set: ElectricCurrent,
pub pwm_limits: PwmLimits,
pub pid_engaged: bool,
pub pid: pid::Controller,
pub bp: bp::Parameters,
pub polarity: Polarity,
pub sh: sh::Parameters,
}
impl ChannelState {
@ -44,18 +45,12 @@ impl ChannelState {
adc_time: Instant::from_secs(0),
// default: 10 Hz
adc_interval: Duration::from_millis(100),
center: CenterPoint::VRef,
center: CenterPoint::Vref,
dac_value: ElectricPotential::new::<volt>(0.0),
i_set: ElectricCurrent::new::<ampere>(0.0),
pwm_limits: PwmLimits {
max_v: ElectricPotential::zero(),
max_i_pos: ElectricCurrent::zero(),
max_i_neg: ElectricCurrent::zero(),
},
pid_engaged: false,
pid: pid::Controller::new(pid::Parameters::default()),
bp: bp::Parameters::default(),
polarity: Polarity::Normal,
sh: sh::Parameters::default(),
}
}
@ -72,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,7 +96,7 @@ impl ChannelState {
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,29 +1,29 @@
use crate::timer::sleep;
use crate::{
ad5680, ad7172, 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 core::cmp::max_by;
use heapless::{consts::U2, Vec};
use num_traits::Zero;
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},
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},
command_handler::JsonBuffer,
pins::{self, Channel0VRef, Channel1VRef},
steinhart_hart,
};
pub enum PinsAdcReadTarget {
VRef,
VREF,
DacVfb,
ITec,
VTec,
@ -32,28 +32,12 @@ pub enum PinsAdcReadTarget {
pub const CHANNELS: usize = 2;
pub const R_SENSE: f64 = 0.05;
// 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.0,
};
const MAX_TEC_I_DUTY_TO_CURRENT_RATE: ElectricCurrent = ElectricCurrent {
dimension: PhantomData,
units: PhantomData,
value: 1.0 / (10.0 * R_SENSE / 3.3),
};
// as stated in the MAX1968 datasheet
pub const MAX_TEC_I: f64 = 3.0;
// 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>,
@ -71,25 +55,19 @@ 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.calibrate_dac_value(channel);
channels.set_i(channel, ElectricCurrent::new::<ampere>(0.0));
@ -130,10 +108,10 @@ 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 =>
self.adc_read(channel, PinsAdcReadTarget::VREF, 8),
CenterPoint::Override(center_point) =>
ElectricPotential::new::<volt>(center_point.into()),
}
}
@ -143,14 +121,14 @@ impl Channels {
voltage
}
pub fn get_i_set(&mut self, channel: usize) -> ElectricCurrent {
pub fn get_i(&mut self, channel: usize) -> ElectricCurrent {
let i_set = self.channel_state(channel).i_set;
i_set
}
/// 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(),
@ -161,72 +139,69 @@ impl Channels {
}
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 {
// Silently clamp i_set
let i_ceiling = ElectricCurrent::new::<ampere>(MAX_TEC_I);
let i_floor = ElectricCurrent::new::<ampere>(-MAX_TEC_I);
let i_set = i_set.min(i_ceiling).max(i_floor);
let vref_meas = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
let center_point = vref_meas;
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = negate * i_set * 10.0 * r_sense + center_point;
let voltage = i_set * 10.0 * r_sense + center_point;
let voltage = self.set_dac(channel, voltage);
negate * (voltage - center_point) / (10.0 * r_sense)
let i_set = (voltage - center_point) / (10.0 * r_sense);
self.channel_state(channel).i_set = i_set;
i_set
}
/// 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 {
pub fn adc_read(&mut self, channel: usize, adc_read_target: PinsAdcReadTarget, avg_pt: u16) -> ElectricPotential {
let mut sample: u32 = 0;
match channel {
0 => {
sample = match adc_read_target {
PinsAdcReadTarget::VRef => match &self.channel0.vref_pin {
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 += self
.pins_adc
.convert(vref_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
as u32;
}
sample / avg_pt as u32
}
Channel0VRef::Disabled(_) => 2048_u32,
},
Channel0VRef::Disabled(_) => {2048 as 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 += 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 += 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 += self
.pins_adc
.convert(&self.channel0.tec_u_meas_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
as u32;
}
sample / avg_pt as u32
}
@ -236,42 +211,44 @@ impl Channels {
}
1 => {
sample = match adc_read_target {
PinsAdcReadTarget::VRef => match &self.channel1.vref_pin {
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 += self
.pins_adc
.convert(vref_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
as u32;
}
sample / avg_pt as u32
}
Channel1VRef::Disabled(_) => 2048_u32,
},
Channel1VRef::Disabled(_) => {2048 as 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 += 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 += 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 += self
.pins_adc
.convert(&self.channel1.tec_u_meas_pin, stm32f4xx_hal::adc::config::SampleTime::Cycles_480)
as u32;
}
sample / avg_pt as u32
}
@ -279,7 +256,18 @@ impl Channels {
let mv = self.pins_adc.sample_to_millivolts(sample as u16);
ElectricPotential::new::<millivolt>(mv as f64)
}
_ => unreachable!(),
_ => unreachable!()
}
}
pub fn read_dac_feedback_until_stable(&mut self, channel: usize, tolerance: ElectricPotential) -> ElectricPotential {
let mut prev = self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1);
loop {
let current = self.adc_read(channel, PinsAdcReadTarget::DacVfb, 1);
if (current - prev).abs() < tolerance {
return current;
}
prev = current;
}
}
@ -303,13 +291,14 @@ impl Channels {
let samples = 50;
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 => {
@ -320,23 +309,24 @@ 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::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;
}
}
@ -362,114 +352,110 @@ 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).pwm_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).pwm_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).pwm_limits.max_i_neg
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.adc_read(channel, PinsAdcReadTarget::ITec, 16) - self.adc_read(channel, PinsAdcReadTarget::VREF, 16)) / 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.adc_read(channel, PinsAdcReadTarget::VTec, 16) - 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) {
pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let max_v = max_v.min(MAX_TEC_V).max(ElectricPotential::zero());
let duty = (max_v / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
self.channel_state(channel).pwm_limits.max_v = max_v;
(duty * max, max)
}
pub fn set_max_i_pos(
&mut self,
channel: usize,
max_i_pos: ElectricCurrent,
) -> (ElectricCurrent, ElectricCurrent) {
pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let max_i_pos = max_i_pos.min(MAX_TEC_I).max(ElectricCurrent::zero());
let duty = (max_i_pos / MAX_TEC_I_DUTY_TO_CURRENT_RATE).get::<ratio>();
let duty = match self.channel_state(channel).polarity {
Polarity::Normal => self.set_pwm(channel, PwmPin::MaxIPos, duty),
Polarity::Reversed => self.set_pwm(channel, PwmPin::MaxINeg, duty),
};
self.channel_state(channel).pwm_limits.max_i_pos = max_i_pos;
(duty * MAX_TEC_I_DUTY_TO_CURRENT_RATE, max)
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) {
pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let max_i_neg = max_i_neg.min(MAX_TEC_I).max(ElectricCurrent::zero());
let duty = (max_i_neg / MAX_TEC_I_DUTY_TO_CURRENT_RATE).get::<ratio>();
let duty = match self.channel_state(channel).polarity {
Polarity::Normal => self.set_pwm(channel, PwmPin::MaxINeg, duty),
Polarity::Reversed => self.set_pwm(channel, PwmPin::MaxIPos, duty),
};
self.channel_state(channel).pwm_limits.max_i_neg = max_i_neg;
(duty * MAX_TEC_I_DUTY_TO_CURRENT_RATE, max)
}
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);
}
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_set = self.get_i(channel);
let i_tec = self.adc_read(channel, PinsAdcReadTarget::ITec, 16);
let tec_i = self.get_tec_i(channel);
let dac_value = self.get_dac(channel);
@ -481,8 +467,7 @@ 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,
@ -520,31 +505,27 @@ impl Channels {
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
.adc
.get_postfilter(channel as u8)
.unwrap()
let rate = self.adc.get_postfilter(channel as u8).unwrap()
.and_then(|filter| filter.output_rate());
PostFilterSummary { channel, rate }
}
@ -557,26 +538,23 @@ 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()
max_by(self.get_tec_i(0).abs(),
self.get_tec_i(1).abs(),
|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal))
}
}
@ -607,36 +585,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);
// 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",
})
}
}
#[derive(Serialize)]
pub struct OutputSummary {
pub struct PwmSummaryField<T: Serialize> {
value: T,
max: T,
}
impl<T: Serialize> From<(T, T)> for PwmSummaryField<T> {
fn from((value, max): (T, T)) -> Self {
PwmSummaryField { value, max }
}
}
#[derive(Serialize)]
pub struct PwmSummary {
channel: usize,
center: CenterPointJson,
i_set: ElectricCurrent,
max_v: ElectricPotential,
max_i_pos: ElectricCurrent,
max_i_neg: ElectricCurrent,
polarity: PolarityJson,
i_set: PwmSummaryField<ElectricCurrent>,
max_v: PwmSummaryField<ElectricPotential>,
max_i_pos: PwmSummaryField<ElectricCurrent>,
max_i_neg: PwmSummaryField<ElectricCurrent>,
}
#[derive(Serialize)]
@ -646,7 +622,7 @@ pub struct PostFilterSummary {
}
#[derive(Serialize)]
pub struct BParameterSummary {
pub struct SteinhartHartSummary {
channel: usize,
params: b_parameter::Parameters,
params: steinhart_hart::Parameters,
}

View File

@ -1,30 +1,45 @@
use smoltcp::socket::TcpSocket;
use log::{error, warn};
use core::fmt::Write;
use heapless::{consts::U1024, Vec};
use super::{
ad7172,
channels::{Channels, CHANNELS},
net,
command_parser::{
BpParameter, CenterPoint, Command, Ipv4Config, PidParameter, Polarity, PwmPin, ShowCommand,
Ipv4Config,
Command,
ShowCommand,
CenterPoint,
PidParameter,
PwmPin,
ShParameter
},
ad7172,
CHANNEL_CONFIG_KEY,
channels::{
Channels,
CHANNELS
},
config::ChannelConfig,
dfu,
flash_store::FlashStore,
session::Session,
FanCtrl,
hw_rev::HWRev,
net, FanCtrl, CHANNEL_CONFIG_KEY,
};
use core::fmt::Write;
use heapless::{consts::U1024, Vec};
use log::{error, warn};
use smoltcp::socket::TcpSocket;
use uom::si::{
use uom::{
si::{
f64::{
ElectricCurrent,
ElectricPotential,
ElectricalResistance,
ThermodynamicTemperature,
},
electric_current::ampere,
electric_potential::volt,
electrical_resistance::ohm,
f64::{
ElectricCurrent, ElectricPotential, ElectricalResistance, TemperatureInterval,
ThermodynamicTemperature,
},
temperature_interval::kelvin,
thermodynamic_temperature::degree_celsius,
},
};
#[derive(Debug, Clone, PartialEq)]
@ -37,9 +52,9 @@ pub enum Handler {
#[derive(Clone, Debug, PartialEq)]
pub enum Error {
Report,
PostFilterRate,
Flash,
ReportError,
PostFilterRateError,
FlashError
}
pub type JsonBuffer = Vec<u8, U1024>;
@ -51,19 +66,19 @@ fn send_line(socket: &mut TcpSocket, data: &[u8]) -> bool {
// instead of sending incomplete line
warn!(
"TCP socket has only {}/{} needed {}",
send_free + 1,
socket.send_capacity(),
data.len(),
send_free + 1, socket.send_capacity(), data.len(),
);
} else {
match socket.send_slice(data) {
match socket.send_slice(&data) {
Ok(sent) if sent == data.len() => {
let _ = socket.send_slice(b"\n");
// success
return true;
return true
}
Ok(sent) => warn!("sent only {}/{} bytes", sent, data.len()),
Err(e) => error!("error sending line: {:?}", e),
Ok(sent) =>
warn!("sent only {}/{} bytes", sent, data.len()),
Err(e) =>
error!("error sending line: {:?}", e),
}
}
// not success
@ -71,6 +86,17 @@ fn send_line(socket: &mut TcpSocket, data: &[u8]) -> bool {
}
impl Handler {
fn reporting(socket: &mut TcpSocket) -> Result<Handler, Error> {
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn show_report_mode(socket: &mut TcpSocket, session: &Session) -> Result<Handler, Error> {
let _ = writeln!(socket, "{{ \"report\": {:?} }}", session.reporting());
Ok(Handler::Handled)
}
fn show_report(socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
match channels.reports_json() {
Ok(buf) => {
@ -79,7 +105,7 @@ impl Handler {
Err(e) => {
error!("unable to serialize report: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Report);
return Err(Error::ReportError);
}
}
Ok(Handler::Handled)
@ -93,41 +119,41 @@ impl Handler {
Err(e) => {
error!("unable to serialize pid summary: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Report);
return Err(Error::ReportError);
}
}
Ok(Handler::Handled)
}
fn show_pwm(socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
match channels.output_summaries_json() {
match channels.pwm_summaries_json() {
Ok(buf) => {
send_line(socket, &buf);
}
Err(e) => {
error!("unable to serialize pwm summary: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Report);
return Err(Error::ReportError);
}
}
Ok(Handler::Handled)
}
fn show_b_parameter(socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
match channels.b_parameter_summaries_json() {
fn show_steinhart_hart(socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
match channels.steinhart_hart_summaries_json() {
Ok(buf) => {
send_line(socket, &buf);
}
Err(e) => {
error!("unable to serialize b parameter summaries: {:?}", e);
error!("unable to serialize steinhart-hart summaries: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Report);
return Err(Error::ReportError);
}
}
Ok(Handler::Handled)
}
fn show_post_filter(socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
fn show_post_filter (socket: &mut TcpSocket, channels: &mut Channels) -> Result<Handler, Error> {
match channels.postfilter_summaries_json() {
Ok(buf) => {
send_line(socket, &buf);
@ -135,13 +161,13 @@ impl Handler {
Err(e) => {
error!("unable to serialize postfilter summary: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Report);
return Err(Error::ReportError);
}
}
Ok(Handler::Handled)
}
fn show_ipv4(socket: &mut TcpSocket, ipv4_config: &mut Ipv4Config) -> Result<Handler, Error> {
fn show_ipv4 (socket: &mut TcpSocket, ipv4_config: &mut Ipv4Config) -> Result<Handler, Error> {
let (cidr, gateway) = net::split_ipv4_config(ipv4_config.clone());
let _ = write!(socket, "{{\"addr\":\"{}\"", cidr);
gateway.map(|gateway| write!(socket, ",\"gateway\":\"{}\"", gateway));
@ -149,34 +175,13 @@ impl Handler {
Ok(Handler::Handled)
}
fn engage_pid(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
) -> Result<Handler, Error> {
fn engage_pid (socket: &mut TcpSocket, channels: &mut Channels, channel: usize) -> Result<Handler, Error> {
channels.channel_state(channel).pid_engaged = true;
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn set_polarity(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
polarity: Polarity,
) -> Result<Handler, Error> {
channels.set_polarity(channel, polarity);
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn set_pwm(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
pin: PwmPin,
value: f64,
) -> Result<Handler, Error> {
fn set_pwm (socket: &mut TcpSocket, channels: &mut Channels, channel: usize, pin: PwmPin, value: f64) -> Result<Handler, Error> {
match pin {
PwmPin::ISet => {
channels.channel_state(channel).pid_engaged = false;
@ -201,13 +206,8 @@ impl Handler {
Ok(Handler::Handled)
}
fn set_center_point(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
center: CenterPoint,
) -> Result<Handler, Error> {
let i_set = channels.get_i_set(channel);
fn set_center_point(socket: &mut TcpSocket, channels: &mut Channels, channel: usize, center: CenterPoint) -> Result<Handler, Error> {
let i_set = channels.get_i(channel);
let state = channels.channel_state(channel);
state.center = center;
if !state.pid_engaged {
@ -217,91 +217,65 @@ impl Handler {
Ok(Handler::Handled)
}
fn set_pid(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
parameter: PidParameter,
value: f64,
) -> Result<Handler, Error> {
fn set_pid (socket: &mut TcpSocket, channels: &mut Channels, channel: usize, parameter: PidParameter, value: f64) -> Result<Handler, Error> {
let pid = &mut channels.channel_state(channel).pid;
use super::command_parser::PidParameter::*;
match parameter {
Target => pid.target = value,
KP => pid.parameters.kp = value as f32,
KI => pid.update_ki(value as f32),
KD => pid.parameters.kd = value as f32,
OutputMin => pid.parameters.output_min = value as f32,
OutputMax => pid.parameters.output_max = value as f32,
Target =>
pid.target = value,
KP =>
pid.parameters.kp = value as f32,
KI =>
pid.update_ki(value as f32),
KD =>
pid.parameters.kd = value as f32,
OutputMin =>
pid.parameters.output_min = value as f32,
OutputMax =>
pid.parameters.output_max = value as f32,
}
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn set_b_parameter(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
parameter: BpParameter,
value: f64,
) -> Result<Handler, Error> {
let bp = &mut channels.channel_state(channel).bp;
use super::command_parser::BpParameter::*;
fn set_steinhart_hart (socket: &mut TcpSocket, channels: &mut Channels, channel: usize, parameter: ShParameter, value: f64) -> Result<Handler, Error> {
let sh = &mut channels.channel_state(channel).sh;
use super::command_parser::ShParameter::*;
match parameter {
T0 => bp.t0 = ThermodynamicTemperature::new::<degree_celsius>(value),
B => bp.b = TemperatureInterval::new::<kelvin>(value),
R0 => bp.r0 = ElectricalResistance::new::<ohm>(value),
T0 => sh.t0 = ThermodynamicTemperature::new::<degree_celsius>(value),
B => sh.b = value,
R0 => sh.r0 = ElectricalResistance::new::<ohm>(value),
}
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn reset_post_filter(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
) -> Result<Handler, Error> {
fn reset_post_filter (socket: &mut TcpSocket, channels: &mut Channels, channel: usize) -> Result<Handler, Error> {
channels.adc.set_postfilter(channel as u8, None).unwrap();
send_line(socket, b"{}");
Ok(Handler::Handled)
}
fn set_post_filter(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: usize,
rate: f32,
) -> Result<Handler, Error> {
fn set_post_filter (socket: &mut TcpSocket, channels: &mut Channels, channel: usize, rate: f32) -> Result<Handler, Error> {
let filter = ad7172::PostFilter::closest(rate);
match filter {
Some(filter) => {
channels
.adc
.set_postfilter(channel as u8, Some(filter))
.unwrap();
channels.adc.set_postfilter(channel as u8, Some(filter)).unwrap();
send_line(socket, b"{}");
}
None => {
error!("unable to choose postfilter for rate {:.3}", rate);
send_line(
socket,
b"{{\"error\": \"unable to choose postfilter rate\"}}",
);
return Err(Error::PostFilterRate);
send_line(socket, b"{{\"error\": \"unable to choose postfilter rate\"}}");
return Err(Error::PostFilterRateError);
}
}
Ok(Handler::Handled)
}
fn load_channel(
socket: &mut TcpSocket,
channels: &mut Channels,
store: &mut FlashStore,
channel: Option<usize>,
) -> Result<Handler, Error> {
for (c, key) in CHANNEL_CONFIG_KEY.iter().enumerate().take(CHANNELS) {
fn load_channel (socket: &mut TcpSocket, channels: &mut Channels, store: &mut FlashStore, channel: Option<usize>) -> Result<Handler, Error> {
for c in 0..CHANNELS {
if channel.is_none() || channel == Some(c) {
match store.read_value::<ChannelConfig>(key) {
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
Ok(Some(config)) => {
config.apply(channels, c);
send_line(socket, b"{}");
@ -313,7 +287,7 @@ impl Handler {
Err(e) => {
error!("unable to load config from flash: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Flash);
return Err(Error::FlashError);
}
}
}
@ -321,24 +295,19 @@ impl Handler {
Ok(Handler::Handled)
}
fn save_channel(
socket: &mut TcpSocket,
channels: &mut Channels,
channel: Option<usize>,
store: &mut FlashStore,
) -> Result<Handler, Error> {
for (c, key) in CHANNEL_CONFIG_KEY.iter().enumerate().take(CHANNELS) {
fn save_channel (socket: &mut TcpSocket, channels: &mut Channels, channel: Option<usize>, store: &mut FlashStore) -> Result<Handler, Error> {
for c in 0..CHANNELS {
let mut store_value_buf = [0u8; 256];
if channel.is_none() || channel == Some(c) {
let config = ChannelConfig::new(channels, c);
match store.write_value(key, &config, &mut store_value_buf) {
match store.write_value(CHANNEL_CONFIG_KEY[c], &config, &mut store_value_buf) {
Ok(()) => {
send_line(socket, b"{}");
}
Err(e) => {
error!("unable to save channel {} config to flash: {:?}", c, e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
return Err(Error::Flash);
return Err(Error::FlashError);
}
}
}
@ -346,11 +315,7 @@ impl Handler {
Ok(Handler::Handled)
}
fn set_ipv4(
socket: &mut TcpSocket,
store: &mut FlashStore,
config: Ipv4Config,
) -> Result<Handler, Error> {
fn set_ipv4 (socket: &mut TcpSocket, store: &mut FlashStore, config: Ipv4Config) -> Result<Handler, Error> {
let _ = store
.write_value("ipv4", &config, [0; 16])
.map_err(|e| error!("unable to save ipv4 config to flash: {:?}", e));
@ -359,7 +324,7 @@ impl Handler {
Ok(Handler::NewIPV4(new_ipv4_config.unwrap()))
}
fn reset(channels: &mut Channels) -> Result<Handler, Error> {
fn reset (channels: &mut Channels) -> Result<Handler, Error> {
for i in 0..CHANNELS {
channels.power_down(i);
}
@ -367,7 +332,7 @@ impl Handler {
Ok(Handler::Reset)
}
fn dfu(channels: &mut Channels) -> Result<Handler, Error> {
fn dfu (channels: &mut Channels) -> Result<Handler, Error> {
for i in 0..CHANNELS {
channels.power_down(i);
}
@ -378,16 +343,9 @@ impl Handler {
Ok(Handler::Reset)
}
fn set_fan(
socket: &mut TcpSocket,
fan_pwm: u32,
fan_ctrl: &mut FanCtrl,
) -> Result<Handler, Error> {
fn set_fan(socket: &mut TcpSocket, fan_pwm: u32, fan_ctrl: &mut FanCtrl) -> Result<Handler, Error> {
if !fan_ctrl.fan_available() {
send_line(
socket,
b"{ \"warning\": \"this thermostat doesn't have a fan!\" }",
);
send_line(socket, b"{ \"warning\": \"this thermostat doesn't have fan!\" }");
return Ok(Handler::Handled);
}
fan_ctrl.set_auto_mode(false);
@ -409,17 +367,14 @@ impl Handler {
Err(e) => {
error!("unable to serialize fan summary: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
Err(Error::Report)
Err(Error::ReportError)
}
}
}
fn fan_auto(socket: &mut TcpSocket, fan_ctrl: &mut FanCtrl) -> Result<Handler, Error> {
if !fan_ctrl.fan_available() {
send_line(
socket,
b"{ \"warning\": \"this thermostat doesn't have a fan!\" }",
);
send_line(socket, b"{ \"warning\": \"this thermostat doesn't have fan!\" }");
return Ok(Handler::Handled);
}
fan_ctrl.set_auto_mode(true);
@ -431,13 +386,7 @@ impl Handler {
Ok(Handler::Handled)
}
fn fan_curve(
socket: &mut TcpSocket,
fan_ctrl: &mut FanCtrl,
k_a: f32,
k_b: f32,
k_c: f32,
) -> Result<Handler, Error> {
fn fan_curve(socket: &mut TcpSocket, fan_ctrl: &mut FanCtrl, k_a: f32, k_b: f32, k_c: f32) -> Result<Handler, Error> {
fan_ctrl.set_curve(k_a, k_b, k_c);
send_line(socket, b"{}");
Ok(Handler::Handled)
@ -458,69 +407,38 @@ impl Handler {
Err(e) => {
error!("unable to serialize HWRev summary: {:?}", e);
let _ = writeln!(socket, "{{\"error\":\"{:?}\"}}", e);
Err(Error::Report)
Err(Error::ReportError)
}
}
}
pub fn handle_command(
command: Command,
socket: &mut TcpSocket,
channels: &mut Channels,
store: &mut FlashStore,
ipv4_config: &mut Ipv4Config,
fan_ctrl: &mut FanCtrl,
hwrev: HWRev,
) -> Result<Self, Error> {
pub fn handle_command(command: Command, socket: &mut TcpSocket, channels: &mut Channels, session: &Session, store: &mut FlashStore, ipv4_config: &mut Ipv4Config, fan_ctrl: &mut FanCtrl, hwrev: HWRev) -> Result<Self, Error> {
match command {
Command::Quit => Ok(Handler::CloseSocket),
Command::Reporting(_reporting) => Handler::reporting(socket),
Command::Show(ShowCommand::Reporting) => Handler::show_report_mode(socket, session),
Command::Show(ShowCommand::Input) => Handler::show_report(socket, channels),
Command::Show(ShowCommand::Pid) => Handler::show_pid(socket, channels),
Command::Show(ShowCommand::Output) => Handler::show_pwm(socket, channels),
Command::Show(ShowCommand::BParameter) => Handler::show_b_parameter(socket, channels),
Command::Show(ShowCommand::Pwm) => Handler::show_pwm(socket, channels),
Command::Show(ShowCommand::SteinhartHart) => Handler::show_steinhart_hart(socket, channels),
Command::Show(ShowCommand::PostFilter) => Handler::show_post_filter(socket, channels),
Command::Show(ShowCommand::Ipv4) => Handler::show_ipv4(socket, ipv4_config),
Command::OutputPid { channel } => Handler::engage_pid(socket, channels, channel),
Command::OutputPolarity { channel, polarity } => {
Handler::set_polarity(socket, channels, channel, polarity)
}
Command::Output {
channel,
pin,
value,
} => Handler::set_pwm(socket, channels, channel, pin, value),
Command::CenterPoint { channel, center } => {
Handler::set_center_point(socket, channels, channel, center)
}
Command::Pid {
channel,
parameter,
value,
} => Handler::set_pid(socket, channels, channel, parameter, value),
Command::BParameter {
channel,
parameter,
value,
} => Handler::set_b_parameter(socket, channels, channel, parameter, value),
Command::PostFilter {
channel,
rate: None,
} => Handler::reset_post_filter(socket, channels, channel),
Command::PostFilter {
channel,
rate: Some(rate),
} => Handler::set_post_filter(socket, channels, channel, rate),
Command::PwmPid { channel } => Handler::engage_pid(socket, channels, channel),
Command::Pwm { channel, pin, value } => Handler::set_pwm(socket, channels, channel, pin, value),
Command::CenterPoint { channel, center } => Handler::set_center_point(socket, channels, channel, center),
Command::Pid { channel, parameter, value } => Handler::set_pid(socket, channels, channel, parameter, value),
Command::SteinhartHart { channel, parameter, value } => Handler::set_steinhart_hart(socket, channels, channel, parameter, value),
Command::PostFilter { channel, rate: None } => Handler::reset_post_filter(socket, channels, channel),
Command::PostFilter { channel, rate: Some(rate) } => Handler::set_post_filter(socket, channels, channel, rate),
Command::Load { channel } => Handler::load_channel(socket, channels, store, channel),
Command::Save { channel } => Handler::save_channel(socket, channels, channel, store),
Command::Ipv4(config) => Handler::set_ipv4(socket, store, config),
Command::Reset => Handler::reset(channels),
Command::Dfu => Handler::dfu(channels),
Command::FanSet { fan_pwm } => Handler::set_fan(socket, fan_pwm, fan_ctrl),
Command::FanSet {fan_pwm} => Handler::set_fan(socket, fan_pwm, fan_ctrl),
Command::ShowFan => Handler::show_fan(socket, fan_ctrl),
Command::FanAuto => Handler::fan_auto(socket, fan_ctrl),
Command::FanCurve { k_a, k_b, k_c } => {
Handler::fan_curve(socket, fan_ctrl, k_a, k_b, k_c)
}
Command::FanCurve { k_a, k_b, k_c } => Handler::fan_curve(socket, fan_ctrl, k_a, k_b, k_c),
Command::FanCurveDefaults => Handler::fan_defaults(socket, fan_ctrl),
Command::ShowHWRev => Handler::show_hwrev(socket, hwrev),
}

View File

@ -2,20 +2,19 @@ use core::fmt;
use core::num::ParseIntError;
use core::str::{from_utf8, Utf8Error};
use nom::{
IResult,
branch::alt,
bytes::complete::{is_a, tag, take_while1},
character::{
complete::{char, one_of},
is_digit,
},
character::{is_digit, complete::{char, one_of}},
combinator::{complete, map, opt, value},
error::ErrorKind,
multi::{fold_many0, fold_many1},
sequence::preceded,
IResult, Needed,
multi::{fold_many0, fold_many1},
error::ErrorKind,
Needed,
};
use num_traits::{Num, ParseFloatError};
use serde::{Deserialize, Serialize};
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug, PartialEq)]
pub enum Error {
@ -31,9 +30,12 @@ pub enum Error {
impl<'t> From<nom::Err<(&'t [u8], ErrorKind)>> for Error {
fn from(e: nom::Err<(&'t [u8], ErrorKind)>) -> Self {
match e {
nom::Err::Incomplete(_) => Error::Incomplete,
nom::Err::Error((_, e)) => Error::Parser(e),
nom::Err::Failure((_, e)) => Error::Parser(e),
nom::Err::Incomplete(_) =>
Error::Incomplete,
nom::Err::Error((_, e)) =>
Error::Parser(e),
nom::Err::Failure((_, e)) =>
Error::Parser(e),
}
}
}
@ -59,7 +61,8 @@ impl From<ParseFloatError> for Error {
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter) -> Result<(), fmt::Error> {
match self {
Error::Incomplete => "incomplete input".fmt(fmt),
Error::Incomplete =>
"incomplete input".fmt(fmt),
Error::UnexpectedInput(c) => {
"unexpected input: ".fmt(fmt)?;
c.fmt(fmt)
@ -76,7 +79,9 @@ impl fmt::Display for Error {
"parsing int: ".fmt(fmt)?;
(e as &dyn core::fmt::Debug).fmt(fmt)
}
Error::ParseFloat => "parsing float".fmt(fmt),
Error::ParseFloat => {
"parsing float".fmt(fmt)
}
}
}
}
@ -91,9 +96,10 @@ pub struct Ipv4Config {
#[derive(Debug, Clone, PartialEq)]
pub enum ShowCommand {
Input,
Output,
Reporting,
Pwm,
Pid,
BParameter,
SteinhartHart,
PostFilter,
Ipv4,
}
@ -108,9 +114,9 @@ pub enum PidParameter {
OutputMax,
}
/// B-Parameter equation parameter
/// Steinhart-Hart equation parameter
#[derive(Debug, Clone, PartialEq)]
pub enum BpParameter {
pub enum ShParameter {
T0,
B,
R0,
@ -126,16 +132,10 @@ pub enum PwmPin {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CenterPoint {
VRef,
Vref,
Override(f32),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Polarity {
Normal,
Reversed,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Quit,
@ -148,20 +148,17 @@ pub enum Command {
Reset,
Ipv4(Ipv4Config),
Show(ShowCommand),
Reporting(bool),
/// PWM parameter setting
Output {
Pwm {
channel: usize,
pin: PwmPin,
value: f64,
},
/// Enable PID control for `i_set`
OutputPid {
PwmPid {
channel: usize,
},
OutputPolarity {
channel: usize,
polarity: Polarity,
},
CenterPoint {
channel: usize,
center: CenterPoint,
@ -172,9 +169,9 @@ pub enum Command {
parameter: PidParameter,
value: f64,
},
BParameter {
SteinhartHart {
channel: usize,
parameter: BpParameter,
parameter: ShParameter,
value: f64,
},
PostFilter {
@ -183,7 +180,7 @@ pub enum Command {
},
Dfu,
FanSet {
fan_pwm: u32,
fan_pwm: u32
},
FanAuto,
ShowFan,
@ -197,7 +194,12 @@ pub enum Command {
}
fn end(input: &[u8]) -> IResult<&[u8], ()> {
complete(fold_many0(one_of("\r\n\t "), (), |(), _| ()))(input)
complete(
fold_many0(
one_of("\r\n\t "),
(), |(), _| ()
)
)(input)
}
fn whitespace(input: &[u8]) -> IResult<&[u8], ()> {
@ -205,10 +207,14 @@ fn whitespace(input: &[u8]) -> IResult<&[u8], ()> {
}
fn unsigned(input: &[u8]) -> IResult<&[u8], Result<u32, Error>> {
take_while1(is_digit)(input).map(|(input, digits)| {
let result = from_utf8(digits)
take_while1(is_digit)(input)
.map(|(input, digits)| {
let result =
from_utf8(digits)
.map_err(|e| e.into())
.and_then(|digits| digits.parse::<u32>().map_err(|e| e.into()));
.and_then(|digits| u32::from_str_radix(digits, 10)
.map_err(|e| e.into())
);
(input, result)
})
}
@ -216,14 +222,23 @@ fn unsigned(input: &[u8]) -> IResult<&[u8], Result<u32, Error>> {
fn float(input: &[u8]) -> IResult<&[u8], Result<f64, Error>> {
let (input, sign) = opt(is_a("-"))(input)?;
let negative = sign.is_some();
let (input, digits) = take_while1(|c| is_digit(c) || c == b'.')(input)?;
let result = from_utf8(digits)
let (input, digits) = take_while1(|c| is_digit(c) || c == '.' as u8)(input)?;
let result =
from_utf8(digits)
.map_err(|e| e.into())
.and_then(|digits| f64::from_str_radix(digits, 10).map_err(|e| e.into()))
.and_then(|digits| f64::from_str_radix(digits, 10)
.map_err(|e| e.into())
)
.map(|result: f64| if negative { -result } else { result });
Ok((input, result))
}
fn off_on(input: &[u8]) -> IResult<&[u8], bool> {
alt((value(false, tag("off")),
value(true, tag("on"))
))(input)
}
fn channel(input: &[u8]) -> IResult<&[u8], usize> {
map(one_of("01"), |c| (c as usize) - ('0' as usize))(input)
}
@ -231,55 +246,83 @@ fn channel(input: &[u8]) -> IResult<&[u8], usize> {
fn report(input: &[u8]) -> IResult<&[u8], Command> {
preceded(
tag("report"),
alt((
preceded(
whitespace,
preceded(
tag("mode"),
alt((
preceded(
whitespace,
// `report mode <on | off>` - Switch repoting mode
map(off_on, Command::Reporting)
),
// `report mode` - Show current reporting state
value(Command::Show(ShowCommand::Reporting), end)
))
)),
// `report` - Report once
value(Command::Show(ShowCommand::Input), end),
value(Command::Show(ShowCommand::Input), end)
))
)(input)
}
fn pwm_setup(input: &[u8]) -> IResult<&[u8], Result<(PwmPin, f64), Error>> {
let result_with_pin =
|pin: PwmPin| move |result: Result<f64, Error>| result.map(|value| (pin, value));
let result_with_pin = |pin: PwmPin|
move |result: Result<f64, Error>|
result.map(|value| (pin, value));
alt((
map(
preceded(tag("i_set"), preceded(whitespace, float)),
result_with_pin(PwmPin::ISet),
),
map(
preceded(tag("max_i_pos"), preceded(whitespace, float)),
result_with_pin(PwmPin::MaxIPos),
),
map(
preceded(tag("max_i_neg"), preceded(whitespace, float)),
result_with_pin(PwmPin::MaxINeg),
),
map(
preceded(tag("max_v"), preceded(whitespace, float)),
result_with_pin(PwmPin::MaxV),
),
))(input)
}
/// `output <0-1> pid` - Set output to be controlled by PID
fn output_pid(input: &[u8]) -> IResult<&[u8], ()> {
value((), tag("pid"))(input)
}
fn output_polarity(input: &[u8]) -> IResult<&[u8], Polarity> {
preceded(
tag("polarity"),
tag("i_set"),
preceded(
whitespace,
alt((
value(Polarity::Normal, tag("normal")),
value(Polarity::Reversed, tag("reversed")),
)),
float
)
),
result_with_pin(PwmPin::ISet)
),
map(
preceded(
tag("max_i_pos"),
preceded(
whitespace,
float
)
),
result_with_pin(PwmPin::MaxIPos)
),
map(
preceded(
tag("max_i_neg"),
preceded(
whitespace,
float
)
),
result_with_pin(PwmPin::MaxINeg)
),
map(
preceded(
tag("max_v"),
preceded(
whitespace,
float
)
),
result_with_pin(PwmPin::MaxV)
))
)(input)
}
fn output(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("output")(input)?;
/// `pwm <0-1> pid` - Set PWM to be controlled by PID
fn pwm_pid(input: &[u8]) -> IResult<&[u8], ()> {
value((), tag("pid"))(input)
}
fn pwm(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("pwm")(input)?;
alt((
|input| {
let (input, _) = whitespace(input)?;
@ -287,32 +330,23 @@ fn output(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = whitespace(input)?;
let (input, result) = alt((
|input| {
let (input, ()) = output_pid(input)?;
Ok((input, Ok(Command::OutputPid { channel })))
},
|input| {
let (input, polarity) = output_polarity(input)?;
Ok((input, Ok(Command::OutputPolarity { channel, polarity })))
let (input, ()) = pwm_pid(input)?;
Ok((input, Ok(Command::PwmPid { channel })))
},
|input| {
let (input, config) = pwm_setup(input)?;
match config {
Ok((pin, value)) => Ok((
input,
Ok(Command::Output {
channel,
pin,
value,
}),
)),
Err(e) => Ok((input, Err(e))),
Ok((pin, value)) =>
Ok((input, Ok(Command::Pwm { channel, pin, value }))),
Err(e) =>
Ok((input, Err(e))),
}
},
))(input)?;
end(input)?;
Ok((input, result))
},
value(Ok(Command::Show(ShowCommand::Output)), end),
value(Ok(Command::Show(ShowCommand::Pwm)), end)
))(input)
}
@ -321,26 +355,26 @@ fn center_point(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = whitespace(input)?;
let (input, channel) = channel(input)?;
let (input, _) = whitespace(input)?;
let (input, center) = alt((value(Ok(CenterPoint::VRef), tag("vref")), |input| {
let (input, center) = alt((
value(Ok(CenterPoint::Vref), tag("vref")),
|input| {
let (input, value) = float(input)?;
Ok((
input,
value.map(|value| CenterPoint::Override(value as f32)),
))
}))(input)?;
Ok((input, value.map(|value| CenterPoint::Override(value as f32))))
}
))(input)?;
end(input)?;
Ok((
input,
center.map(|center| Command::CenterPoint { channel, center }),
))
Ok((input, center.map(|center| Command::CenterPoint {
channel,
center,
})))
}
/// `pid <0-1> <parameter> <value>`
fn pid_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, channel) = channel(input)?;
let (input, _) = whitespace(input)?;
let (input, parameter) = alt((
value(PidParameter::Target, tag("target")),
let (input, parameter) =
alt((value(PidParameter::Target, tag("target")),
value(PidParameter::KP, tag("kp")),
value(PidParameter::KI, tag("ki")),
value(PidParameter::KD, tag("kd")),
@ -349,11 +383,8 @@ fn pid_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
))(input)?;
let (input, _) = whitespace(input)?;
let (input, value) = float(input)?;
let result = value.map(|value| Command::Pid {
channel,
parameter,
value,
});
let result = value
.map(|value| Command::Pid { channel, parameter, value });
Ok((input, result))
}
@ -361,66 +392,70 @@ fn pid_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
fn pid(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("pid")(input)?;
alt((
preceded(whitespace, pid_parameter),
value(Ok(Command::Show(ShowCommand::Pid)), end),
preceded(
whitespace,
pid_parameter
),
value(Ok(Command::Show(ShowCommand::Pid)), end)
))(input)
}
/// `b-p <0-1> <parameter> <value>`
fn b_parameter_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
/// `s-h <0-1> <parameter> <value>`
fn steinhart_hart_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, channel) = channel(input)?;
let (input, _) = whitespace(input)?;
let (input, parameter) = alt((
value(BpParameter::T0, tag("t0")),
value(BpParameter::B, tag("b")),
value(BpParameter::R0, tag("r0")),
let (input, parameter) =
alt((value(ShParameter::T0, tag("t0")),
value(ShParameter::B, tag("b")),
value(ShParameter::R0, tag("r0"))
))(input)?;
let (input, _) = whitespace(input)?;
let (input, value) = float(input)?;
let result = value.map(|value| Command::BParameter {
channel,
parameter,
value,
});
let result = value
.map(|value| Command::SteinhartHart { channel, parameter, value });
Ok((input, result))
}
/// `b-p` | `b-p <b_parameter_parameter>`
fn b_parameter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("b-p")(input)?;
/// `s-h` | `s-h <steinhart_hart_parameter>`
fn steinhart_hart(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("s-h")(input)?;
alt((
preceded(whitespace, b_parameter_parameter),
value(Ok(Command::Show(ShowCommand::BParameter)), end),
preceded(
whitespace,
steinhart_hart_parameter
),
value(Ok(Command::Show(ShowCommand::SteinhartHart)), end)
))(input)
}
fn postfilter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = tag("postfilter")(input)?;
alt((
preceded(whitespace, |input| {
preceded(
whitespace,
|input| {
let (input, channel) = channel(input)?;
let (input, _) = whitespace(input)?;
alt((
value(
Ok(Command::PostFilter {
value(Ok(Command::PostFilter {
channel,
rate: None,
}),
tag("off"),
),
}), tag("off")),
move |input| {
let (input, _) = tag("rate")(input)?;
let (input, _) = whitespace(input)?;
let (input, rate) = float(input)?;
let result = rate.map(|rate| Command::PostFilter {
let result = rate
.map(|rate| Command::PostFilter {
channel,
rate: Some(rate as f32),
});
Ok((input, result))
},
}
))(input)
}),
value(Ok(Command::Show(ShowCommand::PostFilter)), end),
}
),
value(Ok(Command::Show(ShowCommand::PostFilter)), end)
))(input)
}
@ -433,7 +468,7 @@ fn load(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = end(input)?;
Ok((input, Some(channel)))
},
value(None, end),
value(None, end)
))(input)?;
let result = Ok(Command::Load { channel });
@ -449,7 +484,7 @@ fn save(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, _) = end(input)?;
Ok((input, Some(channel)))
},
value(None, end),
value(None, end)
))(input)?;
let result = Ok(Command::Save { channel });
@ -511,17 +546,12 @@ fn fan(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
},
|input| {
let (input, value) = unsigned(input)?;
Ok((
input,
Ok(Command::FanSet {
fan_pwm: value.unwrap_or(0),
}),
))
Ok((input, Ok(Command::FanSet { fan_pwm: value.unwrap_or(0)})))
},
))(input)?;
Ok((input, result))
},
value(Ok(Command::ShowFan), end),
value(Ok(Command::ShowFan), end)
))(input)
}
@ -541,15 +571,8 @@ fn fan_curve(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
let (input, k_b) = float(input)?;
let (input, _) = whitespace(input)?;
let (input, k_c) = float(input)?;
if let (Ok(k_a), Ok(k_b), Ok(k_c)) = (k_a, k_b, k_c) {
Ok((
input,
Ok(Command::FanCurve {
k_a: k_a as f32,
k_b: k_b as f32,
k_c: k_c as f32,
}),
))
if k_a.is_ok() && k_b.is_ok() && k_c.is_ok() {
Ok((input, Ok(Command::FanCurve { k_a: k_a.unwrap() as f32, k_b: k_b.unwrap() as f32, k_c: k_c.unwrap() as f32 })))
} else {
Err(nom::Err::Incomplete(Needed::Size(3)))
}
@ -557,22 +580,21 @@ fn fan_curve(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
))(input)?;
Ok((input, result))
},
value(Err(Error::Incomplete), end),
value(Err(Error::Incomplete), end)
))(input)
}
fn command(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
alt((
value(Ok(Command::Quit), tag("quit")),
alt((value(Ok(Command::Quit), tag("quit")),
load,
save,
value(Ok(Command::Reset), tag("reset")),
ipv4,
map(report, Ok),
output,
pwm,
center_point,
pid,
b_parameter,
steinhart_hart,
postfilter,
value(Ok(Command::Dfu), tag("dfu")),
fan,
@ -584,9 +606,12 @@ fn command(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
impl Command {
pub fn parse(input: &[u8]) -> Result<Self, Error> {
match command(input) {
Ok((input_remain, result)) if input_remain.is_empty() => result,
Ok((input_remain, _)) => Err(Error::UnexpectedInput(input_remain[0])),
Err(e) => Err(e.into()),
Ok((input_remain, result)) if input_remain.len() == 0 =>
result,
Ok((input_remain, _)) =>
Err(Error::UnexpectedInput(input_remain[0])),
Err(e) =>
Err(e.into()),
}
}
}
@ -634,27 +659,21 @@ mod test {
#[test]
fn parse_ipv4() {
let command = Command::parse(b"ipv4 192.168.1.26/24");
assert_eq!(
command,
Ok(Command::Ipv4(Ipv4Config {
assert_eq!(command, Ok(Command::Ipv4(Ipv4Config {
address: [192, 168, 1, 26],
mask_len: 24,
gateway: None,
}))
);
})));
}
#[test]
fn parse_ipv4_and_gateway() {
let command = Command::parse(b"ipv4 10.42.0.126/8 10.1.0.1");
assert_eq!(
command,
Ok(Command::Ipv4(Ipv4Config {
assert_eq!(command, Ok(Command::Ipv4(Ipv4Config {
address: [10, 42, 0, 126],
mask_len: 8,
gateway: Some([10, 1, 0, 1]),
}))
);
})));
}
#[test]
@ -664,73 +683,69 @@ mod test {
}
#[test]
fn parse_output_i_set() {
let command = Command::parse(b"output 1 i_set 16383");
assert_eq!(
command,
Ok(Command::Output {
fn parse_report_mode() {
let command = Command::parse(b"report mode");
assert_eq!(command, Ok(Command::Show(ShowCommand::Reporting)));
}
#[test]
fn parse_report_mode_on() {
let command = Command::parse(b"report mode on");
assert_eq!(command, Ok(Command::Reporting(true)));
}
#[test]
fn parse_report_mode_off() {
let command = Command::parse(b"report mode off");
assert_eq!(command, Ok(Command::Reporting(false)));
}
#[test]
fn parse_pwm_i_set() {
let command = Command::parse(b"pwm 1 i_set 16383");
assert_eq!(command, Ok(Command::Pwm {
channel: 1,
pin: PwmPin::ISet,
value: 16383.0,
})
);
}));
}
#[test]
fn parse_output_polarity() {
let command = Command::parse(b"output 0 polarity reversed");
assert_eq!(
command,
Ok(Command::OutputPolarity {
fn parse_pwm_pid() {
let command = Command::parse(b"pwm 0 pid");
assert_eq!(command, Ok(Command::PwmPid {
channel: 0,
polarity: Polarity::Reversed,
})
);
}));
}
#[test]
fn parse_output_pid() {
let command = Command::parse(b"output 0 pid");
assert_eq!(command, Ok(Command::OutputPid { channel: 0 }));
}
#[test]
fn parse_output_max_i_pos() {
let command = Command::parse(b"output 0 max_i_pos 7");
assert_eq!(
command,
Ok(Command::Output {
fn parse_pwm_max_i_pos() {
let command = Command::parse(b"pwm 0 max_i_pos 7");
assert_eq!(command, Ok(Command::Pwm {
channel: 0,
pin: PwmPin::MaxIPos,
value: 7.0,
})
);
}));
}
#[test]
fn parse_output_max_i_neg() {
let command = Command::parse(b"output 0 max_i_neg 128");
assert_eq!(
command,
Ok(Command::Output {
fn parse_pwm_max_i_neg() {
let command = Command::parse(b"pwm 0 max_i_neg 128");
assert_eq!(command, Ok(Command::Pwm {
channel: 0,
pin: PwmPin::MaxINeg,
value: 128.0,
})
);
}));
}
#[test]
fn parse_output_max_v() {
let command = Command::parse(b"output 0 max_v 32768");
assert_eq!(
command,
Ok(Command::Output {
fn parse_pwm_max_v() {
let command = Command::parse(b"pwm 0 max_v 32768");
assert_eq!(command, Ok(Command::Pwm {
channel: 0,
pin: PwmPin::MaxV,
value: 32768.0,
})
);
}));
}
#[test]
@ -742,33 +757,27 @@ mod test {
#[test]
fn parse_pid_target() {
let command = Command::parse(b"pid 0 target 36.5");
assert_eq!(
command,
Ok(Command::Pid {
assert_eq!(command, Ok(Command::Pid {
channel: 0,
parameter: PidParameter::Target,
value: 36.5,
})
);
}));
}
#[test]
fn parse_b_parameter() {
let command = Command::parse(b"b-p");
assert_eq!(command, Ok(Command::Show(ShowCommand::BParameter)));
fn parse_steinhart_hart() {
let command = Command::parse(b"s-h");
assert_eq!(command, Ok(Command::Show(ShowCommand::SteinhartHart)));
}
#[test]
fn parse_b_parameter_set() {
let command = Command::parse(b"b-p 1 t0 23.05");
assert_eq!(
command,
Ok(Command::BParameter {
fn parse_steinhart_hart_set() {
let command = Command::parse(b"s-h 1 t0 23.05");
assert_eq!(command, Ok(Command::SteinhartHart {
channel: 1,
parameter: BpParameter::T0,
parameter: ShParameter::T0,
value: 23.05,
})
);
}));
}
#[test]
@ -780,49 +789,37 @@ mod test {
#[test]
fn parse_postfilter_off() {
let command = Command::parse(b"postfilter 1 off");
assert_eq!(
command,
Ok(Command::PostFilter {
assert_eq!(command, Ok(Command::PostFilter {
channel: 1,
rate: None,
})
);
}));
}
#[test]
fn parse_postfilter_rate() {
let command = Command::parse(b"postfilter 0 rate 21");
assert_eq!(
command,
Ok(Command::PostFilter {
assert_eq!(command, Ok(Command::PostFilter {
channel: 0,
rate: Some(21.0),
})
);
}));
}
#[test]
fn parse_center_point() {
let command = Command::parse(b"center 0 1.5");
assert_eq!(
command,
Ok(Command::CenterPoint {
assert_eq!(command, Ok(Command::CenterPoint {
channel: 0,
center: CenterPoint::Override(1.5),
})
);
}));
}
#[test]
fn parse_center_point_vref() {
let command = Command::parse(b"center 1 vref");
assert_eq!(
command,
Ok(Command::CenterPoint {
assert_eq!(command, Ok(Command::CenterPoint {
channel: 1,
center: CenterPoint::VRef,
})
);
center: CenterPoint::Vref,
}));
}
#[test]
@ -834,7 +831,7 @@ mod test {
#[test]
fn parse_fan_set() {
let command = Command::parse(b"fan 42");
assert_eq!(command, Ok(Command::FanSet { fan_pwm: 42 }));
assert_eq!(command, Ok(Command::FanSet {fan_pwm: 42}));
}
#[test]
@ -846,14 +843,11 @@ mod test {
#[test]
fn parse_fcurve_set() {
let command = Command::parse(b"fcurve 1.2 3.4 5.6");
assert_eq!(
command,
Ok(Command::FanCurve {
assert_eq!(command, Ok(Command::FanCurve {
k_a: 1.2,
k_b: 3.4,
k_c: 5.6
})
);
}));
}
#[test]

View File

@ -1,13 +1,16 @@
use serde::{Serialize, Deserialize};
use uom::si::{
electric_potential::volt,
electric_current::ampere,
f64::{ElectricCurrent, ElectricPotential},
};
use crate::{
ad7172::PostFilter,
b_parameter,
channels::Channels,
command_parser::{CenterPoint, Polarity},
command_parser::CenterPoint,
pid,
steinhart_hart,
};
use num_traits::Zero;
use serde::{Deserialize, Serialize};
use uom::si::f64::{ElectricCurrent, ElectricPotential};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChannelConfig {
@ -15,9 +18,7 @@ pub struct ChannelConfig {
pid: pid::Parameters,
pid_target: f32,
pid_engaged: bool,
i_set: ElectricCurrent,
polarity: Polarity,
bp: b_parameter::Parameters,
sh: steinhart_hart::Parameters,
pwm: PwmLimits,
/// uses variant `PostFilter::Invalid` instead of `None` to save space
adc_postfilter: PostFilter,
@ -27,26 +28,17 @@ impl ChannelConfig {
pub fn new(channels: &mut Channels, channel: usize) -> Self {
let pwm = PwmLimits::new(channels, channel);
let adc_postfilter = channels
.adc
.get_postfilter(channel as u8)
let adc_postfilter = channels.adc.get_postfilter(channel as u8)
.unwrap()
.unwrap_or(PostFilter::Invalid);
let state = channels.channel_state(channel);
let i_set = if state.pid_engaged {
ElectricCurrent::zero()
} else {
state.i_set
};
ChannelConfig {
center: state.center.clone(),
pid: state.pid.parameters.clone(),
pid_target: state.pid.target as f32,
pid_engaged: state.pid_engaged,
i_set,
polarity: state.polarity.clone(),
bp: state.bp.clone(),
sh: state.sh.clone(),
pwm,
adc_postfilter,
}
@ -58,7 +50,7 @@ impl ChannelConfig {
state.pid.parameters = self.pid.clone();
state.pid.target = self.pid_target.into();
state.pid_engaged = self.pid_engaged;
state.bp = self.bp.clone();
state.sh = self.sh.clone();
self.pwm.apply(channels, channel);
@ -67,33 +59,31 @@ impl ChannelConfig {
adc_postfilter => Some(adc_postfilter),
};
let _ = channels.adc.set_postfilter(channel as u8, adc_postfilter);
let _ = channels.set_i(channel, self.i_set);
channels.set_polarity(channel, self.polarity.clone());
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PwmLimits {
pub max_v: ElectricPotential,
pub max_i_pos: ElectricCurrent,
pub max_i_neg: ElectricCurrent,
struct PwmLimits {
max_v: f64,
max_i_pos: f64,
max_i_neg: f64,
}
impl PwmLimits {
pub fn new(channels: &mut Channels, channel: usize) -> Self {
let max_v = channels.get_max_v(channel);
let max_i_pos = channels.get_max_i_pos(channel);
let max_i_neg = channels.get_max_i_neg(channel);
let (max_i_pos, _) = channels.get_max_i_pos(channel);
let (max_i_neg, _) = channels.get_max_i_neg(channel);
PwmLimits {
max_v,
max_i_pos,
max_i_neg,
max_v: max_v.get::<volt>(),
max_i_pos: max_i_pos.get::<ampere>(),
max_i_neg: max_i_neg.get::<ampere>(),
}
}
pub fn apply(&self, channels: &mut Channels, channel: usize) {
channels.set_max_v(channel, self.max_v);
channels.set_max_i_pos(channel, self.max_i_pos);
channels.set_max_i_neg(channel, self.max_i_neg);
channels.set_max_v(channel, ElectricPotential::new::<volt>(self.max_v));
channels.set_max_i_pos(channel, ElectricCurrent::new::<ampere>(self.max_i_pos));
channels.set_max_i_neg(channel, ElectricCurrent::new::<ampere>(self.max_i_neg));
}
}

View File

@ -27,7 +27,7 @@ unsafe fn __pre_init() {
rcc.apb2enr.modify(|_, w| w.syscfgen().set_bit());
// Bypass BOOT pins and remap bootloader to 0x00000000
let syscfg = &*SYSCFG::ptr();
let syscfg = &*SYSCFG::ptr() ;
syscfg.memrm.write(|w| w.mem_mode().bits(0b01));
// Impose instruction and memory barriers

View File

@ -1,17 +1,25 @@
use crate::{channels::MAX_TEC_I, command_handler::JsonBuffer, hw_rev::HWSettings};
use num_traits::Float;
use serde::Serialize;
use stm32f4xx_hal::{
pac::TIM8,
pwm::{self, PwmChannels},
pac::TIM8,
};
use uom::si::{
f64::ElectricCurrent,
electric_current::ampere,
};
use crate::{
hw_rev::HWSettings,
command_handler::JsonBuffer,
channels::MAX_TEC_I,
};
use uom::si::{electric_current::ampere, f64::ElectricCurrent};
pub type FanPin = PwmChannels<TIM8, pwm::C4>;
const MAX_USER_FAN_PWM: f32 = 100.0;
const MIN_USER_FAN_PWM: f32 = 1.0;
pub struct FanCtrl {
fan: Option<FanPin>,
fan_auto: bool,
@ -46,11 +54,9 @@ impl FanCtrl {
pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) {
self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32;
if self.fan_auto && self.hw_settings.fan_available {
let scaled_current = self.abs_max_tec_i / MAX_TEC_I.get::<ampere>() as f32;
let scaled_current = self.abs_max_tec_i / MAX_TEC_I as f32;
// do not limit upper bound, as it will be limited in the set_pwm()
let pwm = (MAX_USER_FAN_PWM
* (scaled_current * (scaled_current * self.k_a + self.k_b) + self.k_c))
as u32;
let pwm = (MAX_USER_FAN_PWM * (scaled_current * (scaled_current * self.k_a + self.k_b) + self.k_c)) as u32;
self.set_pwm(pwm);
}
}
@ -83,11 +89,9 @@ impl FanCtrl {
}
pub fn restore_defaults(&mut self) {
self.set_curve(
self.hw_settings.fan_k_a,
self.set_curve(self.hw_settings.fan_k_a,
self.hw_settings.fan_k_b,
self.hw_settings.fan_k_c,
);
self.hw_settings.fan_k_c);
}
pub fn set_pwm(&mut self, fan_pwm: u32) -> f32 {
@ -95,14 +99,8 @@ impl FanCtrl {
return 0f32;
}
let fan = self.fan.as_mut().unwrap();
let fan_pwm = fan_pwm.clamp(MIN_USER_FAN_PWM as u32, MAX_USER_FAN_PWM as u32);
let duty = scale_number(
fan_pwm as f32,
self.hw_settings.min_fan_pwm,
self.hw_settings.max_fan_pwm,
MIN_USER_FAN_PWM,
MAX_USER_FAN_PWM,
);
let fan_pwm = fan_pwm.min(MAX_USER_FAN_PWM as u32).max(MIN_USER_FAN_PWM as u32);
let duty = scale_number(fan_pwm as f32, self.hw_settings.min_fan_pwm, self.hw_settings.max_fan_pwm, MIN_USER_FAN_PWM, MAX_USER_FAN_PWM);
let max = fan.get_max_duty();
let value = ((duty * (max as f32)) as u16).min(max);
fan.set_duty(value);
@ -121,17 +119,8 @@ impl FanCtrl {
if let Some(fan) = &self.fan {
let duty = fan.get_duty();
let max = fan.get_max_duty();
scale_number(
duty as f32 / (max as f32),
MIN_USER_FAN_PWM,
MAX_USER_FAN_PWM,
self.hw_settings.min_fan_pwm,
self.hw_settings.max_fan_pwm,
)
.round() as u32
} else {
0
}
scale_number(duty as f32 / (max as f32), MIN_USER_FAN_PWM, MAX_USER_FAN_PWM, self.hw_settings.min_fan_pwm, self.hw_settings.max_fan_pwm).round() as u32
} else { 0 }
}
fn enable_pwm(&mut self) -> bool {
@ -147,6 +136,7 @@ impl FanCtrl {
}
}
fn scale_number(unscaled: f32, to_min: f32, to_max: f32, from_min: f32, from_max: f32) -> f32 {
(to_max - to_min) * (unscaled - from_min) / (from_max - from_min) + to_min
}

View File

@ -1,9 +1,9 @@
use log::{error, info};
use sfkv::{Store, StoreBackend};
use log::{info, error};
use stm32f4xx_hal::{
flash::{Error, FlashExt},
stm32::FLASH,
};
use sfkv::{Store, StoreBackend};
/// 16 KiB
pub const FLASH_SECTOR_SIZE: usize = 0x4000;
@ -21,7 +21,9 @@ pub struct FlashBackend {
}
fn get_offset() -> usize {
unsafe { (&_config_start as *const usize as usize) - (&_flash_start as *const usize as usize) }
unsafe {
(&_config_start as *const usize as usize) - (&_flash_start as *const usize as usize)
}
}
impl StoreBackend for FlashBackend {
@ -38,8 +40,7 @@ impl StoreBackend for FlashBackend {
}
fn program(&mut self, offset: usize, payload: &[u8]) -> Result<(), Self::Error> {
self.flash
.unlocked()
self.flash.unlocked()
.program(get_offset() + offset, payload.iter())
}
@ -59,8 +60,7 @@ pub fn store(flash: FLASH) -> FlashStore {
Ok(_) => {}
Err(e) => {
error!("corrupt store, erasing. error: {:?}", e);
let _ = store
.erase()
let _ = store.erase()
.map_err(|e| error!("flash erase failed: {:?}", e));
}
}

View File

@ -1,6 +1,9 @@
use serde::Serialize;
use crate::{command_handler::JsonBuffer, pins::HWRevPins};
use crate::{
pins::HWRevPins,
command_handler::JsonBuffer,
};
#[derive(Serialize, Copy, Clone)]
pub struct HWRev {
@ -28,17 +31,13 @@ struct HWSummary<'a> {
impl HWRev {
pub fn detect_hw_rev(hwrev_pins: &HWRevPins) -> Self {
let (h0, h1, h2, h3) = (
hwrev_pins.hwrev0.is_high(),
hwrev_pins.hwrev1.is_high(),
hwrev_pins.hwrev2.is_high(),
hwrev_pins.hwrev3.is_high(),
);
let (h0, h1, h2, h3) = (hwrev_pins.hwrev0.is_high(), hwrev_pins.hwrev1.is_high(),
hwrev_pins.hwrev2.is_high(), hwrev_pins.hwrev3.is_high());
match (h0, h1, h2, h3) {
(true, true, true, false) => HWRev { major: 1, minor: 0 },
(true, false, false, false) => HWRev { major: 2, minor: 0 },
(false, true, false, false) => HWRev { major: 2, minor: 2 },
(_, _, _, _) => HWRev { major: 0, minor: 0 },
(_, _, _, _) => HWRev { major: 0, minor: 0 }
}
}
@ -71,16 +70,13 @@ impl HWRev {
fan_pwm_freq_hz: 0,
fan_available: false,
fan_pwm_recommended: false,
},
}
}
}
pub fn summary(&self) -> Result<JsonBuffer, serde_json_core::ser::Error> {
let settings = self.settings();
let summary = HWSummary {
rev: self,
settings: &settings,
};
let summary = HWSummary { rev: self, settings: &settings };
serde_json_core::to_vec(&summary)
}
}

View File

@ -10,15 +10,17 @@ pub fn init_log() {
#[cfg(feature = "semihosting")]
pub fn init_log() {
use cortex_m_log::log::{init, Logger};
use cortex_m_log::printer::semihosting::{hio::HStdout, InterruptOk};
use log::LevelFilter;
use cortex_m_log::log::{Logger, init};
use cortex_m_log::printer::semihosting::{InterruptOk, hio::HStdout};
static mut LOGGER: Option<Logger<InterruptOk<HStdout>>> = None;
let logger = Logger {
inner: InterruptOk::<_>::stdout().expect("semihosting stdout"),
level: LevelFilter::Info,
};
let logger = unsafe { LOGGER.get_or_insert(logger) };
let logger = unsafe {
LOGGER.get_or_insert(logger)
};
init(logger).expect("set logger");
}

View File

@ -1,6 +1,6 @@
use stm32f4xx_hal::{
gpio::{
gpiod::{PD10, PD11, PD9},
gpiod::{PD9, PD10, PD11},
Output, PushPull,
},
hal::digital::v2::OutputPin,

View File

@ -8,26 +8,30 @@ use panic_halt as _;
#[cfg(all(feature = "semihosting", not(test)))]
use panic_semihosting as _;
use log::{error, info, warn};
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use log::{error, info, warn};
use smoltcp::{socket::TcpSocket, time::Instant, wire::EthernetAddress};
use stm32f4xx_hal::{
hal::watchdog::{Watchdog, WatchdogEnable},
hal::watchdog::{WatchdogEnable, Watchdog},
rcc::RccExt,
stm32::{CorePeripherals, Peripherals, SCB},
time::{MegaHertz, U32Ext},
time::{U32Ext, MegaHertz},
watchdog::IndependentWatchdog,
};
use smoltcp::{
time::Instant,
socket::TcpSocket,
wire::EthernetAddress,
};
mod init_log;
use init_log::init_log;
mod usb;
mod leds;
mod pins;
mod usb;
use pins::Pins;
mod ad5680;
mod ad7172;
mod ad5680;
mod net;
mod server;
use server::Server;
@ -35,18 +39,18 @@ mod session;
use session::{Session, SessionInput};
mod command_parser;
use command_parser::Ipv4Config;
mod b_parameter;
mod channels;
mod pid;
mod timer;
use channels::{Channels, CHANNELS};
mod pid;
mod steinhart_hart;
mod channels;
use channels::{CHANNELS, Channels};
mod channel;
mod channel_state;
mod config;
use config::ChannelConfig;
mod command_handler;
mod dfu;
mod flash_store;
mod dfu;
mod command_handler;
use command_handler::Handler;
mod fan_ctrl;
use fan_ctrl::FanCtrl;
@ -69,19 +73,19 @@ fn send_line(socket: &mut TcpSocket, data: &[u8]) -> bool {
// instead of sending incomplete line
warn!(
"TCP socket has only {}/{} needed {}",
send_free + 1,
socket.send_capacity(),
data.len(),
send_free + 1, socket.send_capacity(), data.len(),
);
} else {
match socket.send_slice(data) {
match socket.send_slice(&data) {
Ok(sent) if sent == data.len() => {
let _ = socket.send_slice(b"\n");
// success
return true;
return true
}
Ok(sent) => warn!("sent only {}/{} bytes", sent, data.len()),
Err(e) => error!("error sending line: {:?}", e),
Ok(sent) =>
warn!("sent only {}/{} bytes", sent, data.len()),
Err(e) =>
error!("error sending line: {:?}", e),
}
}
// not success
@ -100,9 +104,7 @@ fn main() -> ! {
cp.SCB.enable_dcache(&mut cp.CPUID);
let dp = Peripherals::take().unwrap();
let clocks = dp
.RCC
.constrain()
let clocks = dp.RCC.constrain()
.cfgr
.use_hse(HSE)
.sysclk(168.mhz())
@ -118,15 +120,14 @@ fn main() -> ! {
timer::setup(cp.SYST, clocks);
let (pins, mut leds, mut eeprom, eth_pins, usb, fan, hwrev, hw_settings) = Pins::setup(
clocks,
(dp.TIM1, dp.TIM3, dp.TIM8),
(
clocks, dp.TIM1, dp.TIM3, dp.TIM8,
dp.GPIOA, dp.GPIOB, dp.GPIOC, dp.GPIOD, dp.GPIOE, dp.GPIOF, dp.GPIOG,
),
dp.I2C1,
(dp.SPI2, dp.SPI4, dp.SPI5),
dp.SPI2, dp.SPI4, dp.SPI5,
dp.ADC1,
(dp.OTG_FS_GLOBAL, dp.OTG_FS_DEVICE, dp.OTG_FS_PWRCLK),
dp.OTG_FS_GLOBAL,
dp.OTG_FS_DEVICE,
dp.OTG_FS_PWRCLK,
);
leds.r1.on();
@ -138,11 +139,14 @@ fn main() -> ! {
let mut store = flash_store::store(dp.FLASH);
let mut channels = Channels::new(pins);
for (c, key) in CHANNEL_CONFIG_KEY.iter().enumerate().take(CHANNELS) {
match store.read_value::<ChannelConfig>(key) {
Ok(Some(config)) => config.apply(&mut channels, c),
Ok(None) => error!("flash config not found for channel {}", c),
Err(e) => error!("unable to load config {} from flash: {:?}", c, e),
for c in 0..CHANNELS {
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
Ok(Some(config)) =>
config.apply(&mut channels, c),
Ok(None) =>
error!("flash config not found for channel {}", c),
Err(e) =>
error!("unable to load config {} from flash: {:?}", c, e),
}
}
@ -155,9 +159,11 @@ fn main() -> ! {
gateway: None,
};
match store.read_value("ipv4") {
Ok(Some(config)) => ipv4_config = config,
Ok(Some(config)) =>
ipv4_config = config,
Ok(None) => {}
Err(e) => error!("cannot read ipv4 config: {:?}", e),
Err(e) =>
error!("cannot read ipv4 config: {:?}", e),
}
// EEPROM ships with a read-only EUI-48 identifier
@ -166,14 +172,7 @@ fn main() -> ! {
let hwaddr = EthernetAddress(eui48);
info!("EEPROM MAC address: {}", hwaddr);
net::run(
clocks,
dp.ETHERNET_MAC,
dp.ETHERNET_DMA,
eth_pins,
hwaddr,
ipv4_config.clone(),
|iface| {
net::run(clocks, dp.ETHERNET_MAC, dp.ETHERNET_DMA, eth_pins, hwaddr, ipv4_config.clone(), |iface| {
Server::<Session>::run(iface, |server| {
leds.r1.off();
let mut should_reset = false;
@ -181,7 +180,10 @@ fn main() -> ! {
loop {
let mut new_ipv4_config = None;
let instant = Instant::from_millis(i64::from(timer::now()));
channels.poll_adc(instant);
let updated_channel = channels.poll_adc(instant);
if let Some(channel) = updated_channel {
server.for_each(|_, session| session.set_report_pending(channel.into()));
}
fan_ctrl.cycle(channels.current_abs_max_tec_i());
@ -193,14 +195,15 @@ fn main() -> ! {
let instant = Instant::from_millis(i64::from(timer::now()));
cortex_m::interrupt::free(net::clear_pending);
server.poll(instant).unwrap_or_else(|e| {
server.poll(instant)
.unwrap_or_else(|e| {
warn!("poll: {:?}", e);
});
if !should_reset {
if ! should_reset {
// TCP protocol handling
server.for_each(|mut socket, session| {
if !socket.is_active() {
if ! socket.is_active() {
let _ = socket.listen(TCP_PORT);
session.reset();
} else if socket.may_send() && !socket.may_recv() {
@ -213,27 +216,33 @@ fn main() -> ! {
// Do nothing and feed more data to the line reader in the next loop cycle.
Ok(SessionInput::Nothing) => {}
Ok(SessionInput::Command(command)) => {
match Handler::handle_command(
command,
&mut socket,
&mut channels,
&mut store,
&mut ipv4_config,
&mut fan_ctrl,
hwrev,
) {
match Handler::handle_command(command, &mut socket, &mut channels, session, &mut store, &mut ipv4_config, &mut fan_ctrl, hwrev) {
Ok(Handler::NewIPV4(ip)) => new_ipv4_config = Some(ip),
Ok(Handler::Handled) => {}
Ok(Handler::Handled) => {},
Ok(Handler::CloseSocket) => socket.close(),
Ok(Handler::Reset) => should_reset = true,
Err(_) => {}
Err(_) => {},
}
}
Ok(SessionInput::Error(e)) => {
error!("session input: {:?}", e);
send_line(&mut socket, b"{ \"error\": \"invalid input\" }");
}
Err(_) => socket.close(),
Err(_) =>
socket.close(),
}
} else if socket.can_send() {
if let Some(channel) = session.is_report_pending() {
match channels.reports_json() {
Ok(buf) => {
send_line(&mut socket, &buf[..]);
session.mark_report_sent(channel);
}
Err(e) => {
error!("unable to serialize report: {:?}", e);
}
}
}
}
});
@ -254,10 +263,11 @@ fn main() -> ! {
}
// Apply new IPv4 address/gateway
if let Some(config) = new_ipv4_config.take() {
new_ipv4_config.take()
.map(|config| {
server.set_ipv4_config(config.clone());
ipv4_config = config;
};
});
// Update watchdog
wd.feed();
@ -273,8 +283,7 @@ fn main() -> ! {
leds.g4.on();
}
});
},
);
});
unreachable!()
}

View File

@ -1,17 +1,20 @@
//! As there is only one peripheral, supporting data structures are
//! declared once and globally.
use crate::command_parser::Ipv4Config;
use crate::pins::EthernetPins;
use core::cell::RefCell;
use cortex_m::interrupt::{CriticalSection, Mutex};
use smoltcp::iface::{EthernetInterface, EthernetInterfaceBuilder, NeighborCache, Routes};
use smoltcp::wire::{EthernetAddress, Ipv4Address, Ipv4Cidr};
use stm32_eth::{Eth, RingEntry, RxDescriptor, TxDescriptor};
use stm32f4xx_hal::{
pac::{interrupt, Peripherals, ETHERNET_DMA, ETHERNET_MAC},
rcc::Clocks,
pac::{interrupt, Peripherals, ETHERNET_MAC, ETHERNET_DMA},
};
use smoltcp::wire::{EthernetAddress, Ipv4Address, Ipv4Cidr};
use smoltcp::iface::{
EthernetInterfaceBuilder, EthernetInterface,
NeighborCache, Routes,
};
use stm32_eth::{Eth, RingEntry, RxDescriptor, TxDescriptor};
use crate::command_parser::Ipv4Config;
use crate::pins::EthernetPins;
/// Not on the stack so that stack can be placed in CCMRAM (which the
/// ethernet peripheral cannot access)
@ -27,27 +30,27 @@ static NET_PENDING: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));
/// Run callback `f` with ethernet driver and TCP/IP stack
pub fn run<F>(
clocks: Clocks,
ethernet_mac: ETHERNET_MAC,
ethernet_dma: ETHERNET_DMA,
ethernet_mac: ETHERNET_MAC, ethernet_dma: ETHERNET_DMA,
eth_pins: EthernetPins,
ethernet_addr: EthernetAddress,
ipv4_config: Ipv4Config,
f: F,
f: F
) where
F: FnOnce(EthernetInterface<&mut stm32_eth::Eth<'static, 'static>>),
{
let rx_ring = unsafe { RX_RING.get_or_insert(Default::default()) };
let tx_ring = unsafe { TX_RING.get_or_insert(Default::default()) };
let rx_ring = unsafe {
RX_RING.get_or_insert(Default::default())
};
let tx_ring = unsafe {
TX_RING.get_or_insert(Default::default())
};
// Ethernet driver
let mut eth_dev = Eth::new(
ethernet_mac,
ethernet_dma,
&mut rx_ring[..],
&mut tx_ring[..],
ethernet_mac, ethernet_dma,
&mut rx_ring[..], &mut tx_ring[..],
clocks,
eth_pins,
)
.unwrap();
).unwrap();
eth_dev.enable_interrupt();
// IP stack
@ -73,7 +76,8 @@ pub fn run<F>(
#[interrupt]
fn ETH() {
cortex_m::interrupt::free(|cs| {
*NET_PENDING.borrow(cs).borrow_mut() = true;
*NET_PENDING.borrow(cs)
.borrow_mut() = true;
});
let p = unsafe { Peripherals::steal() };
@ -82,13 +86,15 @@ fn ETH() {
/// Has an interrupt occurred since last call to `clear_pending()`?
pub fn is_pending(cs: &CriticalSection) -> bool {
*NET_PENDING.borrow(cs).borrow()
*NET_PENDING.borrow(cs)
.borrow()
}
/// Clear the interrupt pending flag before polling the interface for
/// data.
pub fn clear_pending(cs: &CriticalSection) {
*NET_PENDING.borrow(cs).borrow_mut() = false;
*NET_PENDING.borrow(cs)
.borrow_mut() = false;
}
/// utility for destructuring into smoltcp types

View File

@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize};
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Parameters {
@ -29,22 +29,22 @@ impl Default for Parameters {
#[derive(Clone)]
pub struct Controller {
pub parameters: Parameters,
pub target: f64,
u1: f64,
x1: f64,
x2: f64,
pub y1: f64,
pub target : f64,
u1 : f64,
x1 : f64,
x2 : f64,
pub y1 : f64,
}
impl Controller {
pub const fn new(parameters: Parameters) -> Controller {
Controller {
parameters,
target: 0.0,
u1: 0.0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
parameters: parameters,
target : 0.0,
u1 : 0.0,
x1 : 0.0,
x2 : 0.0,
y1 : 0.0,
}
}
@ -54,12 +54,15 @@ impl Controller {
// + x0 * (kp + ki + kd)
// - x1 * (kp + 2kd)
// + x2 * kd
// + kp * (u0 - u1)
// y0 = clip(y0', ymin, ymax)
pub fn update(&mut self, input: f64) -> f64 {
let mut output: f64 = self.y1 - self.target * f64::from(self.parameters.ki)
+ input * f64::from(self.parameters.kp + self.parameters.ki + self.parameters.kd)
- self.x1 * f64::from(self.parameters.kp + 2.0 * self.parameters.kd)
+ self.x2 * f64::from(self.parameters.kd);
+ self.x2 * f64::from(self.parameters.kd)
+ f64::from(self.parameters.kp) * (self.target - self.u1);
if output < self.parameters.output_min.into() {
output = self.parameters.output_min.into();
}

View File

@ -1,41 +1,49 @@
use crate::{
channel::{Channel0, Channel1},
fan_ctrl::FanPin,
hw_rev::{HWRev, HWSettings},
leds::Leds,
};
use eeprom24x::{self, Eeprom24x};
use stm32_eth::EthPins;
use stm32f4xx_hal::{
adc::Adc,
gpio::{
gpioa::*, gpiob::*, gpioc::*, gpioe::*, gpiof::*, gpiog::*, Alternate, AlternateOD, Analog,
Floating, GpioExt, Input, Output, PushPull, AF5,
AF5, Alternate, AlternateOD, Analog, Floating, Input,
gpioa::*,
gpiob::*,
gpioc::*,
gpioe::*,
gpiof::*,
gpiog::*,
GpioExt,
Output, PushPull,
},
hal::{self, blocking::spi::Transfer, digital::v2::OutputPin},
i2c::I2c,
otg_fs::USB,
pac::{
ADC1, GPIOA, GPIOB, GPIOC, GPIOD, GPIOE, GPIOF, GPIOG, I2C1, OTG_FS_DEVICE, OTG_FS_GLOBAL,
OTG_FS_PWRCLK, SPI2, SPI4, SPI5, TIM1, TIM3, TIM8,
},
pwm::{self, PwmChannels},
rcc::Clocks,
spi::{NoMiso, Spi, TransferModeNormal},
time::U32Ext,
pwm::{self, PwmChannels},
spi::{Spi, NoMiso, TransferModeNormal},
pac::{
ADC1,
GPIOA, GPIOB, GPIOC, GPIOD, GPIOE, GPIOF, GPIOG,
I2C1,
OTG_FS_GLOBAL, OTG_FS_DEVICE, OTG_FS_PWRCLK,
SPI2, SPI4, SPI5,
TIM1, TIM3, TIM8
},
timer::Timer,
time::U32Ext,
};
use eeprom24x::{self, Eeprom24x};
use stm32_eth::EthPins;
use crate::{
channel::{Channel0, Channel1},
leds::Leds,
fan_ctrl::FanPin,
hw_rev::{HWRev, HWSettings},
};
pub type Eeprom = Eeprom24x<
I2c<
I2C1,
(
I2c<I2C1, (
PB8<AlternateOD<{ stm32f4xx_hal::gpio::AF4 }>>,
PB9<AlternateOD<{ stm32f4xx_hal::gpio::AF4 }>>,
),
>,
PB9<AlternateOD<{ stm32f4xx_hal::gpio::AF4 }>>
)>,
eeprom24x::page_size::B8,
eeprom24x::addr_size::OneByte,
eeprom24x::addr_size::OneByte
>;
pub type EthernetPins = EthPins<
@ -46,14 +54,14 @@ pub type EthernetPins = EthPins<
PB13<Input<Floating>>,
PC4<Input<Floating>>,
PC5<Input<Floating>>,
>;
>;
pub trait ChannelPins {
type DacSpi: Transfer<u8>;
type DacSync: OutputPin;
type Shdn: OutputPin;
type VRefPin;
type ITecPin;
type ItecPin;
type DacFeedbackPin;
type TecUMeasPin;
}
@ -68,7 +76,7 @@ impl ChannelPins for Channel0 {
type DacSync = PE4<Output<PushPull>>;
type Shdn = PE10<Output<PushPull>>;
type VRefPin = Channel0VRef;
type ITecPin = PA6<Analog>;
type ItecPin = PA6<Analog>;
type DacFeedbackPin = PA4<Analog>;
type TecUMeasPin = PC2<Analog>;
}
@ -83,21 +91,13 @@ impl ChannelPins for Channel1 {
type DacSync = PF6<Output<PushPull>>;
type Shdn = PE15<Output<PushPull>>;
type VRefPin = Channel1VRef;
type ITecPin = PB0<Analog>;
type ItecPin = PB0<Analog>;
type DacFeedbackPin = PA5<Analog>;
type TecUMeasPin = PC3<Analog>;
}
/// SPI peripheral used for communication with the ADC
pub type AdcSpi = Spi<
SPI2,
(
PB10<Alternate<AF5>>,
PB14<Alternate<AF5>>,
PB15<Alternate<AF5>>,
),
TransferModeNormal,
>;
pub type AdcSpi = Spi<SPI2, (PB10<Alternate<AF5>>, PB14<Alternate<AF5>>, PB15<Alternate<AF5>>), TransferModeNormal>;
pub type AdcNss = PB12<Output<PushPull>>;
type Dac0Spi = Spi<SPI4, (PE2<Alternate<AF5>>, NoMiso, PE6<Alternate<AF5>>), TransferModeNormal>;
type Dac1Spi = Spi<SPI5, (PF7<Alternate<AF5>>, NoMiso, PF9<Alternate<AF5>>), TransferModeNormal>;
@ -108,7 +108,7 @@ pub struct ChannelPinSet<C: ChannelPins> {
pub dac_sync: C::DacSync,
pub shdn: C::Shdn,
pub vref_pin: C::VRefPin,
pub itec_pin: C::ITecPin,
pub itec_pin: C::ItecPin,
pub dac_feedback_pin: C::DacFeedbackPin,
pub tec_u_meas_pin: C::TecUMeasPin,
}
@ -133,34 +133,13 @@ impl Pins {
/// Setup GPIO pins and configure MCU peripherals
pub fn setup(
clocks: Clocks,
(tim1, tim3, tim8): (TIM1, TIM3, TIM8),
(gpioa, gpiob, gpioc, gpiod, gpioe, gpiof, gpiog): (
GPIOA,
GPIOB,
GPIOC,
GPIOD,
GPIOE,
GPIOF,
GPIOG,
),
tim1: TIM1, tim3: TIM3, tim8: TIM8,
gpioa: GPIOA, gpiob: GPIOB, gpioc: GPIOC, gpiod: GPIOD, gpioe: GPIOE, gpiof: GPIOF, gpiog: GPIOG,
i2c1: I2C1,
(spi2, spi4, spi5): (SPI2, SPI4, SPI5),
spi2: SPI2, spi4: SPI4, spi5: SPI5,
adc1: ADC1,
(otg_fs_global, otg_fs_device, otg_fs_pwrclk): (
OTG_FS_GLOBAL,
OTG_FS_DEVICE,
OTG_FS_PWRCLK,
),
) -> (
Self,
Leds,
Eeprom,
EthernetPins,
USB,
Option<FanPin>,
HWRev,
HWSettings,
) {
otg_fs_global: OTG_FS_GLOBAL, otg_fs_device: OTG_FS_DEVICE, otg_fs_pwrclk: OTG_FS_PWRCLK,
) -> (Self, Leds, Eeprom, EthernetPins, USB, Option<FanPin>, HWRev, HWSettings) {
let gpioa = gpioa.split();
let gpiob = gpiob.split();
let gpioc = gpioc.split();
@ -175,29 +154,23 @@ impl Pins {
let pins_adc = Adc::adc1(adc1, true, Default::default());
let pwm = PwmPins::setup(
clocks,
(tim1, tim3),
(gpioc.pc6, gpioc.pc7),
(gpioe.pe9, gpioe.pe11),
(gpioe.pe13, gpioe.pe14),
clocks, tim1, tim3,
gpioc.pc6, gpioc.pc7,
gpioe.pe9, gpioe.pe11,
gpioe.pe13, gpioe.pe14
);
let hwrev = HWRev::detect_hw_rev(&HWRevPins {
hwrev0: gpiod.pd0,
hwrev1: gpiod.pd1,
hwrev2: gpiod.pd2,
hwrev3: gpiod.pd3,
});
let hwrev = HWRev::detect_hw_rev(&HWRevPins {hwrev0: gpiod.pd0, hwrev1: gpiod.pd1,
hwrev2: gpiod.pd2, hwrev3: gpiod.pd3});
let hw_settings = hwrev.settings();
let (dac0_spi, dac0_sync) = Self::setup_dac0(clocks, spi4, gpioe.pe2, gpioe.pe4, gpioe.pe6);
let (dac0_spi, dac0_sync) = Self::setup_dac0(
clocks, spi4,
gpioe.pe2, gpioe.pe4, gpioe.pe6
);
let mut shdn0 = gpioe.pe10.into_push_pull_output();
shdn0.set_low();
let vref0_pin = if hwrev.major > 2 {
Channel0VRef::Analog(gpioa.pa0.into_analog())
} else {
Channel0VRef::Disabled(gpioa.pa0)
};
let _ = shdn0.set_low();
let vref0_pin = if hwrev.major > 2 {Channel0VRef::Analog(gpioa.pa0.into_analog())} else {Channel0VRef::Disabled(gpioa.pa0)};
let itec0_pin = gpioa.pa6.into_analog();
let dac_feedback0_pin = gpioa.pa4.into_analog();
let tec_u_meas0_pin = gpioc.pc2.into_analog();
@ -211,14 +184,13 @@ impl Pins {
tec_u_meas_pin: tec_u_meas0_pin,
};
let (dac1_spi, dac1_sync) = Self::setup_dac1(clocks, spi5, gpiof.pf7, gpiof.pf6, gpiof.pf9);
let (dac1_spi, dac1_sync) = Self::setup_dac1(
clocks, spi5,
gpiof.pf7, gpiof.pf6, gpiof.pf9
);
let mut shdn1 = gpioe.pe15.into_push_pull_output();
shdn1.set_low();
let vref1_pin = if hwrev.major > 2 {
Channel1VRef::Analog(gpioa.pa3.into_analog())
} else {
Channel1VRef::Disabled(gpioa.pa3)
};
let _ = shdn1.set_low();
let vref1_pin = if hwrev.major > 2 {Channel1VRef::Analog(gpioa.pa3.into_analog())} else {Channel1VRef::Disabled(gpioa.pa3)};
let itec1_pin = gpiob.pb0.into_analog();
let dac_feedback1_pin = gpioa.pa5.into_analog();
let tec_u_meas1_pin = gpioc.pc3.into_analog();
@ -233,19 +205,14 @@ impl Pins {
};
let pins = Pins {
adc_spi,
adc_nss,
adc_spi, adc_nss,
pins_adc,
pwm,
channel0,
channel1,
};
let leds = Leds::new(
gpiod.pd9,
gpiod.pd10.into_push_pull_output(),
gpiod.pd11.into_push_pull_output(),
);
let leds = Leds::new(gpiod.pd9, gpiod.pd10.into_push_pull_output(), gpiod.pd11.into_push_pull_output());
let eeprom_scl = gpiob.pb8.into_alternate().set_open_drain();
let eeprom_sda = gpiob.pb9.into_alternate().set_open_drain();
@ -272,13 +239,8 @@ impl Pins {
};
let fan = if hw_settings.fan_available {
Some(
Timer::new(tim8, &clocks)
.pwm(gpioc.pc9.into_alternate(), hw_settings.fan_pwm_freq_hz.hz()),
)
} else {
None
};
Some(Timer::new(tim8, &clocks).pwm(gpioc.pc9.into_alternate(), hw_settings.fan_pwm_freq_hz.hz()))
} else { None };
(pins, leds, eeprom, eth_pins, usb, fan, hwrev, hw_settings)
}
@ -290,7 +252,8 @@ impl Pins {
sck: PB10<M1>,
miso: PB14<M2>,
mosi: PB15<M3>,
) -> AdcSpi {
) -> AdcSpi
{
let sck = sck.into_alternate();
let miso = miso.into_alternate();
let mosi = mosi.into_alternate();
@ -299,16 +262,13 @@ impl Pins {
(sck, miso, mosi),
crate::ad7172::SPI_MODE,
crate::ad7172::SPI_CLOCK,
clocks,
clocks
)
}
fn setup_dac0<M1, M2, M3>(
clocks: Clocks,
spi4: SPI4,
sclk: PE2<M1>,
sync: PE4<M2>,
sdin: PE6<M3>,
clocks: Clocks, spi4: SPI4,
sclk: PE2<M1>, sync: PE4<M2>, sdin: PE6<M3>
) -> (Dac0Spi, <Channel0 as ChannelPins>::DacSync) {
let sclk = sclk.into_alternate();
let sdin = sdin.into_alternate();
@ -317,7 +277,7 @@ impl Pins {
(sclk, NoMiso {}, sdin),
crate::ad5680::SPI_MODE,
crate::ad5680::SPI_CLOCK,
clocks,
clocks
);
let sync = sync.into_push_pull_output();
@ -325,11 +285,8 @@ impl Pins {
}
fn setup_dac1<M1, M2, M3>(
clocks: Clocks,
spi5: SPI5,
sclk: PF7<M1>,
sync: PF6<M2>,
sdin: PF9<M3>,
clocks: Clocks, spi5: SPI5,
sclk: PF7<M1>, sync: PF6<M2>, sdin: PF9<M3>
) -> (Dac1Spi, <Channel1 as ChannelPins>::DacSync) {
let sclk = sclk.into_alternate();
let sdin = sdin.into_alternate();
@ -338,7 +295,7 @@ impl Pins {
(sclk, NoMiso {}, sdin),
crate::ad5680::SPI_MODE,
crate::ad5680::SPI_CLOCK,
clocks,
clocks
);
let sync = sync.into_push_pull_output();
@ -358,18 +315,25 @@ pub struct PwmPins {
impl PwmPins {
fn setup<M1, M2, M3, M4, M5, M6>(
clocks: Clocks,
(tim1, tim3): (TIM1, TIM3),
(max_v0, max_v1): (PC6<M1>, PC7<M2>),
(max_i_pos0, max_i_pos1): (PE9<M3>, PE11<M4>),
(max_i_neg0, max_i_neg1): (PE13<M5>, PE14<M6>),
tim1: TIM1,
tim3: TIM3,
max_v0: PC6<M1>,
max_v1: PC7<M2>,
max_i_pos0: PE9<M3>,
max_i_pos1: PE11<M4>,
max_i_neg0: PE13<M5>,
max_i_neg1: PE14<M6>,
) -> PwmPins {
let freq = 20u32.khz();
fn init_pwm_pin<P: hal::PwmPin<Duty = u16>>(pin: &mut P) {
fn init_pwm_pin<P: hal::PwmPin<Duty=u16>>(pin: &mut P) {
pin.set_duty(0);
pin.enable();
}
let channels = (max_v0.into_alternate(), max_v1.into_alternate());
let channels = (
max_v0.into_alternate(),
max_v1.into_alternate(),
);
//let (mut max_v0, mut max_v1) = pwm::tim3(tim3, channels, clocks, freq);
let (mut max_v0, mut max_v1) = Timer::new(tim3, &clocks).pwm(channels, freq);
init_pwm_pin(&mut max_v0);
@ -389,12 +353,9 @@ impl PwmPins {
init_pwm_pin(&mut max_i_neg1);
PwmPins {
max_v0,
max_v1,
max_i_pos0,
max_i_pos1,
max_i_neg0,
max_i_neg1,
max_v0, max_v1,
max_i_pos0, max_i_pos1,
max_i_neg0, max_i_neg1,
}
}
}

View File

@ -1,29 +1,25 @@
use crate::command_parser::Ipv4Config;
use crate::net::split_ipv4_config;
use smoltcp::{
iface::EthernetInterface,
socket::{SocketHandle, SocketRef, SocketSet, TcpSocket, TcpSocketBuffer},
socket::{SocketSet, SocketHandle, TcpSocket, TcpSocketBuffer, SocketRef},
time::Instant,
wire::{IpAddress, IpCidr, Ipv4Address, Ipv4Cidr},
};
use crate::command_parser::Ipv4Config;
use crate::net::split_ipv4_config;
pub struct SocketState<S> {
handle: SocketHandle,
state: S,
}
impl<'a, S: Default> SocketState<S> {
fn new(
sockets: &mut SocketSet<'a>,
tcp_rx_storage: &'a mut [u8; TCP_RX_BUFFER_SIZE],
tcp_tx_storage: &'a mut [u8; TCP_TX_BUFFER_SIZE],
) -> SocketState<S> {
impl<'a, S: Default> SocketState<S>{
fn new(sockets: &mut SocketSet<'a>, tcp_rx_storage: &'a mut [u8; TCP_RX_BUFFER_SIZE], tcp_tx_storage: &'a mut [u8; TCP_TX_BUFFER_SIZE]) -> SocketState<S> {
let tcp_rx_buffer = TcpSocketBuffer::new(&mut tcp_rx_storage[..]);
let tcp_tx_buffer = TcpSocketBuffer::new(&mut tcp_tx_storage[..]);
let tcp_socket = TcpSocket::new(tcp_rx_buffer, tcp_tx_buffer);
SocketState::<S> {
handle: sockets.add(tcp_socket),
state: S::default(),
state: S::default()
}
}
}
@ -54,7 +50,7 @@ impl<'a, 'b, S: Default> Server<'a, 'b, S> {
($rx_storage:ident, $tx_storage:ident) => {
let mut $rx_storage = [0; TCP_RX_BUFFER_SIZE];
let mut $tx_storage = [0; TCP_TX_BUFFER_SIZE];
};
}
}
create_rtx_storage!(tcp_rx_storage0, tcp_tx_storage0);
@ -103,10 +99,15 @@ impl<'a, 'b, S: Default> Server<'a, 'b, S> {
fn set_ipv4_address(&mut self, ipv4_address: Ipv4Cidr) {
self.net.update_ip_addrs(|addrs| {
for addr in addrs.iter_mut() {
if let IpCidr::Ipv4(_) = addr {
match addr {
IpCidr::Ipv4(_) => {
*addr = IpCidr::Ipv4(ipv4_address);
// done
break;
break
}
_ => {
// skip
}
}
}
});
@ -115,7 +116,8 @@ impl<'a, 'b, S: Default> Server<'a, 'b, S> {
fn set_gateway(&mut self, gateway: Option<Ipv4Address>) {
let routes = self.net.routes_mut();
match gateway {
None => routes.update(|routes_storage| {
None =>
routes.update(|routes_storage| {
routes_storage.remove(&IpCidr::new(IpAddress::v4(0, 0, 0, 0), 0));
}),
Some(gateway) => {

View File

@ -1,4 +1,5 @@
use super::command_parser::{Command, Error as ParserError};
use super::channels::CHANNELS;
const MAX_LINE_LEN: usize = 64;
@ -45,14 +46,15 @@ pub enum SessionInput {
impl From<Result<Command, ParserError>> for SessionInput {
fn from(input: Result<Command, ParserError>) -> Self {
input
.map(SessionInput::Command)
input.map(SessionInput::Command)
.unwrap_or_else(SessionInput::Error)
}
}
pub struct Session {
reader: LineReader,
reporting: bool,
report_pending: [bool; CHANNELS],
}
impl Default for Session {
@ -65,11 +67,43 @@ impl Session {
pub fn new() -> Self {
Session {
reader: LineReader::new(),
reporting: false,
report_pending: [false; CHANNELS],
}
}
pub fn reset(&mut self) {
self.reader = LineReader::new();
self.reporting = false;
self.report_pending = [false; CHANNELS];
}
pub fn reporting(&self) -> bool {
self.reporting
}
pub fn set_report_pending(&mut self, channel: usize) {
if self.reporting {
self.report_pending[channel] = true;
}
}
pub fn is_report_pending(&self) -> Option<usize> {
if ! self.reporting {
None
} else {
self.report_pending.iter()
.enumerate()
.fold(None, |result, (channel, report_pending)| {
result.or_else(|| {
if *report_pending { Some(channel) } else { None }
})
})
}
}
pub fn mark_report_sent(&mut self, channel: usize) {
self.report_pending[channel] = false;
}
pub fn feed(&mut self, buf: &[u8]) -> (usize, SessionInput) {
@ -77,10 +111,19 @@ impl Session {
for (i, b) in buf.iter().enumerate() {
buf_bytes = i + 1;
let line = self.reader.feed(*b);
if let Some(line) = line {
let command = Command::parse(line);
match line {
Some(line) => {
let command = Command::parse(&line);
match command {
Ok(Command::Reporting(reporting)) => {
self.reporting = reporting;
}
_ => {}
}
return (buf_bytes, command.into());
}
None => {}
}
}
(buf_bytes, SessionInput::Nothing)
}

View File

@ -1,29 +1,31 @@
use num_traits::float::Float;
use serde::{Deserialize, Serialize};
use uom::si::{
f64::{
ElectricalResistance,
ThermodynamicTemperature,
},
electrical_resistance::ohm,
f64::{ElectricalResistance, TemperatureInterval, ThermodynamicTemperature},
ratio::ratio,
temperature_interval::kelvin as kelvin_interval,
thermodynamic_temperature::{degree_celsius, kelvin},
};
use serde::{Deserialize, Serialize};
/// B-Parameter equation parameters
/// Steinhart-Hart equation parameters
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Parameters {
/// Base temperature
pub t0: ThermodynamicTemperature,
/// Thermistor resistance at base temperature
/// Base resistance
pub r0: ElectricalResistance,
/// Beta (average slope of the function ln R vs. 1/T)
pub b: TemperatureInterval,
/// Beta
pub b: f64,
}
impl Parameters {
/// Perform the resistance to temperature conversion.
/// Perform the voltage to temperature conversion.
pub fn get_temperature(&self, r: ElectricalResistance) -> ThermodynamicTemperature {
let temp = (self.t0.recip() + (r / self.r0).get::<ratio>().ln() / self.b).recip();
ThermodynamicTemperature::new::<kelvin>(temp.get::<kelvin_interval>())
let inv_temp = 1.0 / self.t0.get::<kelvin>() + (r / self.r0).get::<ratio>().ln() / self.b;
ThermodynamicTemperature::new::<kelvin>(1.0 / inv_temp)
}
}
@ -32,7 +34,7 @@ impl Default for Parameters {
Parameters {
t0: ThermodynamicTemperature::new::<degree_celsius>(25.0),
r0: ElectricalResistance::new::<ohm>(10_000.0),
b: TemperatureInterval::new::<kelvin_interval>(3800.0),
b: 3800.0,
}
}
}

View File

@ -4,9 +4,9 @@ use cortex_m::interrupt::Mutex;
use cortex_m_rt::exception;
use stm32f4xx_hal::{
rcc::Clocks,
stm32::SYST,
time::U32Ext,
timer::{Event as TimerEvent, Timer},
timer::{Timer, Event as TimerEvent},
stm32::SYST,
};
/// Rate in Hz
@ -18,6 +18,7 @@ static TIMER_MS: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));
/// Setup SysTick exception
pub fn setup(syst: SYST, clocks: Clocks) {
let timer = Timer::syst(syst, &clocks);
let mut countdown = timer.start_count_down(TIMER_RATE.hz());
countdown.listen(TimerEvent::TimeOut);
@ -27,13 +28,18 @@ pub fn setup(syst: SYST, clocks: Clocks) {
#[exception]
fn SysTick() {
cortex_m::interrupt::free(|cs| {
*TIMER_MS.borrow(cs).borrow_mut() += TIMER_DELTA;
*TIMER_MS.borrow(cs)
.borrow_mut() += TIMER_DELTA;
});
}
/// Obtain current time in milliseconds
pub fn now() -> u32 {
cortex_m::interrupt::free(|cs| *TIMER_MS.borrow(cs).borrow().deref())
cortex_m::interrupt::free(|cs| {
*TIMER_MS.borrow(cs)
.borrow()
.deref()
})
}
/// block for at least `amount` milliseconds

View File

@ -1,18 +1,15 @@
use core::{
fmt::{self, Write},
mem::MaybeUninit,
};
use core::{fmt::{self, Write}, mem::MaybeUninit};
use cortex_m::interrupt::free;
use log::{Log, Metadata, Record};
use stm32f4xx_hal::{
otg_fs::{UsbBus as Bus, USB},
otg_fs::{USB, UsbBus as Bus},
stm32::{interrupt, Interrupt, NVIC},
};
use usb_device::{
class_prelude::UsbBusAllocator,
class_prelude::{UsbBusAllocator},
prelude::{UsbDevice, UsbDeviceBuilder, UsbVidPid},
};
use usbd_serial::SerialPort;
use log::{Record, Log, Metadata};
static mut EP_MEMORY: [u32; 1024] = [0; 1024];
@ -39,8 +36,8 @@ impl State {
.device_class(usbd_serial::USB_CLASS_CDC)
.build();
free(|_| unsafe {
STATE = Some(State { serial, dev });
free(|_| {
unsafe { STATE = Some(State { serial, dev }); }
});
unsafe {
@ -97,7 +94,8 @@ impl Write for SerialOutput {
fn write_str(&mut self, s: &str) -> core::result::Result<(), core::fmt::Error> {
if let Some(ref mut state) = State::get() {
for chunk in s.as_bytes().chunks(16) {
free(|_| state.serial.write(chunk)).map_err(|_| fmt::Error)?;
free(|_| state.serial.write(chunk))
.map_err(|_| fmt::Error)?;
}
}
Ok(())