commit 93b4e2c4f84e8c2fca3357b19a71f911c7467078 Author: Oliver Walter Date: Tue Jul 28 17:17:44 2026 +0200 Initial commit: RP2040 RS485 temperature/humidity node RTIC 2 firmware for an RP2040 acting as an RS485 slave, reading a Sensirion SHT31 over I2C and answering a custom binary protocol. Split into two crates so the protocol is testable without hardware: - wiredsensor-core: framing, CRC-16/MODBUS, command dispatch and SHT3x data-sheet math. Pure computation, no I/O, no peripherals. Covered by 42 host tests including the CRC-16/MODBUS catalogue vector, the SHT3x data-sheet CRC-8 example, and an exhaustive single-bit-corruption sweep over every byte of a frame. - wiredsensor-fw: the RTIC application, PL011 register driver and SHT31 I2C driver. Requests are answered entirely from a cached reading. An SHT31 high-repeatability conversion takes up to 15 ms, well past the turnaround a master expects, so the sensor is polled at the lowest priority and the bus path never touches I2C. RTIC's priority ceilings make "a conversion cannot delay a reply" a checked property rather than an argument. Two PL011 details drive the low-level approach, since rp2040-hal exposes neither: the receive-timeout interrupt for prompt frame delimiting, and the BUSY flag, which is the only way to know the final stop bit has left the shift register before releasing DE. The HAL performs the fiddly baud-divisor setup once, then the raw peripheral is reclaimed via free(). Note that RTIM cannot be the sole frame delimiter: it only fires while the RX FIFO is non-empty, so a frame landing exactly on the FIFO watermark is drained by the watermark interrupt and never times out. The authoritative delimiter is a monotonic timer measured from the last received byte. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..60e9a4c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +[build] +target = "thumbv6m-none-eabi" + +[target.thumbv6m-none-eabi] +runner = "probe-rs run --chip RP2040 --protocol swd" + +[env] +DEFMT_LOG = "info" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2c9f1d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1204 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitfield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield 0.13.2", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-any" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" +dependencies = [ + "debug-helper", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "debug-helper" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a4af69c60438a1a82af89d362f4729fd38db7b73f305a237636fad31ceb2bf" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "defmt-rtt" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05144944c117db54127a8ac17a3c9a28d55e7047bfddb950bcd20b4a94c79b10" +dependencies = [ + "critical-section", + "defmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embedded-dma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "994f7e5b5cb23521c22304927195f236813053eb9c065dd2226a32ba64695446" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "frunk" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28aef0f9aa070bce60767c12ba9cb41efeaf1a2bc6427f87b7d83f11239a16d7" +dependencies = [ + "frunk_core 0.4.4", + "frunk_derives", +] + +[[package]] +name = "frunk_core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476eeaa382e3462b84da5d6ba3da97b5786823c2d0d3a0d04ef088d073da225c" + +[[package]] +name = "frunk_core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd3c9ba2e323e8b19e77f15873f60974a7d82f89b80e50c53be44b8b92927c1" + +[[package]] +name = "frunk_derives" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b4095fc99e1d858e5b8c7125d2638372ec85aa0fe6c807105cf10b0265ca6c" +dependencies = [ + "frunk_proc_macro_helpers", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "frunk_proc_macro_helpers" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b70229a1347a20d4af9c06116cc452acef34f798668c6b69e97dd5c8a88052bd" +dependencies = [ + "frunk_core 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba4153cee9585abc451271aa437d9e8defdea8b468d48ba6b8f098cbe03d7f" +dependencies = [ + "pio-core", + "pio-proc", +] + +[[package]] +name = "pio-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" +dependencies = [ + "arrayvec", + "num_enum", + "paste", +] + +[[package]] +name = "pio-parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825266c1eaddf54f636d06eefa4bf3c99d774c14ec46a4a6c6e5128a0f10d205" +dependencies = [ + "lalrpop", + "lalrpop-util", + "pio-core", +] + +[[package]] +name = "pio-proc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a76571f5fe51af43cc80ac870fe0c79cc0cdd686b9002a6c4c84bfdd0176b" +dependencies = [ + "codespan-reporting", + "lalrpop-util", + "pio-core", + "pio-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "critical-section", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rp-binary-info" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f582261945fa215d40e2988b4df595d11c0908c0fff97a0fe23df766d117b790" + +[[package]] +name = "rp-hal-common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8288358786b1458fb2caac8c4b40fb529ef4200d6c46467e2695b7a8ba573ae8" +dependencies = [ + "fugit", +] + +[[package]] +name = "rp2040-boot2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c92f344f63f950ee36cf4080050e4dce850839b9175da38f9d2ffb69b4dbb21" +dependencies = [ + "crc-any", +] + +[[package]] +name = "rp2040-hal" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b01f11995e606dd13b07f3fb80686149e5b8bbbab21fc4af437a9978f1a783f" +dependencies = [ + "bitfield 0.14.0", + "cortex-m", + "critical-section", + "embedded-dma", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "frunk", + "fugit", + "itertools 0.10.5", + "nb 1.1.0", + "paste", + "pio", + "rand_core", + "rp-binary-info", + "rp-hal-common", + "rp2040-hal-macros", + "rp2040-pac", + "usb-device", + "vcell", + "void", +] + +[[package]] +name = "rp2040-hal-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86479063e497efe1ae81995ef9071f54fd1c7427e04d6c5b84cde545ff672a5e" +dependencies = [ + "cortex-m-rt", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rp2040-pac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cbcd3f7a0ca7bbe61dc4eb7e202842bee4e27b769a7bf3a4a72fa399d6e404" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "critical-section", + "vcell", +] + +[[package]] +name = "rtic" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16dff5e56cf22c25d0223e8abc0f60b428c577d4dd4a2459e55eeb289165106c" +dependencies = [ + "cortex-m", + "critical-section", + "portable-atomic", + "rtic-core", + "rtic-macros", +] + +[[package]] +name = "rtic-common" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67caeecca87cb20e1101eb104b0f05604633b7ab1035cce777417c6fb7c8f9a0" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "rtic-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9369355b04d06a3780ec0f51ea2d225624db777acbc60abd8ca4832da5c1a42" + +[[package]] +name = "rtic-macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db06b1d38d3591975d0971b2a5cde129767a93f5815fb01c99efa8d1898c16" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rtic-monotonics" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d12a5e827e4a491affc0296b7c33242f03c02ef70150f93912cc9c77bb071f65" +dependencies = [ + "cfg-if", + "cortex-m", + "fugit", + "portable-atomic", + "rp2040-pac", + "rtic-time", +] + +[[package]] +name = "rtic-time" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61485474f5a23247ae1d4875f2bfe860be9b3030dbf87c232e50799e021429a1" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "embedded-hal-async", + "fugit", + "futures-util", + "rtic-common", +] + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "usb-device" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" +dependencies = [ + "heapless", + "portable-atomic", +] + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wiredsensor-core" +version = "0.1.0" +dependencies = [ + "defmt", +] + +[[package]] +name = "wiredsensor-fw" +version = "0.1.0" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "defmt", + "defmt-rtt", + "embedded-hal 1.0.0", + "fugit", + "panic-probe", + "portable-atomic", + "rp2040-boot2", + "rp2040-hal", + "rtic", + "rtic-monotonics", + "wiredsensor-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fe1be51 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "2" +members = ["core", "firmware"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +wiredsensor-core = { path = "core" } +defmt = "1.1" + +# Cortex-M0+ has no hardware divide and a small cache-less flash path, so even +# debug builds want size optimisation or timing-sensitive paths get sloppy. +[profile.dev] +codegen-units = 1 +debug = 2 +debug-assertions = true +incremental = false +opt-level = "s" +overflow-checks = true + +[profile.release] +codegen-units = 1 +debug = 2 # keep DWARF for probe-rs/defmt; it is not flashed +debug-assertions = false +incremental = false +lto = "fat" +opt-level = "s" +overflow-checks = false diff --git a/README.md b/README.md new file mode 100644 index 0000000..811f055 --- /dev/null +++ b/README.md @@ -0,0 +1,316 @@ +# wiredsensor + +RP2040 firmware for an RS485 temperature and humidity node. A Sensirion **SHT31** +on I2C, an RS485 transceiver on UART0, and a custom binary protocol in which this +node is always the slave. + +Written in Rust on **RTIC 2** + `rp2040-hal`. + +``` + ┌──────────────┐ I2C1 ┌────────┐ + │ ├────────►│ SHT31 │ + A/B │ RP2040 │ └────────┘ + ◄────►│ │ UART0 + DE + │ wiredsensor ├────────►┌──────────────┐ + └──────────────┘ │ MAX485-class │ + └──────────────┘ +``` + +## Layout + +| Path | Contents | +|------------|--------------------------------------------------------------------------| +| `core/` | `wiredsensor-core` — framing, CRCs, dispatch, SHT3x math. Pure, no I/O. | +| `firmware/`| `wiredsensor-fw` — the RTIC application, drivers and register access. | + +The split exists so the entire protocol can be exercised by ordinary host unit +tests (`cargo test`) with no hardware and no emulator. `core` performs no I/O and +touches no peripherals; everything hardware-shaped lives in `firmware`. + +## Pinout + +| GPIO | Function | Connects to | +|------|------------|---------------------------------------------------| +| 0 | UART0 TX | transceiver `DI` | +| 1 | UART0 RX | transceiver `RO` | +| 2 | SIO output | transceiver `DE` **and** `/RE`, tied together | +| 14 | I2C1 SDA | SHT31 `SDA` | +| 15 | I2C1 SCL | SHT31 `SCL` | +| 25 | SIO output | status LED, active high | +| — | SWD | `SWCLK`/`SWDIO` on the dedicated pads, for the probe | + +Why this arrangement: + +- **GPIO 0/1/2 as one contiguous block.** All three RS485 signals sit at the + corner of the package, so the transceiver can be placed right beside it with + short traces and no crossings. It also keeps the fast switching edges of `DE` + away from the sensor. +- **`DE` and `/RE` on a single pin.** `/RE` is active low and `DE` active high, + so one line puts the transceiver in transmit when high and receive when low. + Saves a GPIO and makes it structurally impossible to drive and listen at once. +- **I2C1 on GPIO 14/15, physically far from the RS485 block.** The sensor is the + noise-sensitive part and the differential driver is the noisy part. Using I2C1 + also leaves I2C0 free. +- **GPIO 25 for the LED** matches the Raspberry Pi Pico, so this image blinks on + a bare Pico as well — handy for bring-up before the real board exists. + +### Hardware notes the firmware cannot enforce + +- Fit **120 Ω termination only at the two physical ends** of the segment, and + fail-safe bias resistors at exactly one point on the segment. +- The SHT31 `ADDR` pin selects the I2C address: low → `0x44`, high → `0x45`. + Strap it deliberately rather than leaving it floating, and keep + `board::SENSOR_I2C_ADDR` in agreement. +- Fit **4.7 kΩ external I2C pull-ups**. The internal pulls the firmware enables + are ~50 kΩ and are a fallback, not a substitute. +- The SHT31 `nRESET` pin can simply be pulled high; the firmware uses the soft + reset command. +- `.boot2` is built for a **W25Q080-class** QSPI flash. Change + `BOOT2_FIRMWARE` in `firmware/src/main.rs` if the board carries something else. + +## Protocol + +19200 baud, 8N1, half duplex. Frames are delimited by an **idle line**, not by +any byte value, so payloads are fully binary-transparent with no escaping. + +``` +┌──────┬─────┬─────┬──────────────┬────────┬────────┐ +│ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │ +└──────┴─────┴─────┴──────────────┴────────┴────────┘ + 1 1 1 0..=64 1 1 +``` + +- **ADDR** — `0x01`..`0xF7` addresses a unit; `0x00` is broadcast and is never + answered. A frame for any other address is ignored silently. +- **LEN** — payload length. Carried explicitly as well as implied by the frame + length, which lets a receiver reject a frame on length grounds before spending + time on the CRC, and makes captures readable by eye. +- **CRC** — CRC-16/MODBUS (poly `0xA001` reflected, init `0xFFFF`, no final XOR), + transmitted **low byte first**. Identical to Modbus RTU, so off-the-shelf bus + analysers validate our frames without being taught anything. +- All multi-byte payload fields are **little-endian**. + +Frame timing follows the Modbus rules: a frame ends after **3.5 character times** +of idle line, pinned to a fixed 1750 µs above 19200 baud. At 19200 that is +1822 µs. + +### Commands + +| Code | Name | Request payload | Reply payload | +|--------|--------------------|-----------------|---------------| +| `0x01` | `READ_MEASUREMENT` | none | 10 bytes | +| `0x02` | `READ_INFO` | none | 16 bytes | +| `0x03` | `READ_STATUS` | none | 11 bytes | +| `0x04` | `PING` | 0..64 bytes | echoed back | +| `0x10` | `SET_ADDRESS` | 1 byte | *error* — see below | + +**`READ_MEASUREMENT`** reply: + +| Offset | Type | Field | +|--------|-------|----------------------------------------------------| +| 0 | `i32` | temperature, milli-degrees Celsius | +| 4 | `i32` | relative humidity, milli-percent (0..=100000) | +| 8 | `u16` | age of the reading in ms, saturating at 65535 | + +The node reports `age_ms` rather than enforcing a freshness policy of its own, so +the master decides what staleness its application tolerates. If no reading has +ever succeeded, the reply is an error: `SENSOR_UNAVAILABLE`, or `SENSOR_FAULT` +once the failure threshold is passed. + +**`READ_INFO`** reply: + +| Offset | Type | Field | +|--------|-------|----------------------------------------------------------| +| 0 | `u8` | firmware major | +| 1 | `u8` | firmware minor | +| 2 | `u8` | firmware patch | +| 3 | `u8` | protocol version | +| 4 | `u32` | board serial (build-time constant) | +| 8 | `u32` | SHT31 factory serial, read over I2C at start-up, 0 if unavailable | +| 12 | `u32` | uptime in seconds | + +**`READ_STATUS`** reply: + +| Offset | Type | Field | +|--------|-------|----------------------------------------------| +| 0 | `u8` | flags, see below | +| 1 | `u16` | raw SHT31 status register | +| 3 | `u16` | I2C transfer errors | +| 5 | `u16` | sensor CRC-8 errors | +| 7 | `u16` | frame errors (length, framing, overrun) | +| 9 | `u16` | frame CRC-16 errors | + +Flags: + +| Bit | Name | Meaning | +|-----|---------------------|----------------------------------------------------| +| 0 | `SENSOR_OK` | the most recent poll succeeded | +| 1 | `DATA_STALE` | cached reading older than `STALE_AFTER_MS` | +| 2 | `SENSOR_FAULT` | consecutive failures past the threshold | +| 3 | `UART_ERROR` | a line error has been seen since power-up | +| 4 | `EVER_MEASURED` | at least one reading has succeeded since power-up | +| 5 | `SENSOR_RESET_SEEN` | the SHT31 reported an unexpected reset | +| 6 | `HEATER_ON` | the SHT31 internal heater is on | + +### Errors + +An error reply is the request's command code with bit 7 set, and a single +payload byte: + +| Code | Meaning | +|--------|--------------------------------------------------| +| `0x01` | `ILLEGAL_COMMAND` — command not implemented | +| `0x02` | `ILLEGAL_LENGTH` — wrong payload length | +| `0x03` | `SENSOR_UNAVAILABLE` — no reading yet | +| `0x04` | `SENSOR_FAULT` — sensor persistently failing | +| `0x05` | `UNSUPPORTED` — recognised but disabled in this build | + +A frame that fails to parse is **counted and ignored**, never answered: with a +bad CRC the address byte cannot be trusted, and replying risks colliding with +whichever node was actually addressed. + +`SET_ADDRESS` is deliberately reserved rather than removed. This build takes its +address from a compile-time constant, so the command answers `UNSUPPORTED` +instead of silently doing nothing — a master can tell the difference between "not +implemented here" and "wrong command code". + +### Example exchanges + +Unit `0x01`, bytes as they appear on the wire: + +``` +READ_MEASUREMENT → 01 01 00 21 90 + ← 01 01 0A 9A 5B 00 00 F0 A0 00 00 89 00 87 66 + └ 23.450 °C, 41.200 %RH, 137 ms old + +READ_INFO → 01 02 00 21 60 +READ_STATUS → 01 03 00 20 F0 + +PING "Hi" → 01 04 02 48 69 4F 1E + ← 01 04 02 48 69 4F 1E + +SET_ADDRESS 0x22 → 01 10 01 22 81 94 + ← 01 90 01 05 C0 66 (UNSUPPORTED) + +unknown cmd 0x7E → 01 7E 00 01 A0 + ← 01 FE 01 01 A0 78 (ILLEGAL_COMMAND) +``` + +`PING` is the intended first bring-up step: it exercises framing, CRC and the +RS485 driver-enable turnaround without involving the sensor at all. + +## Per-unit configuration + +Everything a unit needs is in `firmware/src/board.rs`: + +```rust +pub const UNIT_ADDRESS: u8 = 0x01; // this node's bus address +pub const DEVICE_SERIAL: u32 = 0x0000_0001; +pub const BAUD_RATE: u32 = 19_200; +pub const SENSOR_I2C_ADDR: u8 = 0x44; // match the ADDR strap +pub const SENSOR_PERIOD_MS: u32 = 1_000; +``` + +The address is compile-time by design: no flash wear, no commissioning protocol, +and no way for a bus glitch to renumber a live node. The cost is one image per +unit, so keep `UNIT_ADDRESS` and `DEVICE_SERIAL` the only things that differ. + +If that becomes unwieldy, the two natural upgrades are GPIO address straps read +at boot, or a flash-stored address with `SET_ADDRESS` wired up — the command code +is already reserved for it. + +## Building + +```bash +cargo build --release -p wiredsensor-fw # firmware, thumbv6m-none-eabi +cargo test -p wiredsensor-core --target x86_64-unknown-linux-gnu # 42 host tests +``` + +The host-target flag is needed because `.cargo/config.toml` defaults the whole +workspace to the ARM target. + +Flash and watch `defmt` logs over SWD: + +```bash +cargo run --release -p wiredsensor-fw # runner is probe-rs +``` + +Resource use is ~17 KiB of flash and ~1.2 KiB of RAM, of 2 MiB and 256 KiB. + +## Design notes + +### Why RTIC rather than Embassy + +This node's hard requirements are two pieces of precise register-level UART +behaviour plus one scheduling guarantee, and RTIC provides all three directly: + +- The PL011 **receive-timeout interrupt** (`RTIM`) fires after 32 idle bit + periods with unread data in the FIFO — very close to the 3.5-character gap the + protocol uses as a delimiter. Binding it needs raw register access. +- The PL011 has **no transmit-complete interrupt**, only FIFO-level ones, so + releasing `DE` correctly requires polling the `BUSY` flag. +- The bus must **preempt** the sensor, and the resulting sharing should be + checked rather than argued about. + +`rp2040-hal`'s `Uart` exposes neither `RTIM` nor a hook for post-stop-bit `DE` +release, so the firmware lets the HAL do the fiddly baud-divisor and +line-control setup once, then calls `.free()` to reclaim the raw peripheral and +drives it from registers thereafter. + +### Task structure + +| Prio | Task | Kind | Job | +|------|---------------|----------|--------------------------------------------| +| 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes | +| 2 | `frame_gap` | async | Detect the frame gap, answer the request | +| 1 | `sensor_task` | async | Poll the SHT31 once a second | +| 1 | `heartbeat` | async | Blink the status LED | + +The central decision is that **requests are answered entirely from a cached +reading**. An SHT31 high-repeatability conversion takes up to 15 ms — far longer +than the turnaround a master expects — so the sensor is polled on its own +schedule at the lowest priority and the bus path never touches I2C. RTIC's +priority ceilings then guarantee a conversion in progress cannot delay a reply. + +The ISR deliberately does no parsing. At 19200 baud a character arrives every +~520 µs, roughly 65,000 core cycles, so there is ample slack; keeping the handler +to a FIFO drain is what bounds the jitter everything else sees. + +### Three details that are easy to get wrong + +**`RTIM` alone is not a sufficient frame delimiter.** It only fires while the RX +FIFO is non-empty, so a frame whose length lands exactly on the FIFO watermark +can be drained by the watermark interrupt and never produce a timeout. The +authoritative delimiter is therefore a monotonic timer measured from the last +received byte; `RTIM` merely makes the common case prompt. `frame_gap` re-reads +the arrival timestamp and sleeps again rather than cancelling and respawning a +timer per byte. + +**`DE` must be held until the last stop bit is gone.** Releasing it when the TX +FIFO empties truncates the final character for every listener — a fault that +shows up as a CRC error at the master and is completely invisible at the slave. +`transmit` sleeps through the bulk of the transmission, then confirms with +`BUSY`. It yields rather than spins, which is safe because `DE` stays asserted +throughout: the node owns the bus for the whole call. + +**The RX FIFO is flushed after transmitting.** While `DE` is asserted the +transceiver's `/RE` is disabled and `RO` is not driven, so the edge as it +re-enables can clock a spurious character in. Left in place that byte would +become the first byte of the next frame and break it. + +### SHT3x, not SHT4x + +Worth stating explicitly because the families are easy to confuse and the +firmware would appear to work while reading wrong: + +- Commands are **16-bit** words, not a single byte. +- Humidity is `100 × raw / 65535` — **no** `-6 + 125 ×` offset term. Temperature + is `-45 + 175 × raw / 65535`, the same as SHT4x. +- High-repeatability conversion takes up to **15 ms**, not 8.3 ms. + +Measurements use the clock-stretch-**disabled** command and an explicit timed +wait. The alternative has the sensor hold SCL for up to 15 ms, blocking the bus +and making the transfer duration depend on the controller's stretch timeout. + +Each poll also reads the status register, so an unexpected sensor reset surfaces +in `READ_STATUS` instead of silently reverting the sensor's configuration. diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..4d4cb2b --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "wiredsensor-core" +description = "Pure protocol framing, dispatch and SHT4x data-sheet math for the wiredsensor RS485 node" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +defmt = { workspace = true, optional = true } + +[features] +default = [] +# Derive `defmt::Format` on the public error/data types. +defmt = ["dep:defmt"] diff --git a/core/src/crc.rs b/core/src/crc.rs new file mode 100644 index 0000000..cf6d43b --- /dev/null +++ b/core/src/crc.rs @@ -0,0 +1,76 @@ +//! CRC-16/MODBUS, used to protect every bus frame. +//! +//! Reflected polynomial `0xA001` (normal form `0x8005`), initial value `0xFFFF`, +//! no final XOR, transmitted low byte first. This is the same CRC Modbus RTU +//! uses, so off-the-shelf bus analysers validate our frames without help. + +/// Initial CRC register value. +pub const INIT: u16 = 0xFFFF; + +/// Reflected generator polynomial. +const POLY: u16 = 0xA001; + +/// Fold one byte into a running CRC. +#[inline] +pub const fn update(crc: u16, byte: u8) -> u16 { + let mut crc = crc ^ byte as u16; + let mut bit = 0; + while bit < 8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ POLY + } else { + crc >> 1 + }; + bit += 1; + } + crc +} + +/// CRC over a whole buffer. +pub const fn checksum(data: &[u8]) -> u16 { + let mut crc = INIT; + let mut i = 0; + while i < data.len() { + crc = update(crc, data[i]); + i += 1; + } + crc +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The canonical CRC-16/MODBUS check value for the ASCII string "123456789". + #[test] + fn catalogue_check_value() { + assert_eq!(checksum(b"123456789"), 0x4B37); + } + + #[test] + fn empty_input_is_init() { + assert_eq!(checksum(&[]), INIT); + } + + #[test] + fn incremental_matches_bulk() { + let data = [0x01, 0x03, 0x00, 0x6B, 0x00, 0x03]; + let mut crc = INIT; + for &b in &data { + crc = update(crc, b); + } + assert_eq!(crc, checksum(&data)); + } + + /// Appending a frame's own CRC (little-endian) makes the CRC over the whole + /// buffer collapse to zero. Receivers may rely on this shortcut. + #[test] + fn self_check_collapses_to_zero() { + let body = [0x01u8, 0x01, 0x00]; + let crc = checksum(&body); + let mut whole = [0u8; 5]; + whole[..3].copy_from_slice(&body); + whole[3..].copy_from_slice(&crc.to_le_bytes()); + assert_eq!(checksum(&whole), 0); + } +} diff --git a/core/src/frame.rs b/core/src/frame.rs new file mode 100644 index 0000000..017130f --- /dev/null +++ b/core/src/frame.rs @@ -0,0 +1,253 @@ +//! Frame layout and validation. +//! +//! ```text +//! ┌──────┬─────┬─────┬───────────────┬────────┬────────┐ +//! │ ADDR │ CMD │ LEN │ PAYLOAD[LEN] │ CRC_LO │ CRC_HI │ +//! └──────┴─────┴─────┴───────────────┴────────┴────────┘ +//! 1 1 1 0..=64 1 1 +//! ``` +//! +//! Frames are delimited by an idle line, Modbus-RTU style, rather than by any +//! byte value — so the payload is fully binary-transparent with no escaping. +//! `LEN` is carried explicitly as well, which lets a receiver reject a frame on +//! length grounds before spending time on the CRC and makes captures easy to +//! read by eye. + +use crate::crc; + +/// Largest payload a frame may carry. +pub const MAX_PAYLOAD: usize = 64; + +/// Bytes preceding the payload: `ADDR`, `CMD`, `LEN`. +pub const HEADER_LEN: usize = 3; + +/// Bytes following the payload. +pub const CRC_LEN: usize = 2; + +/// Shortest possible frame (empty payload). +pub const MIN_FRAME: usize = HEADER_LEN + CRC_LEN; + +/// Longest possible frame. +pub const MAX_FRAME: usize = HEADER_LEN + MAX_PAYLOAD + CRC_LEN; + +/// Reserved address meaning "every node on the segment". Nodes must never reply +/// to it, or the bus would collide. +pub const ADDR_BROADCAST: u8 = 0x00; + +/// Lowest assignable unit address. +pub const ADDR_MIN: u8 = 0x01; + +/// Highest assignable unit address (247, matching the Modbus convention). +pub const ADDR_MAX: u8 = 0xF7; + +/// Why a received byte sequence is not a usable frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum ParseError { + /// Fewer bytes than the smallest legal frame. + TooShort, + /// More bytes than the largest legal frame. + TooLong, + /// The `LEN` field disagrees with how many bytes actually arrived. + LengthMismatch, + /// The trailing CRC does not cover the received bytes. + BadCrc, +} + +/// Why a frame could not be built. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum EncodeError { + /// The payload exceeds [`MAX_PAYLOAD`]. + PayloadTooLong, + /// The destination buffer is too small for the resulting frame. + BufferTooSmall, +} + +/// A validated, borrowed view of a received frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Frame<'a> { + /// Destination address as it appeared on the wire. + pub addr: u8, + /// Command code. + pub cmd: u8, + /// Command payload, already length-checked. + pub payload: &'a [u8], +} + +impl<'a> Frame<'a> { + /// Validate `buf` as a complete frame. + /// + /// `buf` must hold exactly one frame — the caller is responsible for + /// delimiting it, which on the wire means "everything received since the + /// last inter-frame idle gap". + pub fn parse(buf: &'a [u8]) -> Result { + if buf.len() < MIN_FRAME { + return Err(ParseError::TooShort); + } + if buf.len() > MAX_FRAME { + return Err(ParseError::TooLong); + } + + let len = buf[2] as usize; + if HEADER_LEN + len + CRC_LEN != buf.len() { + return Err(ParseError::LengthMismatch); + } + + let (body, tail) = buf.split_at(buf.len() - CRC_LEN); + if crc::checksum(body) != u16::from_le_bytes([tail[0], tail[1]]) { + return Err(ParseError::BadCrc); + } + + Ok(Frame { + addr: buf[0], + cmd: buf[1], + payload: &buf[HEADER_LEN..HEADER_LEN + len], + }) + } +} + +/// Build a frame into `out`, returning how many bytes were written. +pub fn encode(addr: u8, cmd: u8, payload: &[u8], out: &mut [u8]) -> Result { + if payload.len() > MAX_PAYLOAD { + return Err(EncodeError::PayloadTooLong); + } + let total = HEADER_LEN + payload.len() + CRC_LEN; + if out.len() < total { + return Err(EncodeError::BufferTooSmall); + } + + out[0] = addr; + out[1] = cmd; + out[2] = payload.len() as u8; + out[HEADER_LEN..HEADER_LEN + payload.len()].copy_from_slice(payload); + + let crc = crc::checksum(&out[..HEADER_LEN + payload.len()]); + out[total - 2..total].copy_from_slice(&crc.to_le_bytes()); + + Ok(total) +} + +/// True if `addr` may be assigned to a node (i.e. is neither the broadcast +/// address nor in the reserved range above [`ADDR_MAX`]). +pub const fn is_valid_unit_address(addr: u8) -> bool { + addr >= ADDR_MIN && addr <= ADDR_MAX +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build(addr: u8, cmd: u8, payload: &[u8]) -> ([u8; MAX_FRAME], usize) { + let mut buf = [0u8; MAX_FRAME]; + let n = encode(addr, cmd, payload, &mut buf).unwrap(); + (buf, n) + } + + #[test] + fn encode_parse_roundtrip() { + let payload = [0xDE, 0xAD, 0xBE, 0xEF]; + let (buf, n) = build(0x2A, 0x01, &payload); + + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.addr, 0x2A); + assert_eq!(f.cmd, 0x01); + assert_eq!(f.payload, &payload); + } + + #[test] + fn empty_payload_roundtrip() { + let (buf, n) = build(0x01, 0x01, &[]); + assert_eq!(n, MIN_FRAME); + let f = Frame::parse(&buf[..n]).unwrap(); + assert!(f.payload.is_empty()); + } + + #[test] + fn max_payload_roundtrip() { + let payload = [0x5Au8; MAX_PAYLOAD]; + let (buf, n) = build(0x01, 0x04, &payload); + assert_eq!(n, MAX_FRAME); + assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload); + } + + #[test] + fn payload_is_binary_transparent() { + // Bytes that would need escaping in a delimiter-based protocol. + let payload = [0x00, 0xFF, 0x0A, 0x0D, 0x3A, 0x7E]; + let (buf, n) = build(0x01, 0x04, &payload); + assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &payload); + } + + #[test] + fn rejects_short_and_long() { + assert_eq!(Frame::parse(&[]), Err(ParseError::TooShort)); + assert_eq!(Frame::parse(&[0, 0, 0, 0]), Err(ParseError::TooShort)); + assert_eq!( + Frame::parse(&[0u8; MAX_FRAME + 1]), + Err(ParseError::TooLong) + ); + } + + #[test] + fn rejects_length_field_mismatch() { + let (mut buf, n) = build(0x01, 0x01, &[1, 2, 3]); + buf[2] = 2; // claim one byte fewer than actually present + assert_eq!(Frame::parse(&buf[..n]), Err(ParseError::LengthMismatch)); + } + + #[test] + fn rejects_single_bit_corruption_anywhere() { + let payload = [0x11, 0x22, 0x33]; + let (good, n) = build(0x07, 0x02, &payload); + + for byte in 0..n { + for bit in 0..8 { + let mut bad = good; + bad[byte] ^= 1 << bit; + // A flip in the LEN byte is caught by the length check first; + // everything else must be caught by the CRC. Either way the + // frame must not parse as valid. + assert!( + Frame::parse(&bad[..n]).is_err(), + "flip of byte {byte} bit {bit} was accepted" + ); + } + } + } + + #[test] + fn truncation_is_rejected() { + let (buf, n) = build(0x01, 0x01, &[9, 9, 9, 9]); + for shorter in MIN_FRAME..n { + assert!(Frame::parse(&buf[..shorter]).is_err()); + } + } + + #[test] + fn oversized_payload_is_refused() { + let mut out = [0u8; MAX_FRAME + 8]; + assert_eq!( + encode(1, 1, &[0u8; MAX_PAYLOAD + 1], &mut out), + Err(EncodeError::PayloadTooLong) + ); + } + + #[test] + fn small_buffer_is_refused() { + let mut out = [0u8; 4]; + assert_eq!( + encode(1, 1, &[], &mut out), + Err(EncodeError::BufferTooSmall) + ); + } + + #[test] + fn address_validity() { + assert!(!is_valid_unit_address(ADDR_BROADCAST)); + assert!(is_valid_unit_address(ADDR_MIN)); + assert!(is_valid_unit_address(ADDR_MAX)); + assert!(!is_valid_unit_address(ADDR_MAX + 1)); + assert!(!is_valid_unit_address(0xFF)); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..418b801 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,15 @@ +//! Hardware-independent core of the wiredsensor RS485 temperature/humidity node. +//! +//! Everything in this crate is pure computation: framing, CRCs, command dispatch +//! and SHT3x data-sheet conversions. It performs no I/O and touches no +//! peripherals, which keeps the protocol testable on the host with a plain +//! `cargo test` while the firmware crate owns all register access. + +#![cfg_attr(not(test), no_std)] +#![warn(missing_docs)] + +pub mod crc; +pub mod frame; +pub mod proto; +pub mod sht3x; +pub mod timing; diff --git a/core/src/proto.rs b/core/src/proto.rs new file mode 100644 index 0000000..573945e --- /dev/null +++ b/core/src/proto.rs @@ -0,0 +1,524 @@ +//! Application protocol: command codes, payload layouts and request dispatch. +//! +//! All multi-byte payload fields are **little-endian**, matching the CRC byte +//! order on the wire and the native order of both the RP2040 and any x86/ARM +//! host talking to it. +//! +//! [`dispatch`] is the whole slave-side behaviour of the node, expressed as a +//! pure function of the received bytes plus a [`Snapshot`] of device state. The +//! firmware supplies the snapshot and puts the returned bytes on the wire; that +//! split is what lets the entire protocol be exercised by host unit tests. + +use crate::frame::{self, Frame, ParseError, MAX_FRAME}; + +/// Protocol revision reported by [`cmd::READ_INFO`]. Bump on any wire-visible +/// change. +pub const PROTOCOL_VERSION: u8 = 1; + +/// Command codes. +pub mod cmd { + /// Return the latest temperature and humidity reading. + pub const READ_MEASUREMENT: u8 = 0x01; + /// Return firmware version, serial numbers and uptime. + pub const READ_INFO: u8 = 0x02; + /// Return health flags and error counters. + pub const READ_STATUS: u8 = 0x03; + /// Echo the payload back unchanged; a pure link test. + pub const PING: u8 = 0x04; + /// Reserved for runtime address assignment. This build takes its address + /// from a compile-time constant, so the command answers + /// [`err::UNSUPPORTED`] rather than pretending to succeed. + pub const SET_ADDRESS: u8 = 0x10; +} + +/// Set on the command byte of a reply to mark it as an error response, so a +/// master can tell success from failure without inspecting the payload. +pub const ERROR_FLAG: u8 = 0x80; + +/// Error codes carried as the single payload byte of an error reply. +pub mod err { + /// The command code is not implemented. + pub const ILLEGAL_COMMAND: u8 = 0x01; + /// The command does not take a payload of the supplied length. + pub const ILLEGAL_LENGTH: u8 = 0x02; + /// No valid reading has been obtained since power-up. + pub const SENSOR_UNAVAILABLE: u8 = 0x03; + /// The sensor is present but persistently failing. + pub const SENSOR_FAULT: u8 = 0x04; + /// The command is recognised but disabled in this build. + pub const UNSUPPORTED: u8 = 0x05; +} + +/// Bit masks for the `flags` byte of [`Status`]. +pub mod flag { + /// The most recent sensor poll succeeded. + pub const SENSOR_OK: u8 = 1 << 0; + /// The cached reading is older than the node's freshness threshold. + pub const DATA_STALE: u8 = 1 << 1; + /// Consecutive sensor failures have exceeded the fault threshold. + pub const SENSOR_FAULT: u8 = 1 << 2; + /// The UART reported a receive overrun or line error since power-up. + pub const UART_ERROR: u8 = 1 << 3; + /// At least one valid reading has been taken since power-up. + pub const EVER_MEASURED: u8 = 1 << 4; + /// The sensor reported an unexpected reset, and was re-initialised. + pub const SENSOR_RESET_SEEN: u8 = 1 << 5; + /// The sensor's internal heater is on. + pub const HEATER_ON: u8 = 1 << 6; +} + +/// Payload of a successful [`cmd::READ_MEASUREMENT`] reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Measurement { + /// Temperature in milli-degrees Celsius. + pub temp_milli_c: i32, + /// Relative humidity in milli-percent (0..=100000). + pub rh_milli_pct: i32, + /// Age of this reading in milliseconds, saturating at [`u16::MAX`]. + /// + /// Reported rather than enforced: the master decides what freshness its + /// application needs instead of having a threshold baked into the node. + pub age_ms: u16, +} + +impl Measurement { + /// Encoded length in bytes. + pub const LEN: usize = 10; + + /// Serialise into the wire layout. + pub fn encode(&self, out: &mut [u8; Self::LEN]) { + out[0..4].copy_from_slice(&self.temp_milli_c.to_le_bytes()); + out[4..8].copy_from_slice(&self.rh_milli_pct.to_le_bytes()); + out[8..10].copy_from_slice(&self.age_ms.to_le_bytes()); + } + + /// Parse the wire layout. Provided for host-side masters and tests. + pub fn decode(raw: &[u8; Self::LEN]) -> Self { + Self { + temp_milli_c: i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]), + rh_milli_pct: i32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]), + age_ms: u16::from_le_bytes([raw[8], raw[9]]), + } + } +} + +/// Payload of a [`cmd::READ_INFO`] reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Info { + /// Firmware major version. + pub fw_major: u8, + /// Firmware minor version. + pub fw_minor: u8, + /// Firmware patch version. + pub fw_patch: u8, + /// Value of [`PROTOCOL_VERSION`] this build implements. + pub proto_version: u8, + /// Board serial, fixed at build time. + pub device_serial: u32, + /// SHT3x factory serial number, or 0 if it could not be read. + pub sensor_serial: u32, + /// Seconds since power-up, saturating. + pub uptime_s: u32, +} + +impl Info { + /// Encoded length in bytes. + pub const LEN: usize = 16; + + /// Serialise into the wire layout. + pub fn encode(&self, out: &mut [u8; Self::LEN]) { + out[0] = self.fw_major; + out[1] = self.fw_minor; + out[2] = self.fw_patch; + out[3] = self.proto_version; + out[4..8].copy_from_slice(&self.device_serial.to_le_bytes()); + out[8..12].copy_from_slice(&self.sensor_serial.to_le_bytes()); + out[12..16].copy_from_slice(&self.uptime_s.to_le_bytes()); + } +} + +/// Payload of a [`cmd::READ_STATUS`] reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Status { + /// Bitwise OR of the [`flag`] masks. + pub flags: u8, + /// Raw SHT3x status register from the last successful read. + pub sensor_status: u16, + /// I2C transfer failures since power-up. + pub i2c_errors: u16, + /// Sensor CRC-8 mismatches since power-up. + pub sensor_crc_errors: u16, + /// Frames rejected on length or framing grounds since power-up. + pub frame_errors: u16, + /// Frames rejected on CRC-16 grounds since power-up. + pub crc_errors: u16, +} + +impl Status { + /// Encoded length in bytes. + pub const LEN: usize = 11; + + /// Serialise into the wire layout. + pub fn encode(&self, out: &mut [u8; Self::LEN]) { + out[0] = self.flags; + out[1..3].copy_from_slice(&self.sensor_status.to_le_bytes()); + out[3..5].copy_from_slice(&self.i2c_errors.to_le_bytes()); + out[5..7].copy_from_slice(&self.sensor_crc_errors.to_le_bytes()); + out[7..9].copy_from_slice(&self.frame_errors.to_le_bytes()); + out[9..11].copy_from_slice(&self.crc_errors.to_le_bytes()); + } +} + +/// Everything [`dispatch`] is allowed to know about the device. +#[derive(Debug, Clone, Copy)] +pub struct Snapshot { + /// Latest reading, or `None` if the sensor has never produced one. + pub measurement: Option, + /// Identity and version information. + pub info: Info, + /// Health flags and counters. + pub status: Status, +} + +/// What the firmware should do with a received byte sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome<'o> { + /// Put these bytes on the wire. + Reply(&'o [u8]), + /// A well-formed frame that is not ours to answer, or a broadcast. Keep the + /// transmitter off the bus. + Silent, + /// The bytes were not a usable frame. The firmware should bump the matching + /// counter and stay silent — replying to a frame whose address we cannot + /// trust risks a collision with the node that was actually addressed. + Malformed(ParseError), +} + +/// Handle one received frame. +/// +/// `raw` is everything received since the last inter-frame gap; `unit` is this +/// node's address. Returns the bytes to transmit, if any. +pub fn dispatch<'o>( + unit: u8, + raw: &[u8], + snap: &Snapshot, + out: &'o mut [u8; MAX_FRAME], +) -> Outcome<'o> { + let f = match Frame::parse(raw) { + Ok(f) => f, + // Note we report malformed frames even when they were probably meant for + // a different node: with a bad CRC the address byte is not trustworthy, + // and the counters are more useful as a measure of segment quality than + // as a per-node tally. + Err(e) => return Outcome::Malformed(e), + }; + + // This also covers the broadcast address, since a valid unit address is + // never 0 — there are no write commands, so a broadcast asks nothing of us. + if f.addr != unit { + return Outcome::Silent; + } + + match f.cmd { + cmd::READ_MEASUREMENT => { + if !f.payload.is_empty() { + return error(out, unit, f.cmd, err::ILLEGAL_LENGTH); + } + match snap.measurement { + Some(m) => { + let mut p = [0u8; Measurement::LEN]; + m.encode(&mut p); + reply(out, unit, f.cmd, &p) + } + None if snap.status.flags & flag::SENSOR_FAULT != 0 => { + error(out, unit, f.cmd, err::SENSOR_FAULT) + } + None => error(out, unit, f.cmd, err::SENSOR_UNAVAILABLE), + } + } + + cmd::READ_INFO => { + if !f.payload.is_empty() { + return error(out, unit, f.cmd, err::ILLEGAL_LENGTH); + } + let mut p = [0u8; Info::LEN]; + snap.info.encode(&mut p); + reply(out, unit, f.cmd, &p) + } + + cmd::READ_STATUS => { + if !f.payload.is_empty() { + return error(out, unit, f.cmd, err::ILLEGAL_LENGTH); + } + let mut p = [0u8; Status::LEN]; + snap.status.encode(&mut p); + reply(out, unit, f.cmd, &p) + } + + // Echo verbatim. Proves the full round trip — framing, CRC and RS485 + // driver-enable turnaround — without involving the sensor at all. + cmd::PING => reply(out, unit, f.cmd, f.payload), + + cmd::SET_ADDRESS => error(out, unit, f.cmd, err::UNSUPPORTED), + + _ => error(out, unit, f.cmd, err::ILLEGAL_COMMAND), + } +} + +/// Encode a success reply. +fn reply<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, payload: &[u8]) -> Outcome<'o> { + let n = match frame::encode(addr, cmd, payload, out.as_mut_slice()) { + Ok(n) => n, + // Unreachable: every payload built above is at most MAX_PAYLOAD, and a + // PING echo is bounded by the request it mirrors. Staying silent is the + // safe failure mode on a shared bus. + Err(_) => return Outcome::Silent, + }; + Outcome::Reply(&out[..n]) +} + +/// Encode an error reply: the command code with [`ERROR_FLAG`] set, and a +/// one-byte reason. +fn error<'o>(out: &'o mut [u8; MAX_FRAME], addr: u8, cmd: u8, code: u8) -> Outcome<'o> { + reply(out, addr, cmd | ERROR_FLAG, &[code]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::MAX_PAYLOAD; + + const UNIT: u8 = 0x11; + + fn snapshot() -> Snapshot { + Snapshot { + measurement: Some(Measurement { + temp_milli_c: 23_450, + rh_milli_pct: 41_200, + age_ms: 137, + }), + info: Info { + fw_major: 0, + fw_minor: 1, + fw_patch: 0, + proto_version: PROTOCOL_VERSION, + device_serial: 0xDEADBEEF, + sensor_serial: 0x0102_0304, + uptime_s: 4242, + }, + status: Status { + flags: flag::SENSOR_OK | flag::EVER_MEASURED, + sensor_status: 0x8010, + i2c_errors: 1, + sensor_crc_errors: 2, + frame_errors: 3, + crc_errors: 4, + }, + } + } + + /// Build a request and run it through dispatch, returning the parsed reply. + fn exchange(unit: u8, cmd: u8, payload: &[u8]) -> (Snapshot, [u8; MAX_FRAME], usize) { + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(unit, cmd, payload, &mut req).unwrap(); + let snap = snapshot(); + let mut out = [0u8; MAX_FRAME]; + match dispatch(UNIT, &req[..n], &snap, &mut out) { + Outcome::Reply(r) => { + let len = r.len(); + let mut copy = [0u8; MAX_FRAME]; + copy[..len].copy_from_slice(r); + (snap, copy, len) + } + other => panic!("expected a reply, got {other:?}"), + } + } + + #[test] + fn read_measurement_roundtrips_through_the_wire() { + let (snap, buf, n) = exchange(UNIT, cmd::READ_MEASUREMENT, &[]); + let f = Frame::parse(&buf[..n]).unwrap(); + + assert_eq!(f.addr, UNIT); + assert_eq!(f.cmd, cmd::READ_MEASUREMENT); + assert_eq!(f.cmd & ERROR_FLAG, 0); + + let mut raw = [0u8; Measurement::LEN]; + raw.copy_from_slice(f.payload); + assert_eq!(Measurement::decode(&raw), snap.measurement.unwrap()); + } + + #[test] + fn negative_temperature_survives_the_encoding() { + let m = Measurement { + temp_milli_c: -12_345, + rh_milli_pct: 0, + age_ms: 0, + }; + let mut raw = [0u8; Measurement::LEN]; + m.encode(&mut raw); + assert_eq!(Measurement::decode(&raw), m); + } + + #[test] + fn read_info_payload_layout() { + let (snap, buf, n) = exchange(UNIT, cmd::READ_INFO, &[]); + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.payload.len(), Info::LEN); + assert_eq!(f.payload[3], PROTOCOL_VERSION); + assert_eq!( + u32::from_le_bytes([f.payload[4], f.payload[5], f.payload[6], f.payload[7]]), + snap.info.device_serial + ); + assert_eq!( + u32::from_le_bytes([f.payload[8], f.payload[9], f.payload[10], f.payload[11]]), + snap.info.sensor_serial + ); + } + + #[test] + fn read_status_payload_layout() { + let (snap, buf, n) = exchange(UNIT, cmd::READ_STATUS, &[]); + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.payload.len(), Status::LEN); + assert_eq!(f.payload[0], snap.status.flags); + assert_eq!( + u16::from_le_bytes([f.payload[1], f.payload[2]]), + snap.status.sensor_status + ); + } + + #[test] + fn ping_echoes_payload_verbatim() { + let probe = [0x00, 0xFF, 0x55, 0xAA, 0x0D, 0x0A]; + let (_, buf, n) = exchange(UNIT, cmd::PING, &probe); + assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe); + } + + #[test] + fn ping_echoes_a_maximum_payload() { + let probe = [0x5Au8; MAX_PAYLOAD]; + let (_, buf, n) = exchange(UNIT, cmd::PING, &probe); + assert_eq!(Frame::parse(&buf[..n]).unwrap().payload, &probe); + } + + /// The one behaviour that matters most on a shared bus: never transmit + /// unless the frame was addressed to us specifically. + #[test] + fn stays_silent_for_other_units_and_broadcast() { + let snap = snapshot(); + for addr in [ + frame::ADDR_BROADCAST, + UNIT.wrapping_sub(1), + UNIT.wrapping_add(1), + frame::ADDR_MAX, + ] { + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(addr, cmd::READ_MEASUREMENT, &[], &mut req).unwrap(); + let mut out = [0u8; MAX_FRAME]; + assert_eq!( + dispatch(UNIT, &req[..n], &snap, &mut out), + Outcome::Silent, + "replied to address {addr:#04x}" + ); + } + } + + #[test] + fn malformed_frames_are_reported_not_answered() { + let snap = snapshot(); + let mut out = [0u8; MAX_FRAME]; + + assert_eq!( + dispatch(UNIT, &[0x11, 0x01], &snap, &mut out), + Outcome::Malformed(ParseError::TooShort) + ); + + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap(); + req[n - 1] ^= 0xFF; // corrupt the CRC + assert_eq!( + dispatch(UNIT, &req[..n], &snap, &mut out), + Outcome::Malformed(ParseError::BadCrc) + ); + } + + #[test] + fn unknown_command_yields_illegal_command() { + let (_, buf, n) = exchange(UNIT, 0x7E, &[]); + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.cmd, 0x7E | ERROR_FLAG); + assert_eq!(f.payload, &[err::ILLEGAL_COMMAND]); + } + + #[test] + fn set_address_is_refused_rather_than_ignored() { + let (_, buf, n) = exchange(UNIT, cmd::SET_ADDRESS, &[0x22]); + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.cmd, cmd::SET_ADDRESS | ERROR_FLAG); + assert_eq!(f.payload, &[err::UNSUPPORTED]); + } + + #[test] + fn readers_reject_unexpected_payloads() { + for c in [cmd::READ_MEASUREMENT, cmd::READ_INFO, cmd::READ_STATUS] { + let (_, buf, n) = exchange(UNIT, c, &[0xAA]); + let f = Frame::parse(&buf[..n]).unwrap(); + assert_eq!(f.cmd, c | ERROR_FLAG, "command {c:#04x}"); + assert_eq!(f.payload, &[err::ILLEGAL_LENGTH], "command {c:#04x}"); + } + } + + #[test] + fn missing_reading_reports_unavailable() { + let mut snap = snapshot(); + snap.measurement = None; + snap.status.flags = 0; + + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap(); + let mut out = [0u8; MAX_FRAME]; + + match dispatch(UNIT, &req[..n], &snap, &mut out) { + Outcome::Reply(r) => { + let f = Frame::parse(r).unwrap(); + assert_eq!(f.payload, &[err::SENSOR_UNAVAILABLE]); + } + other => panic!("expected an error reply, got {other:?}"), + } + } + + #[test] + fn faulted_sensor_reports_fault_instead_of_unavailable() { + let mut snap = snapshot(); + snap.measurement = None; + snap.status.flags = flag::SENSOR_FAULT; + + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(UNIT, cmd::READ_MEASUREMENT, &[], &mut req).unwrap(); + let mut out = [0u8; MAX_FRAME]; + + match dispatch(UNIT, &req[..n], &snap, &mut out) { + Outcome::Reply(r) => { + let f = Frame::parse(r).unwrap(); + assert_eq!(f.payload, &[err::SENSOR_FAULT]); + } + other => panic!("expected an error reply, got {other:?}"), + } + } + + /// Every reply we emit must itself be a frame our own parser accepts. + #[test] + fn all_replies_are_well_formed() { + let snap = snapshot(); + for cmd in 0x00u8..=0xFF { + let mut req = [0u8; MAX_FRAME]; + let n = frame::encode(UNIT, cmd, &[], &mut req).unwrap(); + let mut out = [0u8; MAX_FRAME]; + if let Outcome::Reply(r) = dispatch(UNIT, &req[..n], &snap, &mut out) { + let f = Frame::parse(r).expect("emitted an unparseable reply"); + assert_eq!(f.addr, UNIT); + } + } + } +} diff --git a/core/src/sht3x.rs b/core/src/sht3x.rs new file mode 100644 index 0000000..50e3114 --- /dev/null +++ b/core/src/sht3x.rs @@ -0,0 +1,244 @@ +//! Pure SHT3x (SHT30/SHT31/SHT35) data-sheet logic: commands, CRC-8, conversions. +//! +//! No I/O happens here — the firmware's `sensor` module performs the actual I2C +//! transfers and calls into this module to build commands and interpret replies. +//! +//! Note the differences from the SHT4x family, which are easy to get wrong: +//! commands are **16-bit** rather than a single byte, and relative humidity uses +//! a plain `100 * raw / 65535` scale with no offset term. + +/// I2C address with the ADDR pin tied low (the usual strapping). +pub const I2C_ADDR_DEFAULT: u8 = 0x44; + +/// I2C address with the ADDR pin tied high, for two sensors on one bus. +pub const I2C_ADDR_ALT: u8 = 0x45; + +/// Number of bytes in a temperature + humidity response. +pub const MEASUREMENT_LEN: usize = 6; + +/// Number of bytes in a 16-bit-word-plus-CRC response (status, serial word). +pub const WORD_LEN: usize = 3; + +/// 16-bit command words. +/// +/// The `*_NO_STRETCH` measurement variants deliberately disable clock +/// stretching: we wait out the conversion with an explicit timer instead of +/// holding SCL, which keeps the I2C bus free and avoids depending on the +/// controller's stretch timeout. +pub mod command { + /// Single-shot measurement, high repeatability, clock stretching disabled. + pub const MEASURE_HIGH_NO_STRETCH: u16 = 0x2400; + /// Single-shot measurement, medium repeatability, clock stretching disabled. + pub const MEASURE_MEDIUM_NO_STRETCH: u16 = 0x240B; + /// Single-shot measurement, low repeatability, clock stretching disabled. + pub const MEASURE_LOW_NO_STRETCH: u16 = 0x2416; + /// Soft reset (re-initialises the sensor without a power cycle). + pub const SOFT_RESET: u16 = 0x30A2; + /// Read the 16-bit status register. + pub const READ_STATUS: u16 = 0xF32D; + /// Clear the sticky bits in the status register. + pub const CLEAR_STATUS: u16 = 0x3041; + /// Turn the on-die heater on (for condensation recovery only). + pub const HEATER_ENABLE: u16 = 0x306D; + /// Turn the on-die heater off. + pub const HEATER_DISABLE: u16 = 0x3066; + /// Leave periodic-acquisition mode. + pub const BREAK: u16 = 0x3093; + /// Read the 32-bit factory serial number (two words, each CRC-protected). + pub const READ_SERIAL: u16 = 0x3780; +} + +/// Status-register bit masks. +pub mod status { + /// At least one alert condition is active. + pub const ALERT_PENDING: u16 = 1 << 15; + /// The on-die heater is currently on. + pub const HEATER_ON: u16 = 1 << 13; + /// Humidity tracking alert. + pub const RH_TRACKING_ALERT: u16 = 1 << 11; + /// Temperature tracking alert. + pub const T_TRACKING_ALERT: u16 = 1 << 10; + /// Sticky: the sensor saw a reset (power-on, soft or nRESET) since last cleared. + pub const SYSTEM_RESET_DETECTED: u16 = 1 << 4; + /// The last command could not be processed. + pub const LAST_COMMAND_FAILED: u16 = 1 << 1; + /// The checksum of the last written data was wrong. + pub const WRITE_CRC_FAILED: u16 = 1 << 0; +} + +/// Worst-case conversion time for high repeatability (data sheet: 15 ms). +pub const MEASURE_HIGH_MAX_MS: u32 = 16; +/// Worst-case conversion time for medium repeatability (data sheet: 6 ms). +pub const MEASURE_MEDIUM_MAX_MS: u32 = 7; +/// Worst-case conversion time for low repeatability (data sheet: 4 ms). +pub const MEASURE_LOW_MAX_MS: u32 = 5; +/// Worst-case soft-reset time (data sheet: 1.5 ms). +pub const SOFT_RESET_MAX_MS: u32 = 2; +/// Worst-case power-up time (data sheet: 1.5 ms). +pub const POWER_UP_MAX_MS: u32 = 2; + +/// Split a 16-bit command into the wire byte order (MSB first). +pub const fn command_bytes(cmd: u16) -> [u8; 2] { + cmd.to_be_bytes() +} + +/// CRC-8 as specified by the SHT3x data sheet: polynomial `0x31`, init `0xFF`, +/// no final XOR. +pub const fn crc8(data: &[u8]) -> u8 { + let mut crc: u8 = 0xFF; + let mut i = 0; + while i < data.len() { + crc ^= data[i]; + let mut bit = 0; + while bit < 8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x31 + } else { + crc << 1 + }; + bit += 1; + } + i += 1; + } + crc +} + +/// Uncalibrated sensor output, straight from the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Raw { + /// Raw temperature ticks. + pub temp_ticks: u16, + /// Raw relative-humidity ticks. + pub rh_ticks: u16, +} + +/// Why a sensor response could not be accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum DecodeError { + /// A CRC-8 over one of the returned words did not match. + Crc, +} + +/// Verify and unpack a 16-bit word followed by its CRC. +pub fn decode_word(bytes: &[u8; WORD_LEN]) -> Result { + if crc8(&bytes[..2]) != bytes[2] { + return Err(DecodeError::Crc); + } + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) +} + +/// Verify and unpack a measurement response +/// `[T_msb, T_lsb, T_crc, RH_msb, RH_lsb, RH_crc]`. +pub fn decode_measurement(bytes: &[u8; MEASUREMENT_LEN]) -> Result { + let temp_ticks = decode_word(&[bytes[0], bytes[1], bytes[2]])?; + let rh_ticks = decode_word(&[bytes[3], bytes[4], bytes[5]])?; + Ok(Raw { + temp_ticks, + rh_ticks, + }) +} + +/// Verify and unpack a serial-number response (two CRC-protected words). +pub fn decode_serial(bytes: &[u8; MEASUREMENT_LEN]) -> Result { + let hi = decode_word(&[bytes[0], bytes[1], bytes[2]])?; + let lo = decode_word(&[bytes[3], bytes[4], bytes[5]])?; + Ok(((hi as u32) << 16) | lo as u32) +} + +/// Temperature in milli-degrees Celsius. +/// +/// Data sheet: `T[°C] = -45 + 175 * ticks / 65535`, giving -45000..=130000. +/// Computed in 64-bit to keep the intermediate product exact on a chip with no +/// hardware divider; this runs once per measurement so the cost is irrelevant. +pub const fn temp_milli_c(ticks: u16) -> i32 { + -45_000 + (175_000i64 * ticks as i64 / 65_535) as i32 +} + +/// Relative humidity in milli-percent, clamped to the physically meaningful +/// 0..=100000 range. +/// +/// Data sheet: `RH[%] = 100 * ticks / 65535`. Note this has no offset term, +/// unlike the SHT4x family. +pub const fn rh_milli_pct(ticks: u16) -> i32 { + let raw = (100_000i64 * ticks as i64 / 65_535) as i32; + if raw < 0 { + 0 + } else if raw > 100_000 { + 100_000 + } else { + raw + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The data sheet's worked CRC example: 0xBEEF checksums to 0x92. + #[test] + fn datasheet_crc_example() { + assert_eq!(crc8(&[0xBE, 0xEF]), 0x92); + } + + #[test] + fn temperature_endpoints() { + assert_eq!(temp_milli_c(0), -45_000); + assert_eq!(temp_milli_c(u16::MAX), 130_000); + } + + #[test] + fn humidity_endpoints() { + assert_eq!(rh_milli_pct(0), 0); + assert_eq!(rh_milli_pct(u16::MAX), 100_000); + } + + /// Mid-scale should land near 25 °C / 50 %RH, which is the sanity check most + /// easily confirmed against a bench reading. + #[test] + fn humidity_midscale_is_fifty_percent() { + assert_eq!(rh_milli_pct(0x8000), 50_000); + } + + #[test] + fn temperature_midscale_is_about_425_deci() { + // -45 + 175/2 = 42.5 °C + assert_eq!(temp_milli_c(0x8000), 42_501); + } + + #[test] + fn measurement_roundtrip() { + let t: u16 = 0x6666; + let h: u16 = 0x8000; + let mut buf = [0u8; MEASUREMENT_LEN]; + buf[..2].copy_from_slice(&t.to_be_bytes()); + buf[2] = crc8(&buf[..2]); + buf[3..5].copy_from_slice(&h.to_be_bytes()); + buf[5] = crc8(&buf[3..5]); + + let raw = decode_measurement(&buf).unwrap(); + assert_eq!(raw.temp_ticks, t); + assert_eq!(raw.rh_ticks, h); + } + + #[test] + fn corrupted_measurement_is_rejected() { + let mut buf = [0u8; MEASUREMENT_LEN]; + buf[2] = crc8(&buf[..2]); + buf[5] = crc8(&buf[3..5]); + assert!(decode_measurement(&buf).is_ok()); + + buf[1] ^= 0x01; // flip a data bit without fixing the CRC + assert_eq!(decode_measurement(&buf), Err(DecodeError::Crc)); + } + + #[test] + fn commands_go_out_msb_first() { + assert_eq!( + command_bytes(command::MEASURE_HIGH_NO_STRETCH), + [0x24, 0x00] + ); + assert_eq!(command_bytes(command::SOFT_RESET), [0x30, 0xA2]); + assert_eq!(command_bytes(command::READ_STATUS), [0xF3, 0x2D]); + } +} diff --git a/core/src/timing.rs b/core/src/timing.rs new file mode 100644 index 0000000..18ef7f2 --- /dev/null +++ b/core/src/timing.rs @@ -0,0 +1,82 @@ +//! Wire timing derived from the line rate. +//! +//! Kept here rather than in the firmware so the arithmetic — which decides +//! whether frames are delimited correctly and whether the transceiver is +//! released at the right moment — is covered by host tests. + +/// Bits on the wire per character: 1 start + 8 data + 1 stop, i.e. 8N1. +pub const BITS_PER_CHAR: u32 = 10; + +/// Idle time that delimits one frame from the next, in microseconds. +/// +/// Modbus specifies 3.5 character times, but pins the value at a fixed 1750 µs +/// above 19200 baud, where 3.5 characters shrink to something a busy slave +/// cannot reliably measure. We follow the same rule so that standard bus +/// analysers and masters agree with us about where frames begin and end. +pub const fn inter_frame_gap_us(baud: u32) -> u32 { + if baud > 19_200 { + 1_750 + } else { + // 3.5 * BITS_PER_CHAR / baud, kept in integer arithmetic. + (7 * BITS_PER_CHAR * 1_000_000) / (2 * baud) + } +} + +/// Time to clock out one character, in microseconds. +pub const fn char_time_us(baud: u32) -> u32 { + (BITS_PER_CHAR * 1_000_000) / baud +} + +/// Time to clock out `chars` characters, in microseconds. +pub const fn tx_time_us(chars: usize, baud: u32) -> u32 { + chars as u32 * char_time_us(baud) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gap_is_three_and_a_half_characters_at_low_rates() { + // 3.5 * 10 / 9600 s = 3645.8 µs + assert_eq!(inter_frame_gap_us(9_600), 3_645); + // 3.5 * 10 / 19200 s = 1822.9 µs + assert_eq!(inter_frame_gap_us(19_200), 1_822); + assert_eq!(inter_frame_gap_us(4_800), 7_291); + } + + #[test] + fn gap_is_pinned_above_19200() { + assert_eq!(inter_frame_gap_us(38_400), 1_750); + assert_eq!(inter_frame_gap_us(115_200), 1_750); + } + + /// The gap must always exceed a single character time, or a frame would be + /// declared complete between two back-to-back bytes. + #[test] + fn gap_always_exceeds_one_character() { + for baud in [1_200, 2_400, 4_800, 9_600, 19_200, 38_400, 57_600, 115_200] { + assert!( + inter_frame_gap_us(baud) > char_time_us(baud), + "gap {} is not longer than a character {} at {} baud", + inter_frame_gap_us(baud), + char_time_us(baud), + baud + ); + } + } + + #[test] + fn character_time_tracks_baud() { + assert_eq!(char_time_us(9_600), 1_041); + assert_eq!(char_time_us(19_200), 520); + assert_eq!(char_time_us(115_200), 86); + } + + #[test] + fn transmission_time_scales_linearly() { + assert_eq!(tx_time_us(0, 19_200), 0); + assert_eq!(tx_time_us(1, 19_200), char_time_us(19_200)); + assert_eq!(tx_time_us(21, 19_200), 21 * 520); + } +} diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml new file mode 100644 index 0000000..3d29dd0 --- /dev/null +++ b/firmware/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "wiredsensor-fw" +description = "RP2040 firmware for an RS485 temperature/humidity slave with an SHT31" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "wiredsensor-fw" +test = false +bench = false + +[dependencies] +wiredsensor-core = { workspace = true, features = ["defmt"] } + +# Concurrency framework. `thumbv6-backend` selects the Cortex-M0+ critical +# section and priority implementation. +rtic = { version = "2.3", features = ["thumbv6-backend"] } +rtic-monotonics = { version = "2.2", features = ["rp2040"] } + +rp2040-hal = { version = "0.12", features = ["rt", "critical-section-impl"] } +rp2040-boot2 = "0.3" + +cortex-m = "0.7" +cortex-m-rt = "0.7" + +# Cortex-M0+ has no atomic compare-exchange instruction, so the polyfill has to +# be told to emulate one with a critical section. RTIC's executor needs CAS, and +# without this the build fails with a bare "requires atomic CAS" error. +portable-atomic = { version = "1", features = ["critical-section"] } + +embedded-hal = "1.0" +fugit = "0.3" + +defmt = { workspace = true } +defmt-rtt = "1.0" +panic-probe = { version = "1.0", features = ["print-defmt"] } diff --git a/firmware/build.rs b/firmware/build.rs new file mode 100644 index 0000000..a97010c --- /dev/null +++ b/firmware/build.rs @@ -0,0 +1,19 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + // Copy memory.x somewhere the linker will find it. Relying on the linker's + // working directory does not work in a workspace, where cargo runs rustc + // from the workspace root rather than this package's directory. + let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + fs::copy("memory.x", out.join("memory.x")).expect("copy memory.x into OUT_DIR"); + println!("cargo:rustc-link-search={}", out.display()); + + // --nmagic disables page alignment of sections; without it the vector table + // is pushed away from the start of flash and the boot ROM cannot find it. + println!("cargo:rustc-link-arg-bins=--nmagic"); + println!("cargo:rustc-link-arg-bins=-Tlink.x"); + println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); + + println!("cargo:rerun-if-changed=memory.x"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/firmware/memory.x b/firmware/memory.x new file mode 100644 index 0000000..1bab8a0 --- /dev/null +++ b/firmware/memory.x @@ -0,0 +1,21 @@ +/* RP2040 with 2 MiB of QSPI flash. + * + * The boot ROM reads the first 256 bytes of flash into RAM and executes them to + * bring up XIP, so .boot2 must sit at the very start and the vector table must + * follow immediately after it. */ +MEMORY { + BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100 + FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100 + + /* The four 64 KiB SRAM banks are striped, so treat them as one region. */ + RAM : ORIGIN = 0x20000000, LENGTH = 256K +} + +EXTERN(BOOT2_FIRMWARE) + +SECTIONS { + .boot2 ORIGIN(BOOT2) : + { + KEEP(*(.boot2)); + } > BOOT2 +} INSERT BEFORE .text; diff --git a/firmware/src/board.rs b/firmware/src/board.rs new file mode 100644 index 0000000..e1c8362 --- /dev/null +++ b/firmware/src/board.rs @@ -0,0 +1,154 @@ +//! Board definition: pin assignment, bus parameters and per-unit identity. +//! +//! # Pinout +//! +//! ```text +//! ┌────────┬───────────┬─────────────────────────────────────────────────────┐ +//! │ GPIO │ Function │ Connection │ +//! ├────────┼───────────┼─────────────────────────────────────────────────────┤ +//! │ 0 │ UART0 TX │ transceiver DI │ +//! │ 1 │ UART0 RX │ transceiver RO │ +//! │ 2 │ SIO out │ transceiver DE and /RE, tied together │ +//! │ 14 │ I2C1 SDA │ SHT31 SDA │ +//! │ 15 │ I2C1 SCL │ SHT31 SCL │ +//! │ 25 │ SIO out │ status LED, active high │ +//! │ SWD │ – │ SWCLK/SWDIO on the dedicated pads, for probe-rs │ +//! └────────┴───────────┴─────────────────────────────────────────────────────┘ +//! ``` +//! +//! Rationale for the layout, in case it needs revisiting during PCB work: +//! +//! - **GPIO 0/1/2 as one block.** The three RS485 signals sit adjacent at the +//! corner of the package, so the transceiver can be placed right next to it +//! with short, matched traces and no crossings. Keeping DE next to TX/RX also +//! keeps the fast switching edges away from the sensor. +//! - **UART0 rather than UART1.** Both are identical PL011 blocks; UART0 is the +//! conventional choice and leaves UART1 free for a debug header if wanted. +//! - **DE and /RE tied together on one pin.** /RE is active low and DE active +//! high, so a single line puts the transceiver in transmit when high and +//! receive when low. Costs one GPIO instead of two and makes it impossible to +//! end up driving and listening at once. The consequence is that we cannot +//! hear our own transmission, which is exactly what we want — see +//! [`crate::rs485::Rs485::tx_disable`] for the one wrinkle it causes. +//! - **I2C1 on GPIO 14/15, physically distant from the RS485 block.** The +//! sensor is the noise-sensitive part; the differential driver is the noisy +//! part. Using I2C1 leaves I2C0 free. +//! - **GPIO 25 for the LED** matches the Raspberry Pi Pico, so this firmware +//! blinks on a bare Pico too, which is convenient for bring-up. +//! +//! # Hardware notes not expressible in firmware +//! +//! - Fit the 120 Ω termination only at the two physical ends of the segment, and +//! fail-safe bias resistors at one point on the segment. +//! - The SHT31 ADDR pin selects the I2C address: low for `0x44`, high for +//! `0x45`. Strap it deliberately rather than leaving it floating, and keep +//! [`SENSOR_I2C_ADDR`] in agreement. +//! - Fit 4.7 kΩ external I2C pull-ups. The internal pulls enabled below are +//! roughly 50 kΩ and are a fallback, not a substitute. +//! - The SHT31 nRESET pin can be left pulled high; the firmware uses the soft +//! reset command instead. + +use rp2040_hal::{ + gpio::{ + bank0::{Gpio0, Gpio1, Gpio14, Gpio15, Gpio2, Gpio25}, + FunctionI2c, FunctionSioOutput, FunctionUart, Pin, PullDown, PullNone, PullUp, + }, + i2c::I2C, + pac, +}; +use wiredsensor_core::{sht3x, timing}; + +// ---------------------------------------------------------------- identity --- + +/// This node's RS485 unit address. +/// +/// Compile-time by design: with the address fixed per build there is no flash +/// wear, no commissioning protocol and no way for a bus glitch to renumber a +/// node. The cost is one firmware image per unit, so keep this the only thing +/// that differs between them. +pub const UNIT_ADDRESS: u8 = 0x01; + +/// Board serial number reported by `READ_INFO`. Distinct from the sensor's own +/// factory serial, which is read over I2C at start-up. +pub const DEVICE_SERIAL: u32 = 0x0000_0001; + +/// Firmware major version. +pub const FW_MAJOR: u8 = 0; +/// Firmware minor version. +pub const FW_MINOR: u8 = 1; +/// Firmware patch version. +pub const FW_PATCH: u8 = 0; + +// ------------------------------------------------------------------- clocks --- + +/// Crystal fitted to the board. +pub const XTAL_FREQ_HZ: u32 = 12_000_000; + +// -------------------------------------------------------------- bus timing --- + +/// RS485 line rate. 19200 8N1 is the Modbus default and a safe starting point +/// for long segments; every timing constant below derives from it. +pub const BAUD_RATE: u32 = 19_200; + +/// Idle time that delimits one frame from the next, in microseconds. +pub const INTER_FRAME_GAP_US: u32 = timing::inter_frame_gap_us(BAUD_RATE); + +/// Time to clock out one character at [`BAUD_RATE`], in microseconds. +pub const CHAR_TIME_US: u32 = timing::char_time_us(BAUD_RATE); + +// ------------------------------------------------------------ sensor timing --- + +/// I2C bus rate. The SHT31 supports 1 MHz, but 100 kHz is kinder to the long-ish +/// traces of a sensor board and costs nothing at a 1 Hz poll rate. +pub const I2C_FREQ_HZ: u32 = 100_000; + +/// SHT31 address, matching how the ADDR pin is strapped. +pub const SENSOR_I2C_ADDR: u8 = sht3x::I2C_ADDR_DEFAULT; + +/// How often to take a reading. +pub const SENSOR_PERIOD_MS: u32 = 1_000; + +/// A cached reading older than this is reported with the `DATA_STALE` flag. The +/// master still gets the value and its exact age and can apply its own policy. +pub const STALE_AFTER_MS: u32 = 3_000; + +/// Consecutive failed polls before the node reports a hard sensor fault and +/// starts attempting soft resets. +pub const SENSOR_FAULT_THRESHOLD: u32 = 5; + +/// Status LED blink half-period. +pub const HEARTBEAT_MS: u32 = 500; + +// -------------------------------------------------------------------- types --- + +/// Instants from the 1 MHz timer backing the RTIC monotonic. +pub type Instant = fugit::Instant; + +/// Durations matching [`Instant`]. +pub type Duration = fugit::Duration; + +/// UART0 TX. No pull: the transceiver's DI is a driven input. +pub type Rs485TxPin = Pin; + +/// UART0 RX. Pulled up so the line reads as idle rather than floating while the +/// transceiver's receiver is disabled during transmission. +pub type Rs485RxPin = Pin; + +/// Driver enable, wired to DE and /RE together. Pulled down so the transceiver +/// defaults to receive-only if the RP2040 is held in reset. +pub type Rs485DePin = Pin; + +/// The two UART pads, owned by the driver to keep them assigned. +pub type Rs485Pins = (Rs485TxPin, Rs485RxPin); + +/// I2C1 SDA to the SHT31. +pub type SdaPin = Pin; + +/// I2C1 SCL to the SHT31. +pub type SclPin = Pin; + +/// The configured I2C controller the sensor driver talks through. +pub type SensorI2c = I2C; + +/// Status LED. +pub type StatusLedPin = Pin; diff --git a/firmware/src/main.rs b/firmware/src/main.rs new file mode 100644 index 0000000..40294e6 --- /dev/null +++ b/firmware/src/main.rs @@ -0,0 +1,537 @@ +//! RS485 temperature and humidity node: RP2040 + SHT31. +//! +//! # Concurrency structure +//! +//! Three priority levels, chosen so the bus is never the thing that waits: +//! +//! | Prio | Task | Kind | Job | +//! |------|---------------|----------|--------------------------------------------| +//! | 3 | `uart0_irq` | hardware | Empty the 32-byte RX FIFO, timestamp bytes | +//! | 2 | `frame_gap` | async | Detect the frame gap, answer the request | +//! | 1 | `sensor_task` | async | Poll the SHT31 once a second | +//! | 1 | `heartbeat` | async | Blink the status LED | +//! +//! The key design decision is that **a request is answered entirely from a +//! cached reading**. An SHT31 high-repeatability conversion takes up to 15 ms, +//! which is far longer than the turnaround a master expects, so the sensor is +//! polled on its own schedule at the lowest priority and `frame_gap` never +//! touches I2C. RTIC's priority ceilings then guarantee that a conversion in +//! progress cannot delay a reply, and the compiler checks the resulting resource +//! sharing rather than us reasoning about it. + +#![no_std] +#![no_main] + +mod board; +mod rs485; +mod sensor; + +use defmt_rtt as _; +use panic_probe as _; +use rtic_monotonics::rp2040::prelude::*; + +// A 1 MHz, 64-bit monotonic on the RP2040 TIMER peripheral. This claims +// TIMER_IRQ_0, which is why the RTIC dispatchers below use the software-only +// vectors instead. +rp2040_timer_monotonic!(Mono); + +/// Second-stage bootloader. +/// +/// The boot ROM copies these 256 bytes into RAM and runs them to configure XIP +/// for the attached QSPI flash before any of our code executes. `W25Q080` covers +/// the parts fitted to the Pico and most RP2040 modules; change it if the board +/// carries something else. +#[link_section = ".boot2"] +#[no_mangle] +#[used] +pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080; + +#[rtic::app(device = rp2040_hal::pac, peripherals = true, dispatchers = [SW0_IRQ, SW1_IRQ])] +mod app { + use super::*; + + use embedded_hal::digital::StatefulOutputPin; + use fugit::RateExtU32; + use rp2040_hal as hal; + use rp2040_hal::Clock; + use rtic::Mutex; + use wiredsensor_core::{ + frame::{ParseError, MAX_FRAME}, + proto::{self, flag, Info, Measurement, Outcome, Snapshot, Status}, + sht3x, + }; + + use crate::board::{self, Instant}; + use crate::rs485::{self, Rs485, RxBuffer, FIFO_DEPTH}; + use crate::sensor::Sht31; + + /// A cached sensor reading and when it was taken. + /// + /// `pub` because RTIC's generated resource proxies name it in their public + /// signatures. + pub struct Reading { + temp_milli_c: i32, + rh_milli_pct: i32, + taken_at: Instant, + } + + /// Everything behind `READ_STATUS` and `READ_INFO` that is discovered at + /// runtime rather than fixed at build time. + #[derive(Default)] + pub struct DeviceState { + /// Factory serial read from the sensor at start-up, 0 if unavailable. + sensor_serial: u32, + /// Raw SHT31 status register from the most recent successful read. + sensor_status: u16, + i2c_errors: u16, + sensor_crc_errors: u16, + frame_errors: u16, + crc_errors: u16, + consecutive_failures: u32, + sensor_ok: bool, + ever_measured: bool, + uart_error: bool, + sensor_reset_seen: bool, + } + + #[shared] + struct Shared { + /// The PL011 and the transceiver direction pin. + bus: Rs485, + /// Bytes received since the last inter-frame gap. + rx: RxBuffer, + /// Latest reading, `None` until the first successful poll. + reading: Option, + /// Counters and health bits. + state: DeviceState, + } + + #[local] + struct Local { + sensor: Sht31, + led: board::StatusLedPin, + } + + #[init] + fn init(mut cx: init::Context) -> (Shared, Local) { + let mut watchdog = hal::watchdog::Watchdog::new(cx.device.WATCHDOG); + let clocks = hal::clocks::init_clocks_and_plls( + board::XTAL_FREQ_HZ, + cx.device.XOSC, + cx.device.CLOCKS, + cx.device.PLL_SYS, + cx.device.PLL_USB, + &mut cx.device.RESETS, + &mut watchdog, + ) + .expect("clock init failed — check XTAL_FREQ_HZ against the fitted crystal"); + + Mono::start(cx.device.TIMER, &cx.device.RESETS); + + let sio = hal::Sio::new(cx.device.SIO); + let pins = hal::gpio::Pins::new( + cx.device.IO_BANK0, + cx.device.PADS_BANK0, + sio.gpio_bank0, + &mut cx.device.RESETS, + ); + + // ---- RS485 on UART0 ------------------------------------------------ + // Let the HAL do the baud-divisor and line-control setup, which is + // fiddly and easy to get subtly wrong, then reclaim the raw peripheral: + // the runtime path needs RTIM and BUSY, which the HAL does not expose. + let uart_pins: board::Rs485Pins = (pins.gpio0.reconfigure(), pins.gpio1.reconfigure()); + let uart = + hal::uart::UartPeripheral::new(cx.device.UART0, uart_pins, &mut cx.device.RESETS) + .enable( + hal::uart::UartConfig::new( + board::BAUD_RATE.Hz(), + hal::uart::DataBits::Eight, + None, + hal::uart::StopBits::One, + ), + clocks.peripheral_clock.freq(), + ) + .expect("uart baud rate not achievable from the peripheral clock"); + let (uart0, uart_pins) = uart.free(); + let bus = Rs485::new(uart0, uart_pins, pins.gpio2.reconfigure()); + + // ---- SHT31 on I2C1 -------------------------------------------------- + let i2c = hal::i2c::I2C::new_controller( + cx.device.I2C1, + pins.gpio14.reconfigure(), + pins.gpio15.reconfigure(), + board::I2C_FREQ_HZ.Hz(), + &mut cx.device.RESETS, + clocks.system_clock.freq(), + ); + let sensor = Sht31::new(i2c, board::SENSOR_I2C_ADDR); + + let led = pins.gpio25.reconfigure(); + + defmt::info!( + "wiredsensor v{}.{}.{} up: unit {:#04x}, {} baud, gap {} us", + board::FW_MAJOR, + board::FW_MINOR, + board::FW_PATCH, + board::UNIT_ADDRESS, + board::BAUD_RATE, + board::INTER_FRAME_GAP_US, + ); + + sensor_task::spawn().ok(); + heartbeat::spawn().ok(); + + ( + Shared { + bus, + rx: RxBuffer::new(), + reading: None, + state: DeviceState::default(), + }, + Local { sensor, led }, + ) + } + + /// Highest-priority work in the system: get bytes out of the FIFO before it + /// overruns and record when the last one arrived. + /// + /// Deliberately does no parsing. At 19200 baud a character arrives every + /// ~520 µs — around 65,000 core cycles — so there is plenty of slack, but + /// keeping the ISR to a FIFO drain is what bounds the jitter that everything + /// else in the system sees. + #[task(binds = UART0_IRQ, priority = 3, shared = [bus, rx])] + fn uart0_irq(mut cx: uart0_irq::Context) { + let now = Mono::now(); + + let start_gap_timer = cx.shared.bus.lock(|bus| { + cx.shared.rx.lock(|rx| { + bus.drain_into(rx, now); + + // One gap-detection task per frame. It re-reads `last_byte_at` + // itself, so bytes arriving later need no further spawn. + if rx.len > 0 && !rx.gap_pending { + rx.gap_pending = true; + true + } else { + false + } + }) + }); + + if start_gap_timer { + frame_gap::spawn().ok(); + } + } + + /// Wait out the inter-frame gap, then answer the frame. + /// + /// Runs above the sensor so a conversion in progress can never delay a reply, + /// and below the UART ISO so incoming bytes are never dropped while we work. + #[task(priority = 2, shared = [bus, rx, reading, state])] + async fn frame_gap(mut cx: frame_gap::Context) { + let gap = board::Duration::micros(board::INTER_FRAME_GAP_US as u64); + + // Bytes may still be arriving. Rather than cancelling and respawning a + // timer on every byte, re-read the last arrival time and sleep again. + loop { + let deadline = cx.shared.rx.lock(|rx| rx.last_byte_at) + gap; + if Mono::now() >= deadline { + break; + } + Mono::delay_until(deadline).await; + } + + // Take the frame out of the shared buffer so the ISR can begin the next. + let mut req = [0u8; MAX_FRAME]; + let (len, overflow, line_error) = cx.shared.rx.lock(|rx| { + let len = rx.len.min(MAX_FRAME); + req[..len].copy_from_slice(&rx.buf[..len]); + let taken = (len, rx.overflow, rx.line_error); + rx.reset(); + taken + }); + + // A frame that outgrew the buffer or arrived with line errors is not + // trustworthy. Count it and stay off the wire: answering a frame whose + // address byte we cannot trust risks colliding with the node that was + // actually addressed. + if overflow || line_error { + cx.shared.state.lock(|s| { + s.uart_error |= line_error; + s.frame_errors = s.frame_errors.saturating_add(1); + }); + defmt::warn!( + "discarded frame: {} bytes, overflow={}, line_error={}", + len, + overflow, + line_error + ); + cx.shared.rx.lock(|rx| rx.gap_pending = false); + return; + } + + let snapshot = snapshot(&mut cx); + + let mut out = [0u8; MAX_FRAME]; + let reply_len = match proto::dispatch(board::UNIT_ADDRESS, &req[..len], &snapshot, &mut out) + { + Outcome::Reply(reply) => Some(reply.len()), + Outcome::Silent => None, + Outcome::Malformed(e) => { + cx.shared.state.lock(|s| match e { + ParseError::BadCrc => s.crc_errors = s.crc_errors.saturating_add(1), + _ => s.frame_errors = s.frame_errors.saturating_add(1), + }); + defmt::debug!("rejected frame of {} bytes: {}", len, e); + None + } + }; + + if let Some(n) = reply_len { + transmit(&mut cx, &out[..n]).await; + } + + cx.shared.rx.lock(|rx| rx.gap_pending = false); + } + + /// Assemble the device state `dispatch` is allowed to see. + /// + /// Locks are taken one resource at a time: a reading and the counters need + /// not be mutually consistent, and holding both at once would raise the + /// ceiling for no benefit. + fn snapshot(cx: &mut frame_gap::Context<'_>) -> Snapshot { + let now = Mono::now(); + + let measurement = cx.shared.reading.lock(|slot| { + slot.as_ref().map(|r| { + let age_ms = now + .checked_duration_since(r.taken_at) + .map(|d| d.to_millis()) + .unwrap_or(0); + Measurement { + temp_milli_c: r.temp_milli_c, + rh_milli_pct: r.rh_milli_pct, + // Saturating at ~65 s is fine: anything that old is already + // far past the staleness threshold. + age_ms: age_ms.min(u16::MAX as u64) as u16, + } + }) + }); + + let stale = measurement + .map(|m| u32::from(m.age_ms) >= board::STALE_AFTER_MS) + .unwrap_or(true); + + let (info, status) = cx.shared.state.lock(|s| { + let mut flags = 0u8; + if s.sensor_ok { + flags |= flag::SENSOR_OK; + } + if stale { + flags |= flag::DATA_STALE; + } + if s.consecutive_failures >= board::SENSOR_FAULT_THRESHOLD { + flags |= flag::SENSOR_FAULT; + } + if s.uart_error { + flags |= flag::UART_ERROR; + } + if s.ever_measured { + flags |= flag::EVER_MEASURED; + } + if s.sensor_reset_seen { + flags |= flag::SENSOR_RESET_SEEN; + } + if s.sensor_status & sht3x::status::HEATER_ON != 0 { + flags |= flag::HEATER_ON; + } + + let info = Info { + fw_major: board::FW_MAJOR, + fw_minor: board::FW_MINOR, + fw_patch: board::FW_PATCH, + proto_version: proto::PROTOCOL_VERSION, + device_serial: board::DEVICE_SERIAL, + sensor_serial: s.sensor_serial, + uptime_s: (now.ticks() / 1_000_000).min(u64::from(u32::MAX)) as u32, + }; + + let status = Status { + flags, + sensor_status: s.sensor_status, + i2c_errors: s.i2c_errors, + sensor_crc_errors: s.sensor_crc_errors, + frame_errors: s.frame_errors, + crc_errors: s.crc_errors, + }; + + (info, status) + }); + + Snapshot { + measurement, + info, + status, + } + } + + /// Put `reply` on the wire, holding DE for exactly as long as it takes. + /// + /// Between FIFO refills this yields rather than spinning, so the sensor task + /// still gets to run. That is safe because DE stays asserted throughout: we + /// own the bus for the whole call, and nothing else in the system is allowed + /// to touch the transceiver. + async fn transmit(cx: &mut frame_gap::Context<'_>, reply: &[u8]) { + cx.shared.bus.lock(|bus| bus.tx_enable()); + + let mut remaining = reply; + while !remaining.is_empty() { + remaining = cx.shared.bus.lock(|bus| bus.push(remaining)); + if !remaining.is_empty() { + // FIFO full — wait for roughly half of it to drain before + // topping up. + Mono::delay(board::Duration::micros( + rs485::tx_time_us(FIFO_DEPTH / 2) as u64 + )) + .await; + } + } + + // Everything is queued; at most a full FIFO is still to go. Sleep through + // the bulk of it, then confirm with BUSY, which is the only signal that + // the final stop bit has actually left the shift register. + let queued = reply.len().min(FIFO_DEPTH); + Mono::delay(board::Duration::micros(rs485::tx_time_us(queued) as u64)).await; + + while !cx.shared.bus.lock(|bus| bus.tx_complete()) { + Mono::delay(board::Duration::micros(board::CHAR_TIME_US as u64)).await; + } + + cx.shared.bus.lock(|bus| bus.tx_disable()); + } + + /// Sensor bring-up followed by periodic polling. + /// + /// Lowest priority on purpose: nothing here is time-critical, and the bus + /// must always be able to preempt it. + #[task(priority = 1, shared = [reading, state], local = [sensor])] + async fn sensor_task(mut cx: sensor_task::Context) { + let sensor = cx.local.sensor; + + Mono::delay(board::Duration::millis(u64::from(sht3x::POWER_UP_MAX_MS))).await; + + // Clear whatever state a warm reset left behind, so the sensor's + // configuration is known regardless of how we got here. + let _ = sensor.soft_reset(); + Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS))).await; + + match sensor.read_serial() { + Ok(serial) => { + defmt::info!("SHT31 serial {=u32:#010x}", serial); + cx.shared.state.lock(|s| s.sensor_serial = serial); + } + Err(e) => defmt::warn!("SHT31 serial read failed: {}", e), + } + let _ = sensor.clear_status(); + + let period = board::Duration::millis(u64::from(board::SENSOR_PERIOD_MS)); + let mut next = Mono::now(); + + loop { + next += period; + + match measure(sensor).await { + Ok(raw) => { + let taken_at = Mono::now(); + let temp_milli_c = sht3x::temp_milli_c(raw.temp_ticks); + let rh_milli_pct = sht3x::rh_milli_pct(raw.rh_ticks); + + defmt::debug!("{} m°C, {} m%RH", temp_milli_c, rh_milli_pct); + + cx.shared.reading.lock(|slot| { + *slot = Some(Reading { + temp_milli_c, + rh_milli_pct, + taken_at, + }) + }); + cx.shared.state.lock(|s| { + s.sensor_ok = true; + s.ever_measured = true; + s.consecutive_failures = 0; + }); + } + Err(e) => { + defmt::warn!("sensor poll failed: {}", e); + + let failures = cx.shared.state.lock(|s| { + s.sensor_ok = false; + s.consecutive_failures = s.consecutive_failures.saturating_add(1); + match e { + crate::sensor::Error::I2c => { + s.i2c_errors = s.i2c_errors.saturating_add(1) + } + crate::sensor::Error::Crc => { + s.sensor_crc_errors = s.sensor_crc_errors.saturating_add(1) + } + } + s.consecutive_failures + }); + + // Try to shake a wedged sensor loose rather than reporting a + // fault forever. The reading itself stays cached and ages, + // so a master watching `age_ms` sees the problem either way. + if failures >= board::SENSOR_FAULT_THRESHOLD { + defmt::warn!("{} consecutive failures, resetting sensor", failures); + let _ = sensor.soft_reset(); + Mono::delay(board::Duration::millis(u64::from(sht3x::SOFT_RESET_MAX_MS))) + .await; + let _ = sensor.clear_status(); + } + } + } + + // Read the status register so an unexpected sensor reset shows up in + // READ_STATUS instead of silently reverting the configuration. + if let Ok(status) = sensor.read_status() { + let saw_reset = status & sht3x::status::SYSTEM_RESET_DETECTED != 0; + cx.shared.state.lock(|s| { + s.sensor_status = status; + s.sensor_reset_seen |= saw_reset; + }); + if saw_reset { + defmt::warn!("SHT31 reported a reset (status {=u16:#06x})", status); + let _ = sensor.clear_status(); + } + } + + Mono::delay_until(next).await; + } + } + + /// Start a conversion, wait out the data-sheet worst case, read it back. + async fn measure( + sensor: &mut Sht31, + ) -> Result { + sensor.start_measurement()?; + // Clock stretching is disabled, so the sensor NACKs a read until the + // conversion finishes. Waiting the worst case beats retry-until-ACK: it + // keeps the I2C bus idle and the timing predictable. + Mono::delay(board::Duration::millis(u64::from( + sht3x::MEASURE_HIGH_MAX_MS, + ))) + .await; + sensor.read_measurement() + } + + /// Blink the status LED. Says the executor is alive; says nothing about the + /// bus or the sensor, both of which are reported over the protocol instead. + #[task(priority = 1, local = [led])] + async fn heartbeat(cx: heartbeat::Context) { + loop { + cx.local.led.toggle().ok(); + Mono::delay(board::Duration::millis(u64::from(board::HEARTBEAT_MS))).await; + } + } +} diff --git a/firmware/src/rs485.rs b/firmware/src/rs485.rs new file mode 100644 index 0000000..41d324d --- /dev/null +++ b/firmware/src/rs485.rs @@ -0,0 +1,234 @@ +//! Half-duplex RS485 transport: PL011 register access plus driver-enable control. +//! +//! The HAL sets the baud rate and frame format once during init and then hands +//! the raw peripheral over, because two things this driver depends on are not +//! exposed by `rp2040-hal`: +//! +//! - **The receive-timeout interrupt (`RTIM`).** The PL011 raises it after 32 +//! idle bit periods with unread data in the FIFO, which is very close to the +//! 3.5-character inter-frame gap the protocol uses as a frame delimiter. It +//! gets us an interrupt at the end of a short frame without polling. +//! - **The `BUSY` flag.** The PL011 has no transmit-complete interrupt, only +//! FIFO-level ones, so `BUSY` is the only way to know the final stop bit has +//! left the shift register and the transceiver may stop driving the bus. +//! +//! `RTIM` alone is not a sufficient frame delimiter: it only fires while the RX +//! FIFO is non-empty, so a frame whose length happens to land exactly on the +//! FIFO watermark can be drained by the watermark interrupt and never produce a +//! timeout. The authoritative delimiter is therefore a timer measured from the +//! last received byte; `RTIM` just makes the common case prompt. + +use embedded_hal::digital::OutputPin; +use rp2040_hal::pac; +use wiredsensor_core::{frame::MAX_FRAME, timing}; + +use crate::board::{self, Instant, Rs485DePin, Rs485Pins}; + +/// Every interrupt source this driver ever unmasks, as a mask for `UARTICR`. +/// +/// Bit positions are from the PL011 `UARTRIS`/`UARTICR` layout. +const RX_IRQ_MASK: u32 = (1 << 4) // RXRIS – RX FIFO reached its watermark + | (1 << 6) // RTRIS – receive timeout, line went idle + | (1 << 7) // FERIS – framing error + | (1 << 8) // PERIS – parity error + | (1 << 9) // BERIS – break + | (1 << 10); // OERIS – receive overrun + +/// `UARTIFLS.RXIFLSEL` value selecting a half-full (16 of 32 bytes) watermark. +const RX_WATERMARK_HALF: u8 = 0b010; + +/// Depth of the PL011 transmit and receive FIFOs, in characters. +pub const FIFO_DEPTH: usize = 32; + +/// Time to clock out `chars` characters at the configured line rate. +pub const fn tx_time_us(chars: usize) -> u32 { + timing::tx_time_us(chars, board::BAUD_RATE) +} + +/// Bytes accumulated since the last inter-frame gap, filled from the UART ISR. +pub struct RxBuffer { + /// Received bytes. + pub buf: [u8; MAX_FRAME], + /// How many entries of [`Self::buf`] are valid. + pub len: usize, + /// More bytes arrived than a legal frame can hold, so the frame is unusable. + pub overflow: bool, + /// The PL011 flagged a line error: overrun, framing, parity or break. + pub line_error: bool, + /// When the most recent byte arrived. The gap timer measures from here. + pub last_byte_at: Instant, + /// A gap-detection task is already scheduled for the frame in progress, so + /// further bytes must not spawn another one. + pub gap_pending: bool, +} + +impl RxBuffer { + /// An empty buffer. + pub const fn new() -> Self { + Self { + buf: [0; MAX_FRAME], + len: 0, + overflow: false, + line_error: false, + last_byte_at: Instant::from_ticks(0), + gap_pending: false, + } + } + + /// Drop the frame in progress and its error state. + pub fn reset(&mut self) { + self.len = 0; + self.overflow = false; + self.line_error = false; + } +} + +impl Default for RxBuffer { + fn default() -> Self { + Self::new() + } +} + +/// The RS485 side of the node: one PL011 plus the transceiver's direction pin. +pub struct Rs485 { + uart: pac::UART0, + de: Rs485DePin, + /// Held only so the pads stay assigned to the UART for as long as this + /// driver exists; the peripheral itself is reached through `uart`. + _pins: Rs485Pins, +} + +impl Rs485 { + /// Take over an already-configured PL011 and put the transceiver in receive. + pub fn new(uart: pac::UART0, pins: Rs485Pins, mut de: Rs485DePin) -> Self { + // Receive mode first, before anything can make us transmit. + de.set_low().ok(); + + // Interrupt when the RX FIFO is half full. Requests are far shorter than + // 16 bytes, so in practice the receive timeout is what fires; this + // watermark only matters for an unexpectedly long or back-to-back burst. + uart.uartifls() + .modify(|_, w| unsafe { w.rxiflsel().bits(RX_WATERMARK_HALF) }); + + // Transmission is driven by polling, so no TX interrupt is unmasked. + uart.uartimsc().modify(|_, w| { + w.rxim().set_bit(); + w.rtim().set_bit(); + w.feim().set_bit(); + w.peim().set_bit(); + w.beim().set_bit(); + w.oeim().set_bit(); + w + }); + + // The HAL's `enable()` turns the DMA request lines on; we do not use DMA. + uart.uartdmacr().reset(); + + // Start from a clean slate: discard anything that arrived during boot. + while uart.uartfr().read().rxfe().bit_is_clear() { + let _ = uart.uartdr().read(); + } + uart.uarticr().write(|w| unsafe { w.bits(RX_IRQ_MASK) }); + + Self { + uart, + de, + _pins: pins, + } + } + + /// Move everything in the RX FIFO into `rx`, stamping each byte's arrival. + /// + /// Called from the UART ISR at the highest priority in the system, so it does + /// no more than it must: empty a 32-byte FIFO and record the time. + pub fn drain_into(&mut self, rx: &mut RxBuffer, now: Instant) { + // Catch error interrupts that carry no data of their own, such as a break + // condition on an otherwise idle line. + let mis = self.uart.uartmis().read(); + if mis.oemis().bit_is_set() + || mis.femis().bit_is_set() + || mis.pemis().bit_is_set() + || mis.bemis().bit_is_set() + { + rx.line_error = true; + } + + while self.uart.uartfr().read().rxfe().bit_is_clear() { + let dr = self.uart.uartdr().read(); + + // The PL011 reports per-character errors in the same register as the + // data, so a corrupt byte is identified individually rather than + // condemning the whole burst. + if dr.oe().bit_is_set() + || dr.fe().bit_is_set() + || dr.pe().bit_is_set() + || dr.be().bit_is_set() + { + rx.line_error = true; + } + + if rx.len < rx.buf.len() { + rx.buf[rx.len] = dr.data().bits(); + rx.len += 1; + } else { + // Keep draining even once the frame is too long, or the FIFO + // would overrun and corrupt the *next* frame as well. + rx.overflow = true; + } + + rx.last_byte_at = now; + } + + // Clear the timeout and error latches. RXRIS deasserts by itself now that + // the loop above has taken the FIFO below its watermark. + self.uart + .uarticr() + .write(|w| unsafe { w.bits(RX_IRQ_MASK) }); + } + + /// Assert DE so the transceiver starts driving the differential pair. + /// + /// /RE is tied to DE on this board, so our own receiver is disabled for the + /// whole transmission and cannot hear the echo of what we send. + pub fn tx_enable(&mut self) { + self.de.set_high().ok(); + } + + /// Push as much of `data` as the TX FIFO accepts, returning what is left. + pub fn push<'d>(&mut self, data: &'d [u8]) -> &'d [u8] { + let mut sent = 0; + while sent < data.len() && self.uart.uartfr().read().txff().bit_is_clear() { + self.uart + .uartdr() + .write(|w| unsafe { w.data().bits(data[sent]) }); + sent += 1; + } + &data[sent..] + } + + /// True once the shift register has clocked out the final stop bit. + /// + /// Releasing DE before this is set truncates the last character for every + /// listener on the segment — a classic RS485 fault that looks like a CRC + /// error at the master and is invisible at the slave. + pub fn tx_complete(&self) -> bool { + self.uart.uartfr().read().busy().bit_is_clear() + } + + /// Release the bus and discard whatever the receiver picked up on turnaround. + /// + /// While DE was asserted the transceiver's /RE was disabled and its RO output + /// was not driven, so the edge as it re-enables can clock a spurious + /// character into the FIFO. Left in place that byte would become the first + /// byte of the next frame and break it. + pub fn tx_disable(&mut self) { + self.de.set_low().ok(); + + while self.uart.uartfr().read().rxfe().bit_is_clear() { + let _ = self.uart.uartdr().read(); + } + self.uart + .uarticr() + .write(|w| unsafe { w.bits(RX_IRQ_MASK) }); + } +} diff --git a/firmware/src/sensor.rs b/firmware/src/sensor.rs new file mode 100644 index 0000000..488af06 --- /dev/null +++ b/firmware/src/sensor.rs @@ -0,0 +1,100 @@ +//! SHT31 driver: I2C transfers on top of the pure logic in +//! [`wiredsensor_core::sht3x`]. +//! +//! Measurements are issued with clock stretching **disabled**. The alternative +//! would have the sensor hold SCL low for up to 15 ms, which blocks the whole +//! I2C bus and makes the transfer's duration depend on the controller's stretch +//! timeout. Instead the conversion is started, the caller awaits the data-sheet +//! worst case, and the result is read in a second transfer — which keeps the +//! bus free and the timing explicit. + +use embedded_hal::i2c::I2c; +use wiredsensor_core::sht3x::{self, DecodeError}; + +/// Why a sensor operation failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)] +pub enum Error { + /// The I2C transfer itself failed: NACK, arbitration loss or a stuck bus. + I2c, + /// The transfer completed but a CRC-8 over the returned data did not match. + Crc, +} + +impl From for Error { + fn from(_: DecodeError) -> Self { + Error::Crc + } +} + +/// An SHT31 on an I2C bus. +pub struct Sht31 { + i2c: I, + addr: u8, +} + +impl Sht31 { + /// Bind to the sensor at `addr` (see [`sht3x::I2C_ADDR_DEFAULT`]). + pub fn new(i2c: I, addr: u8) -> Self { + Self { i2c, addr } + } + + /// Send a bare 16-bit command with no reply. + pub fn command(&mut self, cmd: u16) -> Result<(), Error> { + self.i2c + .write(self.addr, &sht3x::command_bytes(cmd)) + .map_err(|_| Error::I2c) + } + + /// Start a high-repeatability conversion. + /// + /// The caller must wait [`sht3x::MEASURE_HIGH_MAX_MS`] before calling + /// [`Self::read_measurement`]; reading sooner is NACKed. + pub fn start_measurement(&mut self) -> Result<(), Error> { + self.command(sht3x::command::MEASURE_HIGH_NO_STRETCH) + } + + /// Read back and validate a conversion started earlier. + pub fn read_measurement(&mut self) -> Result { + let mut buf = [0u8; sht3x::MEASUREMENT_LEN]; + self.i2c.read(self.addr, &mut buf).map_err(|_| Error::I2c)?; + Ok(sht3x::decode_measurement(&buf)?) + } + + /// Read the 16-bit status register. + pub fn read_status(&mut self) -> Result { + let mut buf = [0u8; sht3x::WORD_LEN]; + self.i2c + .write_read( + self.addr, + &sht3x::command_bytes(sht3x::command::READ_STATUS), + &mut buf, + ) + .map_err(|_| Error::I2c)?; + Ok(sht3x::decode_word(&buf)?) + } + + /// Clear the sticky bits in the status register, including the + /// reset-detected flag. + pub fn clear_status(&mut self) -> Result<(), Error> { + self.command(sht3x::command::CLEAR_STATUS) + } + + /// Soft-reset the sensor. The caller must wait + /// [`sht3x::SOFT_RESET_MAX_MS`] afterwards. + pub fn soft_reset(&mut self) -> Result<(), Error> { + self.command(sht3x::command::SOFT_RESET) + } + + /// Read the 32-bit factory serial number. + pub fn read_serial(&mut self) -> Result { + let mut buf = [0u8; sht3x::MEASUREMENT_LEN]; + self.i2c + .write_read( + self.addr, + &sht3x::command_bytes(sht3x::command::READ_SERIAL), + &mut buf, + ) + .map_err(|_| Error::I2c)?; + Ok(sht3x::decode_serial(&buf)?) + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..039b45f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# Pinned so firmware builds are reproducible. RTIC 2 and rp2040-hal 0.12 both +# work on stable; nothing here needs nightly. +channel = "stable" +targets = ["thumbv6m-none-eabi"] +components = ["rust-src", "clippy", "rustfmt"]