Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
No files matched your search
@@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-04-22
|
||||
|
||||
### 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<Color = BinaryColor>`.
|
||||
|
||||
### 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
|
||||
radio is in continuous RX no longer drops the radio (and the OLED)
|
||||
back to idle. The radio now auto-re-arms RX with the new modulation
|
||||
once reconfiguration completes, matching DongLoRa Protocol 1.1's
|
||||
§3.5 / §6.3 amendments.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
|
||||
=> ResumingRx` to shadow the parent `Configured` state's
|
||||
`on(ConfigApplied) => Idle` handler. The reconfigure path now
|
||||
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
|
||||
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
|
||||
entering `Idle`.
|
||||
- The OLED's `Dashboard → Splash` transition was previously driven
|
||||
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
|
||||
event no longer fires during a SET_CONFIG-from-Receiving
|
||||
sequence, so the display stays on Dashboard.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
|
||||
the new normative text the firmware now implements).
|
||||
|
||||
### Use-case unlock
|
||||
|
||||
- Scanner / time-division / dynamic-modulation workflows that
|
||||
rotate LoRa parameters mid-RX no longer need to re-issue
|
||||
`RX_START` after every `SET_CONFIG`. One call, radio continues
|
||||
to receive.
|
||||
|
||||
## [1.0.0] - 2026-04-22
|
||||
|
||||
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
|
||||
|
||||
Generated
+7
-7
@@ -486,7 +486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
@@ -527,9 +527,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora-protocol"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8f2743ac7668845500e3686890917cdf5b0a09d17ec099bde2db4d3fae2a4a4"
|
||||
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"heapless 0.9.2",
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
@@ -30,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||
embassy-usb-driver = "0.2"
|
||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||
heapless = "0.9"
|
||||
donglora-protocol = "1.0.0"
|
||||
hsmc = { version = "0.1.0", default-features = false, features = ["embassy"] }
|
||||
donglora-protocol = "1"
|
||||
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"
|
||||
|
||||
@@ -77,7 +77,7 @@ flash board:
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||
if [ -n "$port" ]; then \
|
||||
echo "Using $port"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||
//!
|
||||
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||
//! OLED + CP2102 UART bridge + 1000 mAh Li-Po. Sold pre-loaded with Meshtastic;
|
||||
//! this firmware turns it into a DongLoRa radio bridge.
|
||||
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||
//!
|
||||
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||
//! and PR meshtastic/firmware#6354):
|
||||
@@ -11,7 +11,7 @@
|
||||
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||
//! UART: TX=43, RX=44 via CP2102 bridge
|
||||
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||
//! LED: GPIO6 (active HIGH indicator)
|
||||
//!
|
||||
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||
|
||||
+5
-2
@@ -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<Output = ()>;
|
||||
@@ -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<Color = BinaryColor>;
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||
/// and DisplayBrightness for the two-level dim control).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||
|
||||
/// RGB LED driver. Boards without an LED use `()`.
|
||||
type LedDriver: RgbLed;
|
||||
|
||||
+66
-7
@@ -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<i16> }) => 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<i16> }) => 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<i16> }) => 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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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<Output = ()>;
|
||||
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
}
|
||||
|
||||
// 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<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||
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<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||
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;
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -170,10 +170,11 @@ statechart! {
|
||||
state Configured {
|
||||
on(CmdSetConfig(m: Modulation)) => apply_set_config;
|
||||
on(ConfigFailedRadio) => Unconfigured;
|
||||
// Re-configure from any substate (Receiving, Transmitting, …)
|
||||
// drops back to Idle. Spec §3.5: "Any continuous RX is
|
||||
// stopped. RX ring is cleared." Only successful applies
|
||||
// emit ConfigApplied, so validation failures keep state.
|
||||
// Default post-reconfigure landing is Idle. The RX ring is
|
||||
// always cleared as part of apply_set_config. Receiving
|
||||
// overrides this handler to auto-resume RX with the new
|
||||
// parameters (spec §3.5) so callers do not need to re-issue
|
||||
// RX_START after SET_CONFIG.
|
||||
on(ConfigApplied) => Idle;
|
||||
default(Idle);
|
||||
|
||||
@@ -202,6 +203,13 @@ statechart! {
|
||||
on(CmdRxStop) => stop_rx_hw, respond_ok;
|
||||
on(CmdRxStop) => Idle;
|
||||
on(CmdTx) => TransmittingFromRx;
|
||||
// Spec §3.5: a SET_CONFIG issued while receiving clears
|
||||
// the RX ring (already handled by apply_set_config) but
|
||||
// auto-rearms RX with the new parameters — the caller
|
||||
// does not need to send a fresh RX_START. Override the
|
||||
// parent's `on(ConfigApplied) => Idle` by landing in
|
||||
// ResumingRx, whose entry kicks the chip back into RX.
|
||||
on(ConfigApplied) => ResumingRx;
|
||||
}
|
||||
|
||||
state ResumingRx {
|
||||
|
||||
Reference in new issue
Block a user