Compare commits
317 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ef8db4b72 | |||
| ebb0280029 | |||
| b4bbdf02d6 | |||
| c36470da8a | |||
| b3f5b82fcd | |||
|
|
38e92c4042 | ||
| 8ccdf8a3b5 | |||
| dd30db95ba | |||
| 4fbe06cdd7 | |||
| 08dee2731c | |||
| 815c3caad6 | |||
| 362866be20 | |||
| 14a07f35e6 | |||
| b26a6a0cf0 | |||
| dd9e5fe195 | |||
| a5c40f706a | |||
| c8af8cb61f | |||
| 23ce99d6d8 | |||
| d594c93166 | |||
| e3abe9384a | |||
| 33ef98c5a8 | |||
| 9305d766d1 | |||
| 1f406fad38 | |||
| e8b217d0fc | |||
| 1bb7c02fa5 | |||
| 1e50c8ce6a | |||
| fa1a688924 | |||
| 8b9e68cf3c | |||
| c61b89edc5 | |||
| ca52bb066a | |||
| b326a8ba74 | |||
| eeff217c9b | |||
| 89ee0894d9 | |||
| 5792eeb22b | |||
| 72423604f9 | |||
| 72ab40c0ce | |||
| b916ffc2a2 | |||
| 7d2d86f62a | |||
| 32f96d96d9 | |||
| 02745cbc55 | |||
| 0753058a4b | |||
| 24cc2671ba | |||
| 2944571601 | |||
| 3c80446fa1 | |||
| b5e32a2d11 | |||
| 266665f6a9 | |||
| f4758a091a | |||
| 9196a306b6 | |||
| 3243a58a90 | |||
| b7b1b928fe | |||
| d89f5b9b49 | |||
| f4ae51b24b | |||
| 8a95eb9fb1 | |||
| 0dc7b248b7 | |||
| 9868ca4447 | |||
| 6aef143e3e | |||
| 8db4867ebf | |||
| 130bde480e | |||
| 36d80ebdff | |||
| 09300b5d44 | |||
| 9743dca775 | |||
| 11131deda2 | |||
| 764774fbce | |||
| 4beeec6021 | |||
| 6b8a5f5bb8 | |||
| 8dd58b364d | |||
| ae0d593139 | |||
| adc25c9b2a | |||
| 9af86be674 | |||
| eabc7f6a12 | |||
| 52e35d2a98 | |||
| f1da910c11 | |||
| 9848c65de5 | |||
| 069d791802 | |||
| bd9ae997ae | |||
| 45eb55d36d | |||
| eddf05cae7 | |||
| f68ae12c8d | |||
| 101a68fcfc | |||
| 9d1adbc7f7 | |||
| 47fa9f757e | |||
| d1df6b8e3a | |||
| 0ffd784e91 | |||
| d8ec083dbc | |||
| 1962135e79 | |||
| fcb5cf1d4e | |||
| d517087e10 | |||
| 798b400aa5 | |||
| 93dc39e943 | |||
| 5c3b759d0c | |||
| 6224486662 | |||
| 32bd49b258 | |||
| ad54842c43 | |||
| b336c4f993 | |||
| 680193b34b | |||
| ae4bea0c8a | |||
| 1f2de942e4 | |||
| 1041d3ecbb | |||
| c6040899dd | |||
| 9d89104f50 | |||
| 136c7a0b52 | |||
| 5000cae1b1 | |||
| 78ec77509f | |||
| 52aa3890c1 | |||
| 1ae6a6fdd4 | |||
| 7333d2cea5 | |||
| 44e9130010 | |||
| 5b0c6f7018 | |||
| 1007982b48 | |||
| 925601f4f5 | |||
| 8c1cb3117c | |||
| 1fcfe41a63 | |||
| 9fce19a418 | |||
| 00d5feaa8d | |||
| 09be55e12a | |||
| 76547be90a | |||
| 8b975e656e | |||
| ae3d8b51d4 | |||
| 17edae44fb | |||
| 03b4561142 | |||
| 631a10938d | |||
| 6cd6a6a2c2 | |||
| b93e2fbb7b | |||
| 76b95f66e0 | |||
| 8008870bc1 | |||
| 7646ff9037 | |||
| 6f81a63d12 | |||
| 78012f6fdd | |||
| bb4f43fe1c | |||
| 9df0fe406f | |||
| 5ba74c6d9b | |||
| 6f0acc73b8 | |||
| f29e86310d | |||
| b04a61c414 | |||
| cd680dd6cd | |||
| e3e3237d2f | |||
| 570c0324b3 | |||
| 5688b2f1bb | |||
| 1b2f2f3888 | |||
| e6f63ec940 | |||
| 67446ae99e | |||
| 26ad2f0119 | |||
|
|
69dabf5aa1 | ||
| a26cdfabb1 | |||
| 4d43709f76 | |||
| 1c00e732fa | |||
| 09082b24a5 | |||
| 85e8273d51 | |||
| e81c6d1692 | |||
| 1f644fd62c | |||
| 4f1d865d2b | |||
| e6a5c31db6 | |||
| a5d8661b10 | |||
| 7cb0ed70be | |||
| 6b9d61737e | |||
| 16844a1dc1 | |||
| 96f52ace8b | |||
| a1a8efd51a | |||
| 8eb3cc4307 | |||
| f3661ac8e3 | |||
| c4e3be1d05 | |||
| cf3ace4d2d | |||
| f6802635a4 | |||
| 9e4d06fdbc | |||
| 3433881d0f | |||
| 193d54a0a6 | |||
| 3067b356c5 | |||
| 3ba2cc9ddc | |||
| 1539b624bd | |||
| 5c84b7438b | |||
| cc0126636c | |||
| 45b7c4e669 | |||
| 73dd6d9154 | |||
| e94601f54f | |||
| 8c9e12587f | |||
| 7c013ff4a4 | |||
| 50a1b9f52d | |||
| 9852b32646 | |||
| 22b0c9fcad | |||
| e13ed37271 | |||
| 5987d9c881 | |||
| 7c55e34145 | |||
| b176fc2788 | |||
| b717ac5495 | |||
| 980d27ebfc | |||
| e9e46b29cf | |||
| b7e6cdbec2 | |||
| 93ea46d512 | |||
| dc41473493 | |||
| 7a28cb1cd4 | |||
| c3dd03dcf3 | |||
| b2f455b2cf | |||
| 2e7be3fe01 | |||
| ff91dd7baa | |||
| ecc00a6aeb | |||
| 97813f917d | |||
| 880a887c40 | |||
| 383ebcd8e4 | |||
| 088bd6eb76 | |||
| 35d1e2e205 | |||
| 1090d0f5b5 | |||
| 23d0c470e5 | |||
| 5c8bb47e11 | |||
| b92a5f18cd | |||
| c125e20bdb | |||
| 7af5d8582d | |||
| fca2de665c | |||
| ffcc3f661b | |||
| 2a6f8ed874 | |||
| 5e8bf0e765 | |||
| 5ddd4d250e | |||
| af0d78237f | |||
| ae77a5c163 | |||
| ffb70bde0a | |||
| d517dd75fe | |||
| a943308203 | |||
| 150d6c2f87 | |||
| c005784df5 | |||
| d574ccb5f4 | |||
| 6ba1459a3c | |||
| aee279b579 | |||
| 1939490410 | |||
| 2e9d22cf47 | |||
| a332b5fcdc | |||
| 83a266852a | |||
| f12214a4df | |||
| 175b88d0e6 | |||
| 9e23b14ace | |||
| 6fd5328042 | |||
| 59103cb2a1 | |||
| 5acebbef9f | |||
| 12e713dc19 | |||
| 1b4a030e7e | |||
| 438da74721 | |||
| 026dd1ed9c | |||
| d4e7036fab | |||
| bfdb64ffd6 | |||
| 6e0cf26d6a | |||
| d4901cbab1 | |||
| 62d89a68a1 | |||
| 5521563c91 | |||
| 11f2ebe961 | |||
| 4b75c6147d | |||
| 445cde6ae8 | |||
| 6951489545 | |||
| 97490e5e1b | |||
| 87287e83b3 | |||
| 407c0998af | |||
| 9e5a58cafd | |||
| aea306cf17 | |||
| 97a09e422b | |||
| 61d2cd6ecf | |||
| f3ec96f425 | |||
| 9f70ef2e0a | |||
| 83589610b5 | |||
| a2caac0fe5 | |||
| 20059aff5c | |||
| f690599f9e | |||
| b3e9a1b636 | |||
| bfbf037006 | |||
| a1ad9b2456 | |||
| 8d70c03520 | |||
| 3b050347d4 | |||
| 254c1c3d73 | |||
| 5a293a0ada | |||
| daa398cb5e | |||
| 17e89b2041 | |||
| 58e648b5e0 | |||
| c5c0ce5625 | |||
| d3606d25b6 | |||
| 27278db1ba | |||
| d40a038c2f | |||
| 5d0d75d395 | |||
| 93f14523d7 | |||
| 201701ee8b | |||
| a84242fb1f | |||
| b394cfa3d4 | |||
| b9902929a9 | |||
| edc675f5af | |||
| a4dde1b8ca | |||
| 7361619a53 | |||
| 34543c8660 | |||
| 83a209397e | |||
| ba84295ec5 | |||
| fb81380955 | |||
| 94e0525002 | |||
| 1157b73f7f | |||
| dd06ae1075 | |||
| f76ee9a607 | |||
| fc0ca8b581 | |||
| 8c80062da8 | |||
| 7d45d5ad32 | |||
| dda1f2f0b4 | |||
| b1b6d1ea94 | |||
| 1849e6f5e7 | |||
| b80fcc430b | |||
| bb26490153 | |||
| 4a1ce342a0 | |||
| 8cddbc5173 | |||
| 37a7898f92 | |||
| 277f239ed7 | |||
| 10208e1ac0 | |||
| 42587810cd | |||
| c11b71cc0d | |||
| 2617895460 | |||
| 5244077144 | |||
| 4e6aa5fe0c | |||
| 9a912392be | |||
| 50dcee0c8a | |||
| f76ae453a9 | |||
| e5c9ee8ed0 | |||
| a3df2bc685 | |||
| 1711feae84 | |||
| f9b55508dd | |||
| cf03613ac5 | |||
| 3c94342448 | |||
| 5418488a2f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
target/
|
||||
result
|
||||
*.bin
|
||||
|
||||
__pycache__/
|
||||
|
||||
457
Cargo.lock
generated
457
Cargo.lock
generated
@@ -1,30 +1,33 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb1ce8b3382016136ab1d31a1b5ce807144f8b7eb2d5f16b2108f0f07edceb94"
|
||||
checksum = "c19796bd8d477f1a9d4ac2465b464a8b1359474f06a96bb3cda650b4fca309bf"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37dfb65bc03b2bc85ee827004f14a6817e04160e3b1a28931986a666a9290e70"
|
||||
checksum = "bb4d1c23475b74e3672afa8c2be22040b8b7783ad9b461021144ed10a46bb0e6"
|
||||
dependencies = [
|
||||
"generic-array 0.12.3",
|
||||
"generic-array 0.13.2",
|
||||
"generic-array 0.14.4",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
|
||||
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
||||
|
||||
[[package]]
|
||||
name = "bare-metal"
|
||||
@@ -36,10 +39,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.0"
|
||||
name = "bare-metal"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a165d606cf084741d4ac3a28fb6e9b1eb0bd31f6cd999098cfddb0b2ab381dc0"
|
||||
checksum = "f8fe8f5a8a398345e52358e18ff07cc17a568fbca5c6f73873d3a62056309603"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcb6dd1c2376d2e096796e234a70e17e94cc2d5d54ff8ce42b28cef1d0d359a4"
|
||||
|
||||
[[package]]
|
||||
name = "bitfield"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
@@ -55,12 +70,9 @@ checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9434b9a5aa1450faa3f9cb14ea0e8c53bb5d2b3c1bfd1ab4fc03e9f33fbfb0"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
@@ -69,37 +81,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m"
|
||||
version = "0.6.2"
|
||||
name = "chrono"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2954942fbbdd49996704e6f048ce57567c3e1a4e2dc59b41ae9fde06a01fc763"
|
||||
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9075300b07c6a56263b9b582c214d0ff037b00d45ec9fde1cc711490c56f1bb9"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"bare-metal",
|
||||
"bare-metal 0.2.5",
|
||||
"bitfield",
|
||||
"cortex-m 0.7.7",
|
||||
"volatile-register",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
||||
dependencies = [
|
||||
"bare-metal 0.2.5",
|
||||
"bitfield",
|
||||
"embedded-hal",
|
||||
"volatile-register",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-log"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978caafe65d1023d38b00c76b83564788fc351d954a5005fb72cf992c0d61458"
|
||||
checksum = "1d63959cb1e003dd97233fee6762351540253237eadf06fcdcb98cbfa3f9be4a"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m 0.6.7",
|
||||
"cortex-m-semihosting",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-rt"
|
||||
version = "0.6.12"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d518da72bba39496024b62607c1d8e37bcece44b2536664f1132a73a499a28"
|
||||
checksum = "980c9d0233a909f355ed297ef122f257942de5e0a2cb1c39f60684b65bcb90fb"
|
||||
dependencies = [
|
||||
"cortex-m-rt-macros",
|
||||
"cortex-m-rt-macros 0.1.8",
|
||||
"r0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-rt"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c433da385b720d5bb9f52362fa2782420798e68d40d67bfe4b0d992aba5dfe7"
|
||||
dependencies = [
|
||||
"cortex-m-rt-macros 0.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-rt-macros"
|
||||
version = "0.1.8"
|
||||
@@ -112,21 +157,50 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-semihosting"
|
||||
version = "0.3.5"
|
||||
name = "cortex-m-rt-macros"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "113ef0ecffee2b62b58f9380f4469099b30e9f9cbee2804771b4203ba1762cfa"
|
||||
checksum = "f0f6f3e36f203cfedbc78b357fb28730aa2c6dc1ab060ee5c2405e843988d3c7"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-semihosting"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bffa6c1454368a6aa4811ae60964c38e6996d397ff8095a8b9211b1c1f749bc"
|
||||
dependencies = [
|
||||
"cortex-m 0.6.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eeprom24x"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f680e8d81a559a97de04c5fab25f17f22a55770120c868ef8fbdea6398d44107"
|
||||
dependencies = [
|
||||
"embedded-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-dma"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c8c02e4347a0267ca60813c952017f4c5948c232474c6010a381a337f1bda4"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal"
|
||||
version = "0.2.3"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4908a155094da7723c2d60d617b820061e3b4efcc3d9e293d206a5a76c170b"
|
||||
checksum = "e36cfb62ff156596c892272f3015ef952fe1525e85261fa3a7f327bd6b384ab9"
|
||||
dependencies = [
|
||||
"nb",
|
||||
"nb 0.1.3",
|
||||
"void",
|
||||
]
|
||||
|
||||
@@ -149,10 +223,36 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash2hwaddr"
|
||||
version = "0.0.1"
|
||||
name = "generic-array"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "857afb5ee9e767c3a73b2ad7212b6deea0c3761a27db1e20ea0ed57ee352cfef"
|
||||
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74911a68a1658cfcfb61bc0ccfbd536e3b6e906f8c2f7883ee50157e3e2184f1"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
"generic-array 0.13.2",
|
||||
"hash32",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -162,81 +262,150 @@ checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.8"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7"
|
||||
checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdcec5e97041c7f0f1c5b7d93f12e57293c831c646f4cc7a5db59460c7ea8de6"
|
||||
checksum = "c75de51135344a4f8ed3cfe2720dc27736f7711989703a0b43aadf3753c55577"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.3.3"
|
||||
version = "2.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
|
||||
checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1411551beb3c11dedfb0a90a0fa256b47d28b9ec2cdff34c25a2fa59e45dbdc"
|
||||
checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
|
||||
dependencies = [
|
||||
"nb 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "546c37ac5d9e56f55e73b677106873d9d9f5190605e41a856503623648488cae"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "5.1.1"
|
||||
version = "5.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b471253da97532da4b61552249c521e01e736071f71c1a4f7ebbfbf0a06aad6"
|
||||
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.11"
|
||||
name = "num-bigint"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096"
|
||||
checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "panic-abort"
|
||||
version = "0.3.2"
|
||||
name = "panic-halt"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e20e6499bbbc412f280b04a42346b356c6fa0753d5fd22b7bd752ff34c778ee"
|
||||
checksum = "a513e167849a384b7f9b746e517604398518590a9142f4846a32e3c2a4de7b11"
|
||||
|
||||
[[package]]
|
||||
name = "panic-semihosting"
|
||||
version = "0.5.3"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c03864ac862876c16a308f5286f4aa217f1a69ac45df87ad3cd2847f818a642c"
|
||||
checksum = "c3d55dedd501dfd02514646e0af4d7016ce36bc12ae177ef52056989966a1eec"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m 0.6.7",
|
||||
"cortex-m-semihosting",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.9"
|
||||
name = "postcard"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c09721c6781493a2a492a96b5a5bf19b65917fe6728884e7c44dd0c60ca3435"
|
||||
checksum = "b3e3f5c2e9a91383c6594ec68aa2dfdfe19a3c86f34b088ba7203f2483d2682f"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"postcard-cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard-cobs"
|
||||
version = "0.1.5-pre"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c68cb38ed13fd7bc9dd5db8f165b7c8d9c1a315104083a2b10f11354c2af97f"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.3"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bdc6c187c65bca4260c9011c9e3132efe4909da44726bad24cf7572ae338d7f"
|
||||
checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -249,9 +418,18 @@ checksum = "e2a38df5b15c8d5c7e8654189744d8e396bddc18ad48041a500ce52d6948941f"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
|
||||
|
||||
[[package]]
|
||||
name = "rtcc"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef35f9dcbf434a34dcc99b3ebba1c1945d49c70832958e932e83dc63a5273994"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
@@ -278,10 +456,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.6.0"
|
||||
name = "serde"
|
||||
version = "1.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe46639fd2ec79eadf8fe719f237a7a0bd4dac5d957f1ca5bbdbc1c3c39e53a"
|
||||
checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-json-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbf406405ada9ef326ca78677324ac66994ff348fc48a16030be08caeed29825"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sfkv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f5bfac3f66a7c10a6f37ee81aeaa471f4d35dc21665b59ad7c555adcb9e8aa"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"postcard",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e4a069bef843d170df47e7c0a8bf8d037f217d9f5b325865acc3e466ffe40d3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
@@ -291,17 +510,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dba1a27d3efae4351c8051072d619e3ade2820635c3958d826bfea39d59b54c8"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "stm32-eth"
|
||||
version = "0.1.2"
|
||||
source = "git+https://github.com/stm32-rs/stm32-eth.git#2c5dce379b85a31fb0b9c58a028b6454be1727aa"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/stm32-rs/stm32-eth.git?rev=3759c5c9#3759c5c99c0ab69bb71759030766bc0fba0b6cde"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"log",
|
||||
"cortex-m 0.7.7",
|
||||
"smoltcp",
|
||||
"stm32f4xx-hal",
|
||||
"volatile-register",
|
||||
@@ -309,75 +528,129 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stm32f4"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44a3d6c58b14e63926273694e7dd644894513c5e35ce6928c4657ddb62cae976"
|
||||
checksum = "da3d56009c8f32e4f208dbea17df72484154d1040a8969b75d8c73eb7b18fe8f"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"bare-metal 0.2.5",
|
||||
"cortex-m 0.6.7",
|
||||
"cortex-m-rt 0.6.13",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stm32f4xx-hal"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/thalesfragoso/stm32f4xx-hal?branch=pwm-impl#cfd073e094daa9be9dd2b0a1f859a4e1c6be2b77"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a06fde2dd27c0ba934c9e69b62af66eb1c20dbb6d741b187a763912e9892d13"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bare-metal 1.0.0",
|
||||
"cast",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"cortex-m 0.7.7",
|
||||
"cortex-m-rt 0.7.1",
|
||||
"embedded-dma",
|
||||
"embedded-hal",
|
||||
"nb",
|
||||
"nb 1.0.0",
|
||||
"rand_core",
|
||||
"rtcc",
|
||||
"stm32f4",
|
||||
"synopsys-usb-otg",
|
||||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.17"
|
||||
version = "1.0.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0df0eb663f387145cab623dea85b09c2c5b4b0aef44e945d928e682fce71bb03"
|
||||
checksum = "9a2af957a63d6bd42255c359c93d9bfdb97076bd3b820897ce55ffbfbf107f44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synopsys-usb-otg"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "461676dcf123675b3d3b02e2390e6a690cd186aacf2f439af7673c79e2561d53"
|
||||
dependencies = [
|
||||
"cortex-m 0.6.7",
|
||||
"usb-device",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thermostat"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bare-metal 1.0.0",
|
||||
"bit_field",
|
||||
"byteorder",
|
||||
"cortex-m",
|
||||
"cortex-m 0.7.7",
|
||||
"cortex-m-log",
|
||||
"cortex-m-rt",
|
||||
"hash2hwaddr",
|
||||
"cortex-m-rt 0.6.13",
|
||||
"eeprom24x",
|
||||
"heapless",
|
||||
"log",
|
||||
"nb 1.0.0",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"panic-abort",
|
||||
"panic-halt",
|
||||
"panic-semihosting",
|
||||
"serde",
|
||||
"serde-json-core",
|
||||
"sfkv",
|
||||
"smoltcp",
|
||||
"stm32-eth",
|
||||
"stm32f4xx-hal",
|
||||
"uom",
|
||||
"usb-device",
|
||||
"usbd-serial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.11.2"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d2783fe2d6b8c1101136184eb41be8b1ad379e4657050b8aaff0c79ee7575f9"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c"
|
||||
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
|
||||
|
||||
[[package]]
|
||||
name = "uom"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd36e5350a65d112584053ee91843955826bf9e56ec0d1351214e01f6d7cd9c"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usb-device"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "849eed9b4dc61a1f17ba1d7a5078ceb095b9410caa38a506eb281ed5eff12fbd"
|
||||
|
||||
[[package]]
|
||||
name = "usbd-serial"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db75519b86287f12dcf0d171c7cf4ecc839149fe9f3b720ac4cfce52959e1dfe"
|
||||
dependencies = [
|
||||
"embedded-hal",
|
||||
"nb 0.1.3",
|
||||
"usb-device",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcell"
|
||||
@@ -387,9 +660,9 @@ checksum = "876e32dcadfe563a4289e994f7cb391197f362b6315dc45e8ba4aa6f564a4b3c"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce"
|
||||
checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
|
||||
|
||||
[[package]]
|
||||
name = "void"
|
||||
|
||||
32
Cargo.toml
32
Cargo.toml
@@ -7,37 +7,39 @@ authors = ["Astro <astro@spaceboyz.net>"]
|
||||
version = "0.0.0"
|
||||
keywords = ["thermostat", "laser", "physics"]
|
||||
repository = "https://git.m-labs.hk/M-Labs/thermostat"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
features = []
|
||||
default-target = "thumbv7em-none-eabihf"
|
||||
|
||||
[dependencies]
|
||||
panic-abort = "0.3.1"
|
||||
panic-semihosting = { version = "0.5.1", optional = true }
|
||||
panic-halt = "1.0"
|
||||
panic-semihosting = { version = "0.5", optional = true }
|
||||
log = "0.4"
|
||||
bare-metal = "0.2"
|
||||
cortex-m = "0.6"
|
||||
bare-metal = "1"
|
||||
cortex-m = "0.7"
|
||||
cortex-m-rt = { version = "0.6", features = ["device"] }
|
||||
cortex-m-log = { version = "0.6", features = ["log-integration"] }
|
||||
stm32f4xx-hal = { version = "0.7", features = ["rt", "stm32f427"] }
|
||||
stm32-eth = { version = "0.1.2", features = ["smoltcp-phy"], git = "https://github.com/stm32-rs/stm32-eth.git" }
|
||||
smoltcp = { version = "0.6.0", default-features = false, features = ["proto-ipv4", "socket-tcp", "log"] }
|
||||
hash2hwaddr = { version = "0.0", optional = true }
|
||||
stm32f4xx-hal = { version = "=0.10.1", features = ["rt", "stm32f427", "usb_fs"] }
|
||||
stm32-eth = { rev = "3759c5c9", features = ["stm32f427", "smoltcp-phy"], git = "https://github.com/stm32-rs/stm32-eth.git" }
|
||||
smoltcp = { version = "0.7.5", default-features = false, features = ["proto-ipv4", "socket-tcp", "log"] }
|
||||
bit_field = "0.10"
|
||||
byteorder = { version = "1", default-features = false }
|
||||
nom = { version = "5", default-features = false }
|
||||
num-traits = { version = "0.2", default-features = false, features = ["libm"] }
|
||||
|
||||
[patch.crates-io]
|
||||
# TODO: pending https://github.com/stm32-rs/stm32f4xx-hal/pull/125
|
||||
stm32f4xx-hal = { git = "https://github.com/thalesfragoso/stm32f4xx-hal", branch = "pwm-impl" }
|
||||
usb-device = "0.2"
|
||||
usbd-serial = "0.1"
|
||||
nb = "1"
|
||||
uom = { version = "0.36", default-features = false, features = ["autoconvert", "si", "f64", "serde"] }
|
||||
eeprom24x = "0.3"
|
||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||
heapless = "0.5"
|
||||
serde-json-core = "0.1"
|
||||
sfkv = "0.1"
|
||||
|
||||
[features]
|
||||
semihosting = ["panic-semihosting", "cortex-m-log/semihosting"]
|
||||
generate-hwaddr = ["hash2hwaddr"]
|
||||
default = ["generate-hwaddr"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
328
README.md
328
README.md
@@ -1,28 +1,91 @@
|
||||
# Firmware for the Sinara 8451 Thermostat
|
||||
|
||||
- [x] [Continuous Integration](https://nixbld.m-labs.hk/job/stm32/stm32/thermostat)
|
||||
- [x] [Download latest firmware build](https://nixbld.m-labs.hk/job/stm32/stm32/thermostat/latest/download-by-type/file/binary-dist)
|
||||
- [x] [Continuous Integration](https://nixbld.m-labs.hk/job/mcu/thermostat/thermostat)
|
||||
- [x] Download latest firmware build: [ELF](https://nixbld.m-labs.hk/job/mcu/thermostat/thermostat/latest/download/1) [BIN](https://nixbld.m-labs.hk/job/mcu/thermostat/thermostat/latest/download/2)
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
### Debian-based systems (tested on Ubuntu 19.10)
|
||||
### Reproducible build with Nix
|
||||
|
||||
- install git, clone this repository
|
||||
- install [rustup](https://rustup.rs/)
|
||||
Thermostat firmware is packaged using the [Nix](https://nixos.org) Flakes system. Install Nix 2.4+ and enable flakes by adding ``experimental-features = nix-command flakes`` to ``nix.conf`` (e.g. ``~/.config/nix/nix.conf``).
|
||||
|
||||
Once you have Flakes enabled, you can use ``nix build`` to build the firmware.
|
||||
|
||||
### Development environment
|
||||
|
||||
Clone this repository and with Nix Flakes enabled, use the following commands:
|
||||
|
||||
```shell
|
||||
rustup toolchain install nightly
|
||||
rustup update
|
||||
rustup target add thumbv7em-none-eabihf --toolchain nightly
|
||||
rustup default nightly
|
||||
nix develop
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The resulting ELF file will be located under `target/thumbv7em-none-eabihf/release/thermostat`
|
||||
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.
|
||||
|
||||
## Network
|
||||
## Debugging
|
||||
|
||||
Connect SWDIO/SWCLK/RST/GND to a programmer such as ST-Link v2.1. Run OpenOCD:
|
||||
```shell
|
||||
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
|
||||
```
|
||||
|
||||
You may need to power up the programmer before powering the device.
|
||||
Leave OpenOCD running. Run the GNU debugger:
|
||||
```shell
|
||||
gdb target/thumbv7em-none-eabihf/release/thermostat
|
||||
|
||||
(gdb) source openocd.gdb
|
||||
```
|
||||
|
||||
## Flashing
|
||||
There are several options for flashing Thermostat. DFU requires only a micro-USB connector, whereas OpenOCD needs a JTAG/SWD adapter.
|
||||
|
||||
### 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)
|
||||
* 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
|
||||
* Push firmware to flash: `dfu-util -a 0 -s 0x08000000:leave -D thermostat.bin`
|
||||
* Remove jumper
|
||||
* Cycle power to leave DFU update mode
|
||||
|
||||
### st.com DfuSe tool on Windows
|
||||
On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware upgrade (DFU) software. [link](https://www.st.com/en/development-tools/stsw-stm32080.html).
|
||||
- add jumper to Thermostat v2.0 across 2-pin jumper adjacent to JTAG connector
|
||||
- cycle board power to put it in DFU update mode
|
||||
- connect micro-USB to PC
|
||||
- use st.com software to upload firmware
|
||||
- remove jumper
|
||||
- cycle power to leave DFU update mode
|
||||
|
||||
### OpenOCD
|
||||
```shell
|
||||
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
|
||||
```
|
||||
|
||||
## GUI Usage
|
||||
|
||||
The Thermostat Control Panel is available for easy configuration and plotting of key parameters.
|
||||
It is included in the enclosed PyThermostat library, developed based on the Python libraries PyQt and PyQtGraph.
|
||||
|
||||
Launch it by either running:
|
||||
|
||||
```sh
|
||||
nix run git+https://git.m-labs.hk/M-Labs/thermostat#control_panel
|
||||
```
|
||||
|
||||
Or, without Nix, run it after installing the PyThermostat library:
|
||||
|
||||
```sh
|
||||
pip install git+https://git.m-labs.hk/M-Labs/thermostat#subdirectory=pythermostat
|
||||
thermostat_control_panel
|
||||
```
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
### Connecting
|
||||
|
||||
@@ -30,7 +93,7 @@ Ethernet, IP: 192.168.1.26/24
|
||||
|
||||
Use netcat to connect to port 23/tcp (telnet)
|
||||
```sh
|
||||
nc -vv 192.168.1.26 23
|
||||
rlwrap nc -vv 192.168.1.26 23
|
||||
```
|
||||
|
||||
telnet clients send binary data after connect. Enter \n once to
|
||||
@@ -39,33 +102,220 @@ invalidate the first line of input.
|
||||
|
||||
### Reading ADC input
|
||||
|
||||
Set report mode to `on` for a continuous stream of input data.
|
||||
|
||||
The scope of this setting is per TCP session.
|
||||
ADC input data is provided in reports. Query for the latest report with the command `report`. See the *Reports* section below.
|
||||
|
||||
|
||||
### Commands
|
||||
### TCP commands
|
||||
|
||||
| Syntax | Function |
|
||||
| --- | --- |
|
||||
| `report` | Show current input |
|
||||
| `report mode` | Show current report mode |
|
||||
| `report mode <off/on>` | Set report mode |
|
||||
| `pwm` | Show current PWM settings |
|
||||
| `pwm <0/1> max_i_pos <ratio>` | Set PWM duty cycle for **max_i_pos** to *ratio* |
|
||||
| `pwm <0/1> max_i_neg <ratio>` | Set PWM duty cycle for **max_i_neg** to *ratio* |
|
||||
| `pwm <0/1> max_v <ratio>` | Set PWM duty cycle for **max_v** to *ratio* |
|
||||
| `pwm <0/1> <volts>` | Disengage PID, set **i_set** DAC to *volts* |
|
||||
| `pwm <0/1> pid` | Set PWM to be controlled by PID |
|
||||
| `pid` | Show PID configuration |
|
||||
| `pid <0/1> target <value>` | Set the PID controller target |
|
||||
| `pid <0/1> kp <value>` | Set proportional gain |
|
||||
| `pid <0/1> ki <value>` | Set integral gain |
|
||||
| `pid <0/1> kd <value>` | Set differential gain |
|
||||
| `pid <0/1> output_min <value>` | Set mininum output |
|
||||
| `pid <0/1> output_max <value>` | Set maximum output |
|
||||
| `pid <0/1> integral_min <value>` | Set integral lower bound |
|
||||
| `pid <0/1> integral_max <value>` | Set integral upper bound |
|
||||
| `s-h` | Show Steinhart-Hart equation parameters |
|
||||
| `s-h <0/1> <t/b/r0> <value>` | Set Steinhart-Hart parameter for a channel |
|
||||
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
|
||||
Send commands as simple text string terminated by `\n`. Responses are
|
||||
formatted as line-delimited JSON.
|
||||
|
||||
| Syntax | Function |
|
||||
|-------------------------------------------|-------------------------------------------------------------------------------|
|
||||
| `report` | Show latest report of channel parameters (see *Reports* section) |
|
||||
| `output` | Show current output settings |
|
||||
| `output <0/1> max_i_pos <amp>` | Set maximum positive output current, clamped to [0, 2] |
|
||||
| `output <0/1> max_i_neg <amp>` | Set maximum negative output current, clamped to [0, 2] |
|
||||
| `output <0/1> max_v <volt>` | Set maximum output voltage, clamped to [0, 4.3] |
|
||||
| `output <0/1> i_set <amp>` | Disengage PID, set fixed output current, clamped to [-2, 2] |
|
||||
| `output <0/1> polarity <normal/reversed>` | Set output current polarity, with 'normal' being the front panel polarity |
|
||||
| `output <0/1> pid` | Let output current to be controlled by the PID |
|
||||
| `center <0/1> <volt>` | Set the MAX1968 0A-centerpoint to the specified fixed voltage |
|
||||
| `center <0/1> vref` | Set the MAX1968 0A-centerpoint to measure from VREF |
|
||||
| `pid` | Show PID configuration |
|
||||
| `pid <0/1> target <deg_celsius>` | Set the PID controller target temperature |
|
||||
| `pid <0/1> kp <value>` | Set proportional gain |
|
||||
| `pid <0/1> ki <value>` | Set integral gain |
|
||||
| `pid <0/1> kd <value>` | Set differential gain |
|
||||
| `pid <0/1> output_min <amp>` | Set lower limit of PID-regulated output current |
|
||||
| `pid <0/1> output_max <amp>` | Set upper limit of PID-regulated output current |
|
||||
| `b-p` | Show B-Parameter equation parameters |
|
||||
| `b-p <0/1> <t0/b/r0> <value>` | Set B-Parameter for a channel |
|
||||
| `postfilter` | Show postfilter settings |
|
||||
| `postfilter <0/1> off` | Disable postfilter |
|
||||
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
|
||||
| `load [0/1]` | Restore configuration for channel all/0/1 from flash |
|
||||
| `save [0/1]` | Save configuration for channel all/0/1 to flash |
|
||||
| `reset` | Reset the device |
|
||||
| `dfu` | Reset device and enters USB device firmware update (DFU) mode |
|
||||
| `ipv4 <X.X.X.X/L> [Y.Y.Y.Y]` | Configure IPv4 address, netmask length, and optional default gateway |
|
||||
| `fan` | Show current fan settings and sensors' measurements |
|
||||
| `fan <value>` | Set fan power with values from 1 to 100 |
|
||||
| `fan auto` | Enable automatic fan speed control |
|
||||
| `fcurve <a> <b> <c>` | Set fan controller curve coefficients (see *Fan control* section) |
|
||||
| `fcurve default` | Set fan controller curve coefficients to defaults (see *Fan control* section) |
|
||||
| `hwrev` | Show hardware revision, and settings related to it |
|
||||
|
||||
|
||||
## USB
|
||||
|
||||
The firmware includes experimental support for acting as a USB-Serial
|
||||
peripheral. Debug logging will be sent there by default (unless build
|
||||
with logging via semihosting.)
|
||||
|
||||
**Caveat:** This logging does not flush its output. Doing so would
|
||||
hang indefinitely if the output is not read by the USB host. Therefore
|
||||
output will be truncated when USB buffers are full.
|
||||
|
||||
|
||||
## Temperature measurement
|
||||
|
||||
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.
|
||||
|
||||
Set the base temperature in degrees celsius for the channel 0 thermistor:
|
||||
```
|
||||
b-p 0 t0 20
|
||||
```
|
||||
|
||||
Set the resistance in Ohms measured at the base temperature t0:
|
||||
```
|
||||
b-p 0 r0 10000
|
||||
```
|
||||
|
||||
Set the Beta parameter:
|
||||
```
|
||||
b-p 0 b 3800
|
||||
```
|
||||
|
||||
### 50/60 Hz filtering
|
||||
|
||||
The AD7172-2 ADC on the SENS inputs supports simultaneous rejection of
|
||||
50 Hz ± 1 Hz and 60 Hz ± 1 Hz (dB). Affecting sampling rate, the
|
||||
postfilter rate can be tuned with the `postfilter` command.
|
||||
|
||||
| Postfilter rate | Rejection | Effective sampling rate |
|
||||
| --- | :---: | --- |
|
||||
| 16.667 Hz | 90 dB | 8.4 Hz |
|
||||
| 20 Hz | 85 dB | 9.1 Hz |
|
||||
| 25 Hz | 62 dB | 10 Hz |
|
||||
| 27.27 Hz | 47 dB | 10.41 Hz |
|
||||
|
||||
## Thermo-Electric Cooling (TEC)
|
||||
|
||||
- Connect TEC module device 0 to TEC0- and TEC0+.
|
||||
- Connect TEC module device 1 to TEC1- and TEC1+.
|
||||
- The GND pin is for shielding not for sinking TEC module currents.
|
||||
|
||||
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
|
||||
output limits.
|
||||
|
||||
Use the `output` command to see them.
|
||||
|
||||
| Limit | Unit | Description |
|
||||
| --- | :---: | --- |
|
||||
| `max_v` | Volts | Maximum voltage |
|
||||
| `max_i_pos` | Amperes | Maximum positive current |
|
||||
| `max_i_neg` | Amperes | Maximum negative current |
|
||||
|
||||
Example: set the maximum voltage of channel 0 to 1.5 V.
|
||||
```
|
||||
output 0 max_v 1.5
|
||||
```
|
||||
|
||||
Example: set the maximum negative current of channel 0 to -2 A.
|
||||
```
|
||||
output 0 max_i_neg 2
|
||||
```
|
||||
|
||||
Example: set the maximum positive current of channel 1 to 2 A.
|
||||
```
|
||||
output 1 max_i_pos 2
|
||||
```
|
||||
|
||||
### 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
|
||||
channel.
|
||||
|
||||
Example: set output current of channel 0 to 0 A.
|
||||
```
|
||||
output 0 i_set 0
|
||||
```
|
||||
|
||||
## PID-stabilized temperature control
|
||||
|
||||
Set the target temperature of channel 0 to 20 degrees celsius:
|
||||
```
|
||||
pid 0 target 20
|
||||
```
|
||||
|
||||
Enter closed-loop mode by switching control of the TEC output current
|
||||
of channel 0 to the PID algorithm:
|
||||
```
|
||||
output 0 pid
|
||||
```
|
||||
|
||||
### PID output clamping
|
||||
|
||||
It is possible to clamp the PID algorithm output independently of channel output limits. This is desirable when e.g. there is a need to keep the current value above a certain threshold in closed-loop mode.
|
||||
|
||||
Note that the actual output will still ultimately be limited by the `max_i_pos` and `max_i_neg` values.
|
||||
|
||||
Set PID maximum output of channel 0 to 1.5 A.
|
||||
```
|
||||
pid 0 output_max 1.5
|
||||
```
|
||||
|
||||
Set PID minimum output of channel 0 to 0.1 A.
|
||||
```
|
||||
pid 0 output_min 0.1
|
||||
```
|
||||
|
||||
## LED indicators
|
||||
|
||||
| Name | Color | Meaning |
|
||||
| --- | :---: | --- |
|
||||
| L1 | Red | Firmware initializing |
|
||||
| L3 | Green | Closed-loop mode (PID engaged) |
|
||||
| L4 | Green | Firmware busy |
|
||||
|
||||
## Reports
|
||||
|
||||
Use the bare `report` command to obtain a single report. 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` |
|
||||
| `pid_engaged` | Boolean | `true` if in closed-loop mode |
|
||||
| `i_set` | Amperes | TEC output current |
|
||||
| `dac_value` | Volts | AD5680 output derived from `i_set` |
|
||||
| `dac_feedback` | Volts | ADC measurement of the AD5680 output |
|
||||
| `i_tec` | Volts | MAX1968 TEC current monitor |
|
||||
| `tec_i` | Amperes | TEC output current feedback derived from `i_tec` |
|
||||
| `tec_u_meas` | Volts | Measurement of the voltage across the TEC |
|
||||
| `pid_output` | Amperes | PID control output |
|
||||
|
||||
Note: Prior to Thermostat hardware revision v2.2.4, the voltage and current readouts `i_tec` and `tec_i` are noisy without the hardware fix shown in [this PR](https://git.m-labs.hk/M-Labs/thermostat/pulls/105).
|
||||
|
||||
## PID Tuning
|
||||
|
||||
The thermostat implements a PID control loop for each of the TEC channels, more details on setting up the PID control loop can be found [here](./doc/PID%20tuning.md).
|
||||
|
||||
## Fan control
|
||||
|
||||
Fan control commands are available for thermostat revisions with an integrated fan system:
|
||||
1. `fan` - show fan stats: `fan_pwm`, `abs_max_tec_i`, `auto_mode`, `k_a`, `k_b`, `k_c`.
|
||||
2. `fan auto` - enable auto speed controller mode, where fan speed is controlled by the fan curve `fcurve`.
|
||||
3. `fan <value>` - set the fan power with the value from `1` to `100` and disable auto mode. There is no way to completely disable the fan.
|
||||
Please note that power doesn't correlate with the actual speed linearly.
|
||||
4. `fcurve <a> <b> <c>` - set coefficients of the controlling curve `a*x^2 + b*x + c`, where `x` is `abs_max_tec_i/MAX_TEC_I`, a normalized value in range [0,1],
|
||||
i.e. the (linear) proportion of current output capacity used, on the channel with the largest current flow. The controlling curve is also clamped to [0,1].
|
||||
5. `fcurve default` - restore fan curve coefficients to defaults: `a = 1.0, b = 0.0, c = 0.0`.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"0ma8dxsw90jrbxb3cd873k98g3pixnqvb059blvg7kf4m5aj9fnq"
|
||||
81
doc/PID tuning.md
Normal file
81
doc/PID tuning.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# PID Tuning
|
||||
|
||||
## Note on hardware setup
|
||||
|
||||
The heat sinking side of the TEC module should be thermally bonded to a large heat-sinking thermal mass to ensure maximum temperature stability, a large optical table had provided good results in tests.
|
||||
|
||||
The thermal load under control should be well insulated from the surrounding for maximum stability, closed cell foam had been tested showing good results.
|
||||
|
||||
## Real time plot
|
||||
|
||||
When tuning Thermostat PID parameters, it is helpful to view the temperature, PID output and other data in the form of a real time graph.
|
||||
|
||||
To use the Python real-time plotting utility, run
|
||||
|
||||
```shell
|
||||
python pythermostat/pythermostat/plot.py
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Temperature Setpoints and Thermal Load
|
||||
|
||||
A PID controller with the same set of PID parameters may not work identically across all temperatures, especially when comparing the performance of a TEC module cooling a load versus heating a load. This is due to self ohmic heating of the TEC module aiding efficiency when heating, but harming efficiency when cooling.
|
||||
|
||||
When a PID loop is expected to operate the TEC in both heating and cooling modes, it is important to verify the loop performance in both modes.
|
||||
|
||||
For systems expected to operate at a narrow range of temperatures, it is a good idea to tune the PID loop at the temperature region of interest.
|
||||
|
||||
The same is also true for controlling loads that are expected to produce heat, e.g. laser cooling blocks. Testing the loop performance across varying amount of thermal load is needed to ensure stability in operation.
|
||||
|
||||
## Manual Tuning
|
||||
|
||||
Below are some general guidelines for manually tuning PID loops. Note that every system is different, and some of the values mentioned below may not apply to all systems.
|
||||
|
||||
1. To start the manual tuning process, set the kp, ki and kd parameters to 0.
|
||||
|
||||
2. Begin by increasing kp until the temperature begins to oscillate. Offset between the target temperature and the actual temperature can be ignored for now.
|
||||
|
||||
3. Reduce kp by 30%, increase ki until the offset between target and actual temperature is eliminated.
|
||||
|
||||
4. Increase kd until the maximum allowable amount of overshoot is observed.
|
||||
|
||||
5. Some tweaking will be needed to obtain the desired result, especially when trying to balance between minimizing overshoot and maximizing response speed.
|
||||
|
||||
## 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.
|
||||
|
||||
To run the auto tuning utility, run
|
||||
|
||||
```shell
|
||||
python pythermostat/pythermostat/autotune.py
|
||||
```
|
||||
|
||||
After some time, the auto tuning utility will output the auto tuning results, below is a sample output
|
||||
|
||||
```shell
|
||||
Ku: 0.7553203471147422
|
||||
Pu: 75.93899999999977
|
||||
rule: ziegler-nichols
|
||||
kp: 0.45319220826884526
|
||||
Ki: 0.011935690706194357
|
||||
Kd: 4.301870387965967
|
||||
rule: tyreus-luyben
|
||||
kp: 0.3432930977636503
|
||||
Ki: 0.0020549280832497956
|
||||
Kd: 4.137825730504864
|
||||
.
|
||||
.
|
||||
.
|
||||
```
|
||||
|
||||
At the end of the test, the ultimate gain `Ku`, oscillation period `Pu` and a few sets of recommended PID parameters are calculated and displayed.
|
||||
|
||||
Multiple suggested sets of PID parameters based on different calculation rules are displayed. While all sets are expected to work, the different sets trade off response time with overshoot differently, and testing is needed to see which set works best for the system on hand.
|
||||
|
||||
With a well designed and constructed setup, the PID parameters calculated by the auto tune utility together with some manual tweaking can yield sub-mK control stability.
|
||||
|
||||
Below shows data captured on an experiment setup, with 300uK stability over 12 hours.
|
||||
|
||||

|
||||
BIN
doc/assets/default view.png
Normal file
BIN
doc/assets/default view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
doc/assets/twelve_hours.png
Normal file
BIN
doc/assets/twelve_hours.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
48
flake.lock
generated
Normal file
48
flake.lock
generated
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1775811116,
|
||||
"narHash": "sha256-t+HZK42pB6N+i5RGbuy7Xluez/VvWbembBdvzsc23Ss=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "54170c54449ea4d6725efd30d719c5e505f1c10e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775877051,
|
||||
"narHash": "sha256-wpSQm2PD/w4uRo2wb8utk0b5hOBkkg/CZ1xICY+qB7M=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "08b4f3633471874c8894632ade1b78d75dbda002",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
123
flake.nix
Normal file
123
flake.nix
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
description = "Firmware for the Sinara 8451 Thermostat";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
inputs.rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
}: let
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [(import rust-overlay)];
|
||||
};
|
||||
|
||||
rust = pkgs.rust-bin.stable."1.66.0".default.override {
|
||||
extensions = ["rust-src"];
|
||||
targets = ["thumbv7em-none-eabihf"];
|
||||
};
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
rustc = rust;
|
||||
cargo = rust;
|
||||
};
|
||||
|
||||
thermostat = rustPlatform.buildRustPackage {
|
||||
name = "thermostat";
|
||||
version = "0.0.0";
|
||||
|
||||
src = self;
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
outputHashes = {
|
||||
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
|
||||
};
|
||||
};
|
||||
|
||||
nativeBuildInputs = [pkgs.llvm];
|
||||
|
||||
buildPhase = ''
|
||||
cargo build --release --bin thermostat
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out $out/nix-support
|
||||
cp target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.elf
|
||||
echo file binary-dist $out/thermostat.elf >> $out/nix-support/hydra-build-products
|
||||
llvm-objcopy -O binary target/thumbv7em-none-eabihf/release/thermostat $out/thermostat.bin
|
||||
echo file binary-dist $out/thermostat.bin >> $out/nix-support/hydra-build-products
|
||||
'';
|
||||
|
||||
dontFixup = true;
|
||||
auditable = false;
|
||||
};
|
||||
|
||||
pythermostat = pkgs.python3Packages.buildPythonPackage {
|
||||
pname = "pythermostat";
|
||||
version = "0.0.0";
|
||||
format = "pyproject";
|
||||
src = "${self}/pythermostat";
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.python3Packages.setuptools
|
||||
pkgs.qt6.wrapQtAppsHook
|
||||
];
|
||||
propagatedBuildInputs =
|
||||
[pkgs.qt6.qtbase]
|
||||
++ (with pkgs.python3Packages; [
|
||||
numpy
|
||||
matplotlib
|
||||
pyqtgraph
|
||||
pyqt6
|
||||
qasync
|
||||
pglive
|
||||
]);
|
||||
|
||||
dontWrapQtApps = true;
|
||||
postFixup = ''
|
||||
wrapQtApp "$out/bin/thermostat_control_panel"
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
packages.x86_64-linux = {
|
||||
inherit thermostat pythermostat;
|
||||
default = thermostat;
|
||||
};
|
||||
|
||||
apps.x86_64-linux.control_panel = {
|
||||
type = "app";
|
||||
program = "${pythermostat}/bin/thermostat_control_panel";
|
||||
};
|
||||
|
||||
hydraJobs = {
|
||||
inherit thermostat;
|
||||
};
|
||||
|
||||
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
|
||||
name = "thermostat-dev-shell";
|
||||
packages = with pkgs;
|
||||
[
|
||||
rust
|
||||
llvm
|
||||
openocd
|
||||
dfu-util
|
||||
rlwrap
|
||||
]
|
||||
++ (with python3Packages; [
|
||||
numpy
|
||||
matplotlib
|
||||
pyqtgraph
|
||||
pyqt6
|
||||
qasync
|
||||
pglive
|
||||
]);
|
||||
};
|
||||
|
||||
formatter.x86_64-linux = pkgs.alejandra;
|
||||
};
|
||||
}
|
||||
11
memory.x
11
memory.x
@@ -1,10 +1,17 @@
|
||||
MEMORY
|
||||
{
|
||||
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 2048K
|
||||
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 112K
|
||||
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 1024K
|
||||
/* reserved for config data */
|
||||
CONFIG (rx) : ORIGIN = 0x8100000, LENGTH = 16K
|
||||
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 112K - 4
|
||||
/* reserved for DFU trigger message */
|
||||
DFU_MSG (wrx) : ORIGIN = 0x2001BFFC, LENGTH = 4
|
||||
RAM2 (xrw) : ORIGIN = 0x2001C000, LENGTH = 16K
|
||||
RAM3 (xrw) : ORIGIN = 0x20020000, LENGTH = 64K
|
||||
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
|
||||
}
|
||||
|
||||
_flash_start = ORIGIN(FLASH);
|
||||
_config_start = ORIGIN(CONFIG);
|
||||
_dfu_msg = ORIGIN(DFU_MSG);
|
||||
_stack_start = ORIGIN(CCMRAM) + LENGTH(CCMRAM);
|
||||
|
||||
4
pythermostat/MANIFEST.in
Normal file
4
pythermostat/MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
||||
graft examples
|
||||
include pythermostat/gui/resources/artiq.svg
|
||||
include pythermostat/gui/view/param_tree.json
|
||||
include pythermostat/gui/view/MainWindow.ui
|
||||
27
pythermostat/pyproject.toml
Normal file
27
pythermostat/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pythermostat"
|
||||
version = "0.0"
|
||||
authors = [{name = "M-Labs"}]
|
||||
description = "Python utilities for the Sinara 8451 Thermostat"
|
||||
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
|
||||
license = {text = "GPLv3"}
|
||||
dependencies = [
|
||||
"numpy >= 1.26.4",
|
||||
"matplotlib >= 3.8.4",
|
||||
"pyqtgraph >= 0.13.7",
|
||||
"pyqt6 >= 6.7.0",
|
||||
"qasync >= 0.27.1",
|
||||
"pglive >= 0.7.2",
|
||||
]
|
||||
|
||||
[project.gui-scripts]
|
||||
thermostat_plot = "pythermostat.plot:main"
|
||||
thermostat_control_panel = "pythermostat.control_panel:main"
|
||||
|
||||
[project.scripts]
|
||||
thermostat_autotune = "pythermostat.autotune:main"
|
||||
thermostat_test = "pythermostat.test:main"
|
||||
0
pythermostat/pythermostat/__init__.py
Normal file
0
pythermostat/pythermostat/__init__.py
Normal file
240
pythermostat/pythermostat/aioclient.py
Normal file
240
pythermostat/pythermostat/aioclient.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
||||
class CommandError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AsyncioClient:
|
||||
def __init__(self):
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
self._read_lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, host="192.168.1.26", port=23):
|
||||
"""Connect to Thermostat at specified host and port.
|
||||
|
||||
Example::
|
||||
thermostat = AsyncioClient()
|
||||
await client.connect()
|
||||
"""
|
||||
self._reader, self._writer = await asyncio.open_connection(host, port)
|
||||
await self._check_zero_limits()
|
||||
|
||||
def connected(self):
|
||||
"""Returns True if client is connected"""
|
||||
return self._writer is not None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the Thermostat"""
|
||||
|
||||
if self._writer is None:
|
||||
return
|
||||
|
||||
# Reader needn't be closed
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
|
||||
async def _check_zero_limits(self):
|
||||
output_report = await self.get_output()
|
||||
for output_channel in output_report:
|
||||
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
|
||||
if output_channel[limit] == 0.0:
|
||||
logging.warning(
|
||||
"`%s` limit is set to zero on channel %d",
|
||||
limit,
|
||||
output_channel["channel"],
|
||||
)
|
||||
|
||||
async def _read_line(self):
|
||||
# read 1 line
|
||||
async with self._read_lock:
|
||||
chunk = await self._reader.readline()
|
||||
return chunk.decode("utf-8", errors="ignore")
|
||||
|
||||
async def _read_write(self, command):
|
||||
self._writer.write(((" ".join(command)).strip() + "\n").encode("utf-8"))
|
||||
await self._writer.drain()
|
||||
|
||||
return await self._read_line()
|
||||
|
||||
async def _command(self, *command):
|
||||
line = await self._read_write(command)
|
||||
|
||||
response = json.loads(line)
|
||||
if "error" in response:
|
||||
raise CommandError(response["error"])
|
||||
return response
|
||||
|
||||
async def _get_conf(self, topic):
|
||||
result = [None, None]
|
||||
for item in await self._command(topic):
|
||||
result[int(item["channel"])] = item
|
||||
return result
|
||||
|
||||
async def get_output(self):
|
||||
"""Retrieve output limits for the TEC
|
||||
|
||||
Example::
|
||||
[{'channel': 0,
|
||||
'center': 'vref',
|
||||
'i_set': -0.02002179650216762,
|
||||
'max_i_neg': 2.0,
|
||||
'max_v': : 3.988,
|
||||
'max_i_pos': 2.0,
|
||||
'polarity': 'normal'},
|
||||
{'channel': 1,
|
||||
'center': 'vref',
|
||||
'i_set': -0.02002179650216762,
|
||||
'max_i_neg': 2.0,
|
||||
'max_v': : 3.988,
|
||||
'max_i_pos': 2.0,
|
||||
'polarity': 'normal'},
|
||||
]
|
||||
"""
|
||||
return await self._get_conf("output")
|
||||
|
||||
async def get_pid(self):
|
||||
"""Retrieve PID control state
|
||||
|
||||
Example::
|
||||
[{'channel': 0,
|
||||
'parameters': {
|
||||
'kp': 10.0,
|
||||
'ki': 0.02,
|
||||
'kd': 0.0,
|
||||
'output_min': 0.0,
|
||||
'output_max': 3.0},
|
||||
'target': 37.0},
|
||||
{'channel': 1,
|
||||
'parameters': {
|
||||
'kp': 10.0,
|
||||
'ki': 0.02,
|
||||
'kd': 0.0,
|
||||
'output_min': 0.0,
|
||||
'output_max': 3.0},
|
||||
'target': 36.5}]
|
||||
"""
|
||||
return await self._get_conf("pid")
|
||||
|
||||
async def get_b_parameter(self):
|
||||
"""
|
||||
Retrieve B-Parameter equation parameters for resistance to
|
||||
temperature conversion
|
||||
|
||||
Example::
|
||||
[{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0},
|
||||
{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}]
|
||||
"""
|
||||
return await self._get_conf("b-p")
|
||||
|
||||
async def get_postfilter(self):
|
||||
"""Retrieve DAC postfilter configuration
|
||||
|
||||
Example::
|
||||
[{'rate': None, 'channel': 0},
|
||||
{'rate': 21.25, 'channel': 1}]
|
||||
"""
|
||||
return await self._get_conf("postfilter")
|
||||
|
||||
async def get_report(self):
|
||||
"""Obtain one-time report on measurement values
|
||||
|
||||
Example of yielded data:
|
||||
{'channel': 0,
|
||||
'time': 2302524,
|
||||
'interval': 0.12
|
||||
'adc': 0.6199188965423515,
|
||||
'sens': 6138.519310282602,
|
||||
'temperature': 36.87032392655527,
|
||||
'pid_engaged': True,
|
||||
'i_set': 2.0635816680889123,
|
||||
'dac_value': 2.527790834044456,
|
||||
'dac_feedback': 2.523,
|
||||
'i_tec': 2.331,
|
||||
'tec_i': 2.0925,
|
||||
'tec_u_meas': 2.5340000000000003,
|
||||
'pid_output': 2.067581958092247}
|
||||
"""
|
||||
return await self._command("report")
|
||||
|
||||
async def get_ipv4(self):
|
||||
"""Get the IPv4 settings of the Thermostat"""
|
||||
return await self._command("ipv4")
|
||||
|
||||
async def get_fan(self):
|
||||
"""Get Thermostat current fan settings"""
|
||||
return await self._command("fan")
|
||||
|
||||
async def get_hwrev(self):
|
||||
"""Get Thermostat hardware revision"""
|
||||
return await self._command("hwrev")
|
||||
|
||||
async def set_param(self, topic, channel, field="", value=""):
|
||||
"""Set configuration parameters
|
||||
|
||||
Examples::
|
||||
await thermostat.set_param("output", 0, "max_v", 2.0)
|
||||
await thermostat.set_param("pid", 1, "output_max", 2.5)
|
||||
await thermostat.set_param("b-p", 0, "t0", 20.0)
|
||||
await thermostat.set_param("center", 0, "vref")
|
||||
await thermostat.set_param("postfilter", 1, 21)
|
||||
|
||||
See the firmware's README.md for a full list.
|
||||
"""
|
||||
if isinstance(value, float):
|
||||
value = f"{value:f}"
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
await self._command(topic, str(channel), field, value)
|
||||
|
||||
async def power_up(self, channel, target):
|
||||
"""Start closed-loop mode"""
|
||||
await self.set_param("pid", channel, "target", value=target)
|
||||
await self.set_param("output", channel, "pid")
|
||||
|
||||
async def save_config(self, channel=""):
|
||||
"""Save current configuration to EEPROM"""
|
||||
await self._command("save", str(channel))
|
||||
if channel == "":
|
||||
await self._read_line() # Read the extra {}
|
||||
|
||||
async def load_config(self, channel=""):
|
||||
"""Load current configuration from EEPROM"""
|
||||
await self._command("load", str(channel))
|
||||
if channel == "":
|
||||
await self._read_line() # Read the extra {}
|
||||
|
||||
async def reset(self):
|
||||
"""Reset the Thermostat
|
||||
|
||||
The client is disconnected as the TCP session is terminated.
|
||||
"""
|
||||
self._writer.write("reset\n".encode("utf-8"))
|
||||
await self._writer.drain()
|
||||
|
||||
await self.disconnect()
|
||||
|
||||
async def enter_dfu_mode(self):
|
||||
"""Put the Thermostat in DFU mode
|
||||
|
||||
The client is disconnected as the Thermostat stops responding to
|
||||
TCP commands in DFU mode. To exit it, submit a DFU leave request
|
||||
or power-cycle the Thermostat.
|
||||
"""
|
||||
self._writer.write("dfu\n".encode("utf-8"))
|
||||
await self._writer.drain()
|
||||
|
||||
await self.disconnect()
|
||||
|
||||
async def set_fan(self, power="auto"):
|
||||
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
|
||||
await self._command("fan", str(power))
|
||||
|
||||
async def set_fcurve(self, a=1.0, b=0.0, c=0.0):
|
||||
"""Set fan controller curve coefficients"""
|
||||
await self._command("fcurve", str(a), str(b), str(c))
|
||||
335
pythermostat/pythermostat/autotune.py
Normal file
335
pythermostat/pythermostat/autotune.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import argparse
|
||||
import math
|
||||
import logging
|
||||
import time
|
||||
from collections import deque, namedtuple
|
||||
from enum import Enum, auto
|
||||
|
||||
from pythermostat.client import Client
|
||||
|
||||
# Based on hirshmann pid-autotune libiary
|
||||
# See https://github.com/hirschmann/pid-autotune
|
||||
# Which is in turn based on a fork of Arduino PID AutoTune Library
|
||||
# See https://github.com/t0mpr1c3/Arduino-PID-AutoTune-Library
|
||||
|
||||
|
||||
def get_argparser():
|
||||
parser = argparse.ArgumentParser(description="Thermostat PID Autotuning Utility")
|
||||
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--channel",
|
||||
default=0,
|
||||
type=int,
|
||||
help="Thermostat channel to autotune",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--target",
|
||||
default=20,
|
||||
type=float,
|
||||
help="Target temperature of the autotune routine, degrees Celcius",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--step",
|
||||
default=1,
|
||||
type=float,
|
||||
help="Value by which output will be increased/decreased from zero, amps",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--lookback",
|
||||
default=3,
|
||||
type=float,
|
||||
help="Reference period for local minima/maxima, seconds",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--noiseband",
|
||||
default=1.5,
|
||||
type=float,
|
||||
help="Determines by how much the input value must overshoot/undershoot the setpoint, degrees Celcius",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--log",
|
||||
dest="logLevel",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Set the logging level",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
class PIDAutotuneState(Enum):
|
||||
OFF = auto()
|
||||
RELAY_STEP_UP = auto()
|
||||
RELAY_STEP_DOWN = auto()
|
||||
SUCCEEDED = auto()
|
||||
FAILED = auto()
|
||||
READY = auto()
|
||||
|
||||
|
||||
class PIDAutotune:
|
||||
PIDParams = namedtuple('PIDParams', ['Kp', 'Ki', 'Kd'])
|
||||
|
||||
PEAK_AMPLITUDE_TOLERANCE = 0.05
|
||||
|
||||
_tuning_rules = {
|
||||
"ziegler-nichols": [0.6, 1.2, 0.075],
|
||||
"tyreus-luyben": [0.4545, 0.2066, 0.07214],
|
||||
"ciancone-marlin": [0.303, 0.1364, 0.0481],
|
||||
"pessen-integral": [0.7, 1.75, 0.105],
|
||||
"some-overshoot": [0.333, 0.667, 0.111],
|
||||
"no-overshoot": [0.2, 0.4, 0.0667]
|
||||
}
|
||||
|
||||
def __init__(self, setpoint, out_step=10, lookback=60,
|
||||
noiseband=0.5, sampletime=1.2):
|
||||
if setpoint is None:
|
||||
raise ValueError('setpoint must be specified')
|
||||
|
||||
self._inputs = deque(maxlen=round(lookback / sampletime))
|
||||
self._setpoint = setpoint
|
||||
self._outputstep = out_step
|
||||
self._noiseband = noiseband
|
||||
self._out_min = -out_step
|
||||
self._out_max = out_step
|
||||
self._state = PIDAutotuneState.OFF
|
||||
self._peak_timestamps = deque(maxlen=5)
|
||||
self._peaks = deque(maxlen=5)
|
||||
self._output = 0
|
||||
self._last_run_timestamp = 0
|
||||
self._peak_type = 0
|
||||
self._peak_count = 0
|
||||
self._initial_output = 0
|
||||
self._induced_amplitude = 0
|
||||
self._Ku = 0
|
||||
self._Pu = 0
|
||||
|
||||
def set_param(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 set_ready(self):
|
||||
self._state = PIDAutotuneState.READY
|
||||
self._peak_count = 0
|
||||
|
||||
def set_off(self):
|
||||
self._state = PIDAutotuneState.OFF
|
||||
|
||||
def state(self):
|
||||
"""Get the current state."""
|
||||
return self._state
|
||||
|
||||
def output(self):
|
||||
"""Get the last output value."""
|
||||
return self._output
|
||||
|
||||
def tuning_rules(self):
|
||||
"""Get a list of all available tuning rules."""
|
||||
return self._tuning_rules.keys()
|
||||
|
||||
def get_pid_parameters(self, tuning_rule='ziegler-nichols'):
|
||||
"""Get PID parameters.
|
||||
|
||||
Args:
|
||||
tuning_rule (str): Sets the rule which should be used to calculate
|
||||
the parameters.
|
||||
"""
|
||||
divisors = self._tuning_rules[tuning_rule]
|
||||
kp = self._Ku * divisors[0]
|
||||
ki = divisors[1] * self._Ku / self._Pu
|
||||
kd = divisors[2] * self._Ku * self._Pu
|
||||
return PIDAutotune.PIDParams(kp, ki, kd)
|
||||
|
||||
def run(self, input_val, time_input):
|
||||
"""To autotune a system, this method must be called periodically.
|
||||
|
||||
Args:
|
||||
input_val (float): The temperature input value.
|
||||
time_input (float): Current time in seconds.
|
||||
|
||||
Returns:
|
||||
`true` if tuning is finished, otherwise `false`.
|
||||
"""
|
||||
now = time_input * 1000
|
||||
|
||||
if self._state not in {
|
||||
PIDAutotuneState.RELAY_STEP_DOWN,
|
||||
PIDAutotuneState.RELAY_STEP_UP,
|
||||
}:
|
||||
self._state = PIDAutotuneState.RELAY_STEP_UP
|
||||
|
||||
self._last_run_timestamp = now
|
||||
|
||||
# check input and change relay state if necessary
|
||||
if (self._state == PIDAutotuneState.RELAY_STEP_UP
|
||||
and input_val > self._setpoint + self._noiseband):
|
||||
self._state = PIDAutotuneState.RELAY_STEP_DOWN
|
||||
logging.debug('switched state: {0}'.format(self._state))
|
||||
logging.debug('input: {0}'.format(input_val))
|
||||
elif (self._state == PIDAutotuneState.RELAY_STEP_DOWN
|
||||
and input_val < self._setpoint - self._noiseband):
|
||||
self._state = PIDAutotuneState.RELAY_STEP_UP
|
||||
logging.debug('switched state: {0}'.format(self._state))
|
||||
logging.debug('input: {0}'.format(input_val))
|
||||
|
||||
# set output
|
||||
if self._state == PIDAutotuneState.RELAY_STEP_UP:
|
||||
self._output = self._initial_output - self._outputstep
|
||||
elif self._state == PIDAutotuneState.RELAY_STEP_DOWN:
|
||||
self._output = self._initial_output + self._outputstep
|
||||
|
||||
# respect output limits
|
||||
self._output = min(self._output, self._out_max)
|
||||
self._output = max(self._output, self._out_min)
|
||||
|
||||
# identify peaks
|
||||
is_max = True
|
||||
is_min = True
|
||||
|
||||
for val in self._inputs:
|
||||
is_max = is_max and (input_val >= val)
|
||||
is_min = is_min and (input_val <= val)
|
||||
|
||||
self._inputs.append(input_val)
|
||||
|
||||
# we don't trust the maxes or mins until the input array is full
|
||||
if len(self._inputs) < self._inputs.maxlen:
|
||||
return False
|
||||
|
||||
# increment peak count and record peak time for maxima and minima
|
||||
inflection = False
|
||||
|
||||
# peak types:
|
||||
# -1: minimum
|
||||
# +1: maximum
|
||||
if is_max:
|
||||
if self._peak_type == -1:
|
||||
inflection = True
|
||||
self._peak_type = 1
|
||||
elif is_min:
|
||||
if self._peak_type == 1:
|
||||
inflection = True
|
||||
self._peak_type = -1
|
||||
|
||||
# update peak times and values
|
||||
if inflection:
|
||||
self._peak_count += 1
|
||||
self._peaks.append(input_val)
|
||||
self._peak_timestamps.append(now)
|
||||
logging.debug('found peak: {0}'.format(input_val))
|
||||
logging.debug('peak count: {0}'.format(self._peak_count))
|
||||
|
||||
# check for convergence of induced oscillation
|
||||
# convergence of amplitude assessed on last 4 peaks (1.5 cycles)
|
||||
self._induced_amplitude = 0
|
||||
|
||||
if inflection and (self._peak_count > 4):
|
||||
abs_max = self._peaks[-2]
|
||||
abs_min = self._peaks[-2]
|
||||
for i in range(0, len(self._peaks) - 2):
|
||||
self._induced_amplitude += abs(self._peaks[i]
|
||||
- self._peaks[i+1])
|
||||
abs_max = max(self._peaks[i], abs_max)
|
||||
abs_min = min(self._peaks[i], abs_min)
|
||||
|
||||
self._induced_amplitude /= 6.0
|
||||
|
||||
# check convergence criterion for amplitude of induced oscillation
|
||||
amplitude_dev = ((0.5 * (abs_max - abs_min)
|
||||
- self._induced_amplitude)
|
||||
/ self._induced_amplitude)
|
||||
|
||||
logging.debug('amplitude: {0}'.format(self._induced_amplitude))
|
||||
logging.debug('amplitude deviation: {0}'.format(amplitude_dev))
|
||||
|
||||
if amplitude_dev < PIDAutotune.PEAK_AMPLITUDE_TOLERANCE:
|
||||
self._state = PIDAutotuneState.SUCCEEDED
|
||||
|
||||
# if the autotune has not already converged
|
||||
# terminate after 10 cycles
|
||||
if self._peak_count >= 20:
|
||||
self._output = 0
|
||||
self._state = PIDAutotuneState.FAILED
|
||||
return True
|
||||
|
||||
if self._state == PIDAutotuneState.SUCCEEDED:
|
||||
self._output = 0
|
||||
logging.debug('peak finding successful')
|
||||
|
||||
# calculate ultimate gain
|
||||
self._Ku = 4.0 * self._outputstep / \
|
||||
(self._induced_amplitude * math.pi)
|
||||
print('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))
|
||||
|
||||
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))
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
args = get_argparser().parse_args()
|
||||
if args.logLevel:
|
||||
logging.basicConfig(level=getattr(logging, args.logLevel))
|
||||
|
||||
# Auto tune parameters
|
||||
# Thermostat channel
|
||||
channel = args.channel
|
||||
# Target temperature of the autotune routine, celcius
|
||||
target_temperature = args.target
|
||||
# Value by which output will be increased/decreased from zero, amps
|
||||
output_step = args.step
|
||||
# Reference period for local minima/maxima, seconds
|
||||
lookback = args.lookback
|
||||
# Determines by how much the input value must
|
||||
# overshoot/undershoot the setpoint, celcius
|
||||
noiseband = args.noiseband
|
||||
|
||||
thermostat = Client()
|
||||
|
||||
data = thermostat.get_report()
|
||||
ch = data[channel]
|
||||
|
||||
tuner = PIDAutotune(target_temperature, output_step,
|
||||
lookback, noiseband, ch['interval'])
|
||||
|
||||
while True:
|
||||
data = thermostat.get_report()
|
||||
|
||||
ch = data[channel]
|
||||
|
||||
temperature = ch['temperature']
|
||||
|
||||
if (tuner.run(temperature, ch['time'])):
|
||||
break
|
||||
|
||||
tuner_out = tuner.output()
|
||||
|
||||
thermostat.set_param("output", channel, "i_set", tuner_out)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
thermostat.set_param("output", channel, "i_set", 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
207
pythermostat/pythermostat/client.py
Normal file
207
pythermostat/pythermostat/client.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import socket
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
||||
class CommandError(Exception):
|
||||
pass
|
||||
|
||||
class Client:
|
||||
def __init__(self, host="192.168.1.26", port=23, timeout=None):
|
||||
self._socket = socket.create_connection((host, port), timeout)
|
||||
self._lines = [""]
|
||||
self._check_zero_limits()
|
||||
|
||||
def disconnect(self):
|
||||
self._socket.shutdown(socket.SHUT_RDWR)
|
||||
self._socket.close()
|
||||
|
||||
def _check_zero_limits(self):
|
||||
output_report = self.get_output()
|
||||
for output_channel in output_report:
|
||||
for limit in ["max_i_neg", "max_i_pos", "max_v"]:
|
||||
if output_channel[limit] == 0.0:
|
||||
logging.warning("`{}` limit is set to zero on channel {}".format(limit, output_channel["channel"]))
|
||||
|
||||
def _read_line(self):
|
||||
# read more lines
|
||||
while len(self._lines) <= 1:
|
||||
chunk = self._socket.recv(4096)
|
||||
if not chunk:
|
||||
return None
|
||||
buf = self._lines[-1] + chunk.decode('utf-8', errors='ignore')
|
||||
self._lines = buf.split("\n")
|
||||
|
||||
line = self._lines[0]
|
||||
self._lines = self._lines[1:]
|
||||
return line
|
||||
|
||||
def _command(self, *command):
|
||||
self._socket.sendall((" ".join(command) + "\n").encode('utf-8'))
|
||||
|
||||
line = self._read_line()
|
||||
response = json.loads(line)
|
||||
if "error" in response:
|
||||
raise CommandError(response["error"])
|
||||
return response
|
||||
|
||||
def _get_conf(self, topic):
|
||||
result = [None, None]
|
||||
for item in self._command(topic):
|
||||
result[int(item["channel"])] = item
|
||||
return result
|
||||
|
||||
def get_output(self):
|
||||
"""Retrieve output limits for the TEC
|
||||
|
||||
Example::
|
||||
[{'channel': 0,
|
||||
'center': 'vref',
|
||||
'i_set': -0.02002179650216762,
|
||||
'max_i_neg': 2.0,
|
||||
'max_v': 3.988,
|
||||
'max_i_pos': 2.0,
|
||||
'polarity': 'normal',
|
||||
{'channel': 1,
|
||||
'center': 'vref',
|
||||
'i_set': -0.02002179650216762,
|
||||
'max_i_neg': 2.0,
|
||||
'max_v': 3.988,
|
||||
'max_i_pos': 2.0}
|
||||
'polarity': 'normal',
|
||||
]
|
||||
"""
|
||||
return self._get_conf("output")
|
||||
|
||||
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 self._get_conf("pid")
|
||||
|
||||
def get_b_parameter(self):
|
||||
"""Retrieve B-Parameter equation parameters for resistance to temperature conversion
|
||||
|
||||
Example::
|
||||
[{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0},
|
||||
{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}]
|
||||
"""
|
||||
return self._get_conf("b-p")
|
||||
|
||||
def get_postfilter(self):
|
||||
"""Retrieve DAC postfilter configuration
|
||||
|
||||
Example::
|
||||
[{'rate': None, 'channel': 0},
|
||||
{'rate': 21.25, 'channel': 1}]
|
||||
"""
|
||||
return self._get_conf("postfilter")
|
||||
|
||||
def get_report(self):
|
||||
"""Obtain one-time report on measurement values
|
||||
|
||||
Example of yielded data::
|
||||
{'channel': 0,
|
||||
'time': 2302524,
|
||||
'interval': 0.12
|
||||
'adc': 0.6199188965423515,
|
||||
'sens': 6138.519310282602,
|
||||
'temperature': 36.87032392655527,
|
||||
'pid_engaged': True,
|
||||
'i_set': 2.0635816680889123,
|
||||
'dac_value': 2.527790834044456,
|
||||
'dac_feedback': 2.523,
|
||||
'i_tec': 2.331,
|
||||
'tec_i': 2.0925,
|
||||
'tec_u_meas': 2.5340000000000003,
|
||||
'pid_output': 2.067581958092247}
|
||||
"""
|
||||
return self._get_conf("report")
|
||||
|
||||
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")
|
||||
|
||||
def set_param(self, topic, channel, field="", value=""):
|
||||
"""Set configuration parameters
|
||||
|
||||
Examples::
|
||||
thermostat.set_param("output", 0, "max_v", 2.0)
|
||||
thermostat.set_param("pid", 1, "output_max", 2.5)
|
||||
thermostat.set_param("b-p", 0, "t0", 20.0)
|
||||
thermostat.set_param("center", 0, "vref")
|
||||
thermostat.set_param("postfilter", 1, 21)
|
||||
|
||||
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)
|
||||
self._command(topic, str(channel), field, value)
|
||||
|
||||
def power_up(self, channel, target):
|
||||
"""Start closed-loop mode"""
|
||||
self.set_param("pid", channel, "target", value=target)
|
||||
self.set_param("output", channel, "pid")
|
||||
|
||||
def save_config(self, channel=""):
|
||||
"""Save current configuration to EEPROM"""
|
||||
self._command("save", channel)
|
||||
if channel != "":
|
||||
self._read_line() # read the extra {}
|
||||
|
||||
def load_config(self, channel=""):
|
||||
"""Load current configuration from EEPROM"""
|
||||
self._command("load", channel)
|
||||
if channel != "":
|
||||
self._read_line() # read the extra {}
|
||||
|
||||
def reset(self):
|
||||
"""Reset the device"""
|
||||
self._socket.sendall("reset".encode("utf-8"))
|
||||
self.disconnect() # resetting ends the TCP session, disconnect anyway
|
||||
|
||||
def enter_dfu_mode(self):
|
||||
"""Reset device and enters USB device firmware update (DFU) mode"""
|
||||
self._socket.sendall("dfu".encode("utf-8"))
|
||||
self.disconnect() # resetting ends the TCP session, disconnect anyway
|
||||
|
||||
def set_ipv4(self, address, netmask, gateway=""):
|
||||
"""Configure IPv4 address, netmask length, and optional default gateway"""
|
||||
self._command("ipv4", f"{address}/{netmask}", gateway)
|
||||
|
||||
def set_fan(self, power=None):
|
||||
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
|
||||
if power is None:
|
||||
power = "auto"
|
||||
self._command("fan", power)
|
||||
|
||||
def set_fcurve(self, a=1.0, b=0.0, c=0.0):
|
||||
"""Set fan controller curve coefficients"""
|
||||
self._command("fcurve", a, b, c)
|
||||
252
pythermostat/pythermostat/control_panel.py
Executable file
252
pythermostat/pythermostat/control_panel.py
Executable file
@@ -0,0 +1,252 @@
|
||||
"""GUI Control Panel for the Sinara 8451 Thermostat"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import argparse
|
||||
import importlib.resources
|
||||
import json
|
||||
from PyQt6 import QtWidgets, QtGui, uic
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
import qasync
|
||||
from qasync import asyncSlot, asyncClose
|
||||
from pythermostat.autotune import PIDAutotuneState
|
||||
from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState
|
||||
from pythermostat.gui.model.pid_autotuner import PIDAutoTuner
|
||||
from pythermostat.gui.view.settings_tree_view import SettingsTreeView
|
||||
from pythermostat.gui.view.info_box import InfoBox
|
||||
from pythermostat.gui.view.menus import PlotOptionsMenu, ThermostatSettingsMenu, ConnectionDetailsMenu
|
||||
from pythermostat.gui.view.live_plot_view import LiveDataPlotter
|
||||
from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
|
||||
|
||||
|
||||
def get_argparser():
|
||||
parser = argparse.ArgumentParser(description="Thermostat Control Panel")
|
||||
|
||||
parser.add_argument(
|
||||
"--connect",
|
||||
default=None,
|
||||
action="store_true",
|
||||
help="Automatically connect to the specified Thermostat in host:port format",
|
||||
)
|
||||
parser.add_argument("host", metavar="HOST", default=None, nargs="?")
|
||||
parser.add_argument("port", metavar="PORT", default=None, nargs="?")
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--log",
|
||||
dest="logLevel",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Set the logging level",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--param_tree",
|
||||
default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"),
|
||||
help="Param Tree Description JSON File",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
NUM_CHANNELS = 2
|
||||
|
||||
def __init__(self, args):
|
||||
super().__init__()
|
||||
|
||||
ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.ui")
|
||||
uic.loadUi(ui_file_path, self)
|
||||
|
||||
self._info_box = InfoBox()
|
||||
|
||||
# Models
|
||||
self._thermostat = Thermostat(self, self.report_refresh_spin.value())
|
||||
self._connecting_task = None
|
||||
self._thermostat.connection_state_update.connect(
|
||||
self._on_connection_state_changed
|
||||
)
|
||||
|
||||
self._autotuners = PIDAutoTuner(self, self._thermostat, 2)
|
||||
self._autotuners.autotune_state_changed.connect(
|
||||
self._on_pid_autotune_state_changed
|
||||
)
|
||||
|
||||
# Handlers for disconnections
|
||||
async def autotune_disconnect():
|
||||
for ch in range(self.NUM_CHANNELS):
|
||||
if self._autotuners.get_state(ch) != PIDAutotuneState.OFF:
|
||||
await self._autotuners.stop_pid_from_running(ch)
|
||||
|
||||
self._thermostat.disconnect_cb = autotune_disconnect
|
||||
|
||||
@pyqtSlot()
|
||||
def handle_connection_error():
|
||||
self._info_box.display_info_box(
|
||||
"Connection Error", "Thermostat connection lost. Is it unplugged?"
|
||||
)
|
||||
|
||||
self._thermostat.connection_error.connect(handle_connection_error)
|
||||
|
||||
# Settings tree view
|
||||
def get_settings_tree_view_config(args):
|
||||
with open(args.param_tree, "r", encoding="utf-8") as f:
|
||||
return json.load(f)["settings_tree"]
|
||||
|
||||
self._settings_tree_view = SettingsTreeView(
|
||||
self._thermostat,
|
||||
self._autotuners,
|
||||
self._info_box,
|
||||
[self.ch0_tree, self.ch1_tree],
|
||||
get_settings_tree_view_config(args),
|
||||
)
|
||||
|
||||
# Graphs
|
||||
self._channel_graphs = LiveDataPlotter(
|
||||
self._thermostat,
|
||||
[
|
||||
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
|
||||
for ch in range(self.NUM_CHANNELS)
|
||||
],
|
||||
)
|
||||
|
||||
# Bottom bar menus
|
||||
self.connection_details_menu = ConnectionDetailsMenu(
|
||||
self._thermostat, self.connect_btn
|
||||
)
|
||||
self.connect_btn.setMenu(self.connection_details_menu)
|
||||
|
||||
self._thermostat_settings_menu = ThermostatSettingsMenu(
|
||||
self._thermostat, self._info_box, self.style()
|
||||
)
|
||||
self.thermostat_settings.setMenu(self._thermostat_settings_menu)
|
||||
|
||||
self._plot_options_menu = PlotOptionsMenu(self._channel_graphs)
|
||||
self.plot_settings.setMenu(self._plot_options_menu)
|
||||
|
||||
# Status line
|
||||
self._zero_limits_warning_view = ZeroLimitsWarningView(
|
||||
self._thermostat, self.style(), self.limits_warning
|
||||
)
|
||||
self.loading_spinner.hide()
|
||||
|
||||
self.report_apply_btn.clicked.connect(
|
||||
lambda: self._thermostat.set_update_s(self.report_refresh_spin.value())
|
||||
)
|
||||
|
||||
@asyncClose
|
||||
async def closeEvent(self, _event):
|
||||
try:
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
except:
|
||||
pass
|
||||
|
||||
@pyqtSlot(ThermostatConnectionState)
|
||||
def _on_connection_state_changed(self, state):
|
||||
self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
|
||||
self.thermostat_settings.setEnabled(
|
||||
state == ThermostatConnectionState.CONNECTED
|
||||
)
|
||||
self.report_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
|
||||
|
||||
match state:
|
||||
case ThermostatConnectionState.CONNECTED:
|
||||
self.connect_btn.setText("Disconnect")
|
||||
self.status_lbl.setText(
|
||||
"Connected to Thermostat v"
|
||||
f"{self._thermostat.hw_rev['rev']['major']}."
|
||||
f"{self._thermostat.hw_rev['rev']['minor']}"
|
||||
)
|
||||
|
||||
case ThermostatConnectionState.CONNECTING:
|
||||
self.connect_btn.setText("Stop")
|
||||
self.status_lbl.setText("Connecting...")
|
||||
|
||||
case ThermostatConnectionState.DISCONNECTED:
|
||||
self.connect_btn.setText("Connect")
|
||||
self.status_lbl.setText("Disconnected")
|
||||
|
||||
@pyqtSlot(int, PIDAutotuneState)
|
||||
def _on_pid_autotune_state_changed(self, _ch, _state):
|
||||
autotuning_channels = []
|
||||
for ch in range(self.NUM_CHANNELS):
|
||||
if self._autotuners.get_state(ch) in {
|
||||
PIDAutotuneState.READY,
|
||||
PIDAutotuneState.RELAY_STEP_UP,
|
||||
PIDAutotuneState.RELAY_STEP_DOWN,
|
||||
}:
|
||||
autotuning_channels.append(ch)
|
||||
|
||||
if len(autotuning_channels) == 0:
|
||||
self.background_task_lbl.setText("Ready.")
|
||||
self.loading_spinner.hide()
|
||||
self.loading_spinner.stop()
|
||||
else:
|
||||
self.background_task_lbl.setText(
|
||||
f"Autotuning channel {autotuning_channels}..."
|
||||
)
|
||||
self.loading_spinner.start()
|
||||
self.loading_spinner.show()
|
||||
|
||||
@asyncSlot()
|
||||
async def on_connect_btn_clicked(self):
|
||||
match self._thermostat.connection_state:
|
||||
case ThermostatConnectionState.DISCONNECTED:
|
||||
self._connecting_task = asyncio.current_task()
|
||||
self._thermostat.connection_state = ThermostatConnectionState.CONNECTING
|
||||
await self._thermostat.start_session(
|
||||
host=self.connection_details_menu.host_set_line.text(),
|
||||
port=self.connection_details_menu.port_set_spin.value(),
|
||||
)
|
||||
self._connecting_task = None
|
||||
self._thermostat.connection_state = ThermostatConnectionState.CONNECTED
|
||||
self._thermostat.start_watching()
|
||||
|
||||
case ThermostatConnectionState.CONNECTING:
|
||||
self._connecting_task.cancel()
|
||||
self._connecting_task = None
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = (
|
||||
ThermostatConnectionState.DISCONNECTED
|
||||
)
|
||||
|
||||
case ThermostatConnectionState.CONNECTED:
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = (
|
||||
ThermostatConnectionState.DISCONNECTED
|
||||
)
|
||||
|
||||
|
||||
async def coro_main():
|
||||
args = get_argparser().parse_args()
|
||||
if args.logLevel:
|
||||
logging.basicConfig(level=getattr(logging, args.logLevel))
|
||||
|
||||
app_quit_event = asyncio.Event()
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
app.aboutToQuit.connect(app_quit_event.set)
|
||||
app.setWindowIcon(
|
||||
QtGui.QIcon(
|
||||
str(importlib.resources.files("pythermostat.gui.resources").joinpath("artiq.svg"))
|
||||
)
|
||||
)
|
||||
|
||||
main_window = MainWindow(args)
|
||||
main_window.show()
|
||||
|
||||
if args.connect:
|
||||
if args.host:
|
||||
main_window.connection_details_menu.host_set_line.setText(args.host)
|
||||
if args.port:
|
||||
main_window.connection_details_menu.port_set_spin.setValue(int(args.port))
|
||||
main_window.connect_btn.click()
|
||||
|
||||
await app_quit_event.wait()
|
||||
|
||||
|
||||
def main():
|
||||
qasync.run(coro_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
pythermostat/pythermostat/examples/aioexample.py
Normal file
37
pythermostat/pythermostat/examples/aioexample.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
from pythermostat.aioclient import AsyncioClient
|
||||
|
||||
|
||||
async def poll_for_reports(thermostat_aio):
|
||||
while True:
|
||||
print(await thermostat_aio.get_report())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
async def poll_for_settings(thermostat_aio):
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
print(await thermostat_aio.get_output())
|
||||
print(await thermostat_aio.get_pid())
|
||||
print(await thermostat_aio.get_fan())
|
||||
print(await thermostat_aio.get_postfilter())
|
||||
print(await thermostat_aio.get_b_parameter())
|
||||
|
||||
|
||||
async def main():
|
||||
thermostat_aio = AsyncioClient()
|
||||
await thermostat_aio.connect()
|
||||
await thermostat_aio.set_param("b-p", 1, "t0", 20)
|
||||
print(await thermostat_aio.get_output())
|
||||
print(await thermostat_aio.get_pid())
|
||||
print(await thermostat_aio.get_fan())
|
||||
print(await thermostat_aio.get_postfilter())
|
||||
print(await thermostat_aio.get_b_parameter())
|
||||
|
||||
# Poll both reports and settings, at different rates
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(poll_for_reports(thermostat_aio))
|
||||
tg.create_task(poll_for_settings(thermostat_aio))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
13
pythermostat/pythermostat/examples/example.py
Normal file
13
pythermostat/pythermostat/examples/example.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import time
|
||||
from pythermostat.client import Client
|
||||
|
||||
thermostat = Client() #(host="localhost", port=6667)
|
||||
thermostat.set_param("b-p", 1, "t0", 20)
|
||||
print(thermostat.get_output())
|
||||
print(thermostat.get_pid())
|
||||
print(thermostat.get_output())
|
||||
print(thermostat.get_postfilter())
|
||||
print(thermostat.get_b_parameter())
|
||||
while True:
|
||||
print(thermostat.get_report())
|
||||
time.sleep(0.05)
|
||||
84
pythermostat/pythermostat/gui/model/pid_autotuner.py
Normal file
84
pythermostat/pythermostat/gui/model/pid_autotuner.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
|
||||
from qasync import asyncSlot
|
||||
from pythermostat.autotune import PIDAutotuneState, PIDAutotune
|
||||
|
||||
|
||||
class PIDAutoTuner(QObject):
|
||||
autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
|
||||
|
||||
def __init__(self, parent, thermostat, num_of_channel):
|
||||
super().__init__(parent)
|
||||
|
||||
self._thermostat = thermostat
|
||||
self._thermostat.report_update.connect(self.tick)
|
||||
|
||||
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
|
||||
self.target_temp = [20.0 for _ in range(num_of_channel)]
|
||||
self.test_current = [1.0 for _ in range(num_of_channel)]
|
||||
self.temp_swing = [1.5 for _ in range(num_of_channel)]
|
||||
self.lookback = [3.0 for _ in range(num_of_channel)]
|
||||
self.sampling_interval = [1 / 16.67 for _ in range(num_of_channel)]
|
||||
|
||||
def set_params(self, params_name, ch, val):
|
||||
getattr(self, params_name)[ch] = val
|
||||
|
||||
def get_state(self, ch):
|
||||
return self.autotuners[ch].state()
|
||||
|
||||
def load_params_and_set_ready(self, ch):
|
||||
self.autotuners[ch].set_param(
|
||||
self.target_temp[ch],
|
||||
self.test_current[ch] / 1000,
|
||||
self.temp_swing[ch],
|
||||
1 / self.sampling_interval[ch],
|
||||
self.lookback[ch],
|
||||
)
|
||||
self.autotuners[ch].set_ready()
|
||||
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
|
||||
|
||||
async def stop_pid_from_running(self, ch):
|
||||
self.autotuners[ch].set_off()
|
||||
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
|
||||
if self._thermostat.connected():
|
||||
await self._thermostat.set_param("output", ch, "i_set", 0)
|
||||
|
||||
@asyncSlot(list)
|
||||
async def tick(self, report):
|
||||
for channel_report in report:
|
||||
ch = channel_report["channel"]
|
||||
|
||||
self.sampling_interval[ch] = channel_report["interval"]
|
||||
|
||||
# TODO: Skip when PID Autotune or emit error message if NTC is not connected
|
||||
if channel_report["temperature"] is None:
|
||||
continue
|
||||
|
||||
match self.autotuners[ch].state():
|
||||
case (
|
||||
PIDAutotuneState.READY
|
||||
| PIDAutotuneState.RELAY_STEP_UP
|
||||
| PIDAutotuneState.RELAY_STEP_DOWN
|
||||
):
|
||||
self.autotuners[ch].run(
|
||||
channel_report["temperature"], channel_report["time"]
|
||||
)
|
||||
await self._thermostat.set_param(
|
||||
"output", ch, "i_set", self.autotuners[ch].output()
|
||||
)
|
||||
case PIDAutotuneState.SUCCEEDED:
|
||||
kp, ki, kd = self.autotuners[ch].get_pid_parameters("tyreus-luyben")
|
||||
self.autotuners[ch].set_off()
|
||||
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
|
||||
|
||||
await self._thermostat.set_param("pid", ch, "kp", kp)
|
||||
await self._thermostat.set_param("pid", ch, "ki", ki)
|
||||
await self._thermostat.set_param("pid", ch, "kd", kd)
|
||||
await self._thermostat.set_param("output", ch, "pid")
|
||||
|
||||
await self._thermostat.set_param(
|
||||
"pid", ch, "target", self.target_temp[ch]
|
||||
)
|
||||
case PIDAutotuneState.FAILED:
|
||||
self.autotuners[ch].set_off()
|
||||
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
|
||||
await self._thermostat.set_param("output", ch, "i_set", 0)
|
||||
126
pythermostat/pythermostat/gui/model/property.py
Normal file
126
pythermostat/pythermostat/gui/model/property.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# A Custom Class that allows defining a QObject Property Dynamically
|
||||
# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
|
||||
|
||||
|
||||
class PropertyMeta(type(QObject)):
|
||||
"""Lets a class succinctly define Qt properties."""
|
||||
|
||||
def __new__(cls, name, bases, attrs):
|
||||
for key in list(attrs.keys()):
|
||||
attr = attrs[key]
|
||||
if not isinstance(attr, Property):
|
||||
continue
|
||||
|
||||
types = {list: "QVariantList", dict: "QVariantMap"}
|
||||
type_ = types.get(attr.type_, attr.type_)
|
||||
|
||||
notifier = pyqtSignal(type_)
|
||||
attrs[f"{key}_update"] = notifier
|
||||
attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier)
|
||||
|
||||
return super().__new__(cls, name, bases, attrs)
|
||||
|
||||
|
||||
class Property:
|
||||
"""Property definition.
|
||||
|
||||
Instances of this class will be replaced with their full
|
||||
implementation by the PropertyMeta metaclass.
|
||||
"""
|
||||
|
||||
def __init__(self, type_):
|
||||
self.type_ = type_
|
||||
|
||||
|
||||
class PropertyImpl(pyqtProperty):
|
||||
"""Property implementation: gets, sets, and notifies of change."""
|
||||
|
||||
def __init__(self, type_, name, notify):
|
||||
super().__init__(type_, self.getter, self.setter, notify=notify)
|
||||
self.name = name
|
||||
|
||||
def getter(self, instance):
|
||||
return getattr(instance, f"_{self.name}")
|
||||
|
||||
def setter(self, instance, value):
|
||||
signal = getattr(instance, f"{self.name}_update")
|
||||
|
||||
if type(value) in {list, dict}:
|
||||
value = make_notified(value, signal)
|
||||
|
||||
setattr(instance, f"_{self.name}", value)
|
||||
signal.emit(value)
|
||||
|
||||
|
||||
class MakeNotified:
|
||||
"""Adds notifying signals to lists and dictionaries.
|
||||
|
||||
Creates the modified classes just once, on initialization.
|
||||
"""
|
||||
|
||||
change_methods = {
|
||||
list: [
|
||||
"__delitem__",
|
||||
"__iadd__",
|
||||
"__imul__",
|
||||
"__setitem__",
|
||||
"append",
|
||||
"extend",
|
||||
"insert",
|
||||
"pop",
|
||||
"remove",
|
||||
"reverse",
|
||||
"sort",
|
||||
],
|
||||
dict: [
|
||||
"__delitem__",
|
||||
"__ior__",
|
||||
"__setitem__",
|
||||
"clear",
|
||||
"pop",
|
||||
"popitem",
|
||||
"setdefault",
|
||||
"update",
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(dict, "__ior__"):
|
||||
# Dictionaries don't have | operator in Python < 3.9.
|
||||
self.change_methods[dict].remove("__ior__")
|
||||
self.notified_class = {
|
||||
type_: self.make_notified_class(type_) for type_ in [list, dict]
|
||||
}
|
||||
|
||||
def __call__(self, seq, signal):
|
||||
"""Returns a notifying version of the supplied list or dict."""
|
||||
notified_class = self.notified_class[type(seq)]
|
||||
notified_seq = notified_class(seq)
|
||||
notified_seq.signal = signal
|
||||
return notified_seq
|
||||
|
||||
@classmethod
|
||||
def make_notified_class(cls, parent):
|
||||
notified_class = type(f"notified_{parent.__name__}", (parent,), {})
|
||||
for method_name in cls.change_methods[parent]:
|
||||
original = getattr(notified_class, method_name)
|
||||
notified_method = cls.make_notified_method(original, parent)
|
||||
setattr(notified_class, method_name, notified_method)
|
||||
return notified_class
|
||||
|
||||
@staticmethod
|
||||
def make_notified_method(method, parent):
|
||||
@wraps(method)
|
||||
def notified_method(self, *args, **kwargs):
|
||||
result = getattr(parent, method.__name__)(self, *args, **kwargs)
|
||||
self.signal.emit(self)
|
||||
return result
|
||||
|
||||
return notified_method
|
||||
|
||||
|
||||
make_notified = MakeNotified()
|
||||
135
pythermostat/pythermostat/gui/model/thermostat.py
Normal file
135
pythermostat/pythermostat/gui/model/thermostat.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import Enum
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
|
||||
from qasync import asyncSlot
|
||||
from pythermostat.aioclient import AsyncioClient
|
||||
from pythermostat.gui.model.property import Property, PropertyMeta
|
||||
|
||||
|
||||
class ThermostatConnectionState(Enum):
|
||||
DISCONNECTED = "disconnected"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
|
||||
|
||||
class Thermostat(QObject, metaclass=PropertyMeta):
|
||||
connection_state = Property(ThermostatConnectionState)
|
||||
hw_rev = Property(dict)
|
||||
fan = Property(dict)
|
||||
thermistor = Property(list)
|
||||
pid = Property(list)
|
||||
output = Property(list)
|
||||
postfilter = Property(list)
|
||||
report = Property(list)
|
||||
|
||||
connection_error = pyqtSignal()
|
||||
|
||||
NUM_CHANNELS = 2
|
||||
|
||||
def __init__(self, parent, update_s, disconnect_cb=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._update_s = update_s
|
||||
self._client = AsyncioClient()
|
||||
self._watch_task = None
|
||||
self._update_params_task = None
|
||||
self.disconnect_cb = disconnect_cb
|
||||
self.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
|
||||
async def start_session(self, host, port):
|
||||
await self._client.connect(host, port)
|
||||
self.hw_rev = await self._client.get_hwrev()
|
||||
|
||||
@asyncSlot()
|
||||
async def end_session(self):
|
||||
self.stop_watching()
|
||||
|
||||
if self.disconnect_cb is not None:
|
||||
if asyncio.iscoroutinefunction(self.disconnect_cb):
|
||||
await self.disconnect_cb()
|
||||
else:
|
||||
self.disconnect_cb()
|
||||
|
||||
await self._client.disconnect()
|
||||
|
||||
def start_watching(self):
|
||||
self._watch_task = asyncio.create_task(self.run())
|
||||
|
||||
def stop_watching(self):
|
||||
if self._watch_task is not None:
|
||||
self._watch_task.cancel()
|
||||
self._watch_task = None
|
||||
self._update_params_task.cancel()
|
||||
self._update_params_task = None
|
||||
|
||||
async def run(self):
|
||||
self._update_params_task = asyncio.create_task(self.update_params())
|
||||
while True:
|
||||
if self._update_params_task.done():
|
||||
try:
|
||||
self._update_params_task.result()
|
||||
except OSError:
|
||||
logging.error(
|
||||
"Encountered an error while polling for information from Thermostat.",
|
||||
exc_info=True,
|
||||
)
|
||||
await self.end_session()
|
||||
self.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
self.connection_error.emit()
|
||||
return
|
||||
self._update_params_task = asyncio.create_task(self.update_params())
|
||||
await asyncio.sleep(self._update_s)
|
||||
|
||||
async def update_params(self):
|
||||
(
|
||||
self.fan,
|
||||
self.output,
|
||||
self.report,
|
||||
self.pid,
|
||||
self.thermistor,
|
||||
self.postfilter,
|
||||
) = await asyncio.gather(
|
||||
self._client.get_fan(),
|
||||
self._client.get_output(),
|
||||
self._client.get_report(),
|
||||
self._client.get_pid(),
|
||||
self._client.get_b_parameter(),
|
||||
self._client.get_postfilter(),
|
||||
)
|
||||
|
||||
def connected(self):
|
||||
return self._client.connected()
|
||||
|
||||
@pyqtSlot(float)
|
||||
def set_update_s(self, update_s):
|
||||
self._update_s = update_s
|
||||
|
||||
async def set_ipv4(self, ipv4):
|
||||
await self._client.set_param("ipv4", ipv4)
|
||||
|
||||
async def get_ipv4(self):
|
||||
return await self._client.get_ipv4()
|
||||
|
||||
@asyncSlot()
|
||||
async def save_cfg(self, ch=""):
|
||||
await self._client.save_config(ch)
|
||||
|
||||
@asyncSlot()
|
||||
async def load_cfg(self, ch=""):
|
||||
await self._client.load_config(ch)
|
||||
|
||||
async def dfu(self):
|
||||
await self._client.enter_dfu_mode()
|
||||
|
||||
async def reset(self):
|
||||
await self._client.reset()
|
||||
|
||||
async def set_fan(self, power="auto"):
|
||||
await self._client.set_fan(power)
|
||||
|
||||
async def get_fan(self):
|
||||
return await self._client.get_fan()
|
||||
|
||||
async def set_param(self, topic, channel, field="", value=""):
|
||||
await self._client.set_param(topic, channel, field, value)
|
||||
134
pythermostat/pythermostat/gui/resources/artiq.svg
Normal file
134
pythermostat/pythermostat/gui/resources/artiq.svg
Normal file
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:i="&#38;#38;ns_ai;"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="360"
|
||||
height="360"
|
||||
viewBox="0 0 360 360"
|
||||
enable-background="new 0 0 800 800"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
inkscape:version="0.91 r13725"
|
||||
sodipodi:docname="logo.svg"><metadata
|
||||
id="metadata548"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs546" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1156"
|
||||
id="namedview544"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:zoom="0.417193"
|
||||
inkscape:cx="287.46503"
|
||||
inkscape:cy="-196.56401"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="44"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path381"
|
||||
d="m 306.69585,335.11213 c 5.09,-0.035 9.227,-4.208 9.217,-9.303 -0.01,-5.062 -4.225,-9.248 -9.291,-9.229 -5.066,0.021 -9.246,4.237 -9.217,9.302 0.027,5.085 4.236,9.264 9.291,9.23 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path383"
|
||||
d="m 89.214854,155.25113 10.609,9.812 c 0.214996,-0.162 0.460996,-0.348 0.688996,-0.549 20.543,-18.001 43.98,-33.619 69.662,-46.423 32.912,-16.40399 60.969,-25.001991 88.295,-27.057991 2.504,-0.188 4.811,-0.279 7.051,-0.279 9.105,0 16.873,1.591 23.744,4.864 7.635,3.636 11.473,9.740991 11.404,18.145991 -0.053,6.609 -1.955,13.229 -5.812,20.239 -2.68,4.868 -5.713,9.68 -8.646,14.332 -1.248,1.979 -2.502,3.969 -3.744,5.982 l 10.135,9.65 c 8.092,-10.235 16.82,-22.731 20.846,-38.001 0.467,-1.765 0.861,-3.586 1.244,-5.348 0.174,-0.804 0.348,-1.606 0.529,-2.408 l 0,-8.887 c -0.049,-0.148 -0.102,-0.297 -0.154,-0.444 -0.141,-0.387 -0.285,-0.787 -0.357,-1.216 -2.037,-12.212991 -8.967,-20.777991 -21.184,-26.185991 -7.824,-3.462 -16.289,-4.355 -23.535,-4.772 -2.264,-0.13 -4.576,-0.196 -6.877,-0.196 -11.945,0 -24.328,1.727 -37.859,5.278 -46.736,12.272 -90.896,35.553991 -131.251996,69.196991 -1.098,0.917 -2.182,1.903 -3.332,2.947 -0.465,0.425 -0.948,0.863 -1.456,1.32 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path385"
|
||||
d="m 118.26585,121.01513 c 2.992,-2.037 5.816,-3.961 8.797,-5.561 3.58,-1.923 4.771,-4.586 5.459,-7.993 4.053,-20.110991 9.557,-35.939991 17.318,-49.815991 4.494,-8.033 9.088,-13.791 14.455,-18.119 9.002,-7.259 18.375,-7.266 27.412,-0.017 5.564,4.462 10.137,10.14 13.98,17.356 2.211,4.151 4.197,8.308 6.303,12.707 0.855,1.791 1.719,3.595 2.602,5.408 l 14.334,-3.655 c -1.174,-2.378 -2.311,-4.763 -3.412,-7.074 -2.658,-5.585 -5.172,-10.859 -8.139,-15.979 -9.824,-16.947 -20.699,-25.812 -35.26,-28.744 l -8.322,0.01 c -12.096,2.398 -22.07,9.437 -30.395,21.507 -3.602,5.219 -6.787,10.571 -9.471,15.906 -7.41,14.732 -12.738,31.635 -16.773,53.191991 -0.568,3.039 -1.053,6.101 -1.566,9.342 -0.193,1.218 -0.391,2.462 -0.598,3.74 1.132,-0.75 2.218,-1.49 3.276,-2.21 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path387"
|
||||
d="m 105.42785,254.22313 c -3.225,0.31 -6.270996,0.602 -9.351996,0.754 -1.867,0.093 -3.594,0.139 -5.277,0.139 -7.129,0 -13.34,-0.867 -18.904,-2.646 -0.795,-0.254 -1.576,-0.526 -2.346,-0.817 -11.328,-4.29 -16.076,-12.875 -13.732,-24.827 2.135,-10.872 7.631,-19.988 13.254,-28.221 1.115,-1.634 2.314,-3.259 3.473,-4.83 0.453,-0.616 0.91,-1.233 1.365,-1.857 l -10.357,-10.004 c -7.527,9.307 -16.645,21.933 -20.824,37.338 -3.191,11.767 -2.23,22.453 2.783,30.906 5.008,8.446 13.908,14.409 25.738,17.245 6.105,1.465 12.57,2.177 19.76,2.177 3.754,-10e-4 7.688,-0.192 12.022996,-0.588 2.494,-0.227 4.928,-0.557 7.504,-0.906 0.973,-0.132 1.951,-0.265 2.936,-0.392 l -3.898,-13.857 c -1.415,0.124 -2.792,0.256 -4.146,0.386 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path389"
|
||||
d="m 90.798854,255.11513 c 1.684,0 3.41,-0.046 5.277,-0.139 -1.866,0.093 -3.593,0.139 -5.277,0.139 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path391"
|
||||
d="m 71.894854,252.47013 c 5.564,1.778 11.775,2.646 18.904,2.646 -7.127,-0.001 -13.339,-0.868 -18.904,-2.646 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path393"
|
||||
d="m 91.007854,269.57913 c -7.189,0 -13.654,-0.712 -19.76,-2.177 6.106,1.465 12.571,2.177 19.76,2.177 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path395"
|
||||
d="m 103.03185,268.99113 c -4.335996,0.396 -8.269996,0.587 -12.022996,0.588 3.753,0 7.685,-0.192 12.022996,-0.588 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path397"
|
||||
d="m 216.19185,131.16013 c -0.625,-4.189 -1.227,-8.218 -1.867,-12.238 -0.326,-2.036 -5.861,-6.224 -8.229,-6.224 -0.156,0 -0.291,0.02 -0.402,0.058 -4.172,1.46 -8.242,3.096 -12.551,4.827 -1.42,0.57 -2.855,1.146 -4.316,1.727 l 28,16.088 -0.635,-4.238 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path399"
|
||||
d="m 125.60785,156.07313 c -0.338,0.264 -0.668,0.525 -1,0.788 -0.463,0.366 -0.936,0.736 -1.393,1.099 -2.838,2.248 -5.516,4.371 -8.346,6.353 -2.75,1.927 -3.779,4.095 -3.336,7.03 0.102,0.675 0.096,1.436 0.09,2.17 -0.01,1.219 -0.02,2.479 0.488,2.946 3.336,3.059 6.891,5.851 10.654,8.807 0.605,0.477 1.227,0.968 1.842,1.452 0.334,0.264 0.664,0.523 1,0.789 0.188,0.148 0.369,0.29 0.557,0.439 l 0,-32.312 c -0.189,0.148 -0.369,0.291 -0.556,0.439 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path401"
|
||||
d="m 151.98285,100.84014 0.104,-0.053 c 3.387,-1.754001 6.783,-3.483001 10.385,-5.316001 l 4.047,-2.062 -17.232,-6.35 -3.984,13.866001 c 1.803,0.81 2.684,1.17 3.451,1.17 0.584,0 1.174,-0.223 2.061,-0.658 0.34,-0.169 0.721,-0.365 1.168,-0.597 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path403"
|
||||
d="m 150.81285,101.43614 c 0.342,-0.168 0.723,-0.364 1.172,-0.597 l 0.102,-0.053 -0.104,0.053 c -0.447,0.233 -0.828,0.429 -1.17,0.597 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path405"
|
||||
d="m 266.77785,190.88113 -10.314,-9.723 c -0.9,2.513 -2.059,14.3 -1.457,19.737 l 11.771,-10.014 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path407"
|
||||
d="m 146.40085,244.68813 c -0.344,0 -0.562,0.08 -0.627,0.134 -0.129,0.123 -0.217,0.812 -0.078,1.328 0.848,3.195 1.752,6.407 2.709,9.809 l 0.814,2.899 14.725,-5.297 -2.984,-1.559 c -4.799,-2.507 -9.33,-4.874 -13.859,-7.181 -0.163,-0.082 -0.431,-0.133 -0.7,-0.133 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path409"
|
||||
d="m 178.01785,182.25313 c 5.09,-0.035 9.227,-4.207 9.217,-9.303 -0.008,-5.061 -4.223,-9.248 -9.291,-9.229 -5.066,0.021 -9.244,4.238 -9.217,9.303 0.029,5.084 4.236,9.264 9.291,9.229 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path411"
|
||||
d="m 178.66685,137.26513 c 10.389,0 20.699,4.453 27.85,13.074 6.838,8.24 9.393,18.624 7.93,28.444 0.682,4.709 4.068,13.639 16.732,28.898 0,0 39.695,50.833 67.607,86.683 1.363,1.533 6.5,6.911 11.957,8.765 4.92,0.979 9.547,3.578 13.004,7.74 7.998,9.641 6.668,23.926 -2.971,31.917 -4.232,3.516 -9.361,5.229 -14.461,5.229 -6.51,0 -12.973,-2.793 -17.459,-8.197 -3.123,-3.762 -4.801,-8.235 -5.139,-12.764 l -0.014,0.019 c 0.004,-0.188 -0.045,-0.956 -0.047,-1.136 -0.498,-5.215 -5.215,-11.978 -7.074,-14.461 l -73.916,-80.137 c -12.717,-15.323 -21.002,-20.271 -25.605,-21.787 -9.816,-0.444 -19.428,-4.869 -26.199,-13.034 -12.762,-15.37 -10.639,-38.165 4.734,-50.921 6.752,-5.6 14.934,-8.332 23.071,-8.332 m -8.434,57.243 22.721,-3.874 8.002,-21.613 -14.719,-17.732 -22.719,3.875 -7.996,21.609 14.711,17.735 m 131.273,145.051 14.541,-2.529 5.084,-13.854 -9.451,-11.331 -14.543,2.523 -5.082,13.857 9.451,11.334" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path431"
|
||||
d="m 200.61585,241.28413 0.006,-0.025 c -24.418,-9.209 -48.256,-21.711 -70.979,-37.256 -24.553,-16.797 -42.628996,-33.192 -56.884996,-51.596 -8.715,-11.247 -13.768,-20.717 -16.381,-30.705 -3.068,-11.729 0.105,-20.53999 9.178,-25.481991 2.277,-1.241 4.834,-2.269 7.596,-3.054 7.576,-2.153 15.721,-2.812 25.201,-2.015 1.244,0.104 2.519996,0.217 3.804996,0.332 1.402,0.123 2.803,0.242 4.209,0.368 l 3.176,0.281 3.846,-13.919 c -0.947,-0.121 -1.893,-0.245 -2.83,-0.37 -2.537,-0.337 -4.934,-0.656 -7.25,-0.857 -4.688996,-0.406 -8.802996,-0.604 -12.577996,-0.604 -8.74,0 -16.342,1.076 -23.24,3.29 -14.58,4.68 -23.049,13.281 -25.893,26.296991 -1.943,8.9 -0.568,18.38 4.328,29.833 6.098,14.267 15.623,27.692 29.977,42.251 31.706996,32.162 69.878996,56.911 116.698996,75.662 3.182,1.274 6.383,2.416 9.771,3.624 1.434,0.511 2.889,1.029 4.369,1.568 l 2.396,-8.365 -8.521,-9.258 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path433"
|
||||
d="m 315.10985,227.63013 c -0.146,-0.262 -0.314,-0.56 -0.359,-0.905 -0.99,-8.005 -3.834,-16.142 -8.688,-24.875 -7.945,-14.297 -18.83,-27.683 -34.252,-42.126 -3.812,-3.572 -7.723,-6.949 -11.863,-10.523 -1.678,-1.448 -3.377,-2.915 -5.096,-4.419 -0.006,0.032 -0.012,0.062 -0.018,0.092 -0.062,0.355 -0.096,0.551 -0.09,0.713 l 0.148,3.794 c 0.176,4.559 0.359,9.272 0.67,13.896 0.047,0.706 0.615,1.672 1.52,2.583 2.135,2.144 4.346,4.286 6.484,6.358 3.807,3.687 7.742,7.5 11.389,11.467 11.611,12.634 19.076,24.245 23.488,36.543 2.049,5.705 2.707,10.802 2.012,15.581 -1.146,7.896 -6.145,13.235 -15.281,16.322 -2.455,0.829 -5.002,1.474 -7.656,1.956 l 9.738,12.6 c 1.551,-0.468 3.08,-0.975 4.574,-1.562 12.387,-4.858 19.754,-12.956 22.521,-24.758 l 0.869,-3.686 0,-8.847 c -0.034,-0.068 -0.071,-0.136 -0.11,-0.204 z" /><g
|
||||
style="fill:#ffffff"
|
||||
id="g435"
|
||||
transform="translate(-215.39315,-165.25587)"><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path437"
|
||||
d="m 439.345,274.729 c 0.58,4.945 1.223,9.971 1.846,14.831 1.416,11.057 2.879,22.489 3.713,33.785 0.807,10.944 0.859,22.254 0.164,34.1 l 13,16.818 c 0.334,-3.384 0.643,-6.817 0.902,-10.349 1.854,-25.214 1.066,-50.093 -2.342,-73.945 -0.709,-4.964 -1.549,-9.816 -2.439,-14.955 -0.377,-2.185 -0.758,-4.387 -1.133,-6.617 l -14.16,3.555 c 0.043,0.257 0.086,0.5 0.127,0.734 0.13,0.742 0.244,1.39 0.322,2.043 z" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff"
|
||||
id="path439"
|
||||
d="m 433.437,425.474 c -2.322,7.348 -4.98,14.184 -8.043,20.678 -3.967,8.416 -9.191,17.993 -17.877,25.219 -9.297,7.733 -19.082,7.701 -28.365,-0.092 -5.934,-4.982 -10.92,-11.633 -15.691,-20.929 -6.629,-12.926 -11.459,-27.311 -15.66,-46.642 l -0.072,-0.342 c -0.174,-0.828 -0.412,-1.962 -0.893,-2.284 -4.152,-2.786 -8.357,-5.448 -12.807,-8.267 -1.068,-0.677 -2.146,-1.359 -3.238,-2.054 0.164,0.969 0.32,1.911 0.475,2.834 0.434,2.596 0.842,5.047 1.303,7.478 4.703,24.702 10.705,42.76 19.463,58.551 7.541,13.604 17.859,28.05 37.209,32.08 l 8.318,0 c 17.949,-3.632 27.887,-16.568 35.24,-28.748 1.953,-3.234 3.717,-6.507 5.244,-9.726 2.389,-5.035 4.557,-10.249 6.533,-15.655 l -11.139,-12.101 z" /></g><i:pgf
|
||||
id="adobe_illustrator_pgf" /></svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
506
pythermostat/pythermostat/gui/view/MainWindow.ui
Normal file
506
pythermostat/pythermostat/gui/view/MainWindow.ui
Normal file
@@ -0,0 +1,506 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1280</width>
|
||||
<height>720</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>1280</width>
|
||||
<height>720</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>3840</width>
|
||||
<height>2160</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Thermostat Control Panel</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset>
|
||||
<normaloff>../resources/artiq.ico</normaloff>../resources/artiq.ico</iconset>
|
||||
</property>
|
||||
<widget class="QWidget" name="main_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item row="0" column="1">
|
||||
<layout class="QVBoxLayout" name="main_layout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QFrame" name="graph_group">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="graphs_layout" rowstretch="1,1" columnstretch="1,1,1" rowminimumheight="100,100" columnminimumwidth="100,100,100">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item row="1" column="1">
|
||||
<widget class="LivePlotWidget" name="ch1_t_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="LivePlotWidget" name="ch0_t_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="LivePlotWidget" name="ch0_i_graph" native="true"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="LivePlotWidget" name="ch1_i_graph" native="true"/>
|
||||
</item>
|
||||
<item row="0" column="0" rowspan="2">
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ch0_tab">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<attribute name="title">
|
||||
<string>Channel 0</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="ParameterTree" name="ch0_tree" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="ch1_tab">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<attribute name="title">
|
||||
<string>Channel 1</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="ParameterTree" name="ch1_tree" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="bottom_settings_group">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::StyledPanel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Raised</enum>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="settings_layout">
|
||||
<item>
|
||||
<widget class="QToolButton" name="connect_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::ToolButtonPopupMode::MenuButtonPopup</enum>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonStyle::ToolButtonFollowStyle</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="status_lbl">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>240</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Disconnected</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="thermostat_settings">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string notr="true">⚙</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="plot_settings">
|
||||
<property name="toolTip">
|
||||
<string>Plot Settings</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>📉</string>
|
||||
</property>
|
||||
<property name="popupMode">
|
||||
<enum>QToolButton::ToolButtonPopupMode::InstantPopup</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="limits_warning">
|
||||
<property name="toolTipDuration">
|
||||
<number>1000000000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="background_task_lbl">
|
||||
<property name="text">
|
||||
<string>Ready.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QtWaitingSpinner" name="loading_spinner" native="true"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="report_group" native="true">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1">
|
||||
<property name="spacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="report_lbl">
|
||||
<property name="text">
|
||||
<string>Poll every: </string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="report_refresh_spin">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>70</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="suffix">
|
||||
<string> s</string>
|
||||
</property>
|
||||
<property name="decimals">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="stepType">
|
||||
<enum>QAbstractSpinBox::StepType::AdaptiveDecimalStepType</enum>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="report_apply_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>ParameterTree</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pyqtgraph.parametertree</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LivePlotWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pglive.sources.live_plot_widget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QtWaitingSpinner</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>pythermostat.gui.view.waitingspinnerwidget</header>
|
||||
<container>1</container>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,69 @@
|
||||
from PyQt6 import QtWidgets, QtCore
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
|
||||
|
||||
class ConnectionDetailsMenu(QtWidgets.QMenu):
|
||||
def __init__(self, thermostat, connect_btn):
|
||||
super().__init__()
|
||||
self._thermostat = thermostat
|
||||
self._connect_btn = connect_btn
|
||||
self._thermostat.connection_state_update.connect(
|
||||
self.thermostat_state_change_handler
|
||||
)
|
||||
|
||||
self._setup_menu_items()
|
||||
|
||||
@pyqtSlot(ThermostatConnectionState)
|
||||
def thermostat_state_change_handler(self, state):
|
||||
self.host_set_line.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
|
||||
self.port_set_spin.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
|
||||
|
||||
def _setup_menu_items(self):
|
||||
# Sets Thermostat host/IP
|
||||
self.host_set_line = QtWidgets.QLineEdit()
|
||||
self.host_set_line.setMinimumWidth(160)
|
||||
self.host_set_line.setMaximumWidth(160)
|
||||
self.host_set_line.setMaxLength(15)
|
||||
self.host_set_line.setClearButtonEnabled(True)
|
||||
self.host_set_line.setText("192.168.1.26")
|
||||
self.host_set_line.setPlaceholderText("IP for the Thermostat")
|
||||
|
||||
def connect_on_enter_press():
|
||||
self._connect_btn.click()
|
||||
self.hide()
|
||||
|
||||
self.host_set_line.returnPressed.connect(connect_on_enter_press)
|
||||
|
||||
host = QtWidgets.QWidgetAction(self)
|
||||
host.setDefaultWidget(self.host_set_line)
|
||||
self.addAction(host)
|
||||
|
||||
# Sets Thermostat port
|
||||
self.port_set_spin = QtWidgets.QSpinBox()
|
||||
self.port_set_spin.setMinimumWidth(70)
|
||||
self.port_set_spin.setMaximumWidth(70)
|
||||
self.port_set_spin.setMaximum(65535)
|
||||
self.port_set_spin.setValue(23)
|
||||
|
||||
def connect_only_if_enter_pressed():
|
||||
if (
|
||||
not self.port_set_spin.hasFocus()
|
||||
): # Don't connect if the spinbox only lost focus
|
||||
return
|
||||
connect_on_enter_press()
|
||||
|
||||
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
|
||||
|
||||
port = QtWidgets.QWidgetAction(self)
|
||||
port.setDefaultWidget(self.port_set_spin)
|
||||
self.addAction(port)
|
||||
|
||||
# Exits GUI
|
||||
exit_button = QtWidgets.QPushButton()
|
||||
exit_button.setText("Exit GUI")
|
||||
exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
|
||||
|
||||
exit_action = QtWidgets.QWidgetAction(exit_button)
|
||||
exit_action.setDefaultWidget(exit_button)
|
||||
self.addAction(exit_action)
|
||||
14
pythermostat/pythermostat/gui/view/info_box.py
Normal file
14
pythermostat/pythermostat/gui/view/info_box.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from PyQt6 import QtWidgets
|
||||
from PyQt6.QtCore import pyqtSlot
|
||||
|
||||
|
||||
class InfoBox(QtWidgets.QMessageBox):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setIcon(QtWidgets.QMessageBox.Icon.Information)
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def display_info_box(self, title, text):
|
||||
self.setWindowTitle(title)
|
||||
self.setText(text)
|
||||
self.show()
|
||||
181
pythermostat/pythermostat/gui/view/live_plot_view.py
Normal file
181
pythermostat/pythermostat/gui/view/live_plot_view.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from collections import deque
|
||||
from PyQt6.QtCore import QObject, pyqtSlot
|
||||
from pglive.sources.data_connector import DataConnector
|
||||
from pglive.kwargs import Axis
|
||||
from pglive.sources.live_plot import LiveLinePlot
|
||||
from pglive.sources.live_axis import LiveAxis
|
||||
import pyqtgraph as pg
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
|
||||
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
||||
|
||||
class LiveDataPlotter(QObject):
|
||||
def __init__(self, thermostat, live_plots):
|
||||
super().__init__()
|
||||
self._thermostat = thermostat
|
||||
|
||||
self._thermostat.report_update.connect(self.update_report)
|
||||
self._thermostat.pid_update.connect(self.update_pid)
|
||||
self._thermostat.connection_state_update.connect(
|
||||
self.thermostat_state_change_handler
|
||||
)
|
||||
|
||||
self.NUM_CHANNELS = len(live_plots)
|
||||
self.graphs = []
|
||||
|
||||
for i, live_plot in enumerate(live_plots):
|
||||
live_plot[0].setTitle(f"Channel {i} Temperature")
|
||||
live_plot[1].setTitle(f"Channel {i} Current")
|
||||
self.graphs.append(_TecGraphs(live_plot[0], live_plot[1]))
|
||||
|
||||
@pyqtSlot(ThermostatConnectionState)
|
||||
def thermostat_state_change_handler(self, state):
|
||||
if state == ThermostatConnectionState.DISCONNECTED:
|
||||
self.clear_graphs()
|
||||
|
||||
def _config_connector_max_pts(self, connector, samples):
|
||||
connector.max_points = samples
|
||||
connector.x = deque(maxlen=int(connector.max_points))
|
||||
connector.y = deque(maxlen=int(connector.max_points))
|
||||
|
||||
@pyqtSlot(int)
|
||||
def set_max_samples(self, samples: int):
|
||||
for graph in self.graphs:
|
||||
self._config_connector_max_pts(graph.t_connector, samples)
|
||||
self._config_connector_max_pts(graph.i_connector, samples)
|
||||
self._config_connector_max_pts(graph.iset_connector, samples)
|
||||
|
||||
@pyqtSlot()
|
||||
def clear_graphs(self):
|
||||
for graph in self.graphs:
|
||||
graph.clear()
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_pid(self, pid_settings):
|
||||
for settings in pid_settings:
|
||||
channel = settings["channel"]
|
||||
self.graphs[channel].update_pid(settings)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_report(self, report_data):
|
||||
for settings in report_data:
|
||||
channel = settings["channel"]
|
||||
self.graphs[channel].update_report(settings)
|
||||
|
||||
|
||||
class _TecGraphs:
|
||||
"""The maximum number of sample points to store."""
|
||||
|
||||
DEFAULT_MAX_SAMPLES = 1000
|
||||
|
||||
def __init__(self, t_widget, i_widget):
|
||||
self._t_widget = t_widget
|
||||
self._i_widget = i_widget
|
||||
|
||||
self._t_plot = LiveLinePlot()
|
||||
self._i_plot = LiveLinePlot(name="Measured")
|
||||
self._iset_plot = LiveLinePlot(name="Set", pen=pg.mkPen("r"))
|
||||
|
||||
self._t_line = self._t_widget.getPlotItem().addLine(label="{value} °C")
|
||||
self._t_line.setVisible(False)
|
||||
# Hack for keeping setpoint line in plot range
|
||||
self._t_setpoint_plot = LiveLinePlot()
|
||||
|
||||
for graph in t_widget, i_widget:
|
||||
time_axis = LiveAxis(
|
||||
"bottom",
|
||||
text="Time since Thermostat reset",
|
||||
**{Axis.TICK_FORMAT: Axis.DURATION},
|
||||
)
|
||||
time_axis.showLabel()
|
||||
graph.setAxisItems({"bottom": time_axis})
|
||||
|
||||
graph.add_crosshair(pg.mkPen(color="red", width=1), {"color": "green"})
|
||||
|
||||
# Enable linking of axes in the graph widget's context menu
|
||||
graph.register(
|
||||
graph.getPlotItem().titleLabel.text # Slight hack getting the title
|
||||
)
|
||||
|
||||
temperature_axis = LiveAxis("left", text="Temperature", units="°C")
|
||||
temperature_axis.showLabel()
|
||||
t_widget.setAxisItems({"left": temperature_axis})
|
||||
|
||||
current_axis = LiveAxis("left", text="Current", units="A")
|
||||
current_axis.showLabel()
|
||||
i_widget.setAxisItems({"left": current_axis})
|
||||
i_widget.addLegend(brush=(50, 50, 200, 150))
|
||||
|
||||
t_widget.addItem(self._t_plot)
|
||||
t_widget.addItem(self._t_setpoint_plot)
|
||||
i_widget.addItem(self._i_plot)
|
||||
i_widget.addItem(self._iset_plot)
|
||||
|
||||
self.t_connector = DataConnector(
|
||||
self._t_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
||||
)
|
||||
self.t_setpoint_connector = DataConnector(self._t_setpoint_plot, max_points=1)
|
||||
self.i_connector = DataConnector(
|
||||
self._i_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
||||
)
|
||||
self.iset_connector = DataConnector(
|
||||
self._iset_plot, max_points=self.DEFAULT_MAX_SAMPLES
|
||||
)
|
||||
|
||||
self.max_samples = self.DEFAULT_MAX_SAMPLES
|
||||
|
||||
def plot_append(self, report):
|
||||
temperature = report["temperature"]
|
||||
current = report["tec_i"]
|
||||
iset = report["i_set"]
|
||||
time = report["time"]
|
||||
|
||||
if temperature is not None:
|
||||
self.t_connector.cb_append_data_point(temperature, time)
|
||||
if self._t_line.isVisible():
|
||||
self.t_setpoint_connector.cb_append_data_point(
|
||||
self._t_line.value(), time
|
||||
)
|
||||
else:
|
||||
self.t_setpoint_connector.cb_append_data_point(temperature, time)
|
||||
if current is not None:
|
||||
self.i_connector.cb_append_data_point(current, time)
|
||||
self.iset_connector.cb_append_data_point(iset, time)
|
||||
|
||||
def set_max_sample(self, samples: int):
|
||||
for connector in self.t_connector, self.i_connector, self.iset_connector:
|
||||
connector.max_points(samples)
|
||||
|
||||
def clear(self):
|
||||
for connector in self.t_connector, self.i_connector, self.iset_connector:
|
||||
connector.clear()
|
||||
|
||||
def set_t_line(self, temp=None, visible=None):
|
||||
if visible is not None:
|
||||
self._t_line.setVisible(visible)
|
||||
if temp is not None:
|
||||
self._t_line.setValue(temp)
|
||||
|
||||
# PyQtGraph normally does not update this text when the line
|
||||
# is not visible, so make sure that the temperature label
|
||||
# gets updated always, and doesn't stay at an old value.
|
||||
self._t_line.label.setText(f"{temp} °C")
|
||||
|
||||
def set_max_samples(self, samples: int):
|
||||
for graph in self.graphs:
|
||||
graph.t_connector.max_points = samples
|
||||
graph.i_connector.max_points = samples
|
||||
graph.iset_connector.max_points = samples
|
||||
|
||||
def clear_graphs(self):
|
||||
for graph in self.graphs:
|
||||
graph.clear()
|
||||
|
||||
def update_pid(self, pid_settings):
|
||||
self.set_t_line(temp=round(pid_settings["target"], 6))
|
||||
|
||||
def update_report(self, report_data):
|
||||
self.plot_append(report_data)
|
||||
self.set_t_line(visible=report_data["pid_engaged"])
|
||||
307
pythermostat/pythermostat/gui/view/menus.py
Normal file
307
pythermostat/pythermostat/gui/view/menus.py
Normal file
@@ -0,0 +1,307 @@
|
||||
from PyQt6 import QtWidgets, QtCore, QtGui
|
||||
from PyQt6.QtCore import pyqtSlot, QSignalBlocker
|
||||
from qasync import asyncSlot
|
||||
from pythermostat.gui.model.thermostat import ThermostatConnectionState
|
||||
from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag
|
||||
|
||||
|
||||
class ConnectionDetailsMenu(QtWidgets.QMenu):
|
||||
def __init__(self, thermostat, connect_btn):
|
||||
super().__init__()
|
||||
self._thermostat = thermostat
|
||||
self._connect_btn = connect_btn
|
||||
self._thermostat.connection_state_update.connect(
|
||||
self.thermostat_state_change_handler
|
||||
)
|
||||
|
||||
self._setup_menu_items()
|
||||
|
||||
@pyqtSlot(ThermostatConnectionState)
|
||||
def thermostat_state_change_handler(self, state):
|
||||
self.host_set_line.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
|
||||
self.port_set_spin.setEnabled(state == ThermostatConnectionState.DISCONNECTED)
|
||||
|
||||
def _setup_menu_items(self):
|
||||
# Sets Thermostat host/IP
|
||||
self.host_set_line = QtWidgets.QLineEdit()
|
||||
self.host_set_line.setMinimumWidth(160)
|
||||
self.host_set_line.setMaximumWidth(160)
|
||||
self.host_set_line.setMaxLength(15)
|
||||
self.host_set_line.setClearButtonEnabled(True)
|
||||
self.host_set_line.setText("192.168.1.26")
|
||||
self.host_set_line.setPlaceholderText("IP for the Thermostat")
|
||||
|
||||
def connect_on_enter_press():
|
||||
self._connect_btn.click()
|
||||
self.hide()
|
||||
|
||||
self.host_set_line.returnPressed.connect(connect_on_enter_press)
|
||||
|
||||
host = QtWidgets.QWidgetAction(self)
|
||||
host.setDefaultWidget(self.host_set_line)
|
||||
self.addAction(host)
|
||||
|
||||
# Sets Thermostat port
|
||||
self.port_set_spin = QtWidgets.QSpinBox()
|
||||
self.port_set_spin.setMinimumWidth(70)
|
||||
self.port_set_spin.setMaximumWidth(70)
|
||||
self.port_set_spin.setMaximum(65535)
|
||||
self.port_set_spin.setValue(23)
|
||||
|
||||
def connect_only_if_enter_pressed():
|
||||
if (
|
||||
not self.port_set_spin.hasFocus()
|
||||
): # Don't connect if the spinbox only lost focus
|
||||
return
|
||||
connect_on_enter_press()
|
||||
|
||||
self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed)
|
||||
|
||||
port = QtWidgets.QWidgetAction(self)
|
||||
port.setDefaultWidget(self.port_set_spin)
|
||||
self.addAction(port)
|
||||
|
||||
# Exits GUI
|
||||
exit_button = QtWidgets.QPushButton()
|
||||
exit_button.setText("Exit GUI")
|
||||
exit_button.pressed.connect(QtWidgets.QApplication.instance().quit)
|
||||
|
||||
exit_action = QtWidgets.QWidgetAction(exit_button)
|
||||
exit_action.setDefaultWidget(exit_button)
|
||||
self.addAction(exit_action)
|
||||
|
||||
|
||||
class PlotOptionsMenu(QtWidgets.QMenu):
|
||||
def __init__(self, channel_graphs, max_samples=1000):
|
||||
super().__init__()
|
||||
|
||||
# Clears plots for both graphs in all channels
|
||||
clear_graphs = QtGui.QAction("Clear graphs", self)
|
||||
clear_graphs.triggered.connect(channel_graphs.clear_graphs)
|
||||
self.addAction(clear_graphs)
|
||||
|
||||
# Set maximum samples in graphs
|
||||
samples_spinbox = QtWidgets.QSpinBox()
|
||||
samples_spinbox.setRange(2, 100000)
|
||||
samples_spinbox.setSuffix(" samples")
|
||||
samples_spinbox.setValue(max_samples)
|
||||
samples_spinbox.valueChanged.connect(channel_graphs.set_max_samples)
|
||||
|
||||
limit_samples = QtWidgets.QWidgetAction(self)
|
||||
limit_samples.setDefaultWidget(samples_spinbox)
|
||||
self.addAction(limit_samples)
|
||||
|
||||
|
||||
class ThermostatSettingsMenu(QtWidgets.QMenu):
|
||||
def __init__(self, thermostat, info_box, style):
|
||||
super().__init__()
|
||||
self._thermostat = thermostat
|
||||
self._info_box = info_box
|
||||
self._style = style
|
||||
|
||||
self.hw_rev_data = {}
|
||||
self._thermostat.hw_rev_update.connect(self.hw_rev)
|
||||
self._thermostat.connection_state_update.connect(
|
||||
self.thermostat_state_change_handler
|
||||
)
|
||||
|
||||
self._setup_menu_items()
|
||||
|
||||
@pyqtSlot("QVariantMap")
|
||||
def fan_update(self, fan_settings):
|
||||
if fan_settings is None:
|
||||
return
|
||||
with QSignalBlocker(self.fan_power_slider):
|
||||
self.fan_power_slider.setValue(
|
||||
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
|
||||
)
|
||||
with QSignalBlocker(self.fan_auto_box):
|
||||
self.fan_auto_box.setChecked(fan_settings["auto_mode"])
|
||||
|
||||
def set_fan_pwm_warning(self):
|
||||
if self.fan_power_slider.value() != 100:
|
||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
||||
icon = self._style.standardIcon(pixmapi)
|
||||
self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16))
|
||||
self.fan_pwm_warning.setToolTip(
|
||||
"Throttling the fan (not recommended on this hardware rev)"
|
||||
)
|
||||
else:
|
||||
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
||||
self.fan_pwm_warning.setToolTip("")
|
||||
|
||||
@pyqtSlot(ThermostatConnectionState)
|
||||
def thermostat_state_change_handler(self, state):
|
||||
if state == ThermostatConnectionState.DISCONNECTED:
|
||||
self.fan_pwm_warning.setPixmap(QtGui.QPixmap())
|
||||
self.fan_pwm_warning.setToolTip("")
|
||||
|
||||
@pyqtSlot("QVariantMap")
|
||||
def hw_rev(self, hw_rev):
|
||||
self.hw_rev_data = hw_rev
|
||||
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
|
||||
|
||||
@asyncSlot(int)
|
||||
async def fan_set_request(self, value):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
if self.fan_auto_box.isChecked():
|
||||
with QSignalBlocker(self.fan_auto_box):
|
||||
self.fan_auto_box.setChecked(False)
|
||||
await self._thermostat.set_fan(value)
|
||||
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
|
||||
self.set_fan_pwm_warning()
|
||||
|
||||
@asyncSlot(int)
|
||||
async def fan_auto_set_request(self, enabled):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
if enabled:
|
||||
await self._thermostat.set_fan("auto")
|
||||
self.fan_update(await self._thermostat.get_fan())
|
||||
else:
|
||||
await self.thermostat.set_fan(self.fan_power_slider.value())
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def reset_request(self, _):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
await self._thermostat.reset()
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def dfu_request(self, _):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
await self._thermostat.dfu()
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def net_settings_request(self, _):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
ipv4 = await self._thermostat.get_ipv4()
|
||||
net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
|
||||
net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
|
||||
|
||||
@asyncSlot(str)
|
||||
async def set_net_settings_request(self, ipv4_settings):
|
||||
assert self._thermostat.connected()
|
||||
|
||||
await self._thermostat.set_ipv4(ipv4_settings)
|
||||
await self._thermostat.end_session()
|
||||
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
|
||||
|
||||
def _setup_menu_items(self):
|
||||
self.addAction(self._setup_fan_group())
|
||||
|
||||
self.reset_action = QtGui.QAction("Reset Thermostat", self)
|
||||
self.reset_action.triggered.connect(self.reset_request)
|
||||
self.addAction(self.reset_action)
|
||||
|
||||
self.dfu_action = QtGui.QAction("Enter DFU Mode", self)
|
||||
self.dfu_action.triggered.connect(self.dfu_request)
|
||||
self.addAction(self.dfu_action)
|
||||
|
||||
self.ipv4_action = QtGui.QAction("Set IPv4 Settings", self)
|
||||
self.ipv4_action.triggered.connect(self.net_settings_request)
|
||||
self.addAction(self.ipv4_action)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def load(_):
|
||||
await self._thermostat.load_cfg()
|
||||
|
||||
self._info_box.display_info_box(
|
||||
"Settings loaded", "All channel settings have been loaded from flash."
|
||||
)
|
||||
|
||||
self.load_config_action = QtGui.QAction("Load Settings", self)
|
||||
self.load_config_action.triggered.connect(load)
|
||||
self.addAction(self.load_config_action)
|
||||
|
||||
@asyncSlot(bool)
|
||||
async def save(_):
|
||||
await self._thermostat.save_cfg()
|
||||
|
||||
self._info_box.display_info_box(
|
||||
"Settings saved", "All channel settings have been saved to flash."
|
||||
)
|
||||
|
||||
self.save_config_action = QtGui.QAction("Save Settings", self)
|
||||
self.save_config_action.triggered.connect(save)
|
||||
self.addAction(self.save_config_action)
|
||||
|
||||
def about_thermostat():
|
||||
QtWidgets.QMessageBox.about(
|
||||
self,
|
||||
"About Thermostat",
|
||||
f"""
|
||||
<h1>Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}</h1>
|
||||
|
||||
<br>
|
||||
|
||||
<h2>Settings:</h2>
|
||||
Default fan curve:
|
||||
a = {self.hw_rev_data['settings']['fan_k_a']},
|
||||
b = {self.hw_rev_data['settings']['fan_k_b']},
|
||||
c = {self.hw_rev_data['settings']['fan_k_c']}
|
||||
<br>
|
||||
Fan PWM range:
|
||||
{self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']}
|
||||
<br>
|
||||
Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz
|
||||
<br>
|
||||
Fan available: {self.hw_rev_data['settings']['fan_available']}
|
||||
<br>
|
||||
Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']}
|
||||
""",
|
||||
)
|
||||
|
||||
self.about_action = QtGui.QAction("About Thermostat", self)
|
||||
self.about_action.triggered.connect(about_thermostat)
|
||||
self.addAction(self.about_action)
|
||||
|
||||
def _setup_fan_group(self):
|
||||
# Fan settings
|
||||
self.fan_group = QtWidgets.QWidget()
|
||||
self.fan_group.setEnabled(False)
|
||||
self.fan_group.setMinimumWidth(40)
|
||||
fan_layout = QtWidgets.QHBoxLayout(self.fan_group)
|
||||
fan_layout.setSpacing(9)
|
||||
|
||||
fan_label = QtWidgets.QLabel(parent=self.fan_group)
|
||||
fan_label.setMinimumWidth(40)
|
||||
fan_label.setMaximumWidth(40)
|
||||
fan_label.setBaseSize(QtCore.QSize(40, 0))
|
||||
|
||||
fan_layout.addWidget(fan_label)
|
||||
self.fan_power_slider = QtWidgets.QSlider(
|
||||
QtCore.Qt.Orientation.Horizontal, parent=self.fan_group
|
||||
)
|
||||
self.fan_power_slider.setMinimumWidth(200)
|
||||
self.fan_power_slider.setMaximumWidth(200)
|
||||
self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0))
|
||||
self.fan_power_slider.setRange(1, 100)
|
||||
fan_layout.addWidget(self.fan_power_slider)
|
||||
|
||||
self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group)
|
||||
self.fan_auto_box.setMinimumWidth(70)
|
||||
self.fan_auto_box.setMaximumWidth(70)
|
||||
fan_layout.addWidget(self.fan_auto_box)
|
||||
self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group)
|
||||
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
|
||||
fan_layout.addWidget(self.fan_pwm_warning)
|
||||
|
||||
self.fan_power_slider.valueChanged.connect(self.fan_set_request)
|
||||
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request)
|
||||
self._thermostat.fan_update.connect(self.fan_update)
|
||||
|
||||
fan_label.setToolTip("Adjust the fan")
|
||||
fan_label.setText("Fan:")
|
||||
self.fan_auto_box.setText("Auto")
|
||||
|
||||
fan = QtWidgets.QWidgetAction(self)
|
||||
fan.setDefaultWidget(self.fan_group)
|
||||
return fan
|
||||
@@ -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)
|
||||
391
pythermostat/pythermostat/gui/view/param_tree.json
Normal file
391
pythermostat/pythermostat/gui/view/param_tree.json
Normal file
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"settings_tree": [
|
||||
{
|
||||
"name": "readings",
|
||||
"title": "Readings",
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "temperature",
|
||||
"title": "Temperature",
|
||||
"type": "float",
|
||||
"format": "{value:.4f} °C",
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"name": "tec_i",
|
||||
"title": "Current through TEC",
|
||||
"type": "float",
|
||||
"suffix": "mA",
|
||||
"decimals": 6,
|
||||
"readonly": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "output",
|
||||
"title": "Output Settings",
|
||||
"expanded": true,
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "control_method",
|
||||
"title": "Control Method",
|
||||
"type": "mutex",
|
||||
"limits": {
|
||||
"Constant Current": "constant_current",
|
||||
"Temperature PID": "temperature_pid"
|
||||
},
|
||||
"value": "constant_current",
|
||||
"thermostat:set_param": {
|
||||
"topic": "output",
|
||||
"field": "pid"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"name": "i_set",
|
||||
"title": "Set Current",
|
||||
"type": "float",
|
||||
"value": 0,
|
||||
"step": 100,
|
||||
"limits": [
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"triggerOnShow": true,
|
||||
"decimals": 6,
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "output",
|
||||
"field": "i_set"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"title": "Set Temperature",
|
||||
"type": "float",
|
||||
"value": 25,
|
||||
"step": 0.1,
|
||||
"limits": [
|
||||
-273,
|
||||
300
|
||||
],
|
||||
"format": "{value:.4f} °C",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "target"
|
||||
},
|
||||
"lock": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"expanded": true,
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "max_i_pos",
|
||||
"title": "Max Cooling Current",
|
||||
"type": "float",
|
||||
"value": 0,
|
||||
"step": 100,
|
||||
"decimals": 6,
|
||||
"limits": [
|
||||
0,
|
||||
2000
|
||||
],
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "output",
|
||||
"field": "max_i_pos"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "max_i_neg",
|
||||
"title": "Max Heating Current",
|
||||
"type": "float",
|
||||
"value": 0,
|
||||
"step": 100,
|
||||
"decimals": 6,
|
||||
"limits": [
|
||||
0,
|
||||
2000
|
||||
],
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "output",
|
||||
"field": "max_i_neg"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "max_v",
|
||||
"title": "Max Voltage Difference",
|
||||
"type": "float",
|
||||
"value": 0,
|
||||
"step": 0.1,
|
||||
"decimals": 3,
|
||||
"limits": [
|
||||
0,
|
||||
4.3
|
||||
],
|
||||
"suffix": "V",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "output",
|
||||
"field": "max_v"
|
||||
},
|
||||
"lock": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "thermistor",
|
||||
"title": "Thermistor Settings",
|
||||
"expanded": true,
|
||||
"type": "group",
|
||||
"tip": "Settings of the connected Thermistor",
|
||||
"children": [
|
||||
{
|
||||
"name": "t0",
|
||||
"title": "T₀",
|
||||
"type": "float",
|
||||
"value": 25,
|
||||
"step": 0.1,
|
||||
"limits": [
|
||||
-100,
|
||||
100
|
||||
],
|
||||
"format": "{value:.4f} °C",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "b-p",
|
||||
"field": "t0"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "r0",
|
||||
"title": "R₀",
|
||||
"type": "float",
|
||||
"value": 10000,
|
||||
"step": 100,
|
||||
"min": 0,
|
||||
"siPrefix": true,
|
||||
"suffix": "Ω",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "b-p",
|
||||
"field": "r0"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "b",
|
||||
"title": "B",
|
||||
"type": "float",
|
||||
"value": 3950,
|
||||
"step": 10,
|
||||
"suffix": "K",
|
||||
"decimals": 4,
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "b-p",
|
||||
"field": "b"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "rate",
|
||||
"title": "Postfilter Rate",
|
||||
"type": "list",
|
||||
"value": 16.67,
|
||||
"thermostat:set_param": {
|
||||
"topic": "postfilter",
|
||||
"field": "rate"
|
||||
},
|
||||
"limits": {
|
||||
"Off": null,
|
||||
"16.667 Hz": 16.667,
|
||||
"20 Hz": 20.0,
|
||||
"25 Hz": 25,
|
||||
"27.27 Hz": 27.27
|
||||
},
|
||||
"lock": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pid",
|
||||
"title": "PID Settings",
|
||||
"expanded": true,
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "kp",
|
||||
"title": "Kp",
|
||||
"type": "float",
|
||||
"step": 0.1,
|
||||
"suffix": "",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "kp"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "ki",
|
||||
"title": "Ki",
|
||||
"type": "float",
|
||||
"step": 0.1,
|
||||
"suffix": "Hz",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "ki"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "kd",
|
||||
"title": "Kd",
|
||||
"type": "float",
|
||||
"step": 0.1,
|
||||
"suffix": "s",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "kd"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "pid_output_clamping",
|
||||
"title": "PID Output Clamping",
|
||||
"expanded": true,
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "output_min",
|
||||
"title": "Minimum",
|
||||
"type": "float",
|
||||
"step": 100,
|
||||
"limits": [
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"decimals": 6,
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "output_min"
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
{
|
||||
"name": "output_max",
|
||||
"title": "Maximum",
|
||||
"type": "float",
|
||||
"step": 100,
|
||||
"limits": [
|
||||
-2000,
|
||||
2000
|
||||
],
|
||||
"decimals": 6,
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"thermostat:set_param": {
|
||||
"topic": "pid",
|
||||
"field": "output_max"
|
||||
},
|
||||
"lock": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pid_autotune",
|
||||
"title": "PID Auto Tune",
|
||||
"expanded": false,
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"name": "target_temp",
|
||||
"title": "Target Temperature",
|
||||
"type": "float",
|
||||
"value": 20,
|
||||
"step": 0.1,
|
||||
"format": "{value:.4f} °C",
|
||||
"compactHeight": false,
|
||||
"pid_autotune": "target_temp"
|
||||
},
|
||||
{
|
||||
"name": "test_current",
|
||||
"title": "Test Current",
|
||||
"type": "float",
|
||||
"value": 0,
|
||||
"decimals": 6,
|
||||
"step": 100,
|
||||
"limits": [
|
||||
0,
|
||||
2000
|
||||
],
|
||||
"suffix": "mA",
|
||||
"compactHeight": false,
|
||||
"pid_autotune": "test_current"
|
||||
},
|
||||
{
|
||||
"name": "temp_swing",
|
||||
"title": "Temperature Swing",
|
||||
"type": "float",
|
||||
"value": 1.5,
|
||||
"step": 0.1,
|
||||
"format": "± {value:.4f} °C",
|
||||
"compactHeight": false,
|
||||
"pid_autotune": "temp_swing"
|
||||
},
|
||||
{
|
||||
"name": "lookback",
|
||||
"title": "Lookback",
|
||||
"type": "float",
|
||||
"value": 3.0,
|
||||
"step": 0.1,
|
||||
"format": "{value:.4f} s",
|
||||
"compactHeight": false,
|
||||
"pid_autotune": "lookback"
|
||||
},
|
||||
{
|
||||
"name": "run_pid",
|
||||
"title": "Run",
|
||||
"type": "action",
|
||||
"tip": "Run"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "save",
|
||||
"title": "Save to flash",
|
||||
"type": "action",
|
||||
"tip": "Save settings to thermostat, applies on reset"
|
||||
},
|
||||
{
|
||||
"name": "load",
|
||||
"title": "Load from flash",
|
||||
"type": "action",
|
||||
"tip": "Load settings from flash"
|
||||
}
|
||||
]
|
||||
}
|
||||
302
pythermostat/pythermostat/gui/view/settings_tree_view.py
Normal file
302
pythermostat/pythermostat/gui/view/settings_tree_view.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from functools import partial
|
||||
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
|
||||
import pyqtgraph.parametertree.parameterTypes as pTypes
|
||||
from pyqtgraph.parametertree import (
|
||||
Parameter,
|
||||
registerParameterType,
|
||||
)
|
||||
from qasync import asyncSlot
|
||||
from pythermostat.autotune import PIDAutotuneState
|
||||
|
||||
|
||||
class MutexParameter(pTypes.ListParameter):
|
||||
"""
|
||||
Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
|
||||
|
||||
The ordering of the list items determines which children will be visible.
|
||||
"""
|
||||
|
||||
def __init__(self, **opts):
|
||||
super().__init__(**opts)
|
||||
|
||||
self.sigValueChanged.connect(self.show_chosen_child)
|
||||
self.sigValueChanged.emit(self, self.opts["value"])
|
||||
|
||||
def _get_param_from_value(self, value):
|
||||
if isinstance(self.opts["limits"], dict):
|
||||
values_list = list(self.opts["limits"].values())
|
||||
else:
|
||||
values_list = self.opts["limits"]
|
||||
|
||||
return self.children()[values_list.index(value)]
|
||||
|
||||
@pyqtSlot(object, object)
|
||||
def show_chosen_child(self, value):
|
||||
for param in self.children():
|
||||
param.hide()
|
||||
|
||||
child_to_show = self._get_param_from_value(value.value())
|
||||
child_to_show.show()
|
||||
|
||||
if child_to_show.opts.get("triggerOnShow", None):
|
||||
child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value())
|
||||
|
||||
|
||||
registerParameterType("mutex", MutexParameter)
|
||||
|
||||
|
||||
class SettingsTreeView(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
thermostat,
|
||||
autotuners,
|
||||
info_box,
|
||||
trees_ui,
|
||||
param_tree,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.thermostat = thermostat
|
||||
self.autotuners = autotuners
|
||||
self.info_box = info_box
|
||||
self.trees_ui = trees_ui
|
||||
self.NUM_CHANNELS = len(trees_ui)
|
||||
|
||||
self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
|
||||
|
||||
self.params = [
|
||||
Parameter.create(
|
||||
name=f"Thermostat Channel {ch} Parameters",
|
||||
type="group",
|
||||
value=ch,
|
||||
children=self.THERMOSTAT_PARAMETERS[ch],
|
||||
)
|
||||
for ch in range(self.NUM_CHANNELS)
|
||||
]
|
||||
|
||||
for i, param in enumerate(self.params):
|
||||
param.channel = i
|
||||
|
||||
for i, tree in enumerate(self.trees_ui):
|
||||
tree.setHeaderHidden(True)
|
||||
tree.setParameters(self.params[i], showTop=False)
|
||||
self.params[i].setValue = self._setValue
|
||||
self.params[i].sigTreeStateChanged.connect(self.send_command)
|
||||
|
||||
self.params[i].child("save").sigActivated.connect(
|
||||
partial(self.save_settings, i)
|
||||
)
|
||||
self.params[i].child("load").sigActivated.connect(
|
||||
partial(self.load_settings, i)
|
||||
)
|
||||
self.params[i].child("pid", "pid_autotune", "run_pid").sigActivated.connect(
|
||||
partial(self.pid_auto_tune_request, i)
|
||||
)
|
||||
|
||||
self.thermostat.pid_update.connect(self.update_pid)
|
||||
self.thermostat.report_update.connect(self.update_report)
|
||||
self.thermostat.thermistor_update.connect(self.update_thermistor)
|
||||
self.thermostat.output_update.connect(self.update_output)
|
||||
self.thermostat.postfilter_update.connect(self.update_postfilter)
|
||||
self.autotuners.autotune_state_changed.connect(self.update_pid_autotune)
|
||||
|
||||
def _setValue(self, value, blockSignal=None):
|
||||
"""
|
||||
Implement 'lock' mechanism for Parameter Type
|
||||
|
||||
Modified from the source
|
||||
"""
|
||||
try:
|
||||
if blockSignal is not None:
|
||||
self.sigValueChanged.disconnect(blockSignal)
|
||||
value = self._interpretValue(value)
|
||||
if fn.eq(self.opts["value"], value):
|
||||
return value
|
||||
|
||||
if "lock" in self.opts.keys():
|
||||
if self.opts["lock"]:
|
||||
return value
|
||||
self.opts["value"] = value
|
||||
self.sigValueChanged.emit(
|
||||
self, value
|
||||
) # value might change after signal is received by tree item
|
||||
finally:
|
||||
if blockSignal is not None:
|
||||
self.sigValueChanged.connect(blockSignal)
|
||||
|
||||
return self.opts["value"]
|
||||
|
||||
def change_params_title(self, channel, path, title):
|
||||
self.params[channel].child(*path).setOpts(title=title)
|
||||
|
||||
@asyncSlot(object, object)
|
||||
async def send_command(self, param, changes):
|
||||
"""Translates parameter tree changes into thermostat set_param calls"""
|
||||
ch = param.channel
|
||||
|
||||
for inner_param, change, data in changes:
|
||||
if change == "value":
|
||||
new_value = data
|
||||
if "thermostat:set_param" in inner_param.opts:
|
||||
if inner_param.opts.get("suffix", None) == "mA":
|
||||
new_value /= 1000 # Given in mA
|
||||
|
||||
thermostat_param = inner_param.opts["thermostat:set_param"]
|
||||
|
||||
# Handle thermostat command irregularities
|
||||
match inner_param.name(), new_value:
|
||||
case "rate", None:
|
||||
thermostat_param = thermostat_param.copy()
|
||||
thermostat_param["field"] = "off"
|
||||
new_value = ""
|
||||
case "control_method", "constant_current":
|
||||
return
|
||||
case "control_method", "temperature_pid":
|
||||
new_value = ""
|
||||
|
||||
inner_param.setOpts(lock=True)
|
||||
await self.thermostat.set_param(
|
||||
channel=ch, value=new_value, **thermostat_param
|
||||
)
|
||||
inner_param.setOpts(lock=False)
|
||||
|
||||
if "pid_autotune" in inner_param.opts:
|
||||
auto_tuner_param = inner_param.opts["pid_autotune"]
|
||||
self.autotuners.set_params(auto_tuner_param, ch, new_value)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_pid(self, pid_settings):
|
||||
for settings in pid_settings:
|
||||
channel = settings["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("pid", "kp").setValue(
|
||||
settings["parameters"]["kp"]
|
||||
)
|
||||
self.params[channel].child("pid", "ki").setValue(
|
||||
settings["parameters"]["ki"]
|
||||
)
|
||||
self.params[channel].child("pid", "kd").setValue(
|
||||
settings["parameters"]["kd"]
|
||||
)
|
||||
self.params[channel].child(
|
||||
"pid", "pid_output_clamping", "output_min"
|
||||
).setValue(settings["parameters"]["output_min"] * 1000)
|
||||
self.params[channel].child(
|
||||
"pid", "pid_output_clamping", "output_max"
|
||||
).setValue(settings["parameters"]["output_max"] * 1000)
|
||||
self.params[channel].child(
|
||||
"output", "control_method", "target"
|
||||
).setValue(settings["target"])
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_report(self, report_data):
|
||||
for settings in report_data:
|
||||
channel = settings["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("output", "control_method").setValue(
|
||||
"temperature_pid" if settings["pid_engaged"] else "constant_current"
|
||||
)
|
||||
self.params[channel].child(
|
||||
"output", "control_method", "i_set"
|
||||
).setValue(settings["i_set"] * 1000)
|
||||
if settings["temperature"] is not None:
|
||||
self.params[channel].child("readings", "temperature").setValue(
|
||||
settings["temperature"]
|
||||
)
|
||||
if settings["tec_i"] is not None:
|
||||
self.params[channel].child("readings", "tec_i").setValue(
|
||||
settings["tec_i"] * 1000
|
||||
)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_thermistor(self, sh_data):
|
||||
for sh_param in sh_data:
|
||||
channel = sh_param["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("thermistor", "t0").setValue(
|
||||
sh_param["params"]["t0"] - 273.15
|
||||
)
|
||||
self.params[channel].child("thermistor", "r0").setValue(
|
||||
sh_param["params"]["r0"]
|
||||
)
|
||||
self.params[channel].child("thermistor", "b").setValue(
|
||||
sh_param["params"]["b"]
|
||||
)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_output(self, output_data):
|
||||
for output_params in output_data:
|
||||
channel = output_params["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("output", "limits", "max_v").setValue(
|
||||
output_params["max_v"]
|
||||
)
|
||||
self.params[channel].child("output", "limits", "max_i_pos").setValue(
|
||||
output_params["max_i_pos"] * 1000
|
||||
)
|
||||
self.params[channel].child("output", "limits", "max_i_neg").setValue(
|
||||
output_params["max_i_neg"] * 1000
|
||||
)
|
||||
|
||||
@pyqtSlot(list)
|
||||
def update_postfilter(self, postfilter_data):
|
||||
for postfilter_params in postfilter_data:
|
||||
channel = postfilter_params["channel"]
|
||||
with QSignalBlocker(self.params[channel]):
|
||||
self.params[channel].child("thermistor", "rate").setValue(
|
||||
postfilter_params["rate"]
|
||||
)
|
||||
|
||||
def update_pid_autotune(self, ch, state):
|
||||
match state:
|
||||
case PIDAutotuneState.OFF:
|
||||
self.change_params_title(ch, ("pid", "pid_autotune", "run_pid"), "Run")
|
||||
case (
|
||||
PIDAutotuneState.READY
|
||||
| PIDAutotuneState.RELAY_STEP_UP
|
||||
| PIDAutotuneState.RELAY_STEP_DOWN
|
||||
):
|
||||
self.change_params_title(ch, ("pid", "pid_autotune", "run_pid"), "Stop")
|
||||
case PIDAutotuneState.SUCCEEDED:
|
||||
self.info_box.display_info_box(
|
||||
"PID Autotune Success",
|
||||
f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.",
|
||||
)
|
||||
case PIDAutotuneState.FAILED:
|
||||
self.info_box.display_info_box(
|
||||
"PID Autotune Failed",
|
||||
f"Channel {ch} PID Autotune has failed.",
|
||||
)
|
||||
|
||||
@asyncSlot(int)
|
||||
async def load_settings(self, ch):
|
||||
await self.thermostat.load_cfg(ch)
|
||||
|
||||
self.info_box.display_info_box(
|
||||
f"Channel {ch} settings loaded",
|
||||
f"Channel {ch} settings has been loaded from flash.",
|
||||
)
|
||||
|
||||
@asyncSlot(int)
|
||||
async def save_settings(self, ch):
|
||||
await self.thermostat.save_cfg(ch)
|
||||
|
||||
self.info_box.display_info_box(
|
||||
f"Channel {ch} settings saved",
|
||||
f"Channel {ch} settings has been saved to flash.\n"
|
||||
"It will be loaded on Thermostat reset, or when settings are explicitly loaded.",
|
||||
)
|
||||
|
||||
@asyncSlot()
|
||||
async def pid_auto_tune_request(self, ch=0):
|
||||
match self.autotuners.get_state(ch):
|
||||
case PIDAutotuneState.OFF | PIDAutotuneState.FAILED:
|
||||
self.autotuners.load_params_and_set_ready(ch)
|
||||
|
||||
case (
|
||||
PIDAutotuneState.READY
|
||||
| PIDAutotuneState.RELAY_STEP_UP
|
||||
| PIDAutotuneState.RELAY_STEP_DOWN
|
||||
):
|
||||
await self.autotuners.stop_pid_from_running(ch)
|
||||
212
pythermostat/pythermostat/gui/view/waitingspinnerwidget.py
Normal file
212
pythermostat/pythermostat/gui/view/waitingspinnerwidget.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-2014 Alexander Turkin
|
||||
Copyright (c) 2014 William Hallatt
|
||||
Copyright (c) 2015 Jacob Dawid
|
||||
Copyright (c) 2016 Luca Weiss
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from PyQt6.QtCore import *
|
||||
from PyQt6.QtGui import *
|
||||
from PyQt6.QtWidgets import *
|
||||
|
||||
|
||||
class QtWaitingSpinner(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# WAS IN initialize()
|
||||
self._color = QColor(Qt.GlobalColor.black)
|
||||
self._roundness = 100.0
|
||||
self._minimumTrailOpacity = 3.14159265358979323846
|
||||
self._trailFadePercentage = 80.0
|
||||
self._revolutionsPerSecond = 1.57079632679489661923
|
||||
self._numberOfLines = 20
|
||||
self._lineLength = 5
|
||||
self._lineWidth = 2
|
||||
self._innerRadius = 5
|
||||
self._currentCounter = 0
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self.rotate)
|
||||
self.updateSize()
|
||||
self.updateTimer()
|
||||
# END initialize()
|
||||
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
def paintEvent(self, QPaintEvent):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
|
||||
if self._currentCounter >= self._numberOfLines:
|
||||
self._currentCounter = 0
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
for i in range(0, self._numberOfLines):
|
||||
painter.save()
|
||||
painter.translate(
|
||||
self._innerRadius + self._lineLength,
|
||||
self._innerRadius + self._lineLength,
|
||||
)
|
||||
rotateAngle = float(360 * i) / float(self._numberOfLines)
|
||||
painter.rotate(rotateAngle)
|
||||
painter.translate(self._innerRadius, 0)
|
||||
distance = self.lineCountDistanceFromPrimary(
|
||||
i, self._currentCounter, self._numberOfLines
|
||||
)
|
||||
color = self.currentLineColor(
|
||||
distance,
|
||||
self._numberOfLines,
|
||||
self._trailFadePercentage,
|
||||
self._minimumTrailOpacity,
|
||||
self._color,
|
||||
)
|
||||
painter.setBrush(color)
|
||||
painter.drawRoundedRect(
|
||||
QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth),
|
||||
self._roundness,
|
||||
self._roundness,
|
||||
Qt.SizeMode.RelativeSize,
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def start(self):
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
self._currentCounter = 0
|
||||
|
||||
def stop(self):
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
self._currentCounter = 0
|
||||
|
||||
def setNumberOfLines(self, lines):
|
||||
self._numberOfLines = lines
|
||||
self._currentCounter = 0
|
||||
self.updateTimer()
|
||||
|
||||
def setLineLength(self, length):
|
||||
self._lineLength = length
|
||||
self.updateSize()
|
||||
|
||||
def setLineWidth(self, width):
|
||||
self._lineWidth = width
|
||||
self.updateSize()
|
||||
|
||||
def setInnerRadius(self, radius):
|
||||
self._innerRadius = radius
|
||||
self.updateSize()
|
||||
|
||||
def color(self):
|
||||
return self._color
|
||||
|
||||
def roundness(self):
|
||||
return self._roundness
|
||||
|
||||
def minimumTrailOpacity(self):
|
||||
return self._minimumTrailOpacity
|
||||
|
||||
def trailFadePercentage(self):
|
||||
return self._trailFadePercentage
|
||||
|
||||
def revolutionsPersSecond(self):
|
||||
return self._revolutionsPerSecond
|
||||
|
||||
def numberOfLines(self):
|
||||
return self._numberOfLines
|
||||
|
||||
def lineLength(self):
|
||||
return self._lineLength
|
||||
|
||||
def lineWidth(self):
|
||||
return self._lineWidth
|
||||
|
||||
def innerRadius(self):
|
||||
return self._innerRadius
|
||||
|
||||
def setRoundness(self, roundness):
|
||||
self._roundness = max(0.0, min(100.0, roundness))
|
||||
|
||||
def setColor(self, color=Qt.GlobalColor.black):
|
||||
self._color = QColor(color)
|
||||
|
||||
def setRevolutionsPerSecond(self, revolutionsPerSecond):
|
||||
self._revolutionsPerSecond = revolutionsPerSecond
|
||||
self.updateTimer()
|
||||
|
||||
def setTrailFadePercentage(self, trail):
|
||||
self._trailFadePercentage = trail
|
||||
|
||||
def setMinimumTrailOpacity(self, minimumTrailOpacity):
|
||||
self._minimumTrailOpacity = minimumTrailOpacity
|
||||
|
||||
def rotate(self):
|
||||
self._currentCounter += 1
|
||||
if self._currentCounter >= self._numberOfLines:
|
||||
self._currentCounter = 0
|
||||
self.update()
|
||||
|
||||
def updateSize(self):
|
||||
self.size = (self._innerRadius + self._lineLength) * 2
|
||||
self.setFixedSize(self.size, self.size)
|
||||
|
||||
def updateTimer(self):
|
||||
self._timer.setInterval(
|
||||
int(1000 / (self._numberOfLines * self._revolutionsPerSecond))
|
||||
)
|
||||
|
||||
def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines):
|
||||
distance = primary - current
|
||||
if distance < 0:
|
||||
distance += totalNrOfLines
|
||||
return distance
|
||||
|
||||
def currentLineColor(
|
||||
self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput
|
||||
):
|
||||
color = QColor(colorinput)
|
||||
if countDistance == 0:
|
||||
return color
|
||||
minAlphaF = minOpacity / 100.0
|
||||
distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0))
|
||||
if countDistance > distanceThreshold:
|
||||
color.setAlphaF(minAlphaF)
|
||||
else:
|
||||
alphaDiff = color.alphaF() - minAlphaF
|
||||
gradient = alphaDiff / float(distanceThreshold + 1)
|
||||
resultAlpha = color.alphaF() - gradient * countDistance
|
||||
# If alpha is out of bounds, clip it.
|
||||
resultAlpha = min(1.0, max(0.0, resultAlpha))
|
||||
color.setAlphaF(resultAlpha)
|
||||
return color
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
waiting_spinner = QtWaitingSpinner()
|
||||
waiting_spinner.show()
|
||||
waiting_spinner.start()
|
||||
app.exec()
|
||||
@@ -0,0 +1,50 @@
|
||||
from PyQt6.QtCore import pyqtSlot, QObject
|
||||
from PyQt6 import QtWidgets, QtGui
|
||||
|
||||
|
||||
class ZeroLimitsWarningView(QObject):
|
||||
def __init__(self, thermostat, style, limit_warning):
|
||||
super().__init__()
|
||||
self._thermostat = thermostat
|
||||
self._thermostat.output_update.connect(self.set_limits_warning)
|
||||
self._lbl = limit_warning
|
||||
self._style = style
|
||||
|
||||
@pyqtSlot(list)
|
||||
def set_limits_warning(self, output_data: list):
|
||||
channels_zeroed_limits = [set() for i in range(self._thermostat.NUM_CHANNELS)]
|
||||
|
||||
for output_params in output_data:
|
||||
channel = output_params["channel"]
|
||||
for limit in "max_i_pos", "max_i_neg", "max_v":
|
||||
if output_params[limit] == 0.0:
|
||||
channels_zeroed_limits[channel].add(limit)
|
||||
|
||||
channel_disabled = [False, False]
|
||||
report_str = "The following output limit(s) are set to zero:\n"
|
||||
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
|
||||
if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits):
|
||||
report_str += "Max Cooling Current, Max Heating Current"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if "max_v" in zeroed_limits:
|
||||
if channel_disabled[ch]:
|
||||
report_str += ", "
|
||||
report_str += "Max Voltage Difference"
|
||||
channel_disabled[ch] = True
|
||||
|
||||
if channel_disabled[ch]:
|
||||
report_str += f" for Channel {ch}\n"
|
||||
|
||||
report_str += (
|
||||
"\nThese limit(s) are restricting the channel(s) from producing current."
|
||||
)
|
||||
|
||||
if True in channel_disabled:
|
||||
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
|
||||
icon = self._style.standardIcon(pixmapi)
|
||||
self._lbl.setPixmap(icon.pixmap(16, 16))
|
||||
self._lbl.setToolTip(report_str)
|
||||
else:
|
||||
self._lbl.setPixmap(QtGui.QPixmap())
|
||||
self._lbl.setToolTip(None)
|
||||
137
pythermostat/pythermostat/plot.py
Normal file
137
pythermostat/pythermostat/plot.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import time
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.animation as animation
|
||||
from threading import Thread, Lock
|
||||
from pythermostat.client import Client
|
||||
|
||||
|
||||
def main():
|
||||
TIME_WINDOW = 300.0
|
||||
|
||||
thermostat = Client()
|
||||
target_temperature = thermostat.get_pid()[0]['target']
|
||||
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
|
||||
|
||||
class Series:
|
||||
def __init__(self, conv=lambda x: x):
|
||||
self.conv = conv
|
||||
self.x_data = []
|
||||
self.y_data = []
|
||||
|
||||
def append(self, x, y):
|
||||
self.x_data.append(x)
|
||||
self.y_data.append(self.conv(y))
|
||||
|
||||
def clip(self, min_x):
|
||||
drop = 0
|
||||
while drop < len(self.x_data) and self.x_data[drop] < min_x:
|
||||
drop += 1
|
||||
self.x_data = self.x_data[drop:]
|
||||
self.y_data = self.y_data[drop:]
|
||||
|
||||
series = {
|
||||
# 'adc': Series(),
|
||||
# 'sens': Series(lambda x: x * 0.0001),
|
||||
'temperature': Series(),
|
||||
# 'i_set': Series(),
|
||||
'pid_output': Series(),
|
||||
# 'vref': Series(),
|
||||
# 'dac_value': Series(),
|
||||
# 'dac_feedback': Series(),
|
||||
# 'i_tec': Series(),
|
||||
'tec_i': Series(),
|
||||
'tec_u_meas': Series(),
|
||||
# 'interval': Series(),
|
||||
}
|
||||
series_lock = Lock()
|
||||
|
||||
quit = False
|
||||
|
||||
def recv_data(thermostat):
|
||||
global last_packet_time
|
||||
while True:
|
||||
data = thermostat.get_report()
|
||||
ch0 = data[0]
|
||||
series_lock.acquire()
|
||||
try:
|
||||
for k, s in series.items():
|
||||
if k in ch0:
|
||||
v = ch0[k]
|
||||
if type(v) is float:
|
||||
s.append(ch0['time'], v)
|
||||
finally:
|
||||
series_lock.release()
|
||||
|
||||
if quit:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
thread = Thread(target=recv_data, args=(thermostat,))
|
||||
thread.start()
|
||||
|
||||
fig, ax = plt.subplots()
|
||||
|
||||
for k, s in series.items():
|
||||
s.plot, = ax.plot([], [], label=k)
|
||||
legend = ax.legend()
|
||||
|
||||
def animate(i):
|
||||
min_x, max_x, min_y, max_y = None, None, None, None
|
||||
|
||||
series_lock.acquire()
|
||||
try:
|
||||
for k, s in series.items():
|
||||
s.plot.set_data(s.x_data, s.y_data)
|
||||
if len(s.y_data) > 0:
|
||||
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
|
||||
|
||||
if len(s.x_data) > 0:
|
||||
min_x_ = min(s.x_data)
|
||||
if min_x is None:
|
||||
min_x = min_x_
|
||||
else:
|
||||
min_x = min(min_x, min_x_)
|
||||
max_x_ = max(s.x_data)
|
||||
if max_x is None:
|
||||
max_x = max_x_
|
||||
else:
|
||||
max_x = max(max_x, max_x_)
|
||||
if len(s.y_data) > 0:
|
||||
min_y_ = min(s.y_data)
|
||||
if min_y is None:
|
||||
min_y = min_y_
|
||||
else:
|
||||
min_y = min(min_y, min_y_)
|
||||
max_y_ = max(s.y_data)
|
||||
if max_y is None:
|
||||
max_y = max_y_
|
||||
else:
|
||||
max_y = max(max_y, max_y_)
|
||||
|
||||
if min_x and max_x - TIME_WINDOW > min_x:
|
||||
for s in series.values():
|
||||
s.clip(max_x - TIME_WINDOW)
|
||||
finally:
|
||||
series_lock.release()
|
||||
|
||||
if min_x != max_x:
|
||||
ax.set_xlim(min_x, max_x)
|
||||
if min_y != max_y:
|
||||
margin_y = 0.01 * (max_y - min_y)
|
||||
ax.set_ylim(min_y - margin_y, max_y + margin_y)
|
||||
|
||||
nonlocal legend
|
||||
legend.remove()
|
||||
legend = ax.legend()
|
||||
|
||||
ani = animation.FuncAnimation(
|
||||
fig, animate, interval=1, blit=False, save_count=50)
|
||||
|
||||
plt.show()
|
||||
quit = True
|
||||
thread.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
81
pythermostat/pythermostat/test.py
Normal file
81
pythermostat/pythermostat/test.py
Normal file
@@ -0,0 +1,81 @@
|
||||
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()
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::timer::sleep;
|
||||
use stm32f4xx_hal::{
|
||||
hal::{
|
||||
blocking::spi::Transfer,
|
||||
digital::v2::OutputPin,
|
||||
},
|
||||
time::MegaHertz,
|
||||
hal::{blocking::spi::Transfer, digital::v2::OutputPin},
|
||||
spi,
|
||||
time::MegaHertz,
|
||||
};
|
||||
|
||||
/// SPI Mode 1
|
||||
@@ -26,30 +24,25 @@ pub struct Dac<SPI: Transfer<u8>, S: OutputPin> {
|
||||
impl<SPI: Transfer<u8>, S: OutputPin> Dac<SPI, S> {
|
||||
pub fn new(spi: SPI, mut sync: S) -> Self {
|
||||
let _ = sync.set_low();
|
||||
|
||||
Dac {
|
||||
spi,
|
||||
sync,
|
||||
}
|
||||
|
||||
Dac { spi, sync }
|
||||
}
|
||||
|
||||
fn write(&mut self, mut buf: [u8; 3]) -> Result<(), SPI::Error> {
|
||||
fn write(&mut self, buf: &mut [u8]) -> Result<(), SPI::Error> {
|
||||
// pulse sync to start a new transfer. leave sync idle low
|
||||
// afterwards to save power as recommended per datasheet.
|
||||
let _ = self.sync.set_high();
|
||||
cortex_m::asm::nop();
|
||||
// must be high for >= 33 ns
|
||||
sleep(1);
|
||||
let _ = self.sync.set_low();
|
||||
|
||||
self.spi.transfer(&mut buf)?;
|
||||
self.spi.transfer(buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&mut self, value: u32) -> Result<(), SPI::Error> {
|
||||
let buf = [
|
||||
(value >> 14) as u8,
|
||||
(value >> 6) as u8,
|
||||
(value << 2) as u8,
|
||||
];
|
||||
self.write(buf)
|
||||
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];
|
||||
self.write(&mut buf)?;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use core::fmt;
|
||||
use log::{info, warn};
|
||||
use stm32f4xx_hal::hal::{
|
||||
blocking::spi::Transfer,
|
||||
digital::v2::OutputPin,
|
||||
};
|
||||
use super::{
|
||||
checksum::{Checksum, ChecksumMode},
|
||||
regs::{self, Register, RegisterData},
|
||||
checksum::{ChecksumMode, Checksum},
|
||||
Mode, Input, RefSource, PostFilter, DigitalFilterOrder,
|
||||
DigitalFilterOrder, Input, Mode, PostFilter, RefSource,
|
||||
};
|
||||
use core::{fmt, marker::PhantomData};
|
||||
use log::{info, warn};
|
||||
use stm32f4xx_hal::hal::{blocking::spi::Transfer, digital::v2::OutputPin};
|
||||
use uom::si::f64::ElectricPotential;
|
||||
|
||||
/// AD7172-2 implementation
|
||||
///
|
||||
@@ -23,7 +21,8 @@ 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()?;
|
||||
@@ -51,8 +50,7 @@ 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(®s::Id)
|
||||
.map(|id| id.id())
|
||||
self.read_reg(®s::Id).map(|id| id.id())
|
||||
}
|
||||
|
||||
pub fn set_checksum_mode(&mut self, mode: ChecksumMode) -> Result<(), SPI::Error> {
|
||||
@@ -72,7 +70,10 @@ 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(®s::SetupCon { index }, |data| {
|
||||
data.set_bipolar(false);
|
||||
@@ -86,6 +87,8 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
|
||||
data.set_enh_filt_en(true);
|
||||
data.set_enh_filt(PostFilter::F16SPS);
|
||||
data.set_order(DigitalFilterOrder::Sinc5Sinc1);
|
||||
// output data rate: 10 Hz
|
||||
data.set_odr(0b10011);
|
||||
})?;
|
||||
self.update_reg(®s::Channel { index }, |data| {
|
||||
data.set_setup(index);
|
||||
@@ -96,45 +99,15 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disable_channel(
|
||||
&mut self, index: u8
|
||||
) -> Result<(), SPI::Error> {
|
||||
self.update_reg(®s::Channel { index }, |data| {
|
||||
data.set_enabled(false);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disable_all_channels(&mut self) -> Result<(), SPI::Error> {
|
||||
for index in 0..4 {
|
||||
self.update_reg(®s::Channel { index }, |data| {
|
||||
data.set_enabled(false);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calibrates offset registers
|
||||
pub fn calibrate(&mut self) -> Result<(), SPI::Error> {
|
||||
// internal offset calibration
|
||||
self.update_reg(®s::AdcMode, |adc_mode| {
|
||||
adc_mode.set_mode(Mode::InternalOffsetCalibration);
|
||||
})?;
|
||||
while ! self.read_reg(®s::Status)?.ready() {}
|
||||
|
||||
// system offset calibration
|
||||
self.update_reg(®s::AdcMode, |adc_mode| {
|
||||
adc_mode.set_mode(Mode::SystemOffsetCalibration);
|
||||
})?;
|
||||
while ! self.read_reg(®s::Status)?.ready() {}
|
||||
|
||||
// system gain calibration
|
||||
self.update_reg(®s::AdcMode, |adc_mode| {
|
||||
adc_mode.set_mode(Mode::SystemGainCalibration);
|
||||
})?;
|
||||
while ! self.read_reg(®s::Status)?.ready() {}
|
||||
|
||||
Ok(())
|
||||
pub fn get_calibration(&mut self, index: u8) -> Result<ChannelCalibration, SPI::Error> {
|
||||
let offset = self.read_reg(®s::Offset { index })?.offset();
|
||||
let gain = self.read_reg(®s::Gain { index })?.gain();
|
||||
let bipolar = self.read_reg(®s::SetupCon { index })?.bipolar();
|
||||
Ok(ChannelCalibration {
|
||||
offset,
|
||||
gain,
|
||||
bipolar,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_continuous_conversion(&mut self) -> Result<(), SPI::Error> {
|
||||
@@ -147,44 +120,43 @@ 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(®s::FiltCon { index })
|
||||
.map(|data| {
|
||||
if data.enh_filt_en() {
|
||||
Some(data.enh_filt())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
self.read_reg(®s::FiltCon { index }).map(|data| {
|
||||
if data.enh_filt_en() {
|
||||
Some(data.enh_filt())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_postfilter(&mut self, index: u8, filter: Option<PostFilter>) -> Result<(), SPI::Error> {
|
||||
self.update_reg(®s::FiltCon { index }, |data| {
|
||||
match filter {
|
||||
None => data.set_enh_filt_en(false),
|
||||
Some(filter) => {
|
||||
data.set_enh_filt_en(true);
|
||||
data.set_enh_filt(filter);
|
||||
}
|
||||
pub fn set_postfilter(
|
||||
&mut self,
|
||||
index: u8,
|
||||
filter: Option<PostFilter>,
|
||||
) -> Result<(), SPI::Error> {
|
||||
self.update_reg(®s::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(®s::Status)
|
||||
.map(|status| {
|
||||
if status.ready() {
|
||||
Some(status.channel())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
self.read_reg(®s::Status).map(|status| {
|
||||
if status.ready() {
|
||||
Some(status.channel())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get data
|
||||
pub fn read_data(&mut self) -> Result<u32, SPI::Error> {
|
||||
self.read_reg(®s::Data)
|
||||
.map(|data| data.data())
|
||||
self.read_reg(®s::Data).map(|data| data.data())
|
||||
}
|
||||
|
||||
fn read_reg<R: regs::Register>(&mut self, reg: &R) -> Result<R::Data, SPI::Error> {
|
||||
@@ -203,12 +175,21 @@ 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 {
|
||||
@@ -218,7 +199,7 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
|
||||
ChecksumMode::Crc => ChecksumMode::Crc,
|
||||
});
|
||||
checksum.feed(&[address]);
|
||||
checksum.feed(®_data);
|
||||
checksum.feed(reg_data);
|
||||
let checksum_out = checksum.result();
|
||||
|
||||
let mut data = reg_data.clone();
|
||||
@@ -229,7 +210,10 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +237,12 @@ impl<SPI: Transfer<u8, Error = E>, NSS: OutputPin, E: fmt::Debug> Adc<SPI, NSS>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transfer<'w>(&mut self, addr: u8, reg_data: &'w mut [u8], checksum: Option<u8>) -> Result<Option<u8>, SPI::Error> {
|
||||
fn transfer(
|
||||
&mut self,
|
||||
addr: u8,
|
||||
reg_data: &mut [u8],
|
||||
checksum: Option<u8>,
|
||||
) -> Result<Option<u8>, SPI::Error> {
|
||||
let mut addr_buf = [addr];
|
||||
|
||||
let _ = self.nss.set_low();
|
||||
@@ -262,8 +251,7 @@ 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) {
|
||||
@@ -271,11 +259,37 @@ 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();
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelCalibration {
|
||||
offset: u32,
|
||||
gain: u32,
|
||||
bipolar: bool,
|
||||
}
|
||||
|
||||
impl ChannelCalibration {
|
||||
pub fn convert_data(&self, data: u32) -> ElectricPotential {
|
||||
let data = if self.bipolar {
|
||||
(data as i32 - 0x80_0000) as f64
|
||||
} else {
|
||||
data as f64 / 2.0
|
||||
};
|
||||
let data = data / (self.gain as f64 / (0x40_0000 as f64));
|
||||
let data = data + (self.offset as i32 - 0x80_0000) as f64;
|
||||
let data = data / (1 << 23) as f64;
|
||||
|
||||
const V_REF: ElectricPotential = ElectricPotential {
|
||||
dimension: PhantomData,
|
||||
units: PhantomData,
|
||||
value: 3.3,
|
||||
};
|
||||
data * V_REF / 0.75
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use core::fmt;
|
||||
use num_traits::float::Float;
|
||||
use stm32f4xx_hal::{
|
||||
time::MegaHertz,
|
||||
spi,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use stm32f4xx_hal::{spi, time::MegaHertz};
|
||||
|
||||
pub mod regs;
|
||||
mod checksum;
|
||||
pub mod regs;
|
||||
pub use checksum::ChecksumMode;
|
||||
mod adc;
|
||||
pub use adc::*;
|
||||
@@ -21,7 +19,6 @@ pub const SPI_CLOCK: MegaHertz = MegaHertz(2);
|
||||
|
||||
pub const MAX_VALUE: u32 = 0xFF_FFFF;
|
||||
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[repr(u8)]
|
||||
pub enum Mode {
|
||||
@@ -104,7 +101,8 @@ impl fmt::Display for Input {
|
||||
RefPos => "ref+",
|
||||
RefNeg => "ref-",
|
||||
_ => "<INVALID>",
|
||||
}.fmt(fmt)
|
||||
}
|
||||
.fmt(fmt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,9 +121,9 @@ pub enum RefSource {
|
||||
impl From<u8> for RefSource {
|
||||
fn from(x: u8) -> Self {
|
||||
match x {
|
||||
0 => RefSource::External,
|
||||
1 => RefSource::Internal,
|
||||
2 => RefSource::Avdd1MinusAvss,
|
||||
0b00 => RefSource::External,
|
||||
0b10 => RefSource::Internal,
|
||||
0b11 => RefSource::Avdd1MinusAvss,
|
||||
_ => RefSource::Invalid,
|
||||
}
|
||||
}
|
||||
@@ -140,28 +138,42 @@ impl fmt::Display for RefSource {
|
||||
Internal => "internal",
|
||||
Avdd1MinusAvss => "avdd1-avss",
|
||||
_ => "<INVALID>",
|
||||
}.fmt(fmt)
|
||||
}
|
||||
.fmt(fmt)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
/// Simultaneous Rejection of 50 Hz +/- 1 Hz and 60 Hz +/- 1 Hz
|
||||
pub enum PostFilter {
|
||||
/// 27 SPS, 47 dB rejection, 36.7 ms settling
|
||||
/// Output Data Rate: 27.27 SPS,
|
||||
/// Settling Time: 36.67 ms,
|
||||
/// Rejection: 47 dB
|
||||
F27SPS = 0b010,
|
||||
/// 21.25 SPS, 62 dB rejection, 40 ms settling
|
||||
F21SPS = 0b011,
|
||||
/// 20 SPS, 86 dB rejection, 50 ms settling
|
||||
|
||||
/// Output Data Rate: 25 SPS,
|
||||
/// Settling Time: 40.0 ms,
|
||||
/// Rejection: 62 dB
|
||||
F25SPS = 0b011,
|
||||
|
||||
/// Output Data Rate: 20 SPS,
|
||||
/// Settling Time: 50.0 ms,
|
||||
/// Rejection: 85 dB
|
||||
F20SPS = 0b101,
|
||||
/// 16.67 SPS, 92 dB rejection, 60 ms settling
|
||||
|
||||
/// Output Data Rate: 16.667 SPS,
|
||||
/// Settling Time: 60.0 ms,
|
||||
/// Rejection: 90 dB
|
||||
F16SPS = 0b110,
|
||||
|
||||
Invalid = 0b111,
|
||||
}
|
||||
|
||||
impl PostFilter {
|
||||
pub const VALID_VALUES: &'static [Self] = &[
|
||||
PostFilter::F27SPS,
|
||||
PostFilter::F21SPS,
|
||||
PostFilter::F25SPS,
|
||||
PostFilter::F20SPS,
|
||||
PostFilter::F16SPS,
|
||||
];
|
||||
@@ -183,10 +195,10 @@ impl PostFilter {
|
||||
/// Samples per Second
|
||||
pub fn output_rate(&self) -> Option<f32> {
|
||||
match self {
|
||||
PostFilter::F27SPS => Some(27.0),
|
||||
PostFilter::F21SPS => Some(21.25),
|
||||
PostFilter::F27SPS => Some(27.27),
|
||||
PostFilter::F25SPS => Some(25.0),
|
||||
PostFilter::F20SPS => Some(20.0),
|
||||
PostFilter::F16SPS => Some(16.67),
|
||||
PostFilter::F16SPS => Some(16.667),
|
||||
PostFilter::Invalid => None,
|
||||
}
|
||||
}
|
||||
@@ -196,7 +208,7 @@ impl From<u8> for PostFilter {
|
||||
fn from(x: u8) -> Self {
|
||||
match x {
|
||||
0b010 => PostFilter::F27SPS,
|
||||
0b011 => PostFilter::F21SPS,
|
||||
0b011 => PostFilter::F25SPS,
|
||||
0b101 => PostFilter::F20SPS,
|
||||
0b110 => PostFilter::F16SPS,
|
||||
_ => PostFilter::Invalid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use core::ops::{Deref, DerefMut};
|
||||
use byteorder::{BigEndian, ByteOrder};
|
||||
use bit_field::BitField;
|
||||
use byteorder::{BigEndian, ByteOrder};
|
||||
use core::ops::{Deref, DerefMut};
|
||||
|
||||
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,7 +49,9 @@ 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 {
|
||||
@@ -76,7 +78,7 @@ macro_rules! def_reg {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! reg_bit {
|
||||
@@ -146,7 +148,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");
|
||||
@@ -159,9 +161,21 @@ 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");
|
||||
}
|
||||
@@ -174,15 +188,19 @@ 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);
|
||||
@@ -200,8 +218,7 @@ 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)]
|
||||
@@ -210,27 +227,66 @@ 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");
|
||||
}
|
||||
|
||||
@@ -238,9 +294,7 @@ 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) {
|
||||
@@ -254,9 +308,7 @@ 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) {
|
||||