diff --git a/Cargo.lock b/Cargo.lock index 69ae8716..caae4309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,18 @@ dependencies = [ "xattr", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -302,6 +314,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -509,6 +532,7 @@ dependencies = [ "glob", "hickory-resolver", "humantime", + "if-addrs", "indicatif", "itertools 0.14.0", "jiff", @@ -553,8 +577,8 @@ dependencies = [ "windows-env", "windows-service", "windows_exe_info", + "zbus", "zip 8.2.0", - "zmq", ] [[package]] @@ -791,7 +815,7 @@ dependencies = [ "serde", "strum 0.28.0", "strum_macros 0.28.0", - "target-lexicon 0.13.5", + "target-lexicon", "url", ] @@ -1048,16 +1072,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon 0.12.16", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1476,19 +1490,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1517,15 +1518,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-skiplist" version = "0.1.3" @@ -1808,17 +1800,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dircpy" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88521b0517f5f9d51d11925d8ab4523497dcf947073fa3231a311b63941131c" -dependencies = [ - "jwalk", - "log", - "walkdir", -] - [[package]] name = "dirs" version = "6.0.0" @@ -2012,6 +1993,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "endian-type" version = "0.1.2" @@ -2030,6 +2017,27 @@ dependencies = [ "syn", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -2092,6 +2100,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -2407,6 +2436,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -3119,6 +3161,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "ignore" version = "0.4.25" @@ -3401,16 +3453,6 @@ dependencies = [ "ucd-trie", ] -[[package]] -name = "jwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" -dependencies = [ - "crossbeam", - "rayon", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -4323,6 +4365,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -4419,6 +4471,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -4861,6 +4919,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -5924,12 +5991,14 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ - "serde", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -6446,19 +6515,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - [[package]] name = "tagptr" version = "0.2.0" @@ -6471,12 +6527,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "target-lexicon" version = "0.13.5" @@ -6752,6 +6802,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.3", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -6851,18 +6902,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -6871,7 +6910,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -6880,47 +6919,46 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "toml_writer", "winnow 0.7.15", ] @@ -7341,6 +7379,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "uncased" version = "0.9.10" @@ -7519,12 +7568,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - [[package]] name = "version_check" version = "0.9.5" @@ -8410,6 +8453,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -8596,6 +8648,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.42" @@ -8657,16 +8765,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zeromq-src" -version = "0.2.6+4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc120b771270365d5ed0dfb4baf1005f2243ae1ae83703265cb3504070f4160b" -dependencies = [ - "cc", - "dircpy", -] - [[package]] name = "zerotrie" version = "0.2.3" @@ -8740,28 +8838,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zmq" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd3091dd571fb84a9b3e5e5c6a807d186c411c812c8618786c3c30e5349234e7" -dependencies = [ - "bitflags 1.3.2", - "libc", - "zmq-sys", -] - -[[package]] -name = "zmq-sys" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8351dc72494b4d7f5652a681c33634063bbad58046c1689e75270908fdc864" -dependencies = [ - "libc", - "system-deps", - "zeromq-src", -] - [[package]] name = "zopfli" version = "0.8.3" @@ -8801,3 +8877,43 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.2", +] diff --git a/crates/bestool/Cargo.toml b/crates/bestool/Cargo.toml index 4e095ea6..e3ea15d9 100644 --- a/crates/bestool/Cargo.toml +++ b/crates/bestool/Cargo.toml @@ -41,6 +41,7 @@ futures = { workspace = true } glob = { version = "0.3.3", optional = true } hickory-resolver = { version = "0.25.2", optional = true } humantime = { version = "2.2.0", optional = true } +if-addrs = { version = "0.15.0", optional = true } indicatif = { workspace = true, optional = true } itertools = { workspace = true, optional = true } jiff = "0.2.15" @@ -79,8 +80,8 @@ tracing = { workspace = true } upgrade = { version = "2.0.1", optional = true } uuid = { version = "1.19.0", features = ["v4"] } walkdir = { version = "2.5.0", optional = true } +zbus = { version = "5.15.0", optional = true, default-features = false, features = ["tokio"] } zip = { version = "8.2.0", optional = true, default-features = false, features = ["time"] } -zmq = { version = "0.10.0", optional = true } [target.'cfg(windows)'.dependencies] tauri-winrt-notification = { version = "0.7.2", optional = true } @@ -160,13 +161,13 @@ __tamanu = [ # internal feature to enable the tamanu subcommand common code ## Iti subcommands iti = [ # enable all iti subcommands "iti-battery", - "iti-lcd", + "iti-display", "iti-temperature", ] iti-battery = ["__iti", "dep:folktime", "dep:humantime", "dep:rppal"] -iti-lcd = ["__iti", "dep:ctrlc", "dep:embedded-graphics", "dep:rpi-st7789v2-driver", "dep:sysinfo"] +iti-display = ["__iti", "iti-battery", "iti-temperature", "dep:embedded-graphics", "dep:if-addrs", "dep:rpi-st7789v2-driver", "dep:sysinfo", "dep:zbus"] iti-temperature = ["__iti", "dep:duct", "dep:humantime"] -__iti = ["dep:zmq"] # internal feature to enable the iti subcommand common code +__iti = [] # internal alias enabled by every iti-* subcommand ## Legacy noop features to avoid breaking builds tamanu-upgrade = [] diff --git a/crates/bestool/src/actions/iti.rs b/crates/bestool/src/actions/iti.rs index 677a111f..2aa87bd3 100644 --- a/crates/bestool/src/actions/iti.rs +++ b/crates/bestool/src/actions/iti.rs @@ -5,6 +5,8 @@ use crate::args::Args; use super::Context; +pub mod samplers; + /// Tamanu Iti subcommands. #[derive(Debug, Clone, Parser)] pub struct ItiArgs { @@ -20,10 +22,8 @@ super::subcommands! { #[cfg(feature = "iti-battery")] battery => Battery(BatteryArgs), - #[cfg(feature = "iti-lcd")] - lcd => Lcd(LcdArgs), - #[cfg(feature = "iti-lcd")] - sparks => Sparks(SparksArgs), + #[cfg(feature = "iti-display")] + display => Display(DisplayArgs), #[cfg(feature = "iti-temperature")] temperature => Temperature(TemperatureArgs) } diff --git a/crates/bestool/src/actions/iti/battery.rs b/crates/bestool/src/actions/iti/battery.rs index ce9c20ea..3761c28f 100644 --- a/crates/bestool/src/actions/iti/battery.rs +++ b/crates/bestool/src/actions/iti/battery.rs @@ -1,18 +1,17 @@ -use std::{collections::VecDeque, time::Duration}; +use std::time::Duration; use clap::Parser; use folktime::duration::{Duration as Folktime, Style as Folkstyle}; -use miette::{IntoDiagnostic, Result, WrapErr}; -use rppal::{gpio::Gpio, i2c::I2c}; +use miette::Result; use tokio::time::sleep; use tracing::instrument; use crate::actions::{ - iti::{ItiArgs, lcd::{ - json::{Item, Screen}, - send, - }}, Context, + iti::{ + ItiArgs, + samplers::battery::{BatteryEstimate, BatteryEstimator, BatterySample, sample}, + }, }; /// Get battery information from the X1201 board. @@ -22,20 +21,6 @@ pub struct BatteryArgs { #[arg(long)] pub json: bool, - /// Update screen with battery status. - /// - /// Argument is the Y position of the battery status. The X position is always 240 (right edge). - /// - /// With --estimate, this will also print the time remaining on the left edge (X=20). - #[cfg(feature = "iti-lcd")] - #[arg(long)] - pub update_screen: Option, - - /// ZMQ socket to use for screen updates. - #[cfg(feature = "iti-lcd")] - #[arg(default_value = "tcp://[::1]:2009")] - pub zmq_socket: String, - /// Keep updating at an interval. /// /// Syntax is a number followed by a unit, such as "5s" or "1m". @@ -52,256 +37,101 @@ pub struct BatteryArgs { pub async fn run(ctx: Context) -> Result<()> { if let Some(n) = ctx.args_sub.watch { - let n = n.as_ref().clone(); - - // gather info only for initial round - let mut rolling = if ctx.args_sub.estimate { - let first = once(ctx.clone(), None).await?; - sleep(n).await; - Some(VecDeque::from([first])) - } else { - None - }; + let interval: Duration = *n; + let mut estimator = ctx + .args_sub + .estimate + .then(|| BatteryEstimator::new(interval)); + + // First round under --estimate is sample-only, to seed the rolling window. + if let Some(est) = estimator.as_mut() { + let s = sample()?; + est.observe(s.capacity); + report(&ctx.args_sub, s, None); + sleep(interval).await; + } loop { - once(ctx.clone(), rolling.as_mut()).await?; - sleep(n).await; + let s = sample()?; + let estimate = estimator.as_mut().map(|e| e.observe(s.capacity)); + report(&ctx.args_sub, s, estimate); + sleep(interval).await; } } else { - once(ctx, None).await?; + let s = sample()?; + report(&ctx.args_sub, s, None); + Ok(()) } - - Ok(()) } -pub async fn once(ctx: Context, rolling: Option<&mut VecDeque>) -> Result { - let gpio = Gpio::new().into_diagnostic().wrap_err("gpio: init")?; - let powered = gpio - .get(6) - .into_diagnostic() - .wrap_err("gpio: read pin=6")? - .into_input() - .is_high(); - - let mut i2c = I2c::new().into_diagnostic().wrap_err("i2c: init")?; - i2c.set_slave_address(0x36) - .into_diagnostic() - .wrap_err("i2c: set address")?; - - // https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf - let vcell = (read(&mut i2c, 0x2)? as f64) * 1.25 / 1000.0 / 16.0; - let mut capacity = ((read(&mut i2c, 0x4)? as f64) / 256.0).clamp(0.0, 100.0); - let version = read(&mut i2c, 0x8)?; - - let estimates = if let Some(rolling) = rolling { - rolling.push_front(capacity); - rolling.truncate(100); - // [now, interval ago, ..., 99 intervals ago] - - // look back and find the first time the value changed - // that is at least 5 intervals away, data-permitting. - let index_to_first_difference = rolling - .iter() - .scan(rolling.front().unwrap(), |prev, curr| { - let pre = *prev; - *prev = curr; - Some(curr - pre) - }) - .enumerate() - .filter(|(n, diff)| *n >= 4.min(rolling.len() - 1) && *diff != 0.0) - .next() - .map(|(n, _)| n) - .unwrap_or(rolling.len() - 1); - - let mut rate = (capacity - rolling.get(index_to_first_difference).unwrap_or(&capacity)) - / ((rolling.len() as u64 * ctx.args_sub.watch.unwrap().as_ref().as_secs()) as f64); - let capacity_left = if rate > 0.0 { - (100.0 - capacity).abs() - } else { - capacity - } - .clamp(0.0, 100.0); - - if capacity >= 98.5 && rate >= 0.0 { - // fudge full capacity if it's close enough and we're "charging" - // otherwise we get non-sensical time remaining like "7 days to reach 100%" - capacity = 100.0; - rate = 0.0; - } else if rate.abs() < 0.00025 { - // fudge rate if it's close enough to zero - rate = 0.0; - } else if rate.abs() < 0.005 { - // fudge rate to a higher value if it's not zeroish but too low to produce good estimates - rate = rate.signum() * 0.005; - } - - // TODO: replace the fudging with a better algorithm (e.g. exponential smoothing) - // or better yet, store historical data and calibrate estimates from that. - - let time_remaining = capacity_left / rate.abs(); - let time_remaining = if time_remaining.is_finite() { - let mut dur = Duration::from_secs(time_remaining as _); - if dur > Duration::from_secs(6 * 60 * 60) { - // clamp time remaining in either direction to 6 hours - // we know that the iti doesn't last that long, and doesn't take that long to charge - dur = Duration::from_secs(6 * 60 * 60); - } - - // only show time remaining if it's more than 5 minutes - if dur < Duration::from_secs(5 * 60) { - None - } else { - Some(dur) - } - } else { - None - }; - - Some(( - rate, - time_remaining.map(|dur| { - Folktime( - dur, - if dur > Duration::from_secs(60 * 60) { - Folkstyle::TwoUnitsWhole - } else { - Folkstyle::OneUnitWhole - }, - ) - }), - )) +#[instrument(level = "debug", skip(args, estimate))] +fn report(args: &BatteryArgs, sample: BatterySample, estimate: Option) { + let BatterySample { + vcell, + capacity, + version, + powered, + } = sample; + let display_capacity = estimate.map(|e| e.capacity).unwrap_or(capacity); + + let status: &str = if let Some(est) = estimate.as_ref() { + est.status + } else if powered { + "charging" } else { - None + // "powered" is frequently false-negative so we can't rely on it for discharging. + "unknown" }; - let status = if let Some((rate, _)) = estimates { - if rate > 0.0 { - "charging" - } else if rate < 0.0 { - "discharging" - } else { - "stable" - } - } else { - if powered { - "charging" - } else { - // "powered" is frequently false-negative so we can't rely on it for discharging - "unknown" - } - }; - - if ctx.args_sub.json { - if let Some((rate, ref time_remaining)) = estimates { + if args.json { + if let Some(est) = estimate.as_ref() { + let time_remaining_pretty = est.time_remaining.map(folktime_pretty); println!( "{}", serde_json::json!({ "status": status, "vcell": vcell, - "capacity": capacity, + "capacity": display_capacity, "version": version, - "rate": rate, - "time_remaining": time_remaining.as_ref().map(|d| d.0.as_secs()), - "time_remaining_pretty": time_remaining.as_ref().map(|d| d.to_string()), + "rate": est.rate_per_second, + "time_remaining": est.time_remaining.map(|d| d.as_secs()), + "time_remaining_pretty": time_remaining_pretty.as_ref().map(ToString::to_string), }) ); } else { println!( "{}", - serde_json::json!({ "status": status, "vcell": vcell, "capacity": capacity, "version": version }) + serde_json::json!({ + "status": status, + "vcell": vcell, + "capacity": display_capacity, + "version": version, + }) ); } } else { - println!("Version: {}", version); - println!("Voltage: {:.2}V", vcell); - println!("Battery: {:.2}%", capacity); - if let Some((rate, ref time_remaining)) = estimates { - println!("Rate: {:.2}%/h ({status})", rate * 60.0 * 60.0,); - if let Some(time_remaining) = time_remaining { - println!("Time remaining: {time_remaining}"); + println!("Version: {version}"); + println!("Voltage: {vcell:.2}V"); + println!("Battery: {display_capacity:.2}%"); + if let Some(est) = estimate.as_ref() { + println!( + "Rate: {:.2}%/h ({status})", + est.rate_per_second * 60.0 * 60.0, + ); + if let Some(time_remaining) = est.time_remaining { + println!("Time remaining: {}", folktime_pretty(time_remaining)); } } } - - #[cfg(feature = "iti-lcd")] - if let Some(y) = ctx.args_sub.update_screen { - const GREEN: [u8; 3] = [0, 255, 0]; - const RED: [u8; 3] = [255, 0, 0]; - const BLACK: [u8; 3] = [0, 0, 0]; - const WHITE: [u8; 3] = [255, 255, 255]; - - let (fill, stroke) = if estimates.as_ref().map_or(false, |(rate, _)| *rate > 0.0) { - (GREEN, BLACK) - } else if capacity <= 3.0 { - (RED, WHITE) - } else if capacity <= 15.0 { - (BLACK, RED) - } else { - (BLACK, WHITE) - }; - - let mut items = vec![Item { - x: 230, - y, - stroke: Some(stroke), - text: Some(format!("{capacity:>3.0}%")), - ..Default::default() - }]; - - let (bg_x, bg_w) = if let Some((rate, time_remaining)) = estimates - .as_ref() - .and_then(|(rate, time_remaining)| time_remaining.as_ref().map(|d| (rate, d))) - { - items.push(Item { - x: 20, - y, - stroke: Some(stroke), - text: Some(if *rate < -0.0 { - format!("{time_remaining} left") - } else { - format!("full in {time_remaining}") - }), - ..Default::default() - }); - (18, 254) - } else if estimates.map_or(false, |(rate, _)| !(rate > 0.0) && !(rate < -0.0)) { - if capacity == 100.0 { - items.push(Item { - x: 20, - y, - stroke: Some(stroke), - text: Some("fully charged".into()), - ..Default::default() - }); - } - (18, 254) - } else { - (238, 34) - }; - - items.insert( - 0, - Item { - x: bg_x, - y: y - 16, - width: Some(bg_w), - height: Some(20), - fill: Some(fill), - ..Default::default() - }, - ); - - send(&ctx.args_sub.zmq_socket, Screen::Layout(items))?; - } - - Ok(capacity) } -#[instrument(level = "debug", skip(i2c))] -fn read(i2c: &mut I2c, addr: u8) -> Result { - let data = i2c - .smbus_read_word(addr) - .into_diagnostic() - .wrap_err(format!("i2c: read {addr:2X?}"))?; - Ok(u16::from_le_bytes(data.to_be_bytes())) +fn folktime_pretty(d: Duration) -> Folktime { + Folktime( + d, + if d > Duration::from_secs(60 * 60) { + Folkstyle::TwoUnitsWhole + } else { + Folkstyle::OneUnitWhole + }, + ) } diff --git a/crates/bestool/src/actions/iti/display.rs b/crates/bestool/src/actions/iti/display.rs new file mode 100644 index 00000000..69fd57b7 --- /dev/null +++ b/crates/bestool/src/actions/iti/display.rs @@ -0,0 +1,183 @@ +use std::{collections::HashSet, str::FromStr, time::Instant}; + +use clap::Parser; +use embedded_graphics::{pixelcolor::Rgb565, prelude::*}; +use miette::{IntoDiagnostic, Result, WrapErr}; +use rpi_st7789v2_driver::{Driver, DriverArgs}; +use tokio::signal::unix::{SignalKind, signal}; +use tracing::{debug, info, instrument, warn}; + +use crate::actions::{Context, iti::ItiArgs}; + +mod canvas; +mod layout; +mod widget; +mod widgets; + +pub use canvas::Canvas; +pub use layout::{LAYOUT, WidgetKind}; +pub use widget::{DynWidget, Widget}; + +/// Drive the Iti's LCD with a fixed widget layout. +/// +/// This is a single long-running service that owns the SPI/GPIO link to the panel and renders +/// every widget itself. There's no IPC: each widget samples whatever it needs (sensors, D-Bus, +/// etc.) on its own cadence. +/// +/// You'll want to set `spidev.bufsiz=131072` in `/boot/firmware/cmdline.txt`, otherwise you'll +/// get "Message too long" errors. +#[derive(Debug, Clone, Parser)] +pub struct DisplayArgs { + /// SPI port to use. + #[arg(long, default_value = "0")] + pub spi: u8, + + /// GPIO pin number for the display's backlight control pin. + #[arg(long, default_value = "18")] + pub backlight: u8, + + /// GPIO pin number for the display's reset pin. + #[arg(long, default_value = "27")] + pub reset: u8, + + /// GPIO pin number for the display's data/command pin. + #[arg(long, default_value = "25")] + pub dc: u8, + + /// SPI CE number for the display's chip select pin. + #[arg(long, default_value = "0")] + pub ce: u8, + + /// SPI frequency in Hz. + #[arg(long, default_value = "20000000")] + pub frequency: u32, + + /// Disable named widgets. Repeatable; comma-separated also accepted. + /// + /// Valid widgets: clock, addresses, wifi, temperature, battery, sparks. + #[arg(long, value_delimiter = ',', value_parser = clap::builder::ValueParser::new(WidgetKind::from_str))] + pub disable: Vec, +} + +impl From<&DisplayArgs> for DriverArgs { + fn from(args: &DisplayArgs) -> Self { + DriverArgs { + spi: args.spi, + backlight: args.backlight, + reset: args.reset, + dc: args.dc, + ce: args.ce, + frequency: args.frequency, + } + } +} + +pub async fn run(ctx: Context) -> Result<()> { + serve(&ctx.args_sub).await +} + +#[instrument(level = "debug", skip(args))] +async fn serve(args: &DisplayArgs) -> Result<()> { + let mut lcd = Driver::new(args.into())?; + lcd.init()?; + lcd.probe_buffer_length()?; + lcd.clear(Rgb565::BLACK)?; + lcd.display(true)?; + lcd.backlight(true); + lcd.wake()?; + + let disabled: HashSet = args.disable.iter().copied().collect(); + let widgets = build_widgets(&disabled).await?; + if widgets.is_empty() { + warn!("no widgets enabled; the display will stay blank"); + } + + let mut term = signal(SignalKind::terminate()) + .into_diagnostic() + .wrap_err("signal: SIGTERM")?; + let mut int = signal(SignalKind::interrupt()) + .into_diagnostic() + .wrap_err("signal: SIGINT")?; + + tokio::select! { + res = tick_loop(widgets, &mut lcd) => res?, + _ = term.recv() => info!("SIGTERM received, shutting down"), + _ = int.recv() => info!("SIGINT received, shutting down"), + } + + lcd.clear(Rgb565::BLACK)?; + lcd.display(false)?; + lcd.backlight(false); + lcd.sleep()?; + + Ok(()) +} + +async fn build_widgets(disabled: &HashSet) -> Result>> { + let mut out: Vec> = Vec::new(); + for entry in LAYOUT { + if disabled.contains(&entry.kind) { + info!(widget = entry.kind.name(), "disabled"); + continue; + } + match entry.kind { + WidgetKind::Clock => { + out.push(Box::new(widgets::clock::ClockWidget::new(entry.area))); + } + WidgetKind::Addresses => { + out.push(Box::new(widgets::addresses::AddressesWidget::new( + entry.area, + ))); + } + WidgetKind::Wifi => { + out.push(Box::new(widgets::wifi::WifiWidget::new(entry.area))); + } + WidgetKind::Temperature => { + out.push(Box::new(widgets::temperature::TemperatureWidget::new( + entry.area, + ))); + } + WidgetKind::Battery => { + out.push(Box::new(widgets::battery::BatteryWidget::new(entry.area))); + } + WidgetKind::Sparks => { + out.push(Box::new(widgets::sparks::SparksWidget::new(entry.area))); + } + } + } + Ok(out) +} + +async fn tick_loop(mut widgets: Vec>, lcd: &mut Driver) -> Result<()> { + if widgets.is_empty() { + std::future::pending::<()>().await; + unreachable!(); + } + + let now = Instant::now(); + let mut next_tick: Vec = widgets.iter().map(|_| now).collect(); + + loop { + // Find the widget whose tick is due first, sleep until then, run it. + let (idx, due) = next_tick + .iter() + .enumerate() + .min_by_key(|(_, t)| *t) + .map(|(i, t)| (i, *t)) + .expect("widgets is non-empty"); + + let now = Instant::now(); + if due > now { + tokio::time::sleep(due - now).await; + } + + let interval = widgets[idx].interval(); + let name = widgets[idx].name(); + debug!(widget = name, "ticking"); + let mut canvas = Canvas::new(lcd); + if let Err(err) = widgets[idx].tick(&mut canvas).await { + warn!(widget = name, ?err, "widget tick failed"); + } + next_tick[idx] = Instant::now() + interval; + } +} diff --git a/crates/bestool/src/actions/iti/display/canvas.rs b/crates/bestool/src/actions/iti/display/canvas.rs new file mode 100644 index 00000000..627b0053 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/canvas.rs @@ -0,0 +1,36 @@ +use embedded_graphics::{ + mono_font::{MonoTextStyle, ascii::FONT_10X20}, + pixelcolor::Rgb565, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::Text, +}; +use miette::Result; +use rpi_st7789v2_driver::Driver; + +/// Drawing surface passed to widgets each tick. Wraps the LCD driver and exposes the small set +/// of primitives the widgets actually need (filled rectangles + monospace text). +pub struct Canvas<'d> { + driver: &'d mut Driver, +} + +impl<'d> Canvas<'d> { + pub fn new(driver: &'d mut Driver) -> Self { + Self { driver } + } + + pub fn fill(&mut self, rect: Rectangle, color: Rgb565) -> Result<()> { + rect.into_styled(PrimitiveStyle::with_fill(color)) + .draw(self.driver)?; + Ok(()) + } + + pub fn text(&mut self, at: Point, s: &str, color: Rgb565) -> Result<()> { + Text::new(s, at, MonoTextStyle::new(&FONT_10X20, color)).draw(self.driver)?; + Ok(()) + } + + pub fn clear_area(&mut self, rect: Rectangle) -> Result<()> { + self.fill(rect, Rgb565::BLACK) + } +} diff --git a/crates/bestool/src/actions/iti/display/layout.rs b/crates/bestool/src/actions/iti/display/layout.rs new file mode 100644 index 00000000..635cfdb2 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/layout.rs @@ -0,0 +1,87 @@ +use std::str::FromStr; + +use embedded_graphics::{prelude::*, primitives::Rectangle}; +use miette::{Result, miette}; + +/// One placeable element on the display. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WidgetKind { + Clock, + Addresses, + Wifi, + Temperature, + Battery, + Sparks, +} + +impl WidgetKind { + pub fn name(self) -> &'static str { + match self { + Self::Clock => "clock", + Self::Addresses => "addresses", + Self::Wifi => "wifi", + Self::Temperature => "temperature", + Self::Battery => "battery", + Self::Sparks => "sparks", + } + } +} + +impl FromStr for WidgetKind { + type Err = miette::Report; + + fn from_str(s: &str) -> Result { + match s { + "clock" => Ok(Self::Clock), + "addresses" => Ok(Self::Addresses), + "wifi" => Ok(Self::Wifi), + "temperature" => Ok(Self::Temperature), + "battery" => Ok(Self::Battery), + "sparks" => Ok(Self::Sparks), + other => Err(miette!( + "unknown widget {other:?}; valid: clock, addresses, wifi, temperature, battery, sparks" + )), + } + } +} + +/// Layout entry: one widget and its area on the panel. +pub struct LayoutEntry { + pub kind: WidgetKind, + pub area: Rectangle, +} + +const fn rect(x: i32, y: i32, w: u32, h: u32) -> Rectangle { + Rectangle::new(Point::new(x, y), Size::new(w, h)) +} + +/// Pixel placement for every widget. Positions match the previous deployment so visual +/// regression on the device is minimal. +/// +/// Panel coordinates: 280 wide × 240 tall (landscape). +pub const LAYOUT: &[LayoutEntry] = &[ + LayoutEntry { + kind: WidgetKind::Clock, + area: rect(100, 4, 160, 20), + }, + LayoutEntry { + kind: WidgetKind::Sparks, + area: rect(10, 30, 260, 27), + }, + LayoutEntry { + kind: WidgetKind::Addresses, + area: rect(10, 65, 260, 80), + }, + LayoutEntry { + kind: WidgetKind::Wifi, + area: rect(10, 180, 200, 20), + }, + LayoutEntry { + kind: WidgetKind::Temperature, + area: rect(218, 180, 62, 20), + }, + LayoutEntry { + kind: WidgetKind::Battery, + area: rect(18, 205, 254, 20), + }, +]; diff --git a/crates/bestool/src/actions/iti/display/widget.rs b/crates/bestool/src/actions/iti/display/widget.rs new file mode 100644 index 00000000..52dc2770 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widget.rs @@ -0,0 +1,47 @@ +use std::{future::Future, pin::Pin, time::Duration}; + +use miette::Result; + +use super::canvas::Canvas; + +/// One ticking display element (clock, battery readout, spark lines, ...). +/// +/// The harness calls [`Widget::tick`] every [`Widget::interval`]. Widgets own their sampling +/// state and the rectangle they were constructed with, and re-draw that rectangle on each tick. +/// Rendering is sequential, so widgets don't have to worry about contention on the LCD. +pub trait Widget: Send + 'static { + /// Stable identifier; used for `--disable` flags and logging. + fn name(&self) -> &'static str; + + /// How often to call [`Widget::tick`]. + fn interval(&self) -> Duration; + + /// Sample current state and re-draw the widget's area. + fn tick(&mut self, canvas: &mut Canvas<'_>) -> impl Future> + Send; +} + +/// Object-safe wrapper for [`Widget`], used by the layout machinery to store heterogeneous +/// widgets behind a common interface. Implemented blanket-style for every `Widget`. +pub trait DynWidget: Send { + fn name(&self) -> &'static str; + fn interval(&self) -> Duration; + fn tick<'a>( + &'a mut self, + canvas: &'a mut Canvas<'a>, + ) -> Pin> + Send + 'a>>; +} + +impl DynWidget for W { + fn name(&self) -> &'static str { + Widget::name(self) + } + fn interval(&self) -> Duration { + Widget::interval(self) + } + fn tick<'a>( + &'a mut self, + canvas: &'a mut Canvas<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(Widget::tick(self, canvas)) + } +} diff --git a/crates/bestool/src/actions/iti/display/widgets.rs b/crates/bestool/src/actions/iti/display/widgets.rs new file mode 100644 index 00000000..1d58f672 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets.rs @@ -0,0 +1,6 @@ +pub mod addresses; +pub mod battery; +pub mod clock; +pub mod sparks; +pub mod temperature; +pub mod wifi; diff --git a/crates/bestool/src/actions/iti/display/widgets/addresses.rs b/crates/bestool/src/actions/iti/display/widgets/addresses.rs new file mode 100644 index 00000000..7bcfa246 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/addresses.rs @@ -0,0 +1,111 @@ +use std::{net::IpAddr, time::Duration}; + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use miette::{IntoDiagnostic, Result, WrapErr}; +use tracing::warn; + +use crate::actions::iti::display::{Canvas, Widget}; + +const STROKE: Rgb565 = Rgb565::new(220, 0, 220); +const MAX_LINES: usize = 4; +const WRAP_AT: usize = 26; +const WRAP_INDENT: usize = 2; + +pub struct AddressesWidget { + area: Rectangle, + last: Option, +} + +impl AddressesWidget { + pub fn new(area: Rectangle) -> Self { + Self { area, last: None } + } +} + +impl Widget for AddressesWidget { + fn name(&self) -> &'static str { + "addresses" + } + + fn interval(&self) -> Duration { + Duration::from_secs(60) + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + let hostname = read_hostname(); + let ips = list_global_ipv4()?; + let mut entries = vec![format!("{hostname}.local")]; + entries.extend(ips.into_iter().take(3).map(|ip| ip.to_string())); + + let lines: Vec = entries + .into_iter() + .flat_map(|s| wrap_line(&s).collect::>()) + .take(MAX_LINES) + .collect(); + let composed = lines.join("\n"); + + if self.last.as_deref() == Some(composed.as_str()) { + return Ok(()); + } + + canvas.clear_area(self.area)?; + let baseline_x = self.area.top_left.x; + let mut baseline_y = self.area.top_left.y + 16; + for line in &lines { + canvas.text(Point::new(baseline_x, baseline_y), line, STROKE)?; + baseline_y += 20; + } + self.last = Some(composed); + Ok(()) + } +} + +fn read_hostname() -> String { + std::fs::read_to_string("/etc/hostname") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".into()) +} + +fn list_global_ipv4() -> Result> { + let ifs = if_addrs::get_if_addrs() + .into_diagnostic() + .wrap_err("if_addrs: get_if_addrs")?; + let mut out = Vec::new(); + for iface in ifs { + if iface.is_loopback() { + continue; + } + if is_excluded(&iface.name) { + continue; + } + let IpAddr::V4(v4) = iface.addr.ip() else { + continue; + }; + if v4.is_link_local() || v4.is_unspecified() { + continue; + } + out.push(IpAddr::V4(v4)); + } + if out.is_empty() { + warn!("no global IPv4 addresses found"); + } + Ok(out) +} + +fn is_excluded(name: &str) -> bool { + name.starts_with("podman") || name.starts_with("docker") || name.starts_with("br-") +} + +fn wrap_line(s: &str) -> impl Iterator + use<> { + let mut out = Vec::new(); + if s.len() <= WRAP_AT { + out.push(s.to_owned()); + } else { + out.push(s[..WRAP_AT.min(s.len())].to_owned()); + let indent = " ".repeat(WRAP_INDENT); + out.push(format!("{indent}{}", &s[WRAP_AT.min(s.len())..])); + } + out.into_iter() +} diff --git a/crates/bestool/src/actions/iti/display/widgets/battery.rs b/crates/bestool/src/actions/iti/display/widgets/battery.rs new file mode 100644 index 00000000..4a0ee842 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/battery.rs @@ -0,0 +1,110 @@ +use std::time::Duration; + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use folktime::duration::{Duration as Folktime, Style as Folkstyle}; +use miette::Result; +use tracing::warn; + +use crate::actions::iti::{ + display::{Canvas, Widget}, + samplers::battery::{BatteryEstimator, sample}, +}; + +const TICK: Duration = Duration::from_secs(10); + +const GREEN: Rgb565 = Rgb565::new(0, 255, 0); +const RED: Rgb565 = Rgb565::new(255, 0, 0); +const BLACK: Rgb565 = Rgb565::new(0, 0, 0); +const WHITE: Rgb565 = Rgb565::new(255, 255, 255); + +pub struct BatteryWidget { + area: Rectangle, + estimator: BatteryEstimator, + last: Option, +} + +impl BatteryWidget { + pub fn new(area: Rectangle) -> Self { + Self { + area, + estimator: BatteryEstimator::new(TICK), + last: None, + } + } +} + +impl Widget for BatteryWidget { + fn name(&self) -> &'static str { + "battery" + } + + fn interval(&self) -> Duration { + TICK + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + let s = match sample() { + Ok(s) => s, + Err(err) => { + warn!(?err, "battery sample failed"); + return Ok(()); + } + }; + let est = self.estimator.observe(s.capacity); + let charging = est.rate_per_second > 0.0; + let stable = est.rate_per_second == 0.0; + + let (fill, stroke) = if charging { + (GREEN, BLACK) + } else if est.capacity <= 3.0 { + (RED, WHITE) + } else if est.capacity <= 15.0 { + (BLACK, RED) + } else { + (BLACK, WHITE) + }; + + let pct_text = format!("{:>3.0}%", est.capacity); + let side_text = if let Some(remaining) = est.time_remaining { + Some(if est.rate_per_second < 0.0 { + format!("{} left", folktime_pretty(remaining)) + } else { + format!("full in {}", folktime_pretty(remaining)) + }) + } else if stable && est.capacity == 100.0 { + Some("fully charged".into()) + } else { + None + }; + + let composed = format!("{} | {}", pct_text, side_text.as_deref().unwrap_or("")); + if self.last.as_deref() == Some(composed.as_str()) { + return Ok(()); + } + + canvas.fill(self.area, fill)?; + let pct_x = self.area.top_left.x + self.area.size.width as i32 - 40; + let baseline_y = self.area.top_left.y + 16; + canvas.text(Point::new(pct_x, baseline_y), &pct_text, stroke)?; + if let Some(side) = side_text.as_deref() { + canvas.text( + Point::new(self.area.top_left.x + 2, baseline_y), + side, + stroke, + )?; + } + self.last = Some(composed); + Ok(()) + } +} + +fn folktime_pretty(d: Duration) -> Folktime { + Folktime( + d, + if d > Duration::from_secs(60 * 60) { + Folkstyle::TwoUnitsWhole + } else { + Folkstyle::OneUnitWhole + }, + ) +} diff --git a/crates/bestool/src/actions/iti/display/widgets/clock.rs b/crates/bestool/src/actions/iti/display/widgets/clock.rs new file mode 100644 index 00000000..1da6fdbe --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/clock.rs @@ -0,0 +1,48 @@ +use std::time::Duration; + +use chrono::Local; +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use miette::Result; + +use crate::actions::iti::display::{Canvas, Widget}; + +// Matches the colour passed by the original `iti-localtime` script. (The 8-bit channels are +// masked to 5/6/5 bits by `Rgb565::new`, yielding a soft low-saturation tone — keeping it +// verbatim avoids visual regression on the panel.) +const STROKE: Rgb565 = Rgb565::new(235, 225, 205); + +pub struct ClockWidget { + area: Rectangle, + last: Option, +} + +impl ClockWidget { + pub fn new(area: Rectangle) -> Self { + Self { area, last: None } + } +} + +impl Widget for ClockWidget { + fn name(&self) -> &'static str { + "clock" + } + + fn interval(&self) -> Duration { + Duration::from_secs(10) + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + let now = Local::now(); + let text = format!("{} {}", now.format("%Y-%m-%d"), now.format("%H:%M")); + if self.last.as_deref() == Some(text.as_str()) { + return Ok(()); + } + + canvas.clear_area(self.area)?; + // Text baseline sits 16px below the area's top edge to vertically centre FONT_10X20. + let baseline = Point::new(self.area.top_left.x, self.area.top_left.y + 16); + canvas.text(baseline, &text, STROKE)?; + self.last = Some(text); + Ok(()) + } +} diff --git a/crates/bestool/src/actions/iti/display/widgets/sparks.rs b/crates/bestool/src/actions/iti/display/widgets/sparks.rs new file mode 100644 index 00000000..bc012537 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/sparks.rs @@ -0,0 +1,152 @@ +use std::{collections::VecDeque, time::Duration}; + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use miette::Result; +use sysinfo::System; + +use crate::actions::iti::display::{Canvas, Widget}; + +const FG_CPU: Rgb565 = Rgb565::new(245, 0, 0); +const FG_MEM: Rgb565 = Rgb565::new(0, 0, 242); +const BG: Rgb565 = Rgb565::new(0, 0, 0); +const GAP: i32 = 10; + +pub struct SparksWidget { + area: Rectangle, + system: System, + cpu_history: VecDeque, + mem_history: VecDeque, + first_tick: bool, +} + +impl SparksWidget { + pub fn new(area: Rectangle) -> Self { + let mut system = System::new(); + system.refresh_cpu_usage(); + system.refresh_memory(); + Self { + area, + system, + cpu_history: VecDeque::new(), + mem_history: VecDeque::new(), + first_tick: true, + } + } + + fn outer_width(&self) -> u32 { + (self.area.size.width.saturating_sub(GAP as u32)) / 2 + } + + fn inner_width(&self) -> u32 { + self.outer_width().saturating_sub(2) + } +} + +impl Widget for SparksWidget { + fn name(&self) -> &'static str { + "sparks" + } + + fn interval(&self) -> Duration { + Duration::from_secs(10).max(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL) + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + self.system.refresh_cpu_usage(); + self.system.refresh_memory(); + + let cpu_avg = if self.system.cpus().is_empty() { + 0.0 + } else { + let sum: f32 = self.system.cpus().iter().map(|c| c.cpu_usage() / 100.0).sum(); + sum / self.system.cpus().len() as f32 + }; + let mem_ratio = self.system.used_memory() as f32 / self.system.total_memory().max(1) as f32; + + let inner = self.inner_width() as usize; + self.cpu_history.push_front(cpu_avg); + self.cpu_history.truncate(inner); + self.mem_history.push_front(mem_ratio); + self.mem_history.truncate(inner); + + // Background frames + inner clears (only on the first tick — afterwards we just refresh + // the spark columns themselves, which fully overwrite the inner area). + if self.first_tick { + let outer = self.outer_width(); + let h = self.area.size.height; + let inner_h = h.saturating_sub(2); + let left = self.area.top_left; + let right = Point::new( + self.area.top_left.x + outer as i32 + GAP, + self.area.top_left.y, + ); + + canvas.fill(Rectangle::new(left, Size::new(outer, h)), FG_CPU)?; + canvas.fill( + Rectangle::new(left + Point::new(1, 1), Size::new(self.inner_width(), inner_h)), + BG, + )?; + canvas.fill(Rectangle::new(right, Size::new(outer, h)), FG_MEM)?; + canvas.fill( + Rectangle::new(right + Point::new(1, 1), Size::new(self.inner_width(), inner_h)), + BG, + )?; + + self.first_tick = false; + } + + let inner_height = self.area.size.height.saturating_sub(2) as i32; + let inner_y = self.area.top_left.y + 1; + + draw_spark( + canvas, + self.cpu_history.iter().rev().copied(), + self.area.top_left.x + 1, + inner_y, + inner_height, + self.inner_width() as i32, + FG_CPU, + )?; + draw_spark( + canvas, + self.mem_history.iter().rev().copied(), + self.area.top_left.x + self.outer_width() as i32 + GAP + 1, + inner_y, + inner_height, + self.inner_width() as i32, + FG_MEM, + )?; + Ok(()) + } +} + +fn draw_spark( + canvas: &mut Canvas<'_>, + data: impl ExactSizeIterator, + min_x: i32, + min_y: i32, + height: i32, + width: i32, + colour: Rgb565, +) -> Result<()> { + let len = data.len(); + let pad = (width as usize).saturating_sub(len); + let h = height as f32; + + // Clear the inner column band first so the previous spark doesn't bleed through. + canvas.fill( + Rectangle::new(Point::new(min_x, min_y), Size::new(width as u32, height as u32)), + BG, + )?; + + for (i, v) in std::iter::repeat_n(0.0, pad).chain(data).enumerate() { + let v = v.clamp(0.0, 1.0); + let y = ((1.0 - v) * h).round() as i32; + let bar_h = (height.saturating_sub(y).max(1) * 2 - 1).max(1) as u32; + canvas.fill( + Rectangle::new(Point::new(min_x + i as i32, min_y + y), Size::new(1, bar_h)), + colour, + )?; + } + Ok(()) +} diff --git a/crates/bestool/src/actions/iti/display/widgets/temperature.rs b/crates/bestool/src/actions/iti/display/widgets/temperature.rs new file mode 100644 index 00000000..25547c80 --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/temperature.rs @@ -0,0 +1,63 @@ +use std::time::Duration; + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use miette::Result; +use tracing::warn; + +use crate::actions::iti::{ + display::{Canvas, Widget}, + samplers::temperature::sample, +}; + +const GREEN: Rgb565 = Rgb565::new(0, 255, 0); +const YELLOW: Rgb565 = Rgb565::new(255, 255, 0); +const RED: Rgb565 = Rgb565::new(255, 0, 0); + +pub struct TemperatureWidget { + area: Rectangle, + last: Option, +} + +impl TemperatureWidget { + pub fn new(area: Rectangle) -> Self { + Self { area, last: None } + } +} + +impl Widget for TemperatureWidget { + fn name(&self) -> &'static str { + "temperature" + } + + fn interval(&self) -> Duration { + Duration::from_secs(10) + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + let temp = match sample() { + Ok(t) => t, + Err(err) => { + warn!(?err, "vcgencmd failed"); + return Ok(()); + } + }; + let text = format!("{temp:>5.1}C"); + let stroke = if temp < 60.0 { + GREEN + } else if temp > 80.0 { + RED + } else { + YELLOW + }; + + if self.last.as_deref() == Some(text.as_str()) { + return Ok(()); + } + + canvas.clear_area(self.area)?; + let baseline = Point::new(self.area.top_left.x, self.area.top_left.y + 16); + canvas.text(baseline, &text, stroke)?; + self.last = Some(text); + Ok(()) + } +} diff --git a/crates/bestool/src/actions/iti/display/widgets/wifi.rs b/crates/bestool/src/actions/iti/display/widgets/wifi.rs new file mode 100644 index 00000000..fc2bc18e --- /dev/null +++ b/crates/bestool/src/actions/iti/display/widgets/wifi.rs @@ -0,0 +1,146 @@ +use std::time::Duration; + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle}; +use miette::{IntoDiagnostic, Result, WrapErr}; +use tracing::warn; +use zbus::{Connection, proxy}; + +use crate::actions::iti::display::{Canvas, Widget}; + +const STROKE: Rgb565 = Rgb565::new(255, 255, 0); +const NM_DEVICE_TYPE_WIFI: u32 = 2; + +#[proxy( + interface = "org.freedesktop.NetworkManager", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager" +)] +trait NetworkManager { + fn get_devices(&self) -> zbus::Result>; +} + +#[proxy( + interface = "org.freedesktop.NetworkManager.Device", + default_service = "org.freedesktop.NetworkManager" +)] +trait NmDevice { + #[zbus(property)] + fn device_type(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.freedesktop.NetworkManager.Device.Wireless", + default_service = "org.freedesktop.NetworkManager" +)] +trait NmWireless { + #[zbus(property)] + fn active_access_point(&self) -> zbus::Result; +} + +#[proxy( + interface = "org.freedesktop.NetworkManager.AccessPoint", + default_service = "org.freedesktop.NetworkManager" +)] +trait NmAccessPoint { + #[zbus(property, name = "Ssid")] + fn ssid(&self) -> zbus::Result>; +} + +pub struct WifiWidget { + area: Rectangle, + connection: Option, + last: Option, +} + +impl WifiWidget { + pub fn new(area: Rectangle) -> Self { + Self { + area, + connection: None, + last: None, + } + } + + async fn ensure_conn(&mut self) -> Result<&Connection> { + if self.connection.is_none() { + let conn = Connection::system() + .await + .into_diagnostic() + .wrap_err("dbus: system bus")?; + self.connection = Some(conn); + } + Ok(self.connection.as_ref().unwrap()) + } + + async fn current_ssid(&mut self) -> Result> { + let conn = self.ensure_conn().await?.clone(); + let nm = NetworkManagerProxy::new(&conn).await.into_diagnostic()?; + let devices = nm.get_devices().await.into_diagnostic()?; + for path in devices { + let dev = NmDeviceProxy::builder(&conn) + .path(path.clone()) + .into_diagnostic()? + .build() + .await + .into_diagnostic()?; + if dev.device_type().await.unwrap_or(0) != NM_DEVICE_TYPE_WIFI { + continue; + } + let wireless = NmWirelessProxy::builder(&conn) + .path(path) + .into_diagnostic()? + .build() + .await + .into_diagnostic()?; + let ap_path = match wireless.active_access_point().await { + Ok(p) => p, + Err(_) => return Ok(None), + }; + if ap_path.as_str() == "/" { + return Ok(None); + } + let ap = NmAccessPointProxy::builder(&conn) + .path(ap_path) + .into_diagnostic()? + .build() + .await + .into_diagnostic()?; + let bytes = ap.ssid().await.into_diagnostic()?; + return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())); + } + Ok(None) + } +} + +impl Widget for WifiWidget { + fn name(&self) -> &'static str { + "wifi" + } + + fn interval(&self) -> Duration { + Duration::from_secs(60) + } + + async fn tick(&mut self, canvas: &mut Canvas<'_>) -> Result<()> { + let ssid = match self.current_ssid().await { + Ok(s) => s, + Err(err) => { + warn!(?err, "querying NetworkManager failed"); + None + } + }; + let mut text = format!("Wifi: {}", ssid.as_deref().unwrap_or("not connected")); + if text.len() > 20 { + text.truncate(20); + } + if self.last.as_deref() == Some(text.as_str()) { + return Ok(()); + } + + canvas.clear_area(self.area)?; + let baseline = Point::new(self.area.top_left.x, self.area.top_left.y + 16); + canvas.text(baseline, &text, STROKE)?; + self.last = Some(text); + Ok(()) + } +} diff --git a/crates/bestool/src/actions/iti/lcd.rs b/crates/bestool/src/actions/iti/lcd.rs deleted file mode 100644 index 83124799..00000000 --- a/crates/bestool/src/actions/iti/lcd.rs +++ /dev/null @@ -1,306 +0,0 @@ -use std::{ - io::Read, - ops::ControlFlow, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, -}; - -use clap::{Parser, Subcommand}; -use embedded_graphics::Drawable; -use miette::{miette, IntoDiagnostic, Result, WrapErr}; -use rpi_st7789v2_driver::{DriverArgs, Driver}; -use tracing::{error, info, instrument, trace}; - -use super::ItiArgs; -use crate::actions::Context; - -pub mod json; - -/// Control an LCD screen. -/// -/// This is made for Waveshare's 1.69 inch LCD display, connected over SPI to a Raspberry Pi. -/// -/// See more info about it here: https://www.waveshare.com/wiki/1.69inch_LCD_Module -/// -/// You'll want to set up SPI's buffer size by adding `spidev.bufsiz=131072` to -/// `/boot/firmware/cmdline.txt`, otherwise you'll get "Message too long" errors. -// 131072 = closest power of 2 to 128400, which is size of the display's framebuffer. -#[derive(Debug, Clone, Parser)] -pub struct LcdArgs { - /// SPI port to use. - #[arg(long, default_value = "0")] - pub spi: u8, - - /// GPIO pin number for the display's backlight control pin. - #[arg(long, default_value = "18")] - pub backlight: u8, - - /// GPIO pin number for the display's reset pin. - #[arg(long, default_value = "27")] - pub reset: u8, - - /// GPIO pin number for the display's data/command pin. - #[arg(long, default_value = "25")] - pub dc: u8, - - /// SPI CE number for the display's chip select pin. - #[arg(long, default_value = "0")] - pub ce: u8, - - /// SPI frequency in Hz. - #[arg(long, default_value = "20000000")] - pub frequency: u32, - - /// ZMQ socket to use for JSON screen updates. - #[arg(default_value = "tcp://[::1]:2009")] - pub zmq_socket: String, - - /// Subcommand - #[command(subcommand)] - pub action: LcdAction, -} - -impl From for DriverArgs { - fn from(args: LcdArgs) -> Self { - DriverArgs { - spi: args.spi, - backlight: args.backlight, - reset: args.reset, - dc: args.dc, - ce: args.ce, - frequency: args.frequency, - } - } -} - -#[derive(Debug, Clone, Subcommand)] -pub enum LcdAction { - /// Start the LCD display server. - /// - /// This will initiatialize the LCD display, listen for JSON messages on a ZMQ REP socket, and - /// update the display based on the contents of the messages. - /// - /// Note that enabling trace-level (`-vvv`) logging will considerably slow down screen updates, - /// as it will log every command sent to the screen, which can be considerable for complex - /// layouts and text. - Serve, - - /// Send an arbitrary JSON message to the display server. - /// - /// This is useful for debugging or testing the display server, or for interacting with the - /// screen without a ZMQ client. - /// - /// The message can be provided either as the first argument, or over stdin. - /// - /// The message will be validated by the client to avoid sending malformed messages to the - /// server. The command will block until the message can be sent to the display server, then - /// wait for a reply and print it if non-empty. - Send { - /// JSON message to send. - message: Option, - }, - - /// Set all pixels to a single color. - /// - /// The command will block until the message can be sent to the display server, then wait for a - /// reply and print it if non-empty. - Clear { - /// Red value for the background color. - #[arg(default_value = "0")] - red: u8, - - /// Green value for the background color. - #[arg(default_value = "0")] - green: u8, - - /// Blue value for the background color. - #[arg(default_value = "0")] - blue: u8, - }, - - /// Turn the display on. - /// - /// This wakes the display, turns on the backlight, and shows the current screen contents. - /// - /// The LCD must then rest for 120ms before any further commands can be sent. - /// - /// The command will block until the message can be sent to the display server, then wait for a - /// reply and print it if non-empty. - On, - - /// Turn the display off. - /// - /// This turns off the backlight and puts the display to sleep, which uses less power. - /// - /// The LCD must then rest for 5ms before any further commands can be sent. - /// - /// The command will block until the message can be sent to the display server, then wait for a - /// reply and print it if non-empty. - Off, -} - -pub async fn run(ctx: Context) -> Result<()> { - use LcdAction::*; - match ctx.args_sub.action.clone() { - Serve => serve(ctx), - Send { message } => { - let screen = serde_json::from_str(&message.unwrap_or_else(|| { - let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf).expect("stdin: "); - buf - })) - .into_diagnostic() - .wrap_err("json: from_str")?; - send(&ctx.args_sub.zmq_socket, screen) - } - Clear { red, green, blue } => send(&ctx.args_sub.zmq_socket, json::Screen::Clear([red, green, blue])), - On => send(&ctx.args_sub.zmq_socket, json::Screen::Light(true)), - Off => send(&ctx.args_sub.zmq_socket, json::Screen::Light(false)), - } -} - -#[instrument(level = "debug", skip(ctx))] -pub fn serve(ctx: Context) -> Result<()> { - let running = Arc::new(AtomicBool::new(true)); - let r = running.clone(); - - ctrlc::set_handler(move || { - r.store(false, Ordering::SeqCst); - }) - .into_diagnostic() - .wrap_err("ctrlc: set_handler")?; - - let z = zmq::Context::new(); - let socket = z - .socket(zmq::REP) - .into_diagnostic() - .wrap_err("zmq: socket(REP)")?; - socket - .set_ipv6(true) - .into_diagnostic() - .wrap_err("zmq: set_ipv6")?; - socket - .bind(&ctx.args_sub.zmq_socket) - .into_diagnostic() - .wrap_err(format!("zmq: bind({})", ctx.args_sub.zmq_socket))?; - info!( - "ZMQ REP listening on {} for JSON messages", - ctx.args_sub.zmq_socket - ); - - let mut lcd = Driver::new(ctx.args_sub.into())?; - lcd.init()?; - lcd.probe_buffer_length()?; - - loop { - match loop_inner(running.clone(), &socket, &mut lcd) { - Ok(ControlFlow::Continue(_)) => continue, - Ok(ControlFlow::Break(_)) => break, - Err(err) => { - let err = format!("{err:?}"); - error!("{err}"); - socket.send(&err, 0).ok(); - continue; - } - } - } - - Ok(()) -} - -#[instrument(level = "trace", skip(socket, lcd))] -fn loop_inner( - running: Arc, - socket: &zmq::Socket, - lcd: &mut Driver, -) -> Result> { - let mut polls = [socket.as_poll_item(zmq::POLLIN)]; - let polled = zmq::poll(&mut polls, 1000) - .into_diagnostic() - .wrap_err("zmq: poll")?; - if running.load(Ordering::SeqCst) == false { - info!("ctrl-c received, exiting"); - return Ok(ControlFlow::Break(())); - } - if polled == 0 || !polls[0].is_readable() { - trace!("zmq: no messages (poll timed out)"); - return Ok(ControlFlow::Continue(())); - } - - let bytes = socket - .recv_bytes(0) - .into_diagnostic() - .wrap_err("zmq: recv")?; - - let screen: json::Screen = serde_json::from_slice(&bytes) - .into_diagnostic() - .wrap_err("json: parse")?; - - trace!(?screen, "received screen control message"); - - use json::Screen::*; - match screen { - Light(true) => { - info!("turning screen on"); - lcd.display(true)?; - lcd.backlight(true); - lcd.wake()?; - } - Light(false) => { - info!("turning screen off"); - lcd.display(false)?; - lcd.backlight(false); - lcd.sleep()?; - } - otherwise => { - info!("updating screen {otherwise:?}"); - otherwise.draw(lcd)?; - } - } - - socket - .send(zmq::Message::new(), 0) - .into_diagnostic() - .wrap_err("zmq: send")?; - - Ok(ControlFlow::Continue(())) -} - -#[instrument(level = "debug")] -pub fn send(addr: &str, screen: json::Screen) -> Result<()> { - let z = zmq::Context::new(); - let socket = z - .socket(zmq::REQ) - .into_diagnostic() - .wrap_err("zmq: socket(REQ)")?; - socket - .set_ipv6(true) - .into_diagnostic() - .wrap_err("zmq: set_ipv6")?; - socket - .connect(addr) - .into_diagnostic() - .wrap_err(format!("zmq: connect({})", addr))?; - - let bytes = serde_json::to_vec(&screen) - .into_diagnostic() - .wrap_err("json: to_vec")?; - socket - .send(&bytes, 0) - .into_diagnostic() - .wrap_err("zmq: send")?; - - let reply = socket - .recv_string(0) - .into_diagnostic() - .wrap_err("zmq: recv")? - .map_err(|bytes| miette!("reply is not valid utf-8, received {} bytes", bytes.len())) - .wrap_err("zmq: recv_string")?; - if !reply.is_empty() { - println!("{reply}"); - } - - Ok(()) -} diff --git a/crates/bestool/src/actions/iti/lcd/json.rs b/crates/bestool/src/actions/iti/lcd/json.rs deleted file mode 100644 index d910bb4a..00000000 --- a/crates/bestool/src/actions/iti/lcd/json.rs +++ /dev/null @@ -1,78 +0,0 @@ -use embedded_graphics::{ - mono_font::{MonoTextStyle, ascii::FONT_10X20}, - pixelcolor::Rgb565, - prelude::*, - primitives::{PrimitiveStyle, Rectangle}, - text::Text, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Screen { - Clear([u8; 3]), - Light(bool), - Layout(Vec), -} - -impl Drawable for Screen { - type Color = Rgb565; - type Output = (); - - fn draw(&self, target: &mut D) -> Result - where - D: DrawTarget, - { - use Screen::*; - match self { - Clear([r, g, b]) => target.clear(Rgb565::new(*r, *g, *b)), - Layout(items) => { - for item in items { - item.draw(target)?; - } - Ok(()) - } - Light(_) => unreachable!("Light is handled outside of the draw method"), - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct Item { - pub x: i32, - pub y: i32, - pub width: Option, - pub height: Option, - pub fill: Option<[u8; 3]>, - pub stroke: Option<[u8; 3]>, - pub text: Option, -} - -impl Drawable for Item { - type Color = Rgb565; - type Output = (); - - fn draw(&self, target: &mut D) -> Result - where - D: DrawTarget, - { - if let (Some(width), Some(height), Some(colour)) = (self.width, self.height, self.fill) { - Rectangle::new(Point::new(self.x, self.y), Size::new(width, height)) - .into_styled(PrimitiveStyle::with_fill(Rgb565::new( - colour[0], colour[1], colour[2], - ))) - .draw(target)?; - } - - if let (Some(text), Some(stroke)) = (self.text.as_deref(), self.stroke) { - Text::new( - text, - Point::new(self.x, self.y), - MonoTextStyle::new(&FONT_10X20, Rgb565::new(stroke[0], stroke[1], stroke[2])), - ) - .draw(target)?; - } - - Ok(()) - } -} diff --git a/crates/bestool/src/actions/iti/samplers.rs b/crates/bestool/src/actions/iti/samplers.rs new file mode 100644 index 00000000..85c25f74 --- /dev/null +++ b/crates/bestool/src/actions/iti/samplers.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "iti-battery")] +pub mod battery; +#[cfg(feature = "iti-temperature")] +pub mod temperature; diff --git a/crates/bestool/src/actions/iti/samplers/battery.rs b/crates/bestool/src/actions/iti/samplers/battery.rs new file mode 100644 index 00000000..363c56f7 --- /dev/null +++ b/crates/bestool/src/actions/iti/samplers/battery.rs @@ -0,0 +1,175 @@ +use std::{collections::VecDeque, time::Duration}; + +use miette::{IntoDiagnostic, Result, WrapErr}; +use rppal::{gpio::Gpio, i2c::I2c}; +use tracing::instrument; + +/// One reading from the X1201 UPS board. +#[derive(Debug, Clone, Copy)] +pub struct BatterySample { + /// Per-cell voltage in volts. + pub vcell: f64, + /// Charge percentage in `0.0..=100.0`. + pub capacity: f64, + /// MAX17048 version register (returned for diagnostic logging). + pub version: u16, + /// True if the GPIO power-detect pin is high. + pub powered: bool, +} + +/// Read one sample from the MAX17048 fuel-gauge over I2C and the powered GPIO. +/// +/// References the MAX17048 datasheet §"Register Summary": +/// . +#[instrument(level = "debug")] +pub fn sample() -> Result { + let gpio = Gpio::new().into_diagnostic().wrap_err("gpio: init")?; + let powered = gpio + .get(6) + .into_diagnostic() + .wrap_err("gpio: read pin=6")? + .into_input() + .is_high(); + + let mut i2c = I2c::new().into_diagnostic().wrap_err("i2c: init")?; + i2c.set_slave_address(0x36) + .into_diagnostic() + .wrap_err("i2c: set address")?; + + let vcell = (read_register(&mut i2c, 0x2)? as f64) * 1.25 / 1000.0 / 16.0; + let capacity = ((read_register(&mut i2c, 0x4)? as f64) / 256.0).clamp(0.0, 100.0); + let version = read_register(&mut i2c, 0x8)?; + + Ok(BatterySample { + vcell, + capacity, + version, + powered, + }) +} + +#[instrument(level = "trace", skip(i2c))] +fn read_register(i2c: &mut I2c, addr: u8) -> Result { + let data = i2c + .smbus_read_word(addr) + .into_diagnostic() + .wrap_err(format!("i2c: read {addr:2X?}"))?; + Ok(u16::from_le_bytes(data.to_be_bytes())) +} + +/// Rolling-window estimator for charge rate and time-remaining. +/// +/// Records up to 100 capacity samples taken at a fixed interval. The first sample is the most +/// recent. The estimator does several heuristic adjustments to avoid producing nonsense +/// time-remaining values from noisy short-window data; see [`BatteryEstimator::observe`]. +pub struct BatteryEstimator { + interval: Duration, + window: VecDeque, +} + +impl BatteryEstimator { + pub fn new(interval: Duration) -> Self { + Self { + interval, + window: VecDeque::new(), + } + } + + /// Push a new capacity sample and produce a best-effort estimate. + pub fn observe(&mut self, capacity: f64) -> BatteryEstimate { + self.window.push_front(capacity); + self.window.truncate(100); + + // Walk the window from "now" backwards, looking for the first interval at which the + // capacity differs from the most recent sample, with a minimum lookback of 5 intervals + // (or shorter if we don't have that much history yet). + let index_to_first_difference = self + .window + .iter() + .scan(self.window.front().copied().unwrap_or(capacity), |prev, curr| { + let pre = *prev; + *prev = *curr; + Some(curr - pre) + }) + .enumerate() + .find(|(n, diff)| *n >= 4.min(self.window.len() - 1) && *diff != 0.0) + .map(|(n, _)| n) + .unwrap_or(self.window.len() - 1); + + let mut rate = (capacity + - self + .window + .get(index_to_first_difference) + .copied() + .unwrap_or(capacity)) + / ((self.window.len() as u64 * self.interval.as_secs()) as f64); + let mut adjusted_capacity = capacity; + let capacity_left = if rate > 0.0 { + (100.0 - capacity).abs() + } else { + capacity + } + .clamp(0.0, 100.0); + + // Heuristic adjustments (kept verbatim from the original `iti battery` implementation + // — see the inline comments there for the why). + if capacity >= 98.5 && rate >= 0.0 { + adjusted_capacity = 100.0; + rate = 0.0; + } else if rate.abs() < 0.00025 { + rate = 0.0; + } else if rate.abs() < 0.005 { + rate = rate.signum() * 0.005; + } + + let time_remaining = compute_time_remaining(capacity_left, rate); + let status = if rate > 0.0 { + "charging" + } else if rate < 0.0 { + "discharging" + } else { + "stable" + }; + + BatteryEstimate { + capacity: adjusted_capacity, + rate_per_second: rate, + time_remaining, + status, + } + } +} + +fn compute_time_remaining(capacity_left: f64, rate_per_second: f64) -> Option { + if rate_per_second == 0.0 { + return None; + } + let secs = capacity_left / rate_per_second.abs(); + if !secs.is_finite() { + return None; + } + let mut dur = Duration::from_secs(secs as u64); + // Clamp at 6h: the device doesn't last that long, and doesn't take that long to charge. + if dur > Duration::from_secs(6 * 60 * 60) { + dur = Duration::from_secs(6 * 60 * 60); + } + if dur < Duration::from_secs(5 * 60) { + None + } else { + Some(dur) + } +} + +/// Estimated charging trend, derived from a window of [`BatterySample`]s. +#[derive(Debug, Clone, Copy)] +pub struct BatteryEstimate { + /// Capacity, possibly clamped to 100.0 when very near full and not discharging. + pub capacity: f64, + /// Charge rate in percentage points per second (positive = charging). + pub rate_per_second: f64, + /// Estimated time to fully charge / fully discharge, if rate is non-zero and the result is + /// between 5 minutes and 6 hours. + pub time_remaining: Option, + /// `"charging"`, `"discharging"`, or `"stable"`. + pub status: &'static str, +} diff --git a/crates/bestool/src/actions/iti/samplers/temperature.rs b/crates/bestool/src/actions/iti/samplers/temperature.rs new file mode 100644 index 00000000..0a46aca5 --- /dev/null +++ b/crates/bestool/src/actions/iti/samplers/temperature.rs @@ -0,0 +1,16 @@ +use miette::{IntoDiagnostic, Result, WrapErr}; +use tracing::instrument; + +/// Read the SoC core temperature in degrees Celsius via `vcgencmd measure_temp`. +#[instrument(level = "debug")] +pub fn sample() -> Result { + duct::cmd!("vcgencmd", "measure_temp") + .read() + .into_diagnostic() + .wrap_err("vcgencmd: measure_temp")? + .trim_start_matches("temp=") + .trim_end_matches("'C") + .parse::() + .into_diagnostic() + .wrap_err("vcgencmd: parse output") +} diff --git a/crates/bestool/src/actions/iti/sparks.rs b/crates/bestool/src/actions/iti/sparks.rs deleted file mode 100644 index 179d0086..00000000 --- a/crates/bestool/src/actions/iti/sparks.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::{collections::VecDeque, iter::repeat, time::Duration}; - -use clap::Parser; -use miette::Result; -use sysinfo::System; - -use crate::actions::{ - iti::{ItiArgs, lcd::{ - json::{Item, Screen}, - send, - }}, - Context, -}; - -/// Display CPU and memory usage as spark lines on the LCD. -#[derive(Debug, Clone, Parser)] -pub struct SparksArgs { - /// Y position of the gauges. - #[arg(long, default_value = "30")] - pub y: i32, - - /// Height of the gauges. - #[arg(long, default_value = "27")] - pub h: u32, - - /// Refresh interval. - #[arg(long, default_value = "10s")] - pub interval: humantime::Duration, - - /// ZMQ socket to use for screen updates. - #[arg(default_value = "tcp://[::1]:2009")] - pub zmq_socket: String, -} - -const FG_CPU: [u8; 3] = [245, 0, 0]; -const FG_MEM: [u8; 3] = [0, 0, 242]; -const BG: [u8; 3] = [0, 0, 0]; -const MIN_X: i32 = 10; -const MAX_X: i32 = 270; -const GAP: i32 = 10; - -const OUTER_WIDTH: u32 = ((MAX_X - MIN_X - GAP) as u32) / 2; -const INNER_WIDTH: u32 = OUTER_WIDTH - 2; - -pub async fn run(ctx: Context) -> Result<()> { - let mut sys = System::new(); - sys.refresh_cpu_usage(); - sys.refresh_memory(); - - let mut cpu = VecDeque::new(); - let mut mem = VecDeque::new(); - - let mut interval: Duration = ctx.args_sub.interval.into(); - if interval < sysinfo::MINIMUM_CPU_UPDATE_INTERVAL { - interval = sysinfo::MINIMUM_CPU_UPDATE_INTERVAL; - } - - loop { - tokio::time::sleep(interval).await; - sys.refresh_cpu_usage(); - sys.refresh_memory(); - - let mut cpu_sum = 0.0; - let mut cpu_count = 0.0; - for cpu in sys.cpus() { - cpu_sum += cpu.cpu_usage() / 100.0; - cpu_count += 1.0; - } - cpu.push_front(cpu_sum / cpu_count); - cpu.truncate(INNER_WIDTH as _); - - mem.push_front(sys.used_memory() as f32 / sys.total_memory() as f32); - mem.truncate(INNER_WIDTH as _); - - render( - &ctx.args_sub, - cpu.iter().rev().copied(), - mem.iter().rev().copied(), - )?; - } -} - -pub fn render( - args: &SparksArgs, - cpu: impl ExactSizeIterator, - mem: impl ExactSizeIterator, -) -> Result<()> { - let SparksArgs { - y, h, zmq_socket, .. - } = args; - let y = *y; - let h = *h; - - let inner_height = h - 2; - let inner_y = y + 1; - - let mut items = vec![ - Item { - x: MIN_X, - y, - fill: Some(FG_CPU), - width: Some(OUTER_WIDTH), - height: Some(h), - ..Default::default() - }, - Item { - x: MIN_X + 1, - y: inner_y, - fill: Some(BG), - width: Some(INNER_WIDTH), - height: Some(inner_height), - ..Default::default() - }, - Item { - x: MIN_X + (OUTER_WIDTH as i32) + GAP, - y, - fill: Some(FG_MEM), - width: Some(OUTER_WIDTH), - height: Some(h), - ..Default::default() - }, - Item { - x: MIN_X + (OUTER_WIDTH as i32) + GAP + 1, - y: inner_y, - fill: Some(BG), - width: Some(INNER_WIDTH), - height: Some(inner_height), - ..Default::default() - }, - ]; - - items.extend(spark_line( - cpu, - MIN_X + 1, - inner_y, - inner_height as i32, - FG_CPU, - )); - items.extend(spark_line( - mem, - MIN_X + (OUTER_WIDTH as i32) + GAP + 1, - inner_y, - inner_height as i32, - FG_MEM, - )); - - send(&zmq_socket, Screen::Layout(items))?; - - Ok(()) -} - -fn spark_line<'a>( - data: impl ExactSizeIterator + 'a, - min_x: i32, - min_y: i32, - height: i32, - colour: [u8; 3], -) -> impl Iterator + 'a { - repeat(0.0) - .take((INNER_WIDTH as usize).saturating_sub(data.len())) - .chain(data) - .map(move |v| { - let v = v.clamp(0.0, 1.0); - let h = height as f32; - ((1.0 - v) * h).round() as i32 - }) - .enumerate() - .map(move |(x, y)| Item { - x: min_x + x as i32, - y: min_y + y, - width: Some(1), - height: Some((height.saturating_sub(y).max(1) * 2 - 1) as u32), - fill: Some(colour), - ..Default::default() - }) -} diff --git a/crates/bestool/src/actions/iti/temperature.rs b/crates/bestool/src/actions/iti/temperature.rs index 8d8f5eff..614e1ef0 100644 --- a/crates/bestool/src/actions/iti/temperature.rs +++ b/crates/bestool/src/actions/iti/temperature.rs @@ -1,13 +1,10 @@ use clap::Parser; -use miette::{IntoDiagnostic, Result}; +use miette::Result; use tokio::time::sleep; use crate::actions::{ - iti::{ItiArgs, lcd::{ - json::{Item, Screen}, - send, - }}, Context, + iti::{ItiArgs, samplers::temperature::sample}, }; /// Get core temperature from the Raspberry Pi. @@ -17,18 +14,6 @@ pub struct TemperatureArgs { #[arg(long)] pub json: bool, - /// Update screen with temperature. - /// - /// Argument is the Y position of the temperature display. The X position is always 240 (right edge). - #[cfg(feature = "iti-lcd")] - #[arg(long)] - pub update_screen: Option, - - /// ZMQ socket to use for screen updates. - #[cfg(feature = "iti-lcd")] - #[arg(default_value = "tcp://[::1]:2009")] - pub zmq_socket: String, - /// Keep updating at an interval. /// /// Syntax is a number followed by a unit, such as "5s" or "1m". @@ -39,68 +24,20 @@ pub struct TemperatureArgs { pub async fn run(ctx: Context) -> Result<()> { if let Some(n) = ctx.args_sub.watch { loop { - once(ctx.clone()).await?; + once(&ctx.args_sub)?; sleep(*n).await; } } else { - once(ctx).await + once(&ctx.args_sub) } } -pub async fn once(ctx: Context) -> Result<()> { - let temperature = duct::cmd!("vcgencmd", "measure_temp") - .read() - .into_diagnostic()? - .trim_start_matches("temp=") - .trim_end_matches("'C") - .parse::() - .into_diagnostic()?; - - if ctx.args_sub.json { - println!( - "{}", - serde_json::json!({ - "temperature": temperature, - }) - ); +fn once(args: &TemperatureArgs) -> Result<()> { + let temperature = sample()?; + if args.json { + println!("{}", serde_json::json!({ "temperature": temperature })); } else { println!("{:.1}°C", temperature); } - - #[cfg(feature = "iti-lcd")] - if let Some(y) = ctx.args_sub.update_screen { - const GREEN: [u8; 3] = [0, 255, 0]; - const RED: [u8; 3] = [255, 0, 0]; - const BLACK: [u8; 3] = [0, 0, 0]; - const YELLOW: [u8; 3] = [255, 255, 0]; - - send( - &ctx.args_sub.zmq_socket, - Screen::Layout(vec![ - Item { - x: 218, - y: y - 16, - width: Some(62), - height: Some(20), - fill: Some(BLACK), - ..Default::default() - }, - Item { - x: 220, - y, - stroke: Some(if temperature < 60.0 { - GREEN - } else if temperature > 80.0 { - RED - } else { - YELLOW - }), - text: Some(format!("{temperature:>5.1}C")), - ..Default::default() - }, - ]), - )?; - } - Ok(()) } diff --git a/services/iti-addresses b/services/iti-addresses deleted file mode 100755 index b4eedfa7..00000000 --- a/services/iti-addresses +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/bash - -update() { - jq -n \ - --argjson y 80 \ - --arg hostname "$(hostname)" \ - --argjson ips "$(ip -4 -j a show up scope global)" \ - ' - ["\($hostname).local", [$ips[].addr_info[] | select(.local != null) | select(.label | test("podman") | not) | .local][:3][]] - | [.[] | if (.|length <= 26) then . else "\(.[:20])\n \(.[20:])" end] - | join("\n") - | {layout:[ - {x:10,y:($y-15),fill:[0,0,0],width:260,height:((. | split("\n") | length)*20)}, - {x:10,y:$y,stroke:[220,0,220],text:.} - ]} - ' | bestool iti lcd send -} - -while true; do - update - sleep 60 -done diff --git a/services/iti-addresses.service b/services/iti-addresses.service deleted file mode 100644 index 43d2a1fb..00000000 --- a/services/iti-addresses.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Iti local addresses display -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStartPre=/bin/sleep 10 -ExecStart=/usr/local/bin/iti-addresses -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-battery.service b/services/iti-battery.service deleted file mode 100644 index 97d30597..00000000 --- a/services/iti-battery.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Iti UPS battery monitoring -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStart=/usr/local/bin/bestool --log-timeless iti battery --watch 10sec --estimate --json --update-screen 220 -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-display.service b/services/iti-display.service new file mode 100644 index 00000000..69852884 --- /dev/null +++ b/services/iti-display.service @@ -0,0 +1,13 @@ +[Unit] +Description=Iti LCD display +After=network.target + +[Service] +ExecStart=/usr/local/bin/bestool --log-timeless iti display +Restart=always +RestartSec=5s +KillSignal=SIGTERM +TimeoutStopSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/services/iti-lcd-server.service b/services/iti-lcd-server.service deleted file mode 100644 index 278f7a81..00000000 --- a/services/iti-lcd-server.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Iti LCD display ZMQ server -After=network.target - -[Service] -ExecStart=/usr/local/bin/bestool --log-timeless iti lcd serve -ExecStartPost=-/usr/local/bin/bestool --log-timeless iti lcd on -ExecStartPost=-/usr/local/bin/bestool --log-timeless iti lcd clear 0 0 0 -ExecStop=-/usr/local/bin/bestool --log-timeless iti lcd clear 0 0 0 -ExecStop=-/usr/local/bin/bestool --log-timeless iti lcd off -ExecStop=/usr/bin/kill -INT $MAINPID -ExecStop=/usr/bin/sleep 1 -Restart=always -RestartSec=5s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-lcd-wifi b/services/iti-lcd-wifi deleted file mode 100755 index 26743a87..00000000 --- a/services/iti-lcd-wifi +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/bash - -update() { - jq -n \ - --argjson y 195 \ - --arg wifiname "$(nmcli -t dev wifi list | grep '^\*:' | head -n1 | sed 's/\\://g' | cut -d: -f3)" \ - ' - "Wifi: \($wifiname // "not connected")"[:20] - | {layout:[ - {x:10,y:($y-15),fill:[0,0,0],width:200,height:20}, - {x:10,y:$y,stroke:[255,255,0],text:.} - ]} - ' | bestool iti lcd send -} - -while true; do - update - sleep 60 -done diff --git a/services/iti-lcd-wifi.service b/services/iti-lcd-wifi.service deleted file mode 100644 index 6332a688..00000000 --- a/services/iti-lcd-wifi.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Iti wifi network display -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStartPre=/bin/sleep 10 -ExecStart=/usr/local/bin/iti-lcd-wifi -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-localtime b/services/iti-localtime deleted file mode 100755 index bcfd87b6..00000000 --- a/services/iti-localtime +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/bash - -store="" -while true; do - date_current="$(date +%Y-%m-%d)" - time_current="$(date +%H:%M)" - if [[ "$store" != "$date_current $time_current" ]]; then - store="$date_current $time_current" - - bestool iti lcd send '{"layout":[ - {"x":100,"y":4,"fill":[0,0,0],"width":100,"height":20}, - {"x":100,"y":20,"stroke":[235,225,205],"text":"'"$date_current"'"} - ]}' - - bestool iti lcd send '{"layout":[ - {"x":210,"y":4,"fill":[0,0,0],"width":50,"height":20}, - {"x":210,"y":20,"stroke":[235,225,205],"text":"'"$time_current"'"} - ]}' - fi - - sleep 10 -done diff --git a/services/iti-localtime.service b/services/iti-localtime.service deleted file mode 100644 index 2828667c..00000000 --- a/services/iti-localtime.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Iti local time display -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStartPre=/bin/sleep 10 -ExecStart=/usr/local/bin/iti-localtime -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-sparks.service b/services/iti-sparks.service deleted file mode 100644 index 6575127d..00000000 --- a/services/iti-sparks.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Iti cpu/memory display -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStart=/usr/local/bin/bestool --log-timeless iti sparks -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target diff --git a/services/iti-temperature.service b/services/iti-temperature.service deleted file mode 100644 index 21b85e48..00000000 --- a/services/iti-temperature.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Iti core temperature monitoring -After=network.target -Requires=iti-lcd-server.service - -[Service] -ExecStart=/usr/local/bin/bestool --log-timeless iti temperature --watch 10sec --json --update-screen 195 -Restart=always -RestartSec=60s - -[Install] -WantedBy=multi-user.target