diff --git a/CHANGELOG.md b/CHANGELOG.md index 950e80e..0ab2300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **OLED auto-dim after inactivity.** The display is bright on any + activity — splash entry, mode change into a dashboard leaf, packet + RX/TX — and dims after a short idle window. Splash holds bright for + 10 s, then drops to dim while the LearnMore / Info carousel keeps + alternating. The dashboard stays bright for 3 s after the last + packet or mode change, then dims in place without changing + Listening/Transmitting state. Cross-leaf TX↔RX hops transition + bright→bright with no intermediate dim — `set_dim` is fired as an + action by the 3 s timer handler rather than as a sibling `exit:` + action, so mode-change leaf-crossings never see a flash. +- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the + two-level backlight API across the `ssd1306` crate's `Ssd1306Async` + and the in-tree `Sh1106` driver. Contrast values centralized as + `CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so + the two levels can be tuned in one place. + +### Changed + +- `src/display.rs` statechart restructured: + - `Splash` gains `entry: set_bright` plus an action-only + `on(after 10s) => set_dim` handler so the splash bright window + is time-scoped without disturbing the LM↔Info alternation. + - `Dashboard` `Listening` and `Transmitting` each gain a nested + `*Bright` substate owning the `entry: set_bright` action. Packet + handlers moved into the leaves and target the leaf's `*Bright` — + sibling-to-sibling within the leaf restarts the 3 s timer without + re-entering the leaf itself (paint / 1 s sparkline timer + preserved). The 3 s timeout handler fires `set_dim` as an action + and up-transitions to the leaf, parking the machine at the leaf + with no active child. +- `On.on(CmdReset)` now targets `LearnMore` directly rather than + `Splash`. Under hsmc 0.2 up-transition semantics, targeting + `Splash` from within `Splash.LearnMore` (USB-disconnect race path) + would only unwind below `Splash` and park with no active child, + leaving the LM↔Info alternation timer dead and the QR frozen on + screen. +- `LoRaBoard::DisplayDriver` trait bound extended with + `+ DisplayBrightness` so brightness is part of the board contract + alongside `DrawTarget`. + +### Fixed + +- Elecrow ThinkNode-M2 board doc corrected: the shipping revision + carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a + Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches + the CH340K / CH340 VID:PIDs directly instead of falling through + to espflash auto-detect. + +### Dependencies + +- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition + to an already-active ancestor unwinds only the subtree strictly + below the target; no exit/entry on the target; no default descent). + ## [1.1.0] - 2026-04-22 Small but high-value behavior change: `SET_CONFIG` issued while the diff --git a/Cargo.lock b/Cargo.lock index 0ffd3d8..74444d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,9 +1404,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hsmc" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d8cb8820e7ff5fa4fa4b5dbb78b9f59d26ccc8e3bcf69d8f4c4ce29ee01b93a" +checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e" dependencies = [ "embassy-futures", "embassy-sync 0.8.0", @@ -1417,9 +1417,9 @@ dependencies = [ [[package]] name = "hsmc-macros" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4fa4dad2c05309fae4f6d15b74d9b37c1fd7325a96701972fdad89637635160" +checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6af71fc..003282c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ embassy-usb-driver = "0.2" ucobs = { version = "0.3", features = ["legacy-msrv"] } heapless = "0.9" donglora-protocol = "1.1.0" -hsmc = { version = "0.1.0", default-features = false, features = ["embassy"] } +hsmc = { version = "0.2", default-features = false, features = ["embassy"] } lora-phy = "3.0" portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] } static_cell = "2.1" diff --git a/src/board/traits.rs b/src/board/traits.rs index bd3f0fc..955f70d 100644 --- a/src/board/traits.rs +++ b/src/board/traits.rs @@ -9,6 +9,8 @@ use embedded_graphics::draw_target::DrawTarget; use embedded_graphics::pixelcolor::BinaryColor; +use crate::driver::DisplayBrightness; + /// Async RGB LED control. Boards without an LED use `()` which is a no-op. pub trait RgbLed { fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future; @@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized { /// Display peripheral bundle (I2C bus for display init). type DisplayParts; - /// Concrete display driver type (must implement DrawTarget for rendering). - type DisplayDriver: DrawTarget; + /// Concrete display driver type (must implement DrawTarget for rendering + /// and DisplayBrightness for the two-level dim control). + type DisplayDriver: DrawTarget + DisplayBrightness; /// RGB LED driver. Boards without an LED use `()`. type LedDriver: RgbLed; diff --git a/src/display.rs b/src/display.rs index 52d372c..79d7e89 100644 --- a/src/display.rs +++ b/src/display.rs @@ -37,6 +37,7 @@ use embassy_time::Timer; use hsmc::statechart; use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed}; +use crate::driver::DisplayBrightness; use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel}; use crate::protocol::LoRaConfig; @@ -220,6 +221,14 @@ impl DisplayContext { Timer::after_millis(50).await; self.peripherals.led.set_rgb(0, 0, 0).await; } + + async fn oled_bright(&mut self) { + self.peripherals.display.set_bright().await; + } + + async fn oled_dim(&mut self) { + self.peripherals.display.set_dim().await; + } } // ── Statechart ───────────────────────────────────────────────────── @@ -243,16 +252,26 @@ statechart! { on(CmdOff) => Off; // `CmdReset` + `RadioIdle` race on USB disconnect; this handler // makes either ordering land in the same place. Reset the run - // state, turn off any blink-in-progress, and re-enter Splash — - // descend_defaults lands us in LearnMore with a fresh 5 s timer. - on(CmdReset) => reset_run_state, turn_led_off, Splash; + // state, turn off any blink-in-progress, and land in LearnMore + // with a fresh 5 s timer. We target `LearnMore` (not `Splash`) + // because under hsmc 0.2 up-transition semantics, targeting a + // state that's already on the active path would only unwind + // below it — we'd park at `Splash` with no active child and + // the alternation timer would never restart. + on(CmdReset) => reset_run_state, turn_led_off, LearnMore; // Config can arrive before the dashboard is up; cache it anywhere. on(ConfigChanged(cfg: LoRaConfig)) => save_new_config; + // Splash is bright for the first 10 s of (re-)entry, then the + // action-only `on(after 10s)` handler drops the OLED to dim. + // LearnMore ↔ Info keep alternating underneath — their own + // timers are independent of the 10 s window. state Splash { + entry: set_bright; default(LearnMore); on(EnteredRx) => Listening; on(EnteredTx) => Transmitting; + on(after Duration::from_secs(10)) => set_dim; state LearnMore { entry: paint_learn_more; @@ -265,24 +284,56 @@ statechart! { } } + // Dashboard brightness pattern: each mode-leaf holds a `Bright` + // substate. `entry: set_bright` owns the backlight going on; + // `set_dim` is fired **as an action** by the 3 s timeout + // handler, immediately before the up-transition that parks + // the machine at the leaf. The symmetric `exit: set_dim` + // would also fire on mode-change leaf-crossings (e.g. + // ListeningBright → TransmittingBright via EnteredTx), and + // the subsequent default descent would fire `set_bright` + // again — producing a visible dim/bright flash on every + // TX↔RX swap. Anchoring dim to the *timer* handler instead + // means leaf-crossings go bright→bright with no intermediate + // dim action. CmdOff/CmdReset exit paths don't fire set_dim + // either, which is fine — the display is blanked or walked + // back to Splash, which re-asserts its own brightness. state Dashboard { default(Listening); on(RadioIdle) => Splash; - // Packet events are pure data + one-shot LED feedback. - on(PacketRx { rssi: i16, snr: Option }) => record_rx_packet, blink_rx_led; - on(PacketTx) => record_tx_packet, blink_tx_led; - state Listening { entry: paint_listening; + default(ListeningBright); on(EnteredTx) => Transmitting; on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening; + // Split from the transition below because hsmc rejects + // payload bindings on transition-bearing handlers. + on(PacketRx { rssi: i16, snr: Option }) => record_rx_packet, blink_rx_led; + on(PacketRx) => ListeningBright; + on(PacketTx) => record_tx_packet, blink_tx_led; + on(PacketTx) => ListeningBright; + + state ListeningBright { + entry: set_bright; + on(after Duration::from_secs(3)) => set_dim, Listening; + } } state Transmitting { entry: paint_transmitting; + default(TransmittingBright); on(EnteredRx) => Listening; on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting; + on(PacketRx { rssi: i16, snr: Option }) => record_rx_packet, blink_rx_led; + on(PacketRx) => TransmittingBright; + on(PacketTx) => record_tx_packet, blink_tx_led; + on(PacketTx) => TransmittingBright; + + state TransmittingBright { + entry: set_bright; + on(after Duration::from_secs(3)) => set_dim, Transmitting; + } } } } @@ -355,6 +406,14 @@ impl DisplayActions for DisplayActionContext<'_> { async fn advance_sparkline(&mut self) { self.run.advance_slot(); } + + async fn set_bright(&mut self) { + self.oled_bright().await; + } + + async fn set_dim(&mut self) { + self.oled_dim().await; + } } // ── Task entry point ─────────────────────────────────────────────── diff --git a/src/driver/mod.rs b/src/driver/mod.rs index ea2cdd4..8802112 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -10,3 +10,56 @@ pub mod sh1106; feature = "waveshare_rp2040_lora" ))] pub mod simple_led; + +// ── Display brightness ──────────────────────────────────────────────── +// +// The display statechart dims the OLED a few seconds after the last +// activity. Our two driver families (`ssd1306` crate and our in-tree +// `sh1106`) expose incompatible brightness APIs, so we wrap them in a +// single async trait that the state-machine actions can call uniformly. + +/// Two-level backlight control used by the display dashboard. +pub trait DisplayBrightness { + fn set_bright(&mut self) -> impl core::future::Future; + fn set_dim(&mut self) -> impl core::future::Future; +} + +// Contrast values for the two brightness levels, picked to be common across +// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3% +// of max — visible in a dim room, barely noticeable in daylight. Tune here. +const CONTRAST_BRIGHT: u8 = 0xFF; +const CONTRAST_DIM: u8 = 0x08; + +impl DisplayBrightness for ssd1306::Ssd1306Async +where + DI: display_interface::AsyncWriteOnlyDataCommand, + SIZE: ssd1306::size::DisplaySizeAsync, +{ + async fn set_bright(&mut self) { + // `Brightness::custom(precharge, contrast)`. Matches the shape of + // the crate's BRIGHTEST preset (precharge 0x2) at max contrast. + let _ = self + .set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT)) + .await; + } + async fn set_dim(&mut self) { + // Low precharge pairs with low contrast — same combination the + // crate's DIMMEST preset uses. + let _ = self + .set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM)) + .await; + } +} + +#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))] +impl DisplayBrightness for sh1106::Sh1106 +where + I: embedded_hal_async::i2c::I2c, +{ + async fn set_bright(&mut self) { + let _ = self.set_brightness(CONTRAST_BRIGHT).await; + } + async fn set_dim(&mut self) { + let _ = self.set_brightness(CONTRAST_DIM).await; + } +}