diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e24590a..9858ee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu + components: clippy, rustfmt - name: Cache cargo registry uses: Swatinem/rust-cache@v2 @@ -33,10 +34,75 @@ jobs: - name: Run tests run: cargo test -p longfred-proto --target x86_64-unknown-linux-gnu - build: - name: Build firmware + - name: rustfmt check + run: cargo fmt --all -- --check + + - name: clippy (proto, host) + run: | + # Workspace lints apply `clippy::all = deny` and `pedantic = warn`. + # `missing_docs` is warn-by-policy (CODING-GUIDELINES §12.2); pre-existing + # gaps are tracked separately and must not block this PR. + cargo clippy -p longfred-proto --target x86_64-unknown-linux-gnu -- \ + -A rustdoc::missing_docs -A missing_docs + + clippy: + name: Clippy firmware (${{ matrix.variant }}) needs: test runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: longfred-standard + features: variant-longfred-standard + - variant: longfred-mini + features: variant-longfred-mini + - variant: markwtech + features: variant-markwtech + - variant: heiko-wifred + features: variant-heiko-wifred + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: riscv32imac-unknown-none-elf + components: clippy + rust-src: true + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + with: + workspaces: . + key: clippy-${{ matrix.variant }} + + - name: Clippy + run: | + # Workspace lints apply `clippy::all = deny` and `pedantic = warn`. + # `missing_docs` is warn-by-policy (CODING-GUIDELINES §12.2); pre-existing + # gaps are tracked separately and must not block this PR. + cargo clippy -p longfred-firmware --no-default-features \ + --features "${{ matrix.features }}" -- \ + -A rustdoc::missing_docs -A missing_docs + + build: + name: Build firmware (${{ matrix.variant }}) + needs: clippy + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: longfred-standard + features: variant-longfred-standard + - variant: longfred-mini + features: variant-longfred-mini + - variant: markwtech + features: variant-markwtech + - variant: heiko-wifred + features: variant-heiko-wifred steps: - name: Checkout uses: actions/checkout@v4 @@ -51,6 +117,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: . + key: ${{ matrix.variant }} - name: Install espflash uses: taiki-e/install-action@v2 @@ -60,22 +127,64 @@ jobs: - name: Build firmware run: | set -euo pipefail - cargo build -p longfred-firmware --release --bin longfred + VARIANT="${{ matrix.variant }}" + FEATURES="${{ matrix.features }}" + cargo build -p longfred-firmware --release --bin longfred \ + --no-default-features --features "${FEATURES}" mkdir -p dist ELF="target/riscv32imac-unknown-none-elf/release/longfred" - cp "$ELF" dist/longfred-esp32c6.elf - espflash save-image --chip esp32c6 --merge "$ELF" dist/longfred-esp32c6.bin + cp "$ELF" "dist/longfred-${VARIANT}-esp32c6.elf" + espflash save-image --chip esp32c6 --merge "$ELF" \ + "dist/longfred-${VARIANT}-esp32c6.bin" ( cd dist - sha256sum longfred-esp32c6.elf longfred-esp32c6.bin > SHA256SUMS + sha256sum "longfred-${VARIANT}-esp32c6.elf" "longfred-${VARIANT}-esp32c6.bin" > SHA256SUMS ) - name: Upload firmware uses: actions/upload-artifact@v4 with: - name: firmware + name: firmware-${{ matrix.variant }} path: dist/ if-no-files-found: error retention-days: 90 + + size-check: + name: Check ESP32-C6 size + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install espflash + uses: taiki-e/install-action@v2 + with: + tool: espflash@4.5.0 + + - name: Download firmware artifacts + uses: actions/download-artifact@v4 + with: + pattern: firmware-* + path: artifact-download + + - name: Stage ELFs into dist/ + run: | + set -euo pipefail + mkdir -p dist + for variant in longfred-standard longfred-mini markwtech heiko-wifred; do + src="artifact-download/firmware-${variant}/longfred-${variant}-esp32c6.elf" + dst="dist/longfred-${variant}-esp32c6.elf" + if [[ ! -f "$src" ]]; then + echo "error: missing artifact ELF: $src" >&2 + ls -laR artifact-download >&2 || true + exit 1 + fi + cp -f "$src" "$dst" + echo "staged $dst" + done + + - name: Check flash/RAM budget + run: ./scripts/check-esp32c6-size.sh --check-only diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 431e4fb..e873f75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,14 +55,17 @@ jobs: echo "Timed out after 15 minutes waiting for CI workflow on commit ${SHA}" exit 1 - - name: Download CI firmware + - name: Download CI firmware artifacts env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh run download "${{ steps.ci.outputs.run_id }}" \ - --name firmware \ - --dir release-assets + mkdir -p release-assets + for variant in longfred-standard longfred-mini markwtech heiko-wifred; do + gh run download "${{ steps.ci.outputs.run_id }}" \ + --name "firmware-${variant}" \ + --dir "release-assets/${variant}" + done - name: Create release if missing env: @@ -84,7 +87,7 @@ jobs: run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" - mapfile -d '' FILES < <(find release-assets -type f -print0) + mapfile -d '' FILES < <(find release-assets -type f \( -name '*.elf' -o -name '*.bin' -o -name 'SHA256SUMS' \) -print0) if [ "${#FILES[@]}" -eq 0 ]; then echo "No release assets found" exit 1 diff --git a/Cargo.lock b/Cargo.lock index 274d9c5..c00e86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,8 @@ name = "longfred-proto" version = "0.1.0" dependencies = [ "heapless 0.9.3", + "serde", + "serde-json-core", ] [[package]] @@ -1724,6 +1726,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "ryu", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index bd868a8..beabdfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,24 @@ edition = "2024" rust-version = "1.95" license = "Apache-2.0" +[workspace.lints.rust] +# esp-hal peripheral ownership transfer requires `unsafe` blocks; the policy +# is `warn` so HAL integration compiles while new `unsafe` outside HAL modules +# stays visible in review. New `unsafe` must include a `SAFETY:` justification. +unsafe_code = "warn" +missing_docs = "warn" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +dbg_macro = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unwrap_used = "deny" + [profile.dev] opt-level = "s" diff --git a/Makefile b/Makefile index 6cb0b05..423709c 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,91 @@ # LongFred — common Cargo tasks (run from this directory). # -# make build # ESP32-C6 firmware (riscv32imac) -# make build-wokwi # same build + stage ELF for Wokwi simulator -# make test # host tests for longfred-proto +# make build # default variant (longfred-standard) +# make build VARIANT=longfred-mini # single hardware variant +# make build-all # all variants (debug) +# make build-all-release # all variants (release) +# make size # flash/RAM report for all variants +# make build-wokwi # same build + stage ELF for Wokwi +# make test # host tests for longfred-proto -.PHONY: all build build-wokwi test help +CARGO ?= cargo +PACKAGE := longfred-firmware +BIN := longfred + +# Hardware variants (Cargo feature = variant-). +VARIANTS := longfred-standard longfred-mini markwtech heiko-wifred +VARIANT ?= longfred-standard + +ifeq ($(filter $(VARIANT),$(VARIANTS)),) +$(error unknown VARIANT='$(VARIANT)'; choose one of: $(VARIANTS)) +endif + +FEATURES := --no-default-features --features variant-$(VARIANT) +# Isolate Cargo artifacts per variant so feature switches cannot reuse a stale ELF. +TARGET_DIR := target/$(VARIANT) + +.PHONY: all build build-release build-all build-all-release \ + build-longfred-standard build-longfred-mini build-markwtech build-heiko-wifred \ + build-wokwi size check-size check-size-only test help all: build test help: @echo "Targets:" - @echo " build - cargo build -p longfred-firmware" - @echo " build-wokwi - build + copy ELF to wokwi/longfred" - @echo " test - cargo test -p longfred-proto (host)" - @echo " all - build + test (default)" + @echo " build [VARIANT=...] - cargo build -p $(PACKAGE) (debug)" + @echo " build-release [VARIANT] - release build for one variant" + @echo " build-all - debug build for every variant" + @echo " build-all-release - release build for every variant" + @echo " build- - shorthand debug builds:" + @echo " $(VARIANTS)" + @echo " size / check-size - release-build all variants + ESP32-C6 flash/RAM report" + @echo " check-size-only - check existing dist/*.elf (no cargo; used by CI)" + @echo " build-wokwi - build + copy ELF to wokwi/longfred" + @echo " test - cargo test -p longfred-proto (host)" + @echo " all - build + test (default)" + @echo "" + @echo "VARIANT (default: $(VARIANT)): $(VARIANTS)" build: - cargo build -p longfred-firmware + $(CARGO) build -p $(PACKAGE) --target-dir $(TARGET_DIR) $(FEATURES) + +build-release: + $(CARGO) build -p $(PACKAGE) --release --bin $(BIN) --target-dir $(TARGET_DIR) $(FEATURES) + +build-all: + @for v in $(VARIANTS); do \ + echo "==> build VARIANT=$$v"; \ + $(MAKE) --no-print-directory build VARIANT=$$v || exit 1; \ + done + +build-all-release: + @for v in $(VARIANTS); do \ + echo "==> build-release VARIANT=$$v"; \ + $(MAKE) --no-print-directory build-release VARIANT=$$v || exit 1; \ + done + +build-longfred-standard: + @$(MAKE) --no-print-directory build VARIANT=longfred-standard + +build-longfred-mini: + @$(MAKE) --no-print-directory build VARIANT=longfred-mini + +build-markwtech: + @$(MAKE) --no-print-directory build VARIANT=markwtech + +build-heiko-wifred: + @$(MAKE) --no-print-directory build VARIANT=heiko-wifred build-wokwi: ./scripts/wokwi-prep.sh +# Release-build every variant and verify it fits ESP32-C6 flash partition + on-chip RAM. +size check-size: + ./scripts/check-esp32c6-size.sh + +# Verify prebuilt dist/longfred--esp32c6.elf files (CI after artifact download). +check-size-only: + ./scripts/check-esp32c6-size.sh --check-only + test: - cargo test -p longfred-proto --target x86_64-unknown-linux-gnu + $(CARGO) test -p longfred-proto --target x86_64-unknown-linux-gnu diff --git a/README.md b/README.md index a10e0b3..f6bc287 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ -# longfred -A BigFred wireless physical client +# LongFred + +Wireless physical throttle client for BigFred (WiThrottle / Z21). + +## Hardware variants + +Build-time Cargo features (mutually exclusive): + +| Feature | Description | +|---------|-------------| +| `variant-longfred-standard` (default) | OLED 128×64, MCP23017×2, 5-way + F-keys + encoder | +| `variant-longfred-mini` | Same as standard, OLED 128×32 | +| `variant-markwtech` | Keypad + 2.42" OLED, WiTcontroller-style | +| `variant-heiko-wifred` | Headless wiFred-style (LEDs + pot), Wi‑Fi config only | + +Docs: [docs/hardware/](docs/hardware/), provisioning: [docs/provisioning.md](docs/provisioning.md). + +```bash +cargo build -p longfred-firmware --release --bin longfred +cargo build -p longfred-firmware --release --bin longfred \ + --no-default-features --features variant-longfred-mini +``` + +## Host tests + +```bash +cargo test -p longfred-proto --target x86_64-unknown-linux-gnu +``` diff --git a/crates/firmware/Cargo.toml b/crates/firmware/Cargo.toml index 30d1eb1..a75b801 100644 --- a/crates/firmware/Cargo.toml +++ b/crates/firmware/Cargo.toml @@ -4,12 +4,20 @@ version = "0.1.0" edition.workspace = true rust-version.workspace = true +[lints] +workspace = true + [[bin]] name = "longfred" path = "src/bin/main.rs" [features] -default = [] +default = ["variant-longfred-standard"] +# Hardware variants (mutually exclusive — see board/variants). +variant-longfred-standard = [] +variant-longfred-mini = [] +variant-markwtech = [] +variant-heiko-wifred = [] # Wokwi/host-friendly: skip WiFi/radio; spawn OLED + nav + encoder + MCP + domain. sim = [] # Diagnostic: sim + spawn no tasks at all. diff --git a/crates/firmware/build.rs b/crates/firmware/build.rs index 2569664..313f7a6 100644 --- a/crates/firmware/build.rs +++ b/crates/firmware/build.rs @@ -1,8 +1,14 @@ +//! Build script for LongFred firmware (linker args and friendly link errors). + fn main() { linker_be_nice(); println!("cargo:rustc-link-arg=-Tlinkall.x"); } +// Build script is a host tool, not runtime code; the workspace `unwrap_used` +// deny policy targets firmware/runtime paths. Allow here for the one-shot +// linker configuration. +#[allow(clippy::unwrap_used)] fn linker_be_nice() { let args: Vec = std::env::args().collect(); if args.len() > 1 { diff --git a/crates/firmware/src/bin/main.rs b/crates/firmware/src/bin/main.rs index 5f0b855..9d310e1 100644 --- a/crates/firmware/src/bin/main.rs +++ b/crates/firmware/src/bin/main.rs @@ -1,5 +1,6 @@ #![no_std] #![no_main] +//! LongFred firmware entry point: HAL init, task spawn, and Soft-AP programming mode. use embassy_executor::Spawner; use embassy_time::{Duration, Timer}; @@ -18,11 +19,11 @@ use embassy_net::{Config as NetConfig, DhcpConfig, StackResources}; #[cfg(not(feature = "sim"))] use esp_radio::wifi::{Interface, WifiController}; -use longfred_firmware::{config, domain, input, storage, ui}; -#[cfg(not(feature = "sim"))] -use longfred_firmware::power; #[cfg(not(feature = "sim"))] use longfred_firmware::net; +#[cfg(not(feature = "sim"))] +use longfred_firmware::power; +use longfred_firmware::{board, config, domain, input, storage, ui}; esp_bootloader_esp_idf::esp_app_desc!(); @@ -50,49 +51,83 @@ async fn main(spawner: Spawner) -> ! { let boot_entropy = rng.random(); let flash = FLASH.init(FlashStorage::new(peripherals.FLASH)); - let wifi_hostname = storage::ensure_boot_hostname(flash, boot_entropy); - info!("wifi hostname: {}", wifi_hostname.as_str()); + let boot = storage::ensure_boot(flash, boot_entropy); + info!("wifi hostname: {}", boot.wifi_hostname.as_str()); + + let enter_programming = boot.programming_mode + || (board::active_variant().auto_pair_when_unconfigured && !boot.has_wifi_credentials); #[cfg(not(feature = "sim"))] { let seed = ((rng.random() as u64) << 32) | rng.random() as u64; - net::WIFI_HOSTNAME.sender().send(wifi_hostname.clone()); - - let controller = WifiController::new(peripherals.WIFI, Default::default()) - .expect("WifiController::new"); - let sta = Interface::station(); - - static RESOURCES: StaticCell> = - StaticCell::new(); - let resources = RESOURCES.init(StackResources::new()); - let mut dhcp = DhcpConfig::default(); - let mut host = heapless::String::<32>::new(); - let _ = host.push_str(wifi_hostname.as_str()); - dhcp.hostname = Some(host); - let (stack, runner) = embassy_net::new(sta, NetConfig::dhcpv4(dhcp), resources, seed); - - if let Ok(token) = net::wifi::connection(controller) { - spawner.spawn(token); - } - if let Ok(token) = net::wifi::net_task(runner) { - spawner.spawn(token); - } - if let Ok(token) = net::wifi::status_task(stack) { - spawner.spawn(token); - } - if let Ok(token) = net::wifi::config_task(stack) { - spawner.spawn(token); - } - if let Ok(token) = net::mdns::task(stack, config::network::NETWORKS[0].ssid) { - spawner.spawn(token); - } - if let Ok(token) = net::session::task(stack) { - spawner.spawn(token); + net::WIFI_HOSTNAME.sender().send(boot.wifi_hostname.clone()); + + if enter_programming { + info!( + "boot: programming mode (flag={} auto_pair={} creds={})", + boot.programming_mode, + board::active_variant().auto_pair_when_unconfigured, + boot.has_wifi_credentials + ); + let mut prog_rec = boot.record.clone(); + prog_rec.programming_mode = true; + if !boot.programming_mode { + storage::write_record(flash, &prog_rec); + } + + let _ = net::provisioning::spawn_programming_net( + &spawner, + peripherals.WIFI, + seed, + prog_rec, + ); + } else { + let controller = match WifiController::new(peripherals.WIFI, Default::default()) { + Ok(c) => c, + Err(e) => { + log::error!("boot: WifiController::new failed: {:?} — hanging", e); + loop { + Timer::after(Duration::from_secs(60)).await; + } + } + }; + let sta = Interface::station(); + + static RESOURCES: StaticCell> = + StaticCell::new(); + let resources = RESOURCES.init(StackResources::new()); + let mut dhcp = DhcpConfig::default(); + let mut host = heapless::String::<32>::new(); + let _ = host.push_str(boot.wifi_hostname.as_str()); + dhcp.hostname = Some(host); + let (stack, runner) = embassy_net::new(sta, NetConfig::dhcpv4(dhcp), resources, seed); + + if let Ok(token) = net::wifi::connection(controller) { + spawner.spawn(token); + } + if let Ok(token) = net::wifi::net_task(runner) { + spawner.spawn(token); + } + if let Ok(token) = net::wifi::status_task(stack) { + spawner.spawn(token); + } + if let Ok(token) = net::wifi::config_task(stack) { + spawner.spawn(token); + } + if let Ok(token) = net::mdns::task(stack, config::network::NETWORKS[0].ssid) { + spawner.spawn(token); + } + if let Ok(token) = net::session::task(stack) { + spawner.spawn(token); + } } } #[cfg(feature = "sim")] - info!("sim: WiFi/net bring-up skipped"); + { + let _ = enter_programming; + info!("sim: WiFi/net bring-up skipped"); + } info!( "LongFred boot: {} | throttles={} | networks={}", @@ -126,42 +161,97 @@ async fn main(spawner: Spawner) -> ! { #[cfg(not(feature = "sim_bare"))] { - let sender = input::INPUT_CHANNEL.sender(); + let raw_sender = board::RAW_CHANNEL.sender(); + info!("board variant: {}", board::active().id); info!("main: i2c init"); let (oled_i2c, expander_i2c) = input::i2c_bus::init(peripherals.I2C0); - let enc = input::encoder::build(); - let nav = input::gpio_nav::build( - peripherals.GPIO18, - peripherals.GPIO19, - peripherals.GPIO20, - peripherals.GPIO21, - peripherals.GPIO22, - peripherals.GPIO23, - peripherals.GPIO10, - ); - - // OLED before expander: shared I2C — init display before MCP probe NACKs. + + // OLED for variants with a display; heiko uses LED presenter instead. + #[cfg(not(feature = "variant-heiko-wifred"))] if let Ok(token) = ui::display::task(oled_i2c) { spawner.spawn(token); } - if let Ok(token) = input::gpio_nav::task(nav, sender) { - spawner.spawn(token); + #[cfg(feature = "variant-heiko-wifred")] + { + let _ = oled_i2c; + let (led_stop, led_fwd, led_rev) = ui::led_presenter::build(); + if let Ok(token) = ui::led_presenter::task(led_stop, led_fwd, led_rev) { + spawner.spawn(token); + } } - if let Ok(token) = input::expander::task(expander_i2c, sender) { - spawner.spawn(token); + + // LongFred family: GPIO nav cluster. + #[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" + ))] + { + let nav = input::gpio_nav::build( + peripherals.GPIO18, + peripherals.GPIO19, + peripherals.GPIO20, + peripherals.GPIO21, + peripherals.GPIO22, + peripherals.GPIO23, + peripherals.GPIO10, + ); + if let Ok(token) = input::gpio_nav::task(nav, raw_sender) { + spawner.spawn(token); + } } - if let Ok(token) = input::encoder::task(enc.a, enc.b, sender) { - spawner.spawn(token); + + // MarkWTech: 3×4 keypad matrix (pins from markwtech constants). + #[cfg(feature = "variant-markwtech")] + { + let keypad = input::keypad::build(); + if let Ok(token) = input::keypad::task(keypad, raw_sender) { + spawner.spawn(token); + } } - if let Ok(token) = input::encoder::button_task(enc.button, sender) { + + // Expanders: LongFred family + heiko-wifred. + #[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini", + feature = "variant-heiko-wifred" + ))] + if let Ok(token) = input::expander::task(expander_i2c, raw_sender) { spawner.spawn(token); } - if let Ok(token) = domain::task::task() { + #[cfg(feature = "variant-markwtech")] + { + let _ = expander_i2c; + } + + // Encoder: LongFred family + markwtech (heiko uses pot). + #[cfg(not(feature = "variant-heiko-wifred"))] + { + let enc = input::encoder::build(); + if let Ok(token) = input::encoder::task(enc.a, enc.b, raw_sender) { + spawner.spawn(token); + } + if let Ok(token) = input::encoder::button_task(enc.button, raw_sender) { + spawner.spawn(token); + } + } + + if let Ok(token) = board::bridge::task() { spawner.spawn(token); } - let _ = sender; + // Normal throttle domain only when not in Soft-AP programming path. + #[cfg(feature = "sim")] + let spawn_domain = true; + #[cfg(not(feature = "sim"))] + let spawn_domain = !enter_programming; + if spawn_domain { + if let Ok(token) = domain::task::task() { + spawner.spawn(token); + } + } + + let _ = raw_sender; } loop { diff --git a/crates/firmware/src/board/bridge.rs b/crates/firmware/src/board/bridge.rs new file mode 100644 index 0000000..47c239e --- /dev/null +++ b/crates/firmware/src/board/bridge.rs @@ -0,0 +1,60 @@ +//! Raw → ControlSurface → INPUT_CHANNEL bridge task. + +use embassy_futures::select::{Either, select}; +use embassy_time::{Duration, Instant, Timer}; + +use crate::board::ControlSurface; +use crate::board::raw::RAW_CHANNEL; +use crate::input::INPUT_CHANNEL; + +#[cfg(feature = "variant-heiko-wifred")] +use crate::board::variants::heiko_wifred::HeikoWifredSurface; +#[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" +))] +use crate::board::variants::longfred_family::LongFredSurface; +#[cfg(feature = "variant-markwtech")] +use crate::board::variants::markwtech::MarkwtechSurface; + +const TICK_MS: u64 = 50; + +#[embassy_executor::task] +pub async fn task() { + #[cfg(feature = "variant-longfred-mini")] + let mut surface = LongFredSurface::mini(); + #[cfg(feature = "variant-longfred-standard")] + let mut surface = LongFredSurface::standard(); + #[cfg(feature = "variant-markwtech")] + let mut surface = MarkwtechSurface::new(); + #[cfg(feature = "variant-heiko-wifred")] + let mut surface = HeikoWifredSurface::new(); + + let raw_rx = RAW_CHANNEL.receiver(); + let input_tx = INPUT_CHANNEL.sender(); + + let desc = surface.descriptor(); + log::info!("board bridge: variant={}", desc.id); + + loop { + match select( + raw_rx.receive(), + Timer::after(Duration::from_millis(TICK_MS)), + ) + .await + { + Either::First(ev) => { + let now = Instant::now(); + surface.on_raw(ev, now, &mut |ie| { + let _ = input_tx.try_send(ie); + }); + } + Either::Second(()) => { + let now = Instant::now(); + surface.tick(now, &mut |ie| { + let _ = input_tx.try_send(ie); + }); + } + } + } +} diff --git a/crates/firmware/src/board/chord.rs b/crates/firmware/src/board/chord.rs new file mode 100644 index 0000000..3038ff6 --- /dev/null +++ b/crates/firmware/src/board/chord.rs @@ -0,0 +1,5 @@ +//! Two-button hold chord detector (Shift1+Stop → programming mode). + +pub use longfred_proto::input_map::ChordState as ChordDetector; + +pub const PROGRAMMING_CHORD_MS: u64 = 8_000; diff --git a/crates/firmware/src/board/descriptor.rs b/crates/firmware/src/board/descriptor.rs new file mode 100644 index 0000000..1120a08 --- /dev/null +++ b/crates/firmware/src/board/descriptor.rs @@ -0,0 +1,39 @@ +//! Hardware variant descriptors. + +#[derive(Clone, Copy, Debug)] +pub struct DisplayGeometry { + pub width: u16, + pub height: u16, + pub grid_rows: usize, + pub grid_cols: usize, + pub grid_lines: usize, +} + +#[derive(Clone, Copy, Debug)] +pub struct VariantDescriptor { + pub id: &'static str, + pub name: &'static str, + pub mcu: &'static str, + pub display: Option, + pub has_expanders: bool, + pub has_encoder: bool, + pub has_keypad: bool, + pub has_pot: bool, + pub auto_pair_when_unconfigured: bool, +} + +pub const LAYOUT_128X64: DisplayGeometry = DisplayGeometry { + width: 128, + height: 64, + grid_rows: 8, + grid_cols: 21, + grid_lines: 12, +}; + +pub const LAYOUT_128X32: DisplayGeometry = DisplayGeometry { + width: 128, + height: 32, + grid_rows: 4, + grid_cols: 21, + grid_lines: 6, +}; diff --git a/crates/firmware/src/board/mod.rs b/crates/firmware/src/board/mod.rs new file mode 100644 index 0000000..a492f02 --- /dev/null +++ b/crates/firmware/src/board/mod.rs @@ -0,0 +1,38 @@ +//! Hardware abstraction: raw events, variant descriptors, ControlSurface. + +pub mod bridge; +pub mod chord; +pub mod descriptor; +pub mod raw; +pub mod shift_layers; +pub mod variants; + +pub use descriptor::{DisplayGeometry, LAYOUT_128X32, LAYOUT_128X64, VariantDescriptor}; +pub use raw::{AnalogId, ButtonId, RAW_CHANNEL, RawEvent, SwitchId}; +pub use variants::{active, active_variant}; + +use embassy_time::Instant; + +use crate::input::InputEvent; +use crate::ui::view::UiView; + +use self::descriptor::VariantDescriptor as VD; +use self::raw::RawEvent as RE; + +/// Maps raw hardware events to domain input events. +pub trait ControlSurface { + fn descriptor(&self) -> &'static VD; + fn on_raw(&mut self, ev: RE, now: Instant, out: &mut dyn FnMut(InputEvent)); + fn tick(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)); +} + +/// Higher-level UI / menu shell over mapped input. +pub trait UiShell { + fn on_input(&mut self, ev: InputEvent, now: Instant); + fn tick(&mut self, now: Instant); +} + +/// Renders a [`UiView`] to the physical display. +pub trait Presenter { + fn present(&mut self, view: &UiView); +} diff --git a/crates/firmware/src/board/raw.rs b/crates/firmware/src/board/raw.rs new file mode 100644 index 0000000..7adfce6 --- /dev/null +++ b/crates/firmware/src/board/raw.rs @@ -0,0 +1,63 @@ +//! Raw hardware events (pre–ControlSurface mapping). + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::{Channel, Receiver, Sender}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ButtonId { + Stop, + Shift1, + Shift2, + JoyUp, + JoyDown, + JoyLeft, + JoyRight, + JoyMenu, + Direction, + F0, + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + /// Keypad digit 0–9 (markwtech / heiko). + KeypadDigit(u8), + Menu, + Hash, + Star, + Extra(u8), + EncoderButton, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AnalogId { + SpeedPot, + Battery, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SwitchId { + Direction, + Loco(u8), +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RawEvent { + /// `pressed = true` on press, `false` on release. + Button(ButtonId, bool), + Encoder(i8), + Analog(AnalogId, u16), + Switch(SwitchId, u8), +} + +pub const RAW_CHANNEL_DEPTH: usize = 32; + +pub type RawChannel = Channel; +pub type RawSender = Sender<'static, CriticalSectionRawMutex, RawEvent, RAW_CHANNEL_DEPTH>; +pub type RawReceiver = Receiver<'static, CriticalSectionRawMutex, RawEvent, RAW_CHANNEL_DEPTH>; + +/// Drivers -> board ControlSurface bridge. +pub static RAW_CHANNEL: RawChannel = Channel::new(); diff --git a/crates/firmware/src/board/shift_layers.rs b/crates/firmware/src/board/shift_layers.rs new file mode 100644 index 0000000..bf776c5 --- /dev/null +++ b/crates/firmware/src/board/shift_layers.rs @@ -0,0 +1,3 @@ +//! F-key shift layer mapping (re-export of host-tested proto logic). + +pub use longfred_proto::input_map::map_fn_key as map_fn; diff --git a/crates/firmware/src/board/variants/heiko_wifred.rs b/crates/firmware/src/board/variants/heiko_wifred.rs new file mode 100644 index 0000000..66751a2 --- /dev/null +++ b/crates/firmware/src/board/variants/heiko_wifred.rs @@ -0,0 +1,130 @@ +//! Heiko wiFred ControlSurface (headless: pot + expanders + LEDs). +//! +//! Hardware: 2× MCP23017 (F0–F8, yellow Shift1, red ESTOP), loco slot switches, +//! 3-position direction switch, speed pot on ADC. No display / encoder. +//! Programming chord: Shift1 + Stop (ESTOP) held for 8 s. +//! Only Shift1 layer: F0–F8 → F9–F16. + +use embassy_time::Instant; +use longfred_proto::model::Direction; + +use crate::board::ControlSurface; +use crate::board::chord::{ChordDetector, PROGRAMMING_CHORD_MS}; +use crate::board::descriptor::VariantDescriptor; +use crate::board::raw::{AnalogId, ButtonId, RawEvent, SwitchId}; +use crate::board::shift_layers::map_fn; +use crate::input::InputEvent; + +pub const DESCRIPTOR: VariantDescriptor = VariantDescriptor { + id: "heiko-wifred", + name: "Heiko WiFred", + mcu: "esp32c6", + display: None, + has_expanders: true, + has_encoder: false, + has_keypad: false, + has_pot: true, + auto_pair_when_unconfigured: true, +}; + +/// Maps wiFred raw events to domain `InputEvent`s. +pub struct HeikoWifredSurface { + shift1: bool, + stop: bool, + chord: ChordDetector, +} + +impl HeikoWifredSurface { + pub const fn new() -> Self { + Self { + shift1: false, + stop: false, + chord: ChordDetector::new(), + } + } + + fn emit_fn(&self, key: u8, pressed: bool, out: &mut dyn FnMut(InputEvent)) { + // Only shift1 → +9 (F9–F16); no shift2 on this hardware. + let mapped = map_fn(key, self.shift1, false); + if pressed { + out(InputEvent::FnPress(mapped)); + } else { + out(InputEvent::FnRelease(mapped)); + } + } + + fn on_button(&mut self, id: ButtonId, pressed: bool, out: &mut dyn FnMut(InputEvent)) { + match id { + ButtonId::Shift1 => self.shift1 = pressed, + ButtonId::Stop => { + self.stop = pressed; + if pressed { + out(InputEvent::EStop); + } + } + ButtonId::F0 => self.emit_fn(0, pressed, out), + ButtonId::F1 => self.emit_fn(1, pressed, out), + ButtonId::F2 => self.emit_fn(2, pressed, out), + ButtonId::F3 => self.emit_fn(3, pressed, out), + ButtonId::F4 => self.emit_fn(4, pressed, out), + ButtonId::F5 => self.emit_fn(5, pressed, out), + ButtonId::F6 => self.emit_fn(6, pressed, out), + ButtonId::F7 => self.emit_fn(7, pressed, out), + ButtonId::F8 => self.emit_fn(8, pressed, out), + _ => {} + } + } + + fn maybe_chord(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)) { + let now_ms = now.as_millis(); + if self + .chord + .update(self.shift1, self.stop, now_ms, PROGRAMMING_CHORD_MS) + { + out(InputEvent::EnterProgrammingMode); + } + } +} + +impl Default for HeikoWifredSurface { + fn default() -> Self { + Self::new() + } +} + +impl ControlSurface for HeikoWifredSurface { + fn descriptor(&self) -> &'static VariantDescriptor { + &DESCRIPTOR + } + + fn on_raw(&mut self, ev: RawEvent, now: Instant, out: &mut dyn FnMut(InputEvent)) { + match ev { + RawEvent::Button(id, pressed) => self.on_button(id, pressed, out), + RawEvent::Encoder(_) => {} + RawEvent::Analog(AnalogId::SpeedPot, value) => { + let speed = ((u32::from(value) * 126) / 4095).min(126) as u8; + out(InputEvent::SpeedAbsolute(speed)); + } + RawEvent::Analog(AnalogId::Battery, _) => {} + RawEvent::Switch(SwitchId::Direction, v) => { + let dir = if v != 0 { + Direction::Forward + } else { + Direction::Reverse + }; + out(InputEvent::DirectionSet(dir)); + } + RawEvent::Switch(SwitchId::Loco(slot), v) => { + // Throttle slots are 1-indexed; ignore stray slot 0 events. + if slot >= 1 { + out(InputEvent::LocoSlot(slot, v != 0)); + } + } + } + self.maybe_chord(now, out); + } + + fn tick(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)) { + self.maybe_chord(now, out); + } +} diff --git a/crates/firmware/src/board/variants/longfred_family.rs b/crates/firmware/src/board/variants/longfred_family.rs new file mode 100644 index 0000000..262c2ee --- /dev/null +++ b/crates/firmware/src/board/variants/longfred_family.rs @@ -0,0 +1,193 @@ +//! LongFred family ControlSurface (standard + mini). + +use embassy_time::Instant; +use longfred_proto::model::Direction; + +use crate::board::ControlSurface; +use crate::board::chord::{ChordDetector, PROGRAMMING_CHORD_MS}; +use crate::board::descriptor::{LAYOUT_128X32, LAYOUT_128X64, VariantDescriptor}; +use crate::board::raw::{AnalogId, ButtonId, RawEvent, SwitchId}; +use crate::board::shift_layers::map_fn; +use crate::input::{InputEvent, NavDir}; + +pub const STANDARD: VariantDescriptor = VariantDescriptor { + id: "longfred-standard", + name: "LongFred Standard", + mcu: "esp32c6", + display: Some(LAYOUT_128X64), + has_expanders: true, + has_encoder: true, + has_keypad: false, + has_pot: false, + auto_pair_when_unconfigured: false, +}; + +pub const MINI: VariantDescriptor = VariantDescriptor { + id: "longfred-mini", + name: "LongFred Mini", + mcu: "esp32c6", + display: Some(LAYOUT_128X32), + has_expanders: true, + has_encoder: true, + has_keypad: false, + has_pot: false, + auto_pair_when_unconfigured: false, +}; + +/// Maps raw LongFred hardware events to domain `InputEvent`s. +pub struct LongFredSurface { + descriptor: &'static VariantDescriptor, + shift1: bool, + shift2: bool, + stop: bool, + chord: ChordDetector, +} + +impl LongFredSurface { + pub const fn standard() -> Self { + Self { + descriptor: &STANDARD, + shift1: false, + shift2: false, + stop: false, + chord: ChordDetector::new(), + } + } + + pub const fn mini() -> Self { + Self { + descriptor: &MINI, + shift1: false, + shift2: false, + stop: false, + chord: ChordDetector::new(), + } + } + + fn emit_fn(&self, key: u8, pressed: bool, out: &mut dyn FnMut(InputEvent)) { + let mapped = map_fn(key, self.shift1, self.shift2); + if pressed { + out(InputEvent::FnPress(mapped)); + } else { + out(InputEvent::FnRelease(mapped)); + } + } + + fn on_button(&mut self, id: ButtonId, pressed: bool, out: &mut dyn FnMut(InputEvent)) { + match id { + ButtonId::Shift1 => { + let rising = pressed && !self.shift1; + self.shift1 = pressed; + if rising { + out(InputEvent::CaseToggle); + } + } + ButtonId::Shift2 => self.shift2 = pressed, + ButtonId::Stop => { + self.stop = pressed; + if pressed { + out(InputEvent::Stop); + } + } + ButtonId::JoyUp if pressed => out(InputEvent::Nav(NavDir::Up)), + ButtonId::JoyDown if pressed => out(InputEvent::Nav(NavDir::Down)), + ButtonId::JoyLeft if pressed => out(InputEvent::Nav(NavDir::Left)), + ButtonId::JoyRight if pressed => out(InputEvent::Nav(NavDir::Right)), + // Center of 5-way = MenuEnter (Select when already in menu). + ButtonId::JoyMenu if pressed => out(InputEvent::Menu), + ButtonId::Menu if pressed => out(InputEvent::Menu), + ButtonId::Direction if pressed => out(InputEvent::DirectionToggle), + ButtonId::F0 => self.emit_fn(0, pressed, out), + ButtonId::F1 => self.emit_fn(1, pressed, out), + ButtonId::F2 => self.emit_fn(2, pressed, out), + ButtonId::F3 => self.emit_fn(3, pressed, out), + ButtonId::F4 => self.emit_fn(4, pressed, out), + ButtonId::F5 => self.emit_fn(5, pressed, out), + ButtonId::F6 => self.emit_fn(6, pressed, out), + ButtonId::F7 => self.emit_fn(7, pressed, out), + ButtonId::F8 => self.emit_fn(8, pressed, out), + ButtonId::Extra(n) => { + // Transitional F9/F10 (and other extras) bypass shift layers. + if pressed { + out(InputEvent::FnPress(n)); + } else { + out(InputEvent::FnRelease(n)); + } + } + ButtonId::EncoderButton if pressed => out(InputEvent::EncoderButton), + ButtonId::KeypadDigit(d) if pressed && d <= 9 => { + out(InputEvent::Digit((b'0' + d) as char)); + } + ButtonId::Hash if pressed => out(InputEvent::Digit('#')), + ButtonId::Star if pressed => out(InputEvent::Digit('*')), + ButtonId::JoyUp + | ButtonId::JoyDown + | ButtonId::JoyLeft + | ButtonId::JoyRight + | ButtonId::JoyMenu + | ButtonId::Menu + | ButtonId::Direction + | ButtonId::EncoderButton + | ButtonId::KeypadDigit(_) + | ButtonId::Hash + | ButtonId::Star => {} + } + } +} + +impl ControlSurface for LongFredSurface { + fn descriptor(&self) -> &'static VariantDescriptor { + self.descriptor + } + + fn on_raw(&mut self, ev: RawEvent, now: Instant, out: &mut dyn FnMut(InputEvent)) { + match ev { + RawEvent::Button(id, pressed) => self.on_button(id, pressed, out), + RawEvent::Encoder(delta) => { + if delta > 0 { + out(InputEvent::EncoderClockwise); + } else if delta < 0 { + out(InputEvent::EncoderCounterClockwise); + } + } + RawEvent::Analog(AnalogId::SpeedPot, value) => { + // 12-bit-ish → 0..=126 speed step. + let speed = ((u32::from(value) * 126) / 4095).min(126) as u8; + out(InputEvent::SpeedAbsolute(speed)); + } + RawEvent::Analog(AnalogId::Battery, _) => {} + RawEvent::Switch(SwitchId::Direction, v) => { + let dir = if v != 0 { + Direction::Forward + } else { + Direction::Reverse + }; + out(InputEvent::DirectionSet(dir)); + } + RawEvent::Switch(SwitchId::Loco(slot), v) => { + // Throttle slots are 1-indexed; ignore stray slot 0 events. + if slot >= 1 { + out(InputEvent::LocoSlot(slot, v != 0)); + } + } + } + + let now_ms = now.as_millis(); + if self + .chord + .update(self.shift1, self.stop, now_ms, PROGRAMMING_CHORD_MS) + { + out(InputEvent::EnterProgrammingMode); + } + } + + fn tick(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)) { + let now_ms = now.as_millis(); + if self + .chord + .update(self.shift1, self.stop, now_ms, PROGRAMMING_CHORD_MS) + { + out(InputEvent::EnterProgrammingMode); + } + } +} diff --git a/crates/firmware/src/board/variants/markwtech.rs b/crates/firmware/src/board/variants/markwtech.rs new file mode 100644 index 0000000..3340aaf --- /dev/null +++ b/crates/firmware/src/board/variants/markwtech.rs @@ -0,0 +1,192 @@ +//! MarkWTech / WiTcontroller-style ControlSurface. +//! +//! Hardware: 3×4 keypad matrix, KY-040 encoder, OLED 128×64 (SSD1309), no MCP expanders. +//! Programming chord: Star (`*`, Menu) + Stop held for 8 s. + +use embassy_time::Instant; + +use crate::board::ControlSurface; +use crate::board::chord::{ChordDetector, PROGRAMMING_CHORD_MS}; +use crate::board::descriptor::{LAYOUT_128X64, VariantDescriptor}; +use crate::board::raw::{AnalogId, ButtonId, RawEvent, SwitchId}; +use crate::config::board::Gpio; +use crate::input::InputEvent; + +/// Keypad matrix row GPIOs (driven, active-low scan). +pub const KEYPAD_ROW_PINS: [Gpio; 4] = [18, 19, 20, 21]; +/// Keypad matrix column GPIOs (inputs with pull-up). +pub const KEYPAD_COL_PINS: [Gpio; 3] = [22, 23, 10]; + +/// Layout (row, col) → digit / star / hash: +/// ```text +/// C0 C1 C2 +/// R0 1 2 3 +/// R1 4 5 6 +/// R2 7 8 9 +/// R3 * 0 # +/// ``` +pub const KEYPAD_MAP: [[ButtonId; 3]; 4] = [ + [ + ButtonId::KeypadDigit(1), + ButtonId::KeypadDigit(2), + ButtonId::KeypadDigit(3), + ], + [ + ButtonId::KeypadDigit(4), + ButtonId::KeypadDigit(5), + ButtonId::KeypadDigit(6), + ], + [ + ButtonId::KeypadDigit(7), + ButtonId::KeypadDigit(8), + ButtonId::KeypadDigit(9), + ], + [ButtonId::Star, ButtonId::KeypadDigit(0), ButtonId::Hash], +]; + +pub const DESCRIPTOR: VariantDescriptor = VariantDescriptor { + id: "markwtech", + name: "MarkWTech", + mcu: "esp32c6", + display: Some(LAYOUT_128X64), + has_expanders: false, + has_encoder: true, + has_keypad: true, + has_pot: false, + auto_pair_when_unconfigured: false, +}; + +/// Maps MarkWTech raw events to domain `InputEvent`s. +pub struct MarkwtechSurface { + star: bool, + stop: bool, + chord: ChordDetector, +} + +impl MarkwtechSurface { + pub const fn new() -> Self { + Self { + star: false, + stop: false, + chord: ChordDetector::new(), + } + } + + fn on_button(&mut self, id: ButtonId, pressed: bool, out: &mut dyn FnMut(InputEvent)) { + match id { + ButtonId::Stop => { + self.stop = pressed; + if pressed { + out(InputEvent::Stop); + } + } + ButtonId::Star => { + self.star = pressed; + if pressed { + // Surface emits Digit('*'); NavProfile / shell maps throttle * → MenuEnter. + out(InputEvent::Digit('*')); + } + } + ButtonId::Hash if pressed => out(InputEvent::Digit('#')), + ButtonId::KeypadDigit(d) if pressed && d <= 9 => { + out(InputEvent::Digit((b'0' + d) as char)); + } + ButtonId::Extra(n) => { + if pressed { + out(InputEvent::FnPress(n)); + } else { + out(InputEvent::FnRelease(n)); + } + } + ButtonId::EncoderButton if pressed => out(InputEvent::EncoderButton), + ButtonId::Menu if pressed => out(InputEvent::Menu), + ButtonId::F0 + | ButtonId::F1 + | ButtonId::F2 + | ButtonId::F3 + | ButtonId::F4 + | ButtonId::F5 + | ButtonId::F6 + | ButtonId::F7 + | ButtonId::F8 => { + let key = match id { + ButtonId::F0 => 0, + ButtonId::F1 => 1, + ButtonId::F2 => 2, + ButtonId::F3 => 3, + ButtonId::F4 => 4, + ButtonId::F5 => 5, + ButtonId::F6 => 6, + ButtonId::F7 => 7, + ButtonId::F8 => 8, + _ => return, + }; + if pressed { + out(InputEvent::FnPress(key)); + } else { + out(InputEvent::FnRelease(key)); + } + } + _ => {} + } + } + + fn maybe_chord(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)) { + let now_ms = now.as_millis(); + if self + .chord + .update(self.star, self.stop, now_ms, PROGRAMMING_CHORD_MS) + { + out(InputEvent::EnterProgrammingMode); + } + } +} + +impl Default for MarkwtechSurface { + fn default() -> Self { + Self::new() + } +} + +impl ControlSurface for MarkwtechSurface { + fn descriptor(&self) -> &'static VariantDescriptor { + &DESCRIPTOR + } + + fn on_raw(&mut self, ev: RawEvent, now: Instant, out: &mut dyn FnMut(InputEvent)) { + match ev { + RawEvent::Button(id, pressed) => self.on_button(id, pressed, out), + RawEvent::Encoder(delta) => { + if delta > 0 { + out(InputEvent::EncoderClockwise); + } else if delta < 0 { + out(InputEvent::EncoderCounterClockwise); + } + } + RawEvent::Analog(AnalogId::SpeedPot, value) => { + let speed = ((u32::from(value) * 126) / 4095).min(126) as u8; + out(InputEvent::SpeedAbsolute(speed)); + } + RawEvent::Analog(AnalogId::Battery, _) => {} + RawEvent::Switch(SwitchId::Direction, v) => { + let dir = if v != 0 { + longfred_proto::model::Direction::Forward + } else { + longfred_proto::model::Direction::Reverse + }; + out(InputEvent::DirectionSet(dir)); + } + RawEvent::Switch(SwitchId::Loco(slot), v) => { + // Throttle slots are 1-indexed; ignore stray slot 0 events. + if slot >= 1 { + out(InputEvent::LocoSlot(slot, v != 0)); + } + } + } + self.maybe_chord(now, out); + } + + fn tick(&mut self, now: Instant, out: &mut dyn FnMut(InputEvent)) { + self.maybe_chord(now, out); + } +} diff --git a/crates/firmware/src/board/variants/mod.rs b/crates/firmware/src/board/variants/mod.rs new file mode 100644 index 0000000..19f8e35 --- /dev/null +++ b/crates/firmware/src/board/variants/mod.rs @@ -0,0 +1,84 @@ +//! Hardware variant selection (compile-time features). + +#[cfg(all( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" +))] +compile_error!("enable only one hardware variant feature"); +#[cfg(all(feature = "variant-longfred-standard", feature = "variant-markwtech"))] +compile_error!("enable only one hardware variant feature"); +#[cfg(all( + feature = "variant-longfred-standard", + feature = "variant-heiko-wifred" +))] +compile_error!("enable only one hardware variant feature"); +#[cfg(all(feature = "variant-longfred-mini", feature = "variant-markwtech"))] +compile_error!("enable only one hardware variant feature"); +#[cfg(all(feature = "variant-longfred-mini", feature = "variant-heiko-wifred"))] +compile_error!("enable only one hardware variant feature"); +#[cfg(all(feature = "variant-markwtech", feature = "variant-heiko-wifred"))] +compile_error!("enable only one hardware variant feature"); + +#[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" +))] +pub mod longfred_family; + +#[cfg(feature = "variant-markwtech")] +pub mod markwtech; + +#[cfg(feature = "variant-heiko-wifred")] +pub mod heiko_wifred; + +use crate::board::descriptor::VariantDescriptor; + +/// Active build variant descriptor. +pub fn active() -> &'static VariantDescriptor { + #[cfg(feature = "variant-longfred-standard")] + { + return &longfred_family::STANDARD; + } + #[cfg(feature = "variant-longfred-mini")] + { + return &longfred_family::MINI; + } + #[cfg(feature = "variant-markwtech")] + { + return &markwtech::DESCRIPTOR; + } + #[cfg(feature = "variant-heiko-wifred")] + { + return &heiko_wifred::DESCRIPTOR; + } + #[cfg(not(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini", + feature = "variant-markwtech", + feature = "variant-heiko-wifred" + )))] + { + compile_error!("select a hardware variant feature"); + } +} + +/// Alias for [`active`]. +pub fn active_variant() -> &'static VariantDescriptor { + active() +} + +/// Control surface for the active LongFred-family variant. +#[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" +))] +pub fn surface() -> longfred_family::LongFredSurface { + #[cfg(feature = "variant-longfred-mini")] + { + longfred_family::LongFredSurface::mini() + } + #[cfg(feature = "variant-longfred-standard")] + { + longfred_family::LongFredSurface::standard() + } +} diff --git a/crates/firmware/src/config/board.rs b/crates/firmware/src/config/board.rs index d1144c9..0b4bd6d 100644 --- a/crates/firmware/src/config/board.rs +++ b/crates/firmware/src/config/board.rs @@ -42,6 +42,11 @@ pub const WAKE_PIN: Gpio = 0; // --- Optional MCP23017 INTA (unused; Menu took GPIO10) --- pub const MCP_INT: Gpio = 11; +// --- Heiko wiFred status LEDs (active-high; steal in LedPresenter) --- +pub const HEIKO_LED_STOP: Gpio = 18; +pub const HEIKO_LED_FORWARD: Gpio = 19; +pub const HEIKO_LED_REVERSE: Gpio = 20; + // Legacy aliases for display module. pub const OLED_SDA: Gpio = I2C_SDA; pub const OLED_SCL: Gpio = I2C_SCL; diff --git a/crates/firmware/src/config/keyboard.rs b/crates/firmware/src/config/keyboard.rs index 32c760c..1febd66 100644 --- a/crates/firmware/src/config/keyboard.rs +++ b/crates/firmware/src/config/keyboard.rs @@ -2,17 +2,17 @@ /// Multitap character groups per function key (F0..F10). pub const MULTITAP: [&str; 11] = [ - " 0", // F0 - "1", // F1 - "abc2", // F2 - "def3", // F3 - "ghi4", // F4 - "jkl5", // F5 - "mno6", // F6 - "pqrs7", // F7 - "tuv8", // F8 - "wxyz9", // F9 - " @.", // F10: space, @, period + " 0", // F0 + "1", // F1 + "abc2", // F2 + "def3", // F3 + "ghi4", // F4 + "jkl5", // F5 + "mno6", // F6 + "pqrs7", // F7 + "tuv8", // F8 + "wxyz9", // F9 + " @.", // F10: space, @, period ]; /// Full charset cycled by joystick Up/Down in text mode. diff --git a/crates/firmware/src/domain/model.rs b/crates/firmware/src/domain/model.rs index 31c9366..eb0dcb9 100644 --- a/crates/firmware/src/domain/model.rs +++ b/crates/firmware/src/domain/model.rs @@ -1,6 +1,8 @@ //! Domain value types (throttle, roster, UI snapshot). -use longfred_proto::model::{Direction, LocoAddr, ShortText, TrackPower, MAX_FUNCTIONS, MAX_THROTTLES}; +use longfred_proto::model::{ + Direction, LocoAddr, MAX_FUNCTIONS, MAX_THROTTLES, ShortText, TrackPower, +}; pub const MAX_LOCOS: usize = 10; pub const MAX_SPEED: u8 = 126; diff --git a/crates/firmware/src/domain/state.rs b/crates/firmware/src/domain/state.rs index b5dd4df..0dc845e 100644 --- a/crates/firmware/src/domain/state.rs +++ b/crates/firmware/src/domain/state.rs @@ -4,14 +4,14 @@ use embassy_time::{Duration, Instant}; use log::warn; use longfred_proto::command::{ClientCommand, LocoId}; use longfred_proto::events::ServerEvent; -use longfred_proto::model::{Direction, LocoAddr, LongText, ShortText, TurnoutAction, TrackPower}; -use longfred_proto::persist::{PersistRecord, SavedLoco, MAX_SAVED_LOCOS}; +use longfred_proto::model::{Direction, LocoAddr, LongText, ShortText, TrackPower, TurnoutAction}; +use longfred_proto::persist::{MAX_SAVED_LOCOS, PersistRecord, SavedLoco}; use crate::config::{self, buttons, network, sizes}; use crate::domain::actions::Action; use crate::domain::model::{ - self, throttle_char, throttle_index, FunctionFollow, NamedEntry, RosterEntry, ThrottleSlot, - MAX_SPEED, SHORT_DCC_ADDRESS_LIMIT, + self, FunctionFollow, MAX_SPEED, NamedEntry, RosterEntry, SHORT_DCC_ADDRESS_LIMIT, + ThrottleSlot, throttle_char, throttle_index, }; use crate::ui::i18n; @@ -120,8 +120,12 @@ impl DomainState { let dir = opposite_slot_direction(self.current_slot().direction); self.change_direction(self.current, dir, out) } - Action::DirectionForward => self.change_direction(self.current, Direction::Forward, out), - Action::DirectionReverse => self.change_direction(self.current, Direction::Reverse, out), + Action::DirectionForward => { + self.change_direction(self.current, Direction::Forward, out) + } + Action::DirectionReverse => { + self.change_direction(self.current, Direction::Reverse, out) + } Action::MaxThrottleIncrease => { if self.max_throttles < config::sizes::MAX_THROTTLES { self.max_throttles += 1; @@ -162,7 +166,11 @@ impl DomainState { } } - pub fn acquire_addr(&mut self, digits: &str, out: &mut heapless::Vec) -> bool { + pub fn acquire_addr( + &mut self, + digits: &str, + out: &mut heapless::Vec, + ) -> bool { if digits.is_empty() { return false; } @@ -195,7 +203,11 @@ impl DomainState { true } - pub fn acquire_roster(&mut self, index: usize, out: &mut heapless::Vec) -> bool { + pub fn acquire_roster( + &mut self, + index: usize, + out: &mut heapless::Vec, + ) -> bool { let Some(entry) = self.roster.get(index) else { return false; }; @@ -267,7 +279,11 @@ impl DomainState { true } - pub fn route_by_addr(&mut self, addr_digits: &str, out: &mut heapless::Vec) -> bool { + pub fn route_by_addr( + &mut self, + addr_digits: &str, + out: &mut heapless::Vec, + ) -> bool { let prefix = network_prefix_route(); let mut sys = heapless::String::<32>::new(); let _ = sys.push_str(prefix); @@ -276,7 +292,11 @@ impl DomainState { true } - pub fn route_by_index(&mut self, index: usize, out: &mut heapless::Vec) -> bool { + pub fn route_by_index( + &mut self, + index: usize, + out: &mut heapless::Vec, + ) -> bool { let Some(entry) = self.routes.get(index) else { return false; }; @@ -291,10 +311,7 @@ impl DomainState { pub fn toggle_heartbeat(&mut self, out: &mut heapless::Vec) -> bool { self.heartbeat_on = !self.heartbeat_on; - push_cmd( - out, - ClientCommand::SetHeartbeat(self.heartbeat_on), - ); + push_cmd(out, ClientCommand::SetHeartbeat(self.heartbeat_on)); true } @@ -377,9 +394,11 @@ impl DomainState { } ServerEvent::Speed { throttle, speed } => self.on_speed_echo(throttle, speed), ServerEvent::DirectionLead { throttle, dir } => self.on_direction_lead(throttle, dir), - ServerEvent::DirectionLoco { throttle, addr, dir } => { - self.on_direction_loco(throttle, addr, dir) - } + ServerEvent::DirectionLoco { + throttle, + addr, + dir, + } => self.on_direction_loco(throttle, addr, dir), ServerEvent::FunctionState { throttle, func, on } => { self.on_function_state(throttle, func, on) } @@ -676,7 +695,11 @@ impl DomainState { return false; } let step = self.effective_speed_step(fast); - let new_speed = self.current_slot().speed.saturating_add(step).min(MAX_SPEED); + let new_speed = self + .current_slot() + .speed + .saturating_add(step) + .min(MAX_SPEED); self.speed_set(new_speed, out) } @@ -696,7 +719,8 @@ impl DomainState { } else { 1 }; - base.saturating_mul(mult).saturating_mul(self.speed_multiplier) + base.saturating_mul(mult) + .saturating_mul(self.speed_multiplier) } fn speed_set(&mut self, speed: u8, out: &mut heapless::Vec) -> bool { @@ -759,7 +783,10 @@ impl DomainState { } } - fn stop_then_toggle_direction(&mut self, out: &mut heapless::Vec) -> bool { + fn stop_then_toggle_direction( + &mut self, + out: &mut heapless::Vec, + ) -> bool { if self.current_slot().speed != 0 { return self.speed_set(0, out); } @@ -832,12 +859,7 @@ impl DomainState { self.pending_speed = None; for i in 0..self.max_throttles { if self.throttles[i].has_loco() { - push_cmd( - out, - ClientCommand::EStop { - throttle: i as u8, - }, - ); + push_cmd(out, ClientCommand::EStop { throttle: i as u8 }); self.throttles[i].speed = 0; changed = true; } @@ -860,13 +882,13 @@ impl DomainState { true } - fn set_track_power(&mut self, on: bool, out: &mut heapless::Vec) -> bool { + fn set_track_power( + &mut self, + on: bool, + out: &mut heapless::Vec, + ) -> bool { push_cmd(out, ClientCommand::TrackPower(on)); - self.track_power = if on { - TrackPower::On - } else { - TrackPower::Off - }; + self.track_power = if on { TrackPower::On } else { TrackPower::Off }; true } @@ -903,7 +925,12 @@ fn opposite_slot_direction(dir: Direction) -> Direction { } fn function_loco_selector(slot: &ThrottleSlot, func: u8) -> &'static str { - match slot.follow.get(func as usize).copied().unwrap_or(FunctionFollow::Lead) { + match slot + .follow + .get(func as usize) + .copied() + .unwrap_or(FunctionFollow::Lead) + { FunctionFollow::All => "*", FunctionFollow::Lead => "", } @@ -921,11 +948,7 @@ fn build_loco_addr(digits: &str) -> Option { Some(s) } -fn write_roster_addr( - address: i32, - length: char, - out: &mut heapless::String<8>, -) -> Result<(), ()> { +fn write_roster_addr(address: i32, length: char, out: &mut heapless::String<8>) -> Result<(), ()> { let mut buf = heapless::String::<8>::new(); let abs = address.unsigned_abs(); if abs >= 10000 { diff --git a/crates/firmware/src/domain/task.rs b/crates/firmware/src/domain/task.rs index 317ef8b..57db9b5 100644 --- a/crates/firmware/src/domain/task.rs +++ b/crates/firmware/src/domain/task.rs @@ -1,28 +1,31 @@ //! Domain task: menu FSM + state + network + UI_VIEW publication. -use embassy_futures::select::{select3, Either3}; +use embassy_futures::select::{Either3, select3}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_time::{Duration, Instant, Timer}; use heapless::String; +use log::info; +#[cfg(feature = "sim")] +use log::warn; use longfred_proto::command::ClientCommand; use longfred_proto::model::Direction; use longfred_proto::persist::PersistRecord; use crate::config::{self, power, sizes}; use crate::domain::actions::Action; -use crate::domain::state::{DomainState, CMD_BUF}; +use crate::domain::state::{CMD_BUF, DomainState}; use crate::input; use crate::net::{ - self, ConnState, NetStatus, ServerEndpoint, WifiCmd, DEVICE, WIFI_HOSTNAME, FOUND_SERVERS, - MDNS_CTRL, NET_CONFIG_CTRL, PROTO_COMMANDS, PROTO_EVENTS, SERVER, STATE, - WIFI_CTRL, WIFI_SCAN, CONN, + self, CONN, ConnState, DEVICE, FOUND_SERVERS, MDNS_CTRL, NET_CONFIG_CTRL, NetStatus, + PROTO_COMMANDS, PROTO_EVENTS, SERVER, STATE, ServerEndpoint, WIFI_CTRL, WIFI_HOSTNAME, + WIFI_SCAN, WifiCmd, }; use crate::power::battery::BATTERY; -use crate::power::sleep::{SleepReason, SLEEP_CTRL}; -use crate::storage::{StorageCmd, PERSIST_LOADED, STORAGE_CTRL}; +use crate::power::sleep::{SLEEP_CTRL, SleepReason}; +use crate::storage::{PERSIST_LOADED, STORAGE_ACK, STORAGE_CTRL, StorageCmd}; use crate::ui::menu::{Intent, ListRef, MenuFsm, Screen}; use crate::ui::view::ViewCtx; -use crate::ui::{i18n, UI_VIEW}; +use crate::ui::{UI_VIEW, i18n}; async fn flush_cmds( cmd_tx: &embassy_sync::channel::Sender< @@ -57,7 +60,12 @@ fn publish_view( pw_preview: &str, ip_formatted: &str, battery: Option, - ui_tx: &embassy_sync::watch::Sender<'static, CriticalSectionRawMutex, crate::ui::view::UiView, 2>, + ui_tx: &embassy_sync::watch::Sender< + 'static, + CriticalSectionRawMutex, + crate::ui::view::UiView, + 2, + >, ) { let (ssid, _) = fsm.ssid_for_connect(scanned, state); let ctx = ViewCtx { @@ -101,8 +109,18 @@ fn interpret( out: &mut heapless::Vec, scanned: &heapless::Vec, servers: &heapless::Vec, - wifi_tx: &embassy_sync::channel::Sender<'static, CriticalSectionRawMutex, WifiCmd, { net::WIFI_CTRL_DEPTH }>, - srv_tx: &embassy_sync::watch::Sender<'static, CriticalSectionRawMutex, Option, 2>, + wifi_tx: &embassy_sync::channel::Sender< + 'static, + CriticalSectionRawMutex, + WifiCmd, + { net::WIFI_CTRL_DEPTH }, + >, + srv_tx: &embassy_sync::watch::Sender< + 'static, + CriticalSectionRawMutex, + Option, + 2, + >, storage_tx: &embassy_sync::channel::Sender<'static, CriticalSectionRawMutex, StorageCmd, 4>, ) { match intent { @@ -234,6 +252,10 @@ fn interpret( state.persist.language = lang; state.show_message(i18n::tr().saved_language); } + Intent::EnterProgrammingMode => { + // Handled eagerly in the input loop (persist + software_reset). + log::info!("domain: EnterProgrammingMode intent (already applied)"); + } } } @@ -256,7 +278,8 @@ pub async fn task() { let mut net_status = NetStatus::Disconnected; let mut conn = ConnState::Disconnected; let mut server: Option = None; - let mut scanned: heapless::Vec = heapless::Vec::new(); + let mut scanned: heapless::Vec = + heapless::Vec::new(); let mut servers: heapless::Vec = heapless::Vec::new(); let mut battery: Option = None; @@ -317,6 +340,19 @@ pub async fn task() { { Either3::First(ev) => { last_activity = Instant::now(); + if matches!(ev, input::InputEvent::EnterProgrammingMode) { + info!("domain: enter programming mode — saving flag and resetting"); + let _ = storage_tx.try_send(StorageCmd::SetProgrammingMode(true)); + STORAGE_ACK.wait().await; + Timer::after(Duration::from_millis(50)).await; + #[cfg(not(feature = "sim"))] + esp_hal::system::software_reset(); + #[cfg(feature = "sim")] + { + warn!("sim: software_reset skipped"); + continue; + } + } if let input::InputEvent::DirectionSet(dir) = ev { spdt_direction = dir; } @@ -358,7 +394,8 @@ pub async fn task() { let _ = MDNS_CTRL.try_send(()); } if let Some((ssid, pw)) = fsm.take_pending_password_save() { - let _ = storage_tx.try_send(StorageCmd::SavePassword { ssid, password: pw }); + let _ = + storage_tx.try_send(StorageCmd::SavePassword { ssid, password: pw }); } } } diff --git a/crates/firmware/src/input/encoder.rs b/crates/firmware/src/input/encoder.rs index 0d958a6..f96b668 100644 --- a/crates/firmware/src/input/encoder.rs +++ b/crates/firmware/src/input/encoder.rs @@ -1,10 +1,11 @@ //! Rotary encoder (quadrature on pin A edge) + encoder button. +//! Emits [`RawEvent`] for the board ControlSurface bridge. use embassy_time::{Duration, Timer}; use esp_hal::gpio::{AnyPin, Input, InputConfig, Pull}; +use crate::board::raw::{ButtonId, RawEvent, RawSender}; use crate::config::board; -use super::{InputEvent, InputSender}; const BTN_DEBOUNCE_MS: u64 = 50; @@ -14,42 +15,46 @@ pub struct Pins { pub button: Input<'static>, } +/// Steal encoder GPIO pins (call once from `main`). +/// /// # Safety /// -/// Call once from `main`. +/// Caller must guarantee these pins are not used elsewhere. Invoked exactly +/// once from `main` before encoder tasks run, so `steal` is sound by construction. +#[allow(unsafe_code)] pub fn build() -> Pins { let cfg = InputConfig::default().with_pull(Pull::Up); Pins { + // SAFETY: `ENCODER_A` is reserved for this driver; single owner from `main`. a: Input::new(unsafe { AnyPin::steal(board::ENCODER_A) }, cfg), + // SAFETY: `ENCODER_B` is reserved for this driver; single owner from `main`. b: Input::new(unsafe { AnyPin::steal(board::ENCODER_B) }, cfg), + // SAFETY: `ENCODER_BUTTON` is reserved for this driver; single owner from `main`. button: Input::new(unsafe { AnyPin::steal(board::ENCODER_BUTTON) }, cfg), } } #[embassy_executor::task] -pub async fn task(mut a: Input<'static>, b: Input<'static>, sender: InputSender) { +pub async fn task(mut a: Input<'static>, b: Input<'static>, sender: RawSender) { loop { a.wait_for_falling_edge().await; // Direction from B at the A edge (KY-040/EC11 detent). let cw = b.is_high(); - let ev = if cw { - InputEvent::EncoderClockwise - } else { - InputEvent::EncoderCounterClockwise - }; - let _ = sender.try_send(ev); + let delta: i8 = if cw { 1 } else { -1 }; + let _ = sender.try_send(RawEvent::Encoder(delta)); Timer::after(Duration::from_millis(2)).await; } } #[embassy_executor::task] -pub async fn button_task(mut button: Input<'static>, sender: InputSender) { +pub async fn button_task(mut button: Input<'static>, sender: RawSender) { loop { button.wait_for_falling_edge().await; Timer::after(Duration::from_millis(BTN_DEBOUNCE_MS)).await; if button.is_low() { - let _ = sender.try_send(InputEvent::EncoderButton); + let _ = sender.try_send(RawEvent::Button(ButtonId::EncoderButton, true)); button.wait_for_high().await; + let _ = sender.try_send(RawEvent::Button(ButtonId::EncoderButton, false)); } } } diff --git a/crates/firmware/src/input/expander.rs b/crates/firmware/src/input/expander.rs index 1f010b0..e637eaf 100644 --- a/crates/firmware/src/input/expander.rs +++ b/crates/firmware/src/input/expander.rs @@ -1,12 +1,12 @@ //! Joystick, tact switches, SPDT direction — MCP23017 x2 (I2C polling). +//! Emits [`RawEvent`] for the board ControlSurface bridge. use embassy_time::{Duration, Timer}; use embedded_hal::i2c::I2c; -use longfred_proto::model::Direction; -use crate::config::board::{LogicalButton, BUTTON_MAP, MCP_ADDRESSES}; use super::i2c_bus::SharedI2cDevice; -use super::{InputEvent, InputSender, NavDir}; +use crate::board::raw::{ButtonId, RawEvent, RawSender, SwitchId}; +use crate::config::board::{BUTTON_MAP, LogicalButton, MCP_ADDRESSES}; const POLL_MS: u64 = 10; const DEBOUNCE_TICKS: u8 = 2; @@ -83,67 +83,41 @@ fn update_debounce( (rising, falling) } -fn emit_button(btn: LogicalButton, rising: u8, falling: u8, port_a: bool, bit: u8, sender: &InputSender) { - let mask = 1u8 << bit; - if port_a { - if rising & mask != 0 { - send_press(btn, sender); - } - if falling & mask != 0 { - send_release(btn, sender); - } - } else { - if rising & mask != 0 { - send_press(btn, sender); - } - if falling & mask != 0 { - send_release(btn, sender); - } - } -} - -fn send_press(btn: LogicalButton, sender: &InputSender) { - let ev = match btn { - LogicalButton::JoyUp => InputEvent::Nav(NavDir::Up), - LogicalButton::JoyDown => InputEvent::Nav(NavDir::Down), - LogicalButton::JoyLeft => InputEvent::Nav(NavDir::Left), - LogicalButton::JoyRight => InputEvent::Nav(NavDir::Right), - LogicalButton::JoyOk => InputEvent::Ok, - LogicalButton::Back => InputEvent::Back, - LogicalButton::Menu => InputEvent::Menu, - LogicalButton::EStop => InputEvent::EStop, - LogicalButton::Direction => return, - LogicalButton::F0 => InputEvent::FnPress(0), - LogicalButton::F1 => InputEvent::FnPress(1), - LogicalButton::F2 => InputEvent::FnPress(2), - LogicalButton::F3 => InputEvent::FnPress(3), - LogicalButton::F4 => InputEvent::FnPress(4), - LogicalButton::F5 => InputEvent::FnPress(5), - LogicalButton::F6 => InputEvent::FnPress(6), - LogicalButton::F7 => InputEvent::FnPress(7), - LogicalButton::F8 => InputEvent::FnPress(8), - LogicalButton::F9 => InputEvent::FnPress(9), - LogicalButton::F10 => InputEvent::FnPress(10), - }; - let _ = sender.try_send(ev); +fn logical_to_button(btn: LogicalButton) -> Option { + Some(match btn { + LogicalButton::JoyUp => ButtonId::JoyUp, + LogicalButton::JoyDown => ButtonId::JoyDown, + LogicalButton::JoyLeft => ButtonId::JoyLeft, + LogicalButton::JoyRight => ButtonId::JoyRight, + LogicalButton::JoyOk => ButtonId::JoyMenu, + LogicalButton::Back | LogicalButton::EStop => ButtonId::Stop, + LogicalButton::Menu => ButtonId::Menu, + LogicalButton::Direction => return None, + LogicalButton::F0 => ButtonId::F0, + LogicalButton::F1 => ButtonId::F1, + LogicalButton::F2 => ButtonId::F2, + LogicalButton::F3 => ButtonId::F3, + LogicalButton::F4 => ButtonId::F4, + LogicalButton::F5 => ButtonId::F5, + LogicalButton::F6 => ButtonId::F6, + LogicalButton::F7 => ButtonId::F7, + LogicalButton::F8 => ButtonId::F8, + LogicalButton::F9 => ButtonId::Extra(9), + LogicalButton::F10 => ButtonId::Extra(10), + }) } -fn send_release(btn: LogicalButton, sender: &InputSender) { - let ev = match btn { - LogicalButton::F0 => InputEvent::FnRelease(0), - LogicalButton::F1 => InputEvent::FnRelease(1), - LogicalButton::F2 => InputEvent::FnRelease(2), - LogicalButton::F3 => InputEvent::FnRelease(3), - LogicalButton::F4 => InputEvent::FnRelease(4), - LogicalButton::F5 => InputEvent::FnRelease(5), - LogicalButton::F6 => InputEvent::FnRelease(6), - LogicalButton::F7 => InputEvent::FnRelease(7), - LogicalButton::F8 => InputEvent::FnRelease(8), - LogicalButton::F9 => InputEvent::FnRelease(9), - LogicalButton::F10 => InputEvent::FnRelease(10), - _ => return, +fn emit_button(btn: LogicalButton, rising: u8, falling: u8, bit: u8, sender: &RawSender) { + let Some(id) = logical_to_button(btn) else { + return; }; - let _ = sender.try_send(ev); + let mask = 1u8 << bit; + if rising & mask != 0 { + let _ = sender.try_send(RawEvent::Button(id, true)); + } + if falling & mask != 0 { + let _ = sender.try_send(RawEvent::Button(id, false)); + } } fn process_chip( @@ -152,7 +126,7 @@ fn process_chip( falling_a: u8, rising_b: u8, falling_b: u8, - sender: &InputSender, + sender: &RawSender, ) { for &(addr, port_a, bit, btn) in BUTTON_MAP.iter() { if addr != mcp.addr { @@ -160,25 +134,24 @@ fn process_chip( } let Some(btn) = btn else { continue }; if port_a { - emit_button(btn, rising_a, falling_a, true, bit, sender); + emit_button(btn, rising_a, falling_a, bit, sender); } else { - emit_button(btn, rising_b, falling_b, false, bit, sender); + emit_button(btn, rising_b, falling_b, bit, sender); } } } -fn read_direction(stable_a: u8) -> Direction { +fn direction_value(stable_a: u8) -> u8 { // GPA3 on MCP #1: LOW = Forward (COM to GND), HIGH = Reverse. - if pressed_bit(stable_a, 3) { - Direction::Forward - } else { - Direction::Reverse - } + if pressed_bit(stable_a, 3) { 1 } else { 0 } } #[embassy_executor::task] -pub async fn task(mut i2c: SharedI2cDevice, sender: InputSender) { - let mut chips = [McpState::new(MCP_ADDRESSES[0]), McpState::new(MCP_ADDRESSES[1])]; +pub async fn task(mut i2c: SharedI2cDevice, sender: RawSender) { + let mut chips = [ + McpState::new(MCP_ADDRESSES[0]), + McpState::new(MCP_ADDRESSES[1]), + ]; let mut present = 0u8; for chip in chips.iter() { @@ -197,7 +170,7 @@ pub async fn task(mut i2c: SharedI2cDevice, sender: InputSender) { // Initial direction sync. if let Ok((a, _)) = mcp_read(&mut i2c, MCP_ADDRESSES[1]) { chips[1].stable_a = a; - let _ = sender.try_send(InputEvent::DirectionSet(read_direction(a))); + let _ = sender.try_send(RawEvent::Switch(SwitchId::Direction, direction_value(a))); } let mut dir_stable = chips[1].stable_a; @@ -210,16 +183,27 @@ pub async fn task(mut i2c: SharedI2cDevice, sender: InputSender) { mcp.raw_a = raw_a; mcp.raw_b = raw_b; - let (rise_a, fall_a) = - update_debounce(raw_a, &mut mcp.stable_a, &mut mcp.debounce_a, &mut mcp.pressed_a); - let (rise_b, fall_b) = - update_debounce(raw_b, &mut mcp.stable_b, &mut mcp.debounce_b, &mut mcp.pressed_b); + let (rise_a, fall_a) = update_debounce( + raw_a, + &mut mcp.stable_a, + &mut mcp.debounce_a, + &mut mcp.pressed_a, + ); + let (rise_b, fall_b) = update_debounce( + raw_b, + &mut mcp.stable_b, + &mut mcp.debounce_b, + &mut mcp.pressed_b, + ); process_chip(mcp, rise_a, fall_a, rise_b, fall_b, &sender); if mcp.addr == MCP_ADDRESSES[1] && mcp.stable_a != dir_stable { dir_stable = mcp.stable_a; - let _ = sender.try_send(InputEvent::DirectionSet(read_direction(dir_stable))); + let _ = sender.try_send(RawEvent::Switch( + SwitchId::Direction, + direction_value(dir_stable), + )); } } Timer::after(Duration::from_millis(POLL_MS)).await; diff --git a/crates/firmware/src/input/gpio_nav.rs b/crates/firmware/src/input/gpio_nav.rs index 010d11f..4d74619 100644 --- a/crates/firmware/src/input/gpio_nav.rs +++ b/crates/firmware/src/input/gpio_nav.rs @@ -1,9 +1,10 @@ //! Direct GPIO nav cluster: Up/Down/Left/Right/Ok/Back/Menu (active-low). +//! Emits [`RawEvent`] for the board ControlSurface bridge. use embassy_time::{Duration, Timer}; use esp_hal::gpio::{Input, InputConfig, InputPin, Pull}; -use super::{InputEvent, InputSender, NavDir}; +use crate::board::raw::{ButtonId, RawEvent, RawSender}; const POLL_MS: u64 = 20; const DEBOUNCE_TICKS: u8 = 2; @@ -53,25 +54,35 @@ impl Btn { } } - /// Returns true on a stable high→low edge (press with pull-up). - fn update(&mut self, raw_high: bool) -> bool { + /// Returns `Some(true)` on press (high→low), `Some(false)` on release. + fn update(&mut self, raw_high: bool) -> Option { if raw_high == self.stable_high { self.debounce = 0; - return false; + return None; } self.debounce = self.debounce.saturating_add(1); if self.debounce < DEBOUNCE_TICKS { - return false; + return None; } let was_high = self.stable_high; self.stable_high = raw_high; self.debounce = 0; - was_high && !raw_high + if was_high && !raw_high { + Some(true) + } else if !was_high && raw_high { + Some(false) + } else { + None + } } } +fn emit(sender: &RawSender, id: ButtonId, pressed: bool) { + let _ = sender.try_send(RawEvent::Button(id, pressed)); +} + #[embassy_executor::task] -pub async fn task(pins: Pins, sender: InputSender) { +pub async fn task(pins: Pins, sender: RawSender) { let mut up = Btn::new(pins.up.is_high()); let mut down = Btn::new(pins.down.is_high()); let mut left = Btn::new(pins.left.is_high()); @@ -81,26 +92,29 @@ pub async fn task(pins: Pins, sender: InputSender) { let mut menu = Btn::new(pins.menu.is_high()); loop { - if up.update(pins.up.is_high()) { - let _ = sender.try_send(InputEvent::Nav(NavDir::Up)); + // Transitional GPIO map → ButtonId (ControlSurface → InputEvent). + if let Some(p) = up.update(pins.up.is_high()) { + emit(&sender, ButtonId::JoyUp, p); } - if down.update(pins.down.is_high()) { - let _ = sender.try_send(InputEvent::Nav(NavDir::Down)); + if let Some(p) = down.update(pins.down.is_high()) { + emit(&sender, ButtonId::JoyDown, p); } - if left.update(pins.left.is_high()) { - let _ = sender.try_send(InputEvent::Nav(NavDir::Left)); + if let Some(p) = left.update(pins.left.is_high()) { + emit(&sender, ButtonId::JoyLeft, p); } - if right.update(pins.right.is_high()) { - let _ = sender.try_send(InputEvent::Nav(NavDir::Right)); + if let Some(p) = right.update(pins.right.is_high()) { + emit(&sender, ButtonId::JoyRight, p); } - if ok.update(pins.ok.is_high()) { - let _ = sender.try_send(InputEvent::Ok); + // Old Ok → JoyMenu (surface emits Ok/select). + if let Some(p) = ok.update(pins.ok.is_high()) { + emit(&sender, ButtonId::JoyMenu, p); } - if back.update(pins.back.is_high()) { - let _ = sender.try_send(InputEvent::Back); + // Old Back → Stop (shell maps Stop → EStop/Cancel). + if let Some(p) = back.update(pins.back.is_high()) { + emit(&sender, ButtonId::Stop, p); } - if menu.update(pins.menu.is_high()) { - let _ = sender.try_send(InputEvent::Menu); + if let Some(p) = menu.update(pins.menu.is_high()) { + emit(&sender, ButtonId::Menu, p); } Timer::after(Duration::from_millis(POLL_MS)).await; diff --git a/crates/firmware/src/input/i2c_bus.rs b/crates/firmware/src/input/i2c_bus.rs index bdc2081..b838fc5 100644 --- a/crates/firmware/src/input/i2c_bus.rs +++ b/crates/firmware/src/input/i2c_bus.rs @@ -7,26 +7,28 @@ use core::cell::RefCell; use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::Mutex; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use esp_hal::Blocking; use esp_hal::gpio::AnyPin; use esp_hal::i2c::master::{Config, I2c, Instance, SoftwareTimeout}; use esp_hal::time::{Duration, Rate}; -use esp_hal::Blocking; use static_cell::StaticCell; use crate::config::board; pub type SharedI2cBus = Mutex>>; -pub type SharedI2cDevice = - I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>; +pub type SharedI2cDevice = I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>; static I2C_BUS: StaticCell = StaticCell::new(); /// Builds blocking I2C on `I2C0`, stores it in a static mutex, returns two device handles. -pub fn init( - i2c: impl Instance + 'static, -) -> (SharedI2cDevice, SharedI2cDevice) { +/// +/// # Safety +/// +/// Steals `I2C_SDA` / `I2C_SCL`. Call once from `main` before OLED/MCP tasks start. +#[allow(clippy::unwrap_used, unsafe_code)] +pub fn init(i2c: impl Instance + 'static) -> (SharedI2cDevice, SharedI2cDevice) { let freq_khz = if cfg!(feature = "sim") { 100 } else { @@ -37,6 +39,7 @@ pub fn init( .with_software_timeout(SoftwareTimeout::Transaction(Duration::from_millis(50))); let bus = I2c::new(i2c, cfg) .unwrap() + // SAFETY: I2C pins are reserved for this shared bus; single init from `main`. .with_sda(unsafe { AnyPin::steal(board::I2C_SDA) }) .with_scl(unsafe { AnyPin::steal(board::I2C_SCL) }); diff --git a/crates/firmware/src/input/keypad.rs b/crates/firmware/src/input/keypad.rs new file mode 100644 index 0000000..821e992 --- /dev/null +++ b/crates/firmware/src/input/keypad.rs @@ -0,0 +1,101 @@ +//! 3×4 matrix keypad scanner (markwtech). +//! +//! Rows are driven low one at a time; columns are read with pull-ups (active-low). +//! Pin numbers come from [`crate::board::variants::markwtech`]. + +use embassy_time::{Duration, Timer}; +use esp_hal::gpio::{AnyPin, Input, InputConfig, Level, Output, OutputConfig, Pull}; + +use crate::board::raw::{RawEvent, RawSender}; +#[cfg(feature = "variant-markwtech")] +use crate::board::variants::markwtech::{KEYPAD_COL_PINS, KEYPAD_MAP, KEYPAD_ROW_PINS}; + +const POLL_MS: u64 = 15; +const DEBOUNCE_TICKS: u8 = 2; + +pub struct Pins { + pub rows: [Output<'static>; 4], + pub cols: [Input<'static>; 3], +} + +/// Build keypad GPIO from markwtech pin constants. +/// +/// # Safety +/// +/// Call once from `main`; pins must not overlap other drivers. +#[cfg(feature = "variant-markwtech")] +#[allow(unsafe_code)] +pub fn build() -> Pins { + let out_cfg = OutputConfig::default(); + let in_cfg = InputConfig::default().with_pull(Pull::Up); + // SAFETY: keypad row/col pins are reserved for this driver; single owner from `main`. + let rows = [ + Output::new( + unsafe { AnyPin::steal(KEYPAD_ROW_PINS[0]) }, + Level::High, + out_cfg, + ), + Output::new( + unsafe { AnyPin::steal(KEYPAD_ROW_PINS[1]) }, + Level::High, + out_cfg, + ), + Output::new( + unsafe { AnyPin::steal(KEYPAD_ROW_PINS[2]) }, + Level::High, + out_cfg, + ), + Output::new( + unsafe { AnyPin::steal(KEYPAD_ROW_PINS[3]) }, + Level::High, + out_cfg, + ), + ]; + let cols = [ + Input::new(unsafe { AnyPin::steal(KEYPAD_COL_PINS[0]) }, in_cfg), + Input::new(unsafe { AnyPin::steal(KEYPAD_COL_PINS[1]) }, in_cfg), + Input::new(unsafe { AnyPin::steal(KEYPAD_COL_PINS[2]) }, in_cfg), + ]; + Pins { rows, cols } +} + +#[cfg(feature = "variant-markwtech")] +#[embassy_executor::task] +pub async fn task(mut pins: Pins, sender: RawSender) { + // Debounced pressed state [row][col]. + let mut pressed = [[false; 3]; 4]; + let mut debounce = [[0u8; 3]; 4]; + + loop { + for r in 0..4 { + // Idle: all rows high; scan one row low. + for row in pins.rows.iter_mut() { + row.set_high(); + } + pins.rows[r].set_low(); + // Settle. + Timer::after(Duration::from_micros(50)).await; + + for c in 0..3 { + let raw_pressed = pins.cols[c].is_low(); + if raw_pressed == pressed[r][c] { + debounce[r][c] = 0; + continue; + } + debounce[r][c] = debounce[r][c].saturating_add(1); + if debounce[r][c] < DEBOUNCE_TICKS { + continue; + } + pressed[r][c] = raw_pressed; + debounce[r][c] = 0; + let id = KEYPAD_MAP[r][c]; + let _ = sender.try_send(RawEvent::Button(id, raw_pressed)); + } + } + // Release drive. + for row in pins.rows.iter_mut() { + row.set_high(); + } + Timer::after(Duration::from_millis(POLL_MS)).await; + } +} diff --git a/crates/firmware/src/input/mod.rs b/crates/firmware/src/input/mod.rs index 407888a..53a23ea 100644 --- a/crates/firmware/src/input/mod.rs +++ b/crates/firmware/src/input/mod.rs @@ -1,10 +1,13 @@ -//! Input: GPIO nav cluster, MCP23017 tact/F-keys, encoder. -//! Input channel contract -> domain: `InputEvent` + `INPUT_CHANNEL`. +//! Input: GPIO nav cluster, MCP23017 tact/F-keys, encoder, keypad. +//! Drivers emit [`crate::board::raw::RawEvent`] to `RAW_CHANNEL`; +//! the board bridge maps them to [`InputEvent`] on `INPUT_CHANNEL`. pub mod encoder; pub mod expander; pub mod gpio_nav; pub mod i2c_bus; +#[cfg(feature = "variant-markwtech")] +pub mod keypad; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::{Channel, Receiver, Sender}; @@ -19,7 +22,7 @@ pub enum NavDir { Right, } -/// Input event emitted to the domain layer. +/// Input event emitted to the domain / UI layer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputEvent { Nav(NavDir), @@ -27,19 +30,30 @@ pub enum InputEvent { Back, Menu, EStop, + /// Physical Stop — UiShell maps to EStop or Cancel/Back by screen. + Stop, FnPress(u8), FnRelease(u8), DirectionSet(Direction), + DirectionToggle, EncoderClockwise, EncoderCounterClockwise, EncoderButton, + Digit(char), + SpeedAbsolute(u8), + LocoSlot(u8, bool), + CharCycle(i8), + CursorMove(i8), + CaseToggle, + EnterProgrammingMode, } pub const INPUT_CHANNEL_DEPTH: usize = 16; pub type InputChannel = Channel; pub type InputSender = Sender<'static, CriticalSectionRawMutex, InputEvent, INPUT_CHANNEL_DEPTH>; -pub type InputReceiver = Receiver<'static, CriticalSectionRawMutex, InputEvent, INPUT_CHANNEL_DEPTH>; +pub type InputReceiver = + Receiver<'static, CriticalSectionRawMutex, InputEvent, INPUT_CHANNEL_DEPTH>; -/// Sole input channel: drivers -> domain. +/// Sole input channel: board bridge -> domain. pub static INPUT_CHANNEL: InputChannel = Channel::new(); diff --git a/crates/firmware/src/lib.rs b/crates/firmware/src/lib.rs index 9c9f5f7..c410b4c 100644 --- a/crates/firmware/src/lib.rs +++ b/crates/firmware/src/lib.rs @@ -1,7 +1,12 @@ #![no_std] //! LongFred firmware: application library (config, domain, input, UI, network). //! Entry point and HAL initialization are in `src/bin/main.rs`. +//! +//! Public item docs are filled incrementally; CI clippy allows `missing_docs` for the +//! same reason. Prefer documenting new public API when adding it. +#![allow(missing_docs)] +pub mod board; pub mod config; pub mod domain; pub mod input; diff --git a/crates/firmware/src/net/mdns.rs b/crates/firmware/src/net/mdns.rs index b3cdb66..5cf782f 100644 --- a/crates/firmware/src/net/mdns.rs +++ b/crates/firmware/src/net/mdns.rs @@ -1,18 +1,18 @@ //! Discovery of WiThrottle and Z21 command stations via mDNS. -use embassy_futures::select::{select, Either}; +use embassy_futures::select::{Either, select}; use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpAddress, IpEndpoint, Stack}; -use embassy_time::{with_timeout, Duration, Instant, Timer}; +use embassy_time::{Duration, Instant, Timer, with_timeout}; use log::{info, warn}; use longfred_proto::command::Protocol; use longfred_proto::mdns::{ - build_ptr_query, collect_servers, WitServer, MDNS_MULTICAST_V4, MDNS_PORT, WITHROTTLE_SERVICE, - Z21_SERVICE, + MDNS_MULTICAST_V4, MDNS_PORT, WITHROTTLE_SERVICE, WitServer, Z21_SERVICE, build_ptr_query, + collect_servers, }; use crate::config::{network, sizes}; -use crate::net::{NetStatus, ServerEndpoint, FOUND_SERVERS, MDNS_CTRL, SERVER, STATE}; +use crate::net::{FOUND_SERVERS, MDNS_CTRL, NetStatus, SERVER, STATE, ServerEndpoint}; const MAX_SERVERS: usize = sizes::MAX_FOUND_SERVERS; diff --git a/crates/firmware/src/net/mod.rs b/crates/firmware/src/net/mod.rs index 4cb9927..5118b0d 100644 --- a/crates/firmware/src/net/mod.rs +++ b/crates/firmware/src/net/mod.rs @@ -1,6 +1,8 @@ //! Network layer: WiFi STA + embassy-net stack, mDNS, protocol session. pub mod mdns; +#[cfg(not(feature = "sim"))] +pub mod provisioning; pub mod session; pub mod wifi; @@ -85,8 +87,10 @@ pub struct SsidInfo { pub open: bool, } -pub static WIFI_SCAN: Signal> = - Signal::new(); +pub static WIFI_SCAN: Signal< + CriticalSectionRawMutex, + heapless::Vec, +> = Signal::new(); /// mDNS-discovered command stations. pub static FOUND_SERVERS: Signal< diff --git a/crates/firmware/src/net/provisioning/http_server.rs b/crates/firmware/src/net/provisioning/http_server.rs new file mode 100644 index 0000000..cf2281a --- /dev/null +++ b/crates/firmware/src/net/provisioning/http_server.rs @@ -0,0 +1,219 @@ +//! Minimal HTTP/1.1 server for Soft-AP provisioning (manual TcpSocket parser). + +use embassy_net::Stack; +use embassy_net::tcp::TcpSocket; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; +use embassy_time::{Duration, Timer}; +use log::{info, warn}; +use longfred_proto::persist::PersistRecord; +use longfred_proto::provisioning::{ + apply_settings_put, deserialize_settings_put, serialize_settings_from_record, +}; + +use crate::net::provisioning::exit_programming_mode; +use crate::storage::{STORAGE_ACK, STORAGE_CTRL, StorageCmd}; + +const INDEX_HTML: &str = include_str!("index.html"); + +const RX_BUF: usize = 2048; +const TX_BUF: usize = 4096; +const BODY_MAX: usize = 1536; +const JSON_MAX: usize = 1536; + +#[embassy_executor::task] +pub async fn task( + stack: Stack<'static>, + rec: &'static Mutex, +) { + info!("programming: HTTP listening on :80"); + loop { + let mut rx = [0u8; RX_BUF]; + let mut tx = [0u8; TX_BUF]; + let mut sock = TcpSocket::new(stack, &mut rx, &mut tx); + sock.set_timeout(Some(Duration::from_secs(20))); + + if sock.accept(80).await.is_err() { + warn!("programming: accept failed"); + Timer::after(Duration::from_millis(100)).await; + continue; + } + + if let Err(e) = handle_client(&mut sock, rec).await { + warn!("programming: request error: {}", e); + } + sock.abort(); + let _ = sock.flush().await; + } +} + +async fn handle_client( + sock: &mut TcpSocket<'_>, + rec: &'static Mutex, +) -> Result<(), &'static str> { + let mut hdr = [0u8; RX_BUF]; + let n = read_headers(sock, &mut hdr).await?; + let (method, path, content_len) = parse_request(&hdr[..n])?; + + let mut body_buf = [0u8; BODY_MAX]; + let body = if content_len > 0 { + if content_len > BODY_MAX { + return Err("body too large"); + } + // Body may already be partially in hdr after \\r\\n\\r\\n. + let header_end = find_header_end(&hdr[..n]).ok_or("bad headers")?; + let already = n.saturating_sub(header_end); + if already > content_len { + return Err("bad body framing"); + } + body_buf[..already].copy_from_slice(&hdr[header_end..header_end + already]); + let mut got = already; + while got < content_len { + match sock.read(&mut body_buf[got..content_len]).await { + Ok(0) => return Err("eof body"), + Ok(k) => got += k, + Err(_) => return Err("read body"), + } + } + &body_buf[..content_len] + } else { + &[][..] + }; + + match (method, path) { + ("GET", "/") => respond(sock, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes()).await, + ("GET", "/api/v1/settings") => { + let guard = rec.lock().await; + let mut json = [0u8; JSON_MAX]; + match serialize_settings_from_record(&mut json, &*guard) { + Ok(len) => respond(sock, 200, "application/json", &json[..len]).await, + Err(_) => respond(sock, 500, "text/plain", b"serialize error").await, + } + } + ("PUT", "/api/v1/settings") => { + let put = deserialize_settings_put(body).map_err(|_| "bad json")?; + let mut guard = rec.lock().await; + let apply_err = apply_settings_put(&mut *guard, &put); + let msg: &'static str = match apply_err { + Ok(()) => "", + Err(longfred_proto::provisioning::ApplyError::HostnameTooLong) => { + "hostname too long" + } + Err(longfred_proto::provisioning::ApplyError::LoginTooLong) => "login too long", + Err(longfred_proto::provisioning::ApplyError::PinTooLong) => "pin too long", + Err(longfred_proto::provisioning::ApplyError::RosterAddrTooLong) => { + "roster addr too long" + } + Err(longfred_proto::provisioning::ApplyError::RosterNameTooLong) => { + "roster name too long" + } + Err(longfred_proto::provisioning::ApplyError::RosterFull) => "roster full", + }; + if !msg.is_empty() { + return respond(sock, 400, "text/plain", msg.as_bytes()).await; + } + let snapshot = guard.clone(); + drop(guard); + let tx = STORAGE_CTRL.sender(); + if tx.try_send(StorageCmd::ReplaceRecord(snapshot)).is_err() { + return respond(sock, 503, "text/plain", b"storage busy").await; + } + STORAGE_ACK.wait().await; + respond(sock, 200, "application/json", b"{\"ok\":true}").await + } + ("POST", "/api/v1/programming-mode/off") => { + respond(sock, 200, "application/json", b"{\"ok\":true}").await?; + // Exit after responding (never returns). + exit_programming_mode(500).await + } + _ => respond(sock, 404, "text/plain", b"not found").await, + } +} + +async fn read_headers(sock: &mut TcpSocket<'_>, buf: &mut [u8]) -> Result { + let mut n = 0usize; + loop { + if n >= buf.len() { + return Err("headers too large"); + } + match sock.read(&mut buf[n..]).await { + Ok(0) => return Err("eof"), + Ok(k) => { + n += k; + if find_header_end(&buf[..n]).is_some() { + return Ok(n); + } + } + Err(_) => return Err("read"), + } + } +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +fn parse_request(buf: &[u8]) -> Result<(&str, &str, usize), &'static str> { + let header_end = find_header_end(buf).ok_or("incomplete")?; + let head = core::str::from_utf8(&buf[..header_end]).map_err(|_| "utf8")?; + let mut lines = head.split("\r\n"); + let req = lines.next().ok_or("no request line")?; + let mut parts = req.split_whitespace(); + let method = parts.next().ok_or("no method")?; + let path = parts.next().ok_or("no path")?; + // Strip query string. + let path = path.split('?').next().unwrap_or(path); + + let mut content_len = 0usize; + for line in lines { + let lower = line.as_bytes(); + if lower.len() >= 15 && lower[..15].eq_ignore_ascii_case(b"content-length:") { + let v = line[15..].trim(); + content_len = v.parse().map_err(|_| "bad content-length")?; + } + } + Ok((method, path, content_len)) +} + +async fn respond( + sock: &mut TcpSocket<'_>, + status: u16, + content_type: &str, + body: &[u8], +) -> Result<(), &'static str> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Error", + }; + let mut hdr = heapless::String::<160>::new(); + if core::fmt::write( + &mut hdr, + format_args!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ), + ) + .is_err() + { + return Err("header overflow"); + } + write_all(sock, hdr.as_bytes()).await?; + write_all(sock, body).await?; + sock.flush().await.map_err(|_| "flush")?; + Ok(()) +} + +async fn write_all(sock: &mut TcpSocket<'_>, mut data: &[u8]) -> Result<(), &'static str> { + while !data.is_empty() { + match sock.write(data).await { + Ok(0) => return Err("write zero"), + Ok(n) => data = &data[n..], + Err(_) => return Err("write"), + } + } + Ok(()) +} diff --git a/crates/firmware/src/net/provisioning/index.html b/crates/firmware/src/net/provisioning/index.html new file mode 100644 index 0000000..349ebc6 --- /dev/null +++ b/crates/firmware/src/net/provisioning/index.html @@ -0,0 +1,103 @@ + + + + + +LongFred Pairing + + + +

LongFred pairing

+
+
+BigFred + + +
+
+Wi‑Fi + + +
+
+Roster + + + + +
+ + + + + +
+

+ + + diff --git a/crates/firmware/src/net/provisioning/mod.rs b/crates/firmware/src/net/provisioning/mod.rs new file mode 100644 index 0000000..f38e8d5 --- /dev/null +++ b/crates/firmware/src/net/provisioning/mod.rs @@ -0,0 +1,214 @@ +//! Soft-AP programming / pairing mode (HTTP provisioning). + +mod http_server; + +use embassy_net::{ + Config as NetConfig, Ipv4Address, Ipv4Cidr, Stack, StackResources, StaticConfigV4, +}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; +use embassy_time::{Duration, Timer}; +use esp_hal::efuse::{self, InterfaceMacAddress}; +use esp_hal::system::software_reset; +use esp_radio::wifi::{ + Config as WifiConfig, ControllerConfig, Interface, WifiController, ap::AccessPointConfig, +}; +use heapless::String; +use log::{info, warn}; +use longfred_proto::persist::PersistRecord; +use static_cell::StaticCell; + +use crate::board; +use crate::config::sizes; +use crate::input::{INPUT_CHANNEL, InputEvent}; +use crate::storage::{PERSIST_LOADED, STORAGE_ACK, STORAGE_CTRL, StorageCmd}; +use crate::ui::UI_VIEW; +use crate::ui::view::{GridView, UiView}; + +const AP_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const AP_PREFIX: u8 = 24; +const SSID_PREFIX: &str = "longfred_prog_"; + +static PROG_REC: StaticCell> = StaticCell::new(); + +/// Build Soft-AP SSID `longfred_prog_XXXXXX` from the last 3 MAC octets (hex). +pub fn ap_ssid_from_mac(mac: &[u8; 6]) -> String<32> { + const HEX: &[u8] = b"0123456789abcdef"; + let mut s = String::<32>::new(); + let _ = s.push_str(SSID_PREFIX); + for &b in &mac[3..6] { + let _ = s.push(HEX[(b >> 4) as usize] as char); + let _ = s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +fn static_ap_config() -> NetConfig { + NetConfig::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(AP_IP, AP_PREFIX), + gateway: None, + dns_servers: Default::default(), + }) +} + +/// Configure Soft-AP and return (controller, embassy-net interface). +/// +/// Prefer calling with a fresh `WifiController`; on failure returns `None` after logging. +pub fn start_ap( + wifi: esp_hal::peripherals::WIFI<'static>, +) -> Option<(WifiController<'static>, Interface)> { + let mac = efuse::interface_mac_address(InterfaceMacAddress::AccessPoint); + let mut mac_bytes = [0u8; 6]; + mac_bytes.copy_from_slice(mac.as_bytes()); + let ssid = ap_ssid_from_mac(&mac_bytes); + info!("programming: Soft-AP SSID={}", ssid.as_str()); + + let ap_cfg = AccessPointConfig::default().with_ssid(ssid.as_str()); + let ctrl_cfg = ControllerConfig::default().with_initial_config(WifiConfig::AccessPoint(ap_cfg)); + + match WifiController::new(wifi, ctrl_cfg) { + Ok(controller) => { + let iface = Interface::access_point(); + info!( + "programming: Soft-AP started, static IP {}/{}", + AP_IP, AP_PREFIX + ); + Some((controller, iface)) + } + Err(e) => { + warn!("programming: Soft-AP start failed: {:?} — stub mode", e); + None + } + } +} + +/// Hold the Wi-Fi controller so Soft-AP stays up. +#[embassy_executor::task] +pub async fn ap_hold_task(controller: WifiController<'static>) { + let _controller = controller; + loop { + Timer::after(Duration::from_secs(60)).await; + } +} + +/// OLED / LED pairing indication. +#[embassy_executor::task] +pub async fn pairing_ui_task(ssid: String<32>) { + let desc = board::active_variant(); + if desc.display.is_some() { + let mut grid = GridView::new(); + grid.set(0, "Pairing mode", false); + grid.set(1, ssid.as_str(), false); + grid.set(2, "192.168.0.1", false); + UI_VIEW.sender().send(UiView::Grid(grid)); + info!("programming: display shows Pairing mode"); + } + #[cfg(feature = "variant-heiko-wifred")] + { + crate::ui::led_presenter::LED_MODE + .sender() + .send(crate::ui::led_presenter::LedMode::Pairing); + info!("programming: LED pairing pattern"); + } + #[cfg(not(feature = "variant-heiko-wifred"))] + if desc.display.is_none() { + info!("programming: pairing active (no display/LEDs)"); + } + loop { + Timer::after(Duration::from_secs(30)).await; + } +} + +/// Stop / E-Stop clears programming mode and reboots. +#[embassy_executor::task] +pub async fn cancel_task() { + let rx = INPUT_CHANNEL.receiver(); + let tx = STORAGE_CTRL.sender(); + loop { + match rx.receive().await { + InputEvent::Stop | InputEvent::EStop => { + info!("programming: cancel via Stop/EStop"); + let _ = tx.try_send(StorageCmd::SetProgrammingMode(false)); + STORAGE_ACK.wait().await; + Timer::after(Duration::from_millis(50)).await; + software_reset(); + } + _ => {} + } + } +} + +/// Spawn Soft-AP stack + HTTP server. Returns `false` if AP could not be created +/// and no interface is available (caller should fall back or hang). +pub fn spawn_programming_net( + spawner: &embassy_executor::Spawner, + wifi: esp_hal::peripherals::WIFI<'static>, + seed: u64, + initial: PersistRecord, +) -> bool { + let mac = efuse::interface_mac_address(InterfaceMacAddress::AccessPoint); + let mut mac_bytes = [0u8; 6]; + mac_bytes.copy_from_slice(mac.as_bytes()); + let ssid = ap_ssid_from_mac(&mac_bytes); + + let Some((controller, iface)) = start_ap(wifi) else { + warn!("programming: no Soft-AP interface; HTTP not started"); + if let Ok(token) = pairing_ui_task(ssid) { + spawner.spawn(token); + } + if let Ok(token) = cancel_task() { + spawner.spawn(token); + } + return false; + }; + + static RESOURCES: StaticCell> = StaticCell::new(); + let resources = RESOURCES.init(StackResources::new()); + let (stack, runner) = embassy_net::new(iface, static_ap_config(), resources, seed); + + let rec = PROG_REC.init(Mutex::new(initial)); + + if let Ok(token) = ap_hold_task(controller) { + spawner.spawn(token); + } + if let Ok(token) = crate::net::wifi::net_task(runner) { + spawner.spawn(token); + } + if let Ok(token) = http_server::task(stack, rec) { + spawner.spawn(token); + } + if let Ok(token) = pairing_ui_task(ssid) { + spawner.spawn(token); + } + if let Ok(token) = cancel_task() { + spawner.spawn(token); + } + + // Refresh local record if storage republishes. + if let Ok(token) = sync_persist_task(rec) { + spawner.spawn(token); + } + + true +} + +#[embassy_executor::task] +async fn sync_persist_task(rec: &'static Mutex) { + loop { + let updated = PERSIST_LOADED.wait().await; + let mut guard = rec.lock().await; + *guard = updated; + } +} + +/// Clear programming flag, ack storage, then reboot after `delay_ms`. +pub async fn exit_programming_mode(delay_ms: u64) -> ! { + let tx = STORAGE_CTRL.sender(); + let _ = tx.try_send(StorageCmd::SetProgrammingMode(false)); + STORAGE_ACK.wait().await; + Timer::after(Duration::from_millis(delay_ms)).await; + software_reset(); +} + +/// Used by HTTP server / tests: re-export stack type. +pub type ProgStack = Stack<'static>; diff --git a/crates/firmware/src/net/session.rs b/crates/firmware/src/net/session.rs index 61812a1..f996199 100644 --- a/crates/firmware/src/net/session.rs +++ b/crates/firmware/src/net/session.rs @@ -1,6 +1,6 @@ //! Generic protocol session: TCP (WiThrottle) or UDP (Z21) with shared adapter loop. -use embassy_futures::select::{select3, Either3}; +use embassy_futures::select::{Either3, select3}; use embassy_net::tcp::TcpSocket; use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpAddress, IpEndpoint, Stack}; @@ -14,7 +14,7 @@ use longfred_proto::wt::WtAdapter; use longfred_proto::z21::Z21Adapter; use crate::config; -use crate::net::{ConnState, ServerEndpoint, DEVICE, PROTO_COMMANDS, PROTO_EVENTS, CONN, SERVER}; +use crate::net::{CONN, ConnState, DEVICE, PROTO_COMMANDS, PROTO_EVENTS, SERVER, ServerEndpoint}; const TCP_RX_SIZE: usize = 1024; const TCP_TX_SIZE: usize = 1024; diff --git a/crates/firmware/src/net/wifi.rs b/crates/firmware/src/net/wifi.rs index bd07c7b..c66b5ee 100644 --- a/crates/firmware/src/net/wifi.rs +++ b/crates/firmware/src/net/wifi.rs @@ -3,15 +3,16 @@ use embassy_net::{ConfigV4, DhcpConfig, Ipv4Address, Ipv4Cidr, Runner, Stack, StaticConfigV4}; use embassy_time::{Duration, Timer}; use esp_radio::wifi::{ - ap::AccessPointInfo, scan::ScanConfig, sta::StationConfig, AuthenticationMethod, - Config as WifiConfig, Interface, PowerSaveMode, Protocol, Protocols, WifiController, - WifiError, + AuthenticationMethod, Config as WifiConfig, Interface, PowerSaveMode, Protocol, Protocols, + WifiController, WifiError, ap::AccessPointInfo, scan::ScanConfig, sta::StationConfig, }; use log::{info, warn}; use crate::config; use crate::config::sizes; -use crate::net::{NetStatus, SsidInfo, WifiCmd, NET_CONFIG_CTRL, STATE, WIFI_CTRL, WIFI_HOSTNAME, WIFI_SCAN}; +use crate::net::{ + NET_CONFIG_CTRL, NetStatus, STATE, SsidInfo, WIFI_CTRL, WIFI_HOSTNAME, WIFI_SCAN, WifiCmd, +}; /// embassy-net driver type provided by esp-radio (STA). pub type NetDriver = Interface; diff --git a/crates/firmware/src/power/battery.rs b/crates/firmware/src/power/battery.rs index 60bb0de..3e7b72f 100644 --- a/crates/firmware/src/power/battery.rs +++ b/crates/firmware/src/power/battery.rs @@ -6,7 +6,7 @@ use embassy_time::{Duration, Timer}; use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation}; use crate::config::power; -use crate::power::sleep::{SleepReason, SLEEP_CTRL}; +use crate::power::sleep::{SLEEP_CTRL, SleepReason}; pub static BATTERY: Watch, 2> = Watch::new(); diff --git a/crates/firmware/src/power/sleep.rs b/crates/firmware/src/power/sleep.rs index da612d4..7c2e73e 100644 --- a/crates/firmware/src/power/sleep.rs +++ b/crates/firmware/src/power/sleep.rs @@ -4,13 +4,13 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::signal::Signal; use embassy_time::{Duration, Timer}; use esp_hal::gpio::RtcPinWithResistors; -use esp_hal::rtc_cntl::sleep::{Ext1WakeupSource, WakeupLevel}; use esp_hal::rtc_cntl::Rtc; +use esp_hal::rtc_cntl::sleep::{Ext1WakeupSource, WakeupLevel}; use crate::config::power; +use crate::ui::UI_VIEW; use crate::ui::i18n; use crate::ui::view::{GridView, UiView}; -use crate::ui::UI_VIEW; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SleepReason { diff --git a/crates/firmware/src/storage/mod.rs b/crates/firmware/src/storage/mod.rs index 31e93a0..b8d2572 100644 --- a/crates/firmware/src/storage/mod.rs +++ b/crates/firmware/src/storage/mod.rs @@ -5,19 +5,23 @@ use embassy_sync::channel::Channel; use embassy_sync::signal::Signal; use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; use esp_bootloader_esp_idf::partitions::{ - read_partition_table, DataPartitionSubType, PartitionType, + DataPartitionSubType, PartitionType, read_partition_table, }; use esp_hal::rng::Rng; use esp_storage::FlashStorage; use heapless::String; use log::{info, warn}; use longfred_proto::persist::{ - id_from_entropy, wifi_hostname_from_entropy, DeviceIdentity, Language, PersistRecord, SavedLoco, - StaticIpConfig, MAX_SAVED_LOCOS, MAX_WIFI_HOSTNAME_LEN, + DeviceIdentity, Language, MAX_SAVED_LOCOS, MAX_WIFI_HOSTNAME_LEN, PersistRecord, SavedLoco, + StaticIpConfig, id_from_entropy, wifi_hostname_from_entropy, }; pub static PERSIST_LOADED: Signal = Signal::new(); +/// Signalled after a storage write that requested acknowledgement +/// ([`StorageCmd::SetProgrammingMode`], [`StorageCmd::ReplaceRecord`]). +pub static STORAGE_ACK: Signal = Signal::new(); + pub enum StorageCmd { SavePassword { ssid: String<32>, @@ -28,11 +32,22 @@ pub enum StorageCmd { SaveDevice(DeviceIdentity), RegenerateDeviceId, SaveLanguage(Language), + SetProgrammingMode(bool), + ReplaceRecord(PersistRecord), Clear, } pub static STORAGE_CTRL: Channel = Channel::new(); +/// Boot-time NVS snapshot used to choose STA vs programming path. +#[derive(Clone)] +pub struct BootState { + pub wifi_hostname: heapless::String, + pub programming_mode: bool, + pub has_wifi_credentials: bool, + pub record: PersistRecord, +} + const SECTOR: usize = 4096; const PT_BUF_LEN: usize = 4096; @@ -73,6 +88,11 @@ fn persist(flash: &mut FlashStorage<'_>, rec: &PersistRecord) { } } +/// Synchronous NVS write (boot path before the storage task runs). +pub fn write_record(flash: &mut FlashStorage<'_>, rec: &PersistRecord) { + persist(flash, rec); +} + fn ensure_device_id(rec: &mut PersistRecord, entropy: u32) { if rec.device.id == 0 { rec.device.id = id_from_entropy(entropy); @@ -95,10 +115,7 @@ fn regenerate_device_id(rec: &mut PersistRecord) { } /// Load NVS and ensure device id + DHCP hostname exist (called before embassy-net init). -pub fn ensure_boot_hostname( - flash: &mut FlashStorage<'_>, - boot_entropy: u32, -) -> heapless::String { +pub fn ensure_boot(flash: &mut FlashStorage<'_>, boot_entropy: u32) -> BootState { let mut rec = load(flash).unwrap_or_default(); let mut dirty = false; if rec.wifi_hostname.is_empty() { @@ -112,7 +129,20 @@ pub fn ensure_boot_hostname( if dirty { persist(flash, &rec); } - rec.wifi_hostname + BootState { + wifi_hostname: rec.wifi_hostname.clone(), + programming_mode: rec.programming_mode, + has_wifi_credentials: !rec.credentials.is_empty(), + record: rec, + } +} + +/// Load NVS and ensure device id + DHCP hostname exist (called before embassy-net init). +pub fn ensure_boot_hostname( + flash: &mut FlashStorage<'_>, + boot_entropy: u32, +) -> heapless::String { + ensure_boot(flash, boot_entropy).wifi_hostname } #[embassy_executor::task] @@ -166,6 +196,24 @@ pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) persist(flash, &rec); PERSIST_LOADED.signal(rec.clone()); } + StorageCmd::SetProgrammingMode(on) => { + rec.programming_mode = on; + persist(flash, &rec); + PERSIST_LOADED.signal(rec.clone()); + STORAGE_ACK.signal(()); + } + StorageCmd::ReplaceRecord(new_rec) => { + rec = new_rec; + if rec.device.id == 0 { + ensure_device_id(&mut rec, boot_entropy); + } + if rec.wifi_hostname.is_empty() { + ensure_wifi_hostname(&mut rec, boot_entropy); + } + persist(flash, &rec); + PERSIST_LOADED.signal(rec.clone()); + STORAGE_ACK.signal(()); + } StorageCmd::Clear => { rec = PersistRecord::default(); ensure_device_id(&mut rec, boot_entropy); diff --git a/crates/firmware/src/ui/display.rs b/crates/firmware/src/ui/display.rs index 425438a..506fbc8 100644 --- a/crates/firmware/src/ui/display.rs +++ b/crates/firmware/src/ui/display.rs @@ -1,4 +1,4 @@ -//! SSD1306 128x64 driver — UiView renderer (no menu logic). +//! SSD1306 OLED driver — UiView renderer (geometry from active variant). use embassy_time::{Duration, Timer}; use embedded_graphics::{ @@ -8,33 +8,38 @@ use embedded_graphics::{ primitives::{PrimitiveStyleBuilder, Rectangle}, text::{Baseline, Text}, }; -use ssd1306::{ - mode::BufferedGraphicsMode, - prelude::*, - I2CDisplayInterface, Ssd1306, -}; +use ssd1306::{I2CDisplayInterface, Ssd1306, mode::BufferedGraphicsMode, prelude::*}; +use crate::board::descriptor::{DisplayGeometry, LAYOUT_128X64}; use crate::config::board; use crate::input::i2c_bus::SharedI2cDevice; -use crate::ui::view::{GridView, ThrottleView, UiView, GRID_LINES, LINE_LEN}; -use crate::ui::{fonts, UI_VIEW}; +use crate::ui::view::{GridView, LINE_LEN, ThrottleView, UiView}; +use crate::ui::{UI_VIEW, fonts}; const BLINK_PERIOD_MS: u64 = 200; const GRID_LEFT_X: i32 = 0; const GRID_RIGHT_X: i32 = 64; -const GRID_Y: [i32; 6] = [10, 20, 30, 40, 50, 60]; - -pub type Display = Ssd1306< - I2CInterface, - DisplaySize128x64, - BufferedGraphicsMode, ->; +/// Content-row Y positions for 128×64 (6 rows × 2 cols). +const GRID_Y_64: [i32; 6] = [10, 20, 30, 40, 50, 60]; +/// Content-row Y positions for 128×32 (3 rows × 2 cols). +const GRID_Y_32: [i32; 3] = [8, 16, 24]; + +#[cfg(feature = "variant-longfred-mini")] +type PanelSize = DisplaySize128x32; +#[cfg(not(feature = "variant-longfred-mini"))] +type PanelSize = DisplaySize128x64; + +pub type Display = + Ssd1306, PanelSize, BufferedGraphicsMode>; + +fn geometry() -> DisplayGeometry { + crate::board::variants::active() + .display + .unwrap_or(LAYOUT_128X64) +} fn line_text(grid: &GridView, idx: usize) -> &str { - grid.lines - .get(idx) - .map(|l| l.as_str()) - .unwrap_or("") + grid.lines.get(idx).map(|l| l.as_str()).unwrap_or("") } fn line_invert(grid: &GridView, idx: usize) -> bool { @@ -75,7 +80,12 @@ fn draw_grid_line( } fn draw_grid(display: &mut Display, grid: &GridView, text_style: MonoTextStyle<'_, BinaryColor>) { - if grid.top_line { + let geom = geometry(); + let is_mini = geom.height <= 32; + let rows = geom.grid_lines / 2; + let grid_y: &[i32] = if is_mini { &GRID_Y_32 } else { &GRID_Y_64 }; + + if grid.top_line && !is_mini { Rectangle::new(Point::new(0, 11), Size::new(127, 1)) .into_styled( PrimitiveStyleBuilder::new() @@ -85,7 +95,7 @@ fn draw_grid(display: &mut Display, grid: &GridView, text_style: MonoTextStyle<' .draw(display) .ok(); } - if grid.foot_line { + if grid.foot_line && !is_mini { Rectangle::new(Point::new(0, 51), Size::new(127, 1)) .into_styled( PrimitiveStyleBuilder::new() @@ -96,31 +106,32 @@ fn draw_grid(display: &mut Display, grid: &GridView, text_style: MonoTextStyle<' .ok(); } - for row in 0..6 { + for row in 0..rows { + let y = grid_y.get(row).copied().unwrap_or(0); let left_idx = row + 1; - if left_idx < GRID_LINES { + if left_idx < geom.grid_lines { draw_grid_line( display, GRID_LEFT_X, - GRID_Y[row], + y, line_text(grid, left_idx), line_invert(grid, left_idx), text_style, ); } - let right_idx = row + 7; - if right_idx < GRID_LINES { + let right_idx = row + 1 + rows; + if right_idx < geom.grid_lines { draw_grid_line( display, GRID_RIGHT_X, - GRID_Y[row], + y, line_text(grid, right_idx), line_invert(grid, right_idx), text_style, ); } } - if grid.lines.len() > 0 { + if !grid.lines.is_empty() { draw_grid_line( display, GRID_LEFT_X, @@ -132,15 +143,19 @@ fn draw_grid(display: &mut Display, grid: &GridView, text_style: MonoTextStyle<' } } -/// Compact row of currently-ON function numbers (F0–F28) above the footer. -fn draw_fn_active(display: &mut Display, functions: u32) { - const Y: i32 = 44; +/// Compact row of currently-ON function numbers (F0–F28). +fn draw_fn_active( + display: &mut Display, + functions: u32, + y: i32, + char_w: i32, + font: &embedded_graphics::mono_font::MonoFont<'_>, +) { const X0: i32 = 4; const MAX_X: i32 = 124; - const CHAR_W: i32 = 6; let style = MonoTextStyleBuilder::new() - .font(&fonts::TEXT) + .font(font) .text_color(BinaryColor::On) .build(); @@ -154,8 +169,8 @@ fn draw_fn_active(display: &mut Display, functions: u32) { } let digits = if f < 10 { 1i32 } else { 2i32 }; - let gap = if first { 0 } else { CHAR_W }; - let needed = gap + digits * CHAR_W; + let gap = if first { 0 } else { char_w }; + let needed = gap + digits * char_w; if x + needed > MAX_X { truncated = true; @@ -163,10 +178,10 @@ fn draw_fn_active(display: &mut Display, functions: u32) { } if !first { - Text::with_baseline(" ", Point::new(x, Y), style, Baseline::Top) + Text::with_baseline(" ", Point::new(x, y), style, Baseline::Top) .draw(display) .ok(); - x += CHAR_W; + x += char_w; } first = false; @@ -175,14 +190,14 @@ fn draw_fn_active(display: &mut Display, functions: u32) { let _ = label.push((b'0' + f / 10) as char); } let _ = label.push((b'0' + f % 10) as char); - Text::with_baseline(label.as_str(), Point::new(x, Y), style, Baseline::Top) + Text::with_baseline(label.as_str(), Point::new(x, y), style, Baseline::Top) .draw(display) .ok(); - x += digits * CHAR_W; + x += digits * char_w; } - if truncated && x + CHAR_W <= MAX_X { - Text::with_baseline("+", Point::new(x, Y), style, Baseline::Top) + if truncated && x + char_w <= MAX_X { + Text::with_baseline("+", Point::new(x, y), style, Baseline::Top) .draw(display) .ok(); } @@ -235,7 +250,7 @@ fn draw_battery_icon( } } -fn draw_throttle( +fn draw_throttle_standard( display: &mut Display, t: &ThrottleView, title_style: MonoTextStyle<'_, BinaryColor>, @@ -288,7 +303,6 @@ fn draw_throttle( ) .draw(display) .ok(); - // strikethrough Rectangle::new(Point::new(100, 6), Size::new(8, 1)) .into_styled( PrimitiveStyleBuilder::new() @@ -324,21 +338,97 @@ fn draw_throttle( draw_battery_icon(display, pct, t.battery_show_percent, text_style); } - Text::with_baseline(t.loco.as_str(), Point::new(4, 18), text_style, Baseline::Top) + Text::with_baseline( + t.loco.as_str(), + Point::new(4, 18), + text_style, + Baseline::Top, + ) + .draw(display) + .ok(); + + draw_fn_active(display, t.functions, 44, 6, &fonts::TEXT); + + Text::with_baseline( + t.footer.as_str(), + Point::new(4, 54), + text_style, + Baseline::Top, + ) + .draw(display) + .ok(); +} + +/// Compact throttle for 128×32: speed + dir + loco / footer / function strip. +fn draw_throttle_mini( + display: &mut Display, + t: &ThrottleView, + text_style: MonoTextStyle<'_, BinaryColor>, +) { + let speed_style = MonoTextStyleBuilder::new() + .font(&fonts::FONT_8X13) + .text_color(BinaryColor::On) + .build(); + + let mut spd = heapless::String::<4>::new(); + if t.speed >= 100 { + let _ = spd.push((b'0' + t.speed / 100) as char); + } + if t.speed >= 10 { + let _ = spd.push((b'0' + (t.speed / 10) % 10) as char); + } + let _ = spd.push((b'0' + t.speed % 10) as char); + Text::with_baseline(spd.as_str(), Point::new(0, 0), speed_style, Baseline::Top) .draw(display) .ok(); - draw_fn_active(display, t.functions); - - Text::with_baseline(t.footer.as_str(), Point::new(4, 54), text_style, Baseline::Top) + let dir = if t.forward { "F" } else { "R" }; + Text::with_baseline(dir, Point::new(40, 2), text_style, Baseline::Top) .draw(display) .ok(); + + Text::with_baseline( + t.loco.as_str(), + Point::new(54, 2), + text_style, + Baseline::Top, + ) + .draw(display) + .ok(); + + Text::with_baseline( + t.footer.as_str(), + Point::new(0, 13), + text_style, + Baseline::Top, + ) + .draw(display) + .ok(); + + draw_fn_active(display, t.functions, 25, 4, &fonts::FONT_4X6); +} + +fn draw_throttle( + display: &mut Display, + t: &ThrottleView, + title_style: MonoTextStyle<'_, BinaryColor>, + text_style: MonoTextStyle<'_, BinaryColor>, +) { + let geom = geometry(); + if geom.height <= 32 { + draw_throttle_mini(display, t, text_style); + } else { + draw_throttle_standard(display, t, title_style, text_style); + } } #[embassy_executor::task] pub async fn task(i2c: SharedI2cDevice) { + let geom = geometry(); + let is_mini = geom.height <= 32; + let interface = I2CDisplayInterface::new_custom_address(i2c, board::OLED_I2C_ADDRESS); - let mut display: Display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) + let mut display: Display = Ssd1306::new(interface, PanelSize {}, DisplayRotation::Rotate0) .into_buffered_graphics_mode(); // Blocking I2C: async esp-hal master hard-resets in Wokwi on first xfer. @@ -346,14 +436,16 @@ pub async fn task(i2c: SharedI2cDevice) { log::error!("oled: init failed"); return; } - log::info!("oled: init ok"); + log::info!("oled: init ok ({}x{})", geom.width, geom.height); // Splash so the panel shows something before domain publishes UiView. { let mut splash = crate::ui::view::GridView::new(); splash.set(0, "LongFred", false); splash.set(1, "boot...", false); - crate::ui::UI_VIEW.sender().send(crate::ui::view::UiView::Grid(splash)); + crate::ui::UI_VIEW + .sender() + .send(crate::ui::view::UiView::Grid(splash)); } let title_style = MonoTextStyleBuilder::new() @@ -375,15 +467,14 @@ pub async fn task(i2c: SharedI2cDevice) { loop { display.clear_buffer(); - Rectangle::new(Point::new(0, 0), Size::new(127, 63)) - .into_styled(frame) - .draw(&mut display) - .ok(); + if !is_mini { + Rectangle::new(Point::new(0, 0), Size::new(127, 63)) + .into_styled(frame) + .draw(&mut display) + .ok(); + } - let view = ui_rx - .as_mut() - .and_then(|r| r.try_get()) - .unwrap_or_default(); + let view = ui_rx.as_mut().and_then(|r| r.try_get()).unwrap_or_default(); match &view { UiView::Grid(g) => draw_grid(&mut display, g, text_style), @@ -391,7 +482,8 @@ pub async fn task(i2c: SharedI2cDevice) { } if blink { - display.set_pixel(124, 4, true); + let blink_y = if is_mini { 2 } else { 4 }; + display.set_pixel(124, blink_y, true); } blink = !blink; diff --git a/crates/firmware/src/ui/fonts.rs b/crates/firmware/src/ui/fonts.rs index f093a69..a52aa25 100644 --- a/crates/firmware/src/ui/fonts.rs +++ b/crates/firmware/src/ui/fonts.rs @@ -1,3 +1,5 @@ //! embedded-graphics font selection. Single source of truth for UI typography. -pub use embedded_graphics::mono_font::ascii::{FONT_10X20 as TITLE, FONT_6X10 as TEXT}; +pub use embedded_graphics::mono_font::ascii::{ + FONT_4X6, FONT_6X10 as TEXT, FONT_8X13, FONT_10X20 as TITLE, +}; diff --git a/crates/firmware/src/ui/headless_shell.rs b/crates/firmware/src/ui/headless_shell.rs new file mode 100644 index 0000000..1b70358 --- /dev/null +++ b/crates/firmware/src/ui/headless_shell.rs @@ -0,0 +1,95 @@ +//! Headless shell for heiko-wifred (no menu / permanent drive mode). + +use crate::domain::actions::Action; +use crate::domain::state::DomainState; +use crate::input::InputEvent; +use crate::ui::menu::Intent; +use crate::ui::view::{Line, ThrottleView, UiView}; + +/// Passes only drive / programming events; ignores Nav, Menu, Digit, etc. +pub struct HeadlessShell; + +impl HeadlessShell { + pub const fn new() -> Self { + Self + } + + /// Whether this event is relevant in headless drive mode. + pub fn should_pass(ev: &InputEvent) -> bool { + matches!( + ev, + InputEvent::SpeedAbsolute(_) + | InputEvent::DirectionSet(_) + | InputEvent::DirectionToggle + | InputEvent::FnPress(_) + | InputEvent::FnRelease(_) + | InputEvent::EStop + | InputEvent::Stop + | InputEvent::LocoSlot(_, _) + | InputEvent::EnterProgrammingMode + ) + } + + /// Map a drive event to a domain intent. + /// + /// `SpeedAbsolute` and `EnterProgrammingMode` return [`Intent::None`] — + /// the domain task applies them from the raw [`InputEvent`] directly. + pub fn handle(&mut self, ev: InputEvent, _domain: &DomainState) -> Intent { + if !Self::should_pass(&ev) { + return Intent::None; + } + match ev { + InputEvent::DirectionSet(dir) => { + if dir == longfred_proto::model::Direction::Forward { + Intent::Action(Action::DirectionForward) + } else { + Intent::Action(Action::DirectionReverse) + } + } + InputEvent::DirectionToggle => Intent::Action(Action::DirectionToggle), + InputEvent::FnPress(f) => Intent::Function(f, true), + InputEvent::FnRelease(f) => Intent::Function(f, false), + InputEvent::EStop | InputEvent::Stop => Intent::Action(Action::EStop), + InputEvent::LocoSlot(slot, on) if on => Intent::Action(Action::Throttle(slot)), + InputEvent::SpeedAbsolute(_) + | InputEvent::EnterProgrammingMode + | InputEvent::LocoSlot(_, _) => Intent::None, + _ => Intent::None, + } + } + + /// Minimal throttle view from domain state (no grid / menu screens). + pub fn view(&self, domain: &DomainState) -> UiView { + let slot = domain.current_slot(); + let mut functions: u32 = 0; + for (i, on) in slot.functions.iter().enumerate().take(32) { + if *on { + functions |= 1u32 << i; + } + } + let mut loco = Line::new(); + if let Some(addr) = slot.consist.first() { + let _ = loco.push_str(addr.as_str()); + } + UiView::Throttle(ThrottleView { + current: domain.current as u8, + speed: slot.speed, + forward: domain.current_forward(), + consist_len: slot.consist.len() as u8, + power_on: domain.track_power_on(), + heartbeat_on: domain.heartbeat_enabled(), + functions, + loco, + footer: Line::new(), + next_hint: Line::new(), + battery: None, + battery_show_percent: false, + }) + } +} + +impl Default for HeadlessShell { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/firmware/src/ui/keyboard.rs b/crates/firmware/src/ui/keyboard.rs index 141a017..568a84a 100644 --- a/crates/firmware/src/ui/keyboard.rs +++ b/crates/firmware/src/ui/keyboard.rs @@ -1,4 +1,4 @@ -//! Text-entry engine: joystick Up/Down + multitap F0-F10. +//! Text-entry engine: joystick picker (CharCycle) + optional multitap F0-F10. use heapless::String; @@ -26,6 +26,7 @@ pub struct TextKeyboard { charset_idx: usize, last_fn: Option, multitap_tap: u8, + uppercase: bool, } impl TextKeyboard { @@ -37,6 +38,7 @@ impl TextKeyboard { charset_idx: 0, last_fn: None, multitap_tap: 0, + uppercase: false, } } @@ -46,26 +48,34 @@ impl TextKeyboard { self.charset_idx = 0; self.last_fn = None; self.multitap_tap = 0; + self.uppercase = false; } pub fn preview(&self) -> String { let mut s = String::new(); let _ = s.push_str(self.buffer.as_str()); if let Some(c) = self.pending { - let _ = s.push(c); + let _ = s.push(self.apply_case(c)); } s } - pub fn nav_up(&mut self) -> KeyboardAction { - self.cycle_pending(true) - } - - pub fn nav_down(&mut self) -> KeyboardAction { - self.cycle_pending(false) + fn apply_case(&self, c: char) -> char { + if self.mode != KeyboardMode::Text { + return c; + } + if self.uppercase { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + } } - fn cycle_pending(&mut self, up: bool) -> KeyboardAction { + /// Joystick / NavProfile picker: cycle pending character by `delta`. + pub fn char_cycle(&mut self, delta: i8) -> KeyboardAction { + if delta == 0 { + return KeyboardAction::None; + } let set = match self.mode { KeyboardMode::Text => kbd_cfg::TEXT_CHARSET, KeyboardMode::Digits => kbd_cfg::DIGIT_CHARSET, @@ -73,26 +83,41 @@ impl TextKeyboard { if set.is_empty() { return KeyboardAction::None; } - let len = set.chars().count(); + let len = set.chars().count() as isize; + let step = delta as isize; if let Some(c) = self.pending { - let idx = set.chars().position(|ch| ch == c).unwrap_or(0); - let next = if up { - (idx + len - 1) % len - } else { - (idx + 1) % len - }; + let idx = set.chars().position(|ch| ch == c).unwrap_or(0) as isize; + let next = (idx + step).rem_euclid(len) as usize; self.pending = kbd_cfg::charset_char(set, next); + self.charset_idx = next; } else { - self.charset_idx = if up { - (self.charset_idx + len - 1) % len - } else { - (self.charset_idx + 1) % len - }; - self.pending = kbd_cfg::charset_char(set, self.charset_idx); + let next = (self.charset_idx as isize + step).rem_euclid(len) as usize; + self.charset_idx = next; + self.pending = kbd_cfg::charset_char(set, next); } KeyboardAction::Changed } + /// Toggle letter case for pending / future text characters. + pub fn case_toggle(&mut self) -> KeyboardAction { + if self.mode != KeyboardMode::Text { + return KeyboardAction::None; + } + self.uppercase = !self.uppercase; + if let Some(c) = self.pending { + self.pending = Some(self.apply_case(c)); + } + KeyboardAction::Changed + } + + pub fn nav_up(&mut self) -> KeyboardAction { + self.char_cycle(-1) + } + + pub fn nav_down(&mut self) -> KeyboardAction { + self.char_cycle(1) + } + pub fn nav_right(&mut self) -> KeyboardAction { self.commit_pending(); KeyboardAction::Committed @@ -120,6 +145,7 @@ impl TextKeyboard { KeyboardAction::Committed } + /// Optional multitap path (markwtech / legacy F-key text entry). pub fn fn_press(&mut self, key: u8) -> KeyboardAction { match self.mode { KeyboardMode::Digits => { @@ -143,7 +169,8 @@ impl TextKeyboard { self.last_fn = Some(key); self.multitap_tap = 0; } - self.pending = kbd_cfg::multitap_char(key, self.multitap_tap); + self.pending = + kbd_cfg::multitap_char(key, self.multitap_tap).map(|c| self.apply_case(c)); KeyboardAction::Changed } } @@ -151,6 +178,7 @@ impl TextKeyboard { fn commit_pending(&mut self) { if let Some(c) = self.pending { + let c = self.apply_case(c); if self.buffer.len() < N { let _ = self.buffer.push(c); } diff --git a/crates/firmware/src/ui/led_presenter.rs b/crates/firmware/src/ui/led_presenter.rs new file mode 100644 index 0000000..a047dc4 --- /dev/null +++ b/crates/firmware/src/ui/led_presenter.rs @@ -0,0 +1,151 @@ +//! 3-LED status presenter for heiko-wifred (STOP / Forward / Reverse). + +use embassy_time::{Duration, Instant, Timer}; +use esp_hal::gpio::{AnyPin, Level, Output, OutputConfig}; + +use crate::config::board; + +/// Device / drive indication mode (set by domain or boot path). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LedMode { + /// Boot / WiFi connecting — STOP blinks 1 Hz. + Boot, + /// Pairing active — greens alternate in antiphase, STOP off. + Pairing, + /// Driving forward — solid forward green. + DriveForward, + /// Driving reverse — solid reverse green. + DriveReverse, + /// Emergency stop — solid STOP + blink direction green. + EStop, + /// Lost server connection — STOP blinks 1 Hz (greens keep last dir). + Disconnect, +} + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::watch::Watch; + +/// Domain → LED presenter. Two subscribers allowed (presenter + debug). +pub static LED_MODE: Watch = Watch::new_with(LedMode::Boot); + +struct Leds { + stop: Output<'static>, + forward: Output<'static>, + reverse: Output<'static>, +} + +impl Leds { + fn set(&mut self, stop: bool, forward: bool, reverse: bool) { + if stop { + self.stop.set_high(); + } else { + self.stop.set_low(); + } + if forward { + self.forward.set_high(); + } else { + self.forward.set_low(); + } + if reverse { + self.reverse.set_high(); + } else { + self.reverse.set_low(); + } + } +} + +/// Steal status LED pins (call once from `main` under heiko feature). +/// +/// # Safety +/// +/// Caller must guarantee these pins are not used elsewhere. In practice this +/// is invoked exactly once from `main` before any other task touches the +/// heiko-wifred GPIOs, so the `steal` is sound by construction. +#[allow(unsafe_code)] +pub fn build() -> (Output<'static>, Output<'static>, Output<'static>) { + let cfg = OutputConfig::default(); + // SAFETY: `HEIKO_LED_STOP` is reserved for this presenter; `main` calls + // `build()` once before spawning the LED task, so no aliasing occurs. + let stop = Output::new( + unsafe { AnyPin::steal(board::HEIKO_LED_STOP) }, + Level::Low, + cfg, + ); + // SAFETY: `HEIKO_LED_FORWARD` is reserved for this presenter; single + // owner established in `main` before any other task runs. + let forward = Output::new( + unsafe { AnyPin::steal(board::HEIKO_LED_FORWARD) }, + Level::Low, + cfg, + ); + // SAFETY: `HEIKO_LED_REVERSE` is reserved for this presenter; single + // owner established in `main` before any other task runs. + let reverse = Output::new( + unsafe { AnyPin::steal(board::HEIKO_LED_REVERSE) }, + Level::Low, + cfg, + ); + (stop, forward, reverse) +} + +#[embassy_executor::task] +pub async fn task(stop: Output<'static>, forward: Output<'static>, reverse: Output<'static>) { + let mut leds = Leds { + stop, + forward, + reverse, + }; + let mut rx = match LED_MODE.receiver() { + Some(r) => r, + None => { + log::error!("led_presenter: no receiver slot in LED_MODE"); + return; + } + }; + let mut mode = rx.try_get().unwrap_or(LedMode::Boot); + let mut phase = false; + let mut last_tick = Instant::now(); + + loop { + // Prefer mode updates; otherwise advance blink phase. + match embassy_futures::select::select( + rx.changed(), + Timer::after(Duration::from_millis(125)), + ) + .await + { + embassy_futures::select::Either::First(m) => { + mode = m; + phase = false; + last_tick = Instant::now(); + } + embassy_futures::select::Either::Second(()) => { + let period_ms = match mode { + LedMode::Boot | LedMode::Disconnect => 500, // 1 Hz half-period + LedMode::Pairing | LedMode::EStop => 250, // 2 Hz half / alternate + LedMode::DriveForward | LedMode::DriveReverse => 1000, + }; + if last_tick.elapsed().as_millis() >= period_ms { + phase = !phase; + last_tick = Instant::now(); + } + } + } + + match mode { + LedMode::Boot | LedMode::Disconnect => { + leds.set(phase, false, false); + } + LedMode::Pairing => { + // Greens alternate; STOP off. + leds.set(false, phase, !phase); + } + LedMode::DriveForward => leds.set(false, true, false), + LedMode::DriveReverse => leds.set(false, false, true), + LedMode::EStop => { + // Solid STOP; blink the last direction green (forward by convention). + leds.set(true, phase, false); + } + } + } +} diff --git a/crates/firmware/src/ui/menu.rs b/crates/firmware/src/ui/menu.rs index d0e5763..6eb21fb 100644 --- a/crates/firmware/src/ui/menu.rs +++ b/crates/firmware/src/ui/menu.rs @@ -5,15 +5,15 @@ mod menu_nav; use longfred_proto::command::Protocol; use longfred_proto::model::TurnoutAction; -use longfred_proto::persist::{DeviceIdentity, Language, StaticIpConfig, DEVICE_ID_MIN}; +use longfred_proto::persist::{DEVICE_ID_MIN, DeviceIdentity, Language, StaticIpConfig}; use crate::config::{self, buttons, network, power, sizes}; use crate::domain::actions::Action; use crate::domain::state::DomainState; use crate::input::InputEvent; -use crate::ui::keyboard::{KeyboardMode, TextKeyboard}; use crate::net::SsidInfo; use crate::ui::i18n; +use crate::ui::keyboard::{KeyboardMode, TextKeyboard}; use crate::ui::view::{GridView, Line, ThrottleView, UiView, ViewCtx}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -74,6 +74,7 @@ pub enum Intent { SaveDevice(DeviceIdentity), RegenerateDeviceId, SetLanguage(Language), + EnterProgrammingMode, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -178,7 +179,9 @@ impl MenuFsm { pub fn tick_splash(&mut self) -> Intent { if self.screen == Screen::Splash && !self.splash_done { self.splash_done = true; - if network::AUTO_CONNECT_TO_FIRST_DEFINED_SERVER && !config::network::NETWORKS.is_empty() { + if network::AUTO_CONNECT_TO_FIRST_DEFINED_SERVER + && !config::network::NETWORKS.is_empty() + { self.screen = Screen::Connecting; Intent::WifiConnect } else { @@ -240,7 +243,7 @@ impl MenuFsm { if self.menu_cmd.is_empty() { return Intent::None; } - use longfred_proto::menu::{finish_menu as finish, MenuFinish}; + use longfred_proto::menu::{MenuFinish, finish_menu as finish}; let result = finish(self.menu_cmd.as_str()); self.menu_cmd.clear(); match result { @@ -339,10 +342,7 @@ impl MenuFsm { } pub(crate) fn begin_ip_edit(&mut self, domain: &DomainState) { - self.net_cfg = domain - .persist - .network - .unwrap_or(StaticIpConfig::default()); + self.net_cfg = domain.persist.network.unwrap_or(StaticIpConfig::default()); self.ip_field = 0; self.net_digits.clear(); self.load_net_field_digits(); @@ -364,12 +364,18 @@ impl MenuFsm { self.net_digits.clear(); match self.ip_field { 0 => { - let _ = self.net_digits.push(if self.net_cfg.dhcp { '0' } else { '1' }); + let _ = self + .net_digits + .push(if self.net_cfg.dhcp { '0' } else { '1' }); } 1 => push_ip_digits(&mut self.net_digits, self.net_cfg.ip), 2 => { - let _ = self.net_digits.push((b'0' + self.net_cfg.prefix_len / 10) as char); - let _ = self.net_digits.push((b'0' + self.net_cfg.prefix_len % 10) as char); + let _ = self + .net_digits + .push((b'0' + self.net_cfg.prefix_len / 10) as char); + let _ = self + .net_digits + .push((b'0' + self.net_cfg.prefix_len % 10) as char); } 3 => { if let Some(gw) = self.net_cfg.gateway { @@ -474,11 +480,13 @@ impl MenuFsm { } else { let _ = s.push(self.net_digits.as_bytes()[0] as char); } - let _ = s.push_str(if self.net_cfg.dhcp || self.net_digits.as_bytes().first() == Some(&b'0') { - " DHCP" - } else { - " Static" - }); + let _ = s.push_str( + if self.net_cfg.dhcp || self.net_digits.as_bytes().first() == Some(&b'0') { + " DHCP" + } else { + " Static" + }, + ); return s; } if self.ip_field == 2 { @@ -507,9 +515,14 @@ impl MenuFsm { pub(crate) fn begin_device_name_edit(&mut self, domain: &DomainState) { self.text_kbd.clear(); self.text_kbd.mode = KeyboardMode::Text; - let _ = self.text_kbd.buffer.push_str(domain.persist.device.name.as_str()); + let _ = self + .text_kbd + .buffer + .push_str(domain.persist.device.name.as_str()); self.device_name_edit.clear(); - let _ = self.device_name_edit.push_str(domain.persist.device.name.as_str()); + let _ = self + .device_name_edit + .push_str(domain.persist.device.name.as_str()); self.screen = Screen::DeviceNameEdit; } @@ -541,7 +554,10 @@ impl MenuFsm { } return Intent::None; } - if matches!(self.screen, Screen::ServerEntry | Screen::IpEdit | Screen::DeviceIdEdit) { + if matches!( + self.screen, + Screen::ServerEntry | Screen::IpEdit | Screen::DeviceIdEdit + ) { if cw { let _ = match self.screen { Screen::ServerEntry => self.ip_kbd.nav_up(), @@ -616,7 +632,9 @@ impl MenuFsm { } else if !self.selected_ssid.is_empty() { if !self.pw.is_empty() { (self.selected_ssid.as_str(), self.pw.as_str()) - } else if let Some(stored) = domain.persist.find_password(self.selected_ssid.as_str()) { + } else if let Some(stored) = + domain.persist.find_password(self.selected_ssid.as_str()) + { (self.selected_ssid.as_str(), stored) } else { (self.selected_ssid.as_str(), "") diff --git a/crates/firmware/src/ui/menu_nav.rs b/crates/firmware/src/ui/menu_nav.rs index f95a14d..c5232a0 100644 --- a/crates/firmware/src/ui/menu_nav.rs +++ b/crates/firmware/src/ui/menu_nav.rs @@ -2,15 +2,16 @@ use longfred_proto::command::Protocol; use longfred_proto::model::TurnoutAction; -use longfred_proto::persist::{Language, DEVICE_ID_MAX, DEVICE_ID_MIN}; +use longfred_proto::persist::{DEVICE_ID_MAX, DEVICE_ID_MIN, Language}; use crate::config::{self, buttons, sizes}; use crate::domain::actions::Action; use crate::domain::state::DomainState; use crate::input::{InputEvent, NavDir}; use crate::net::SsidInfo; -use crate::ui::keyboard::{KeyboardMode, TextKeyboard}; -use crate::ui::menu::{Intent, ListRef, MenuFsm, MenuItemType, Screen, MENU_KEYS, MENU_TYPES}; +use crate::ui::keyboard::KeyboardMode; +use crate::ui::menu::{Intent, ListRef, MENU_KEYS, MENU_TYPES, MenuFsm, MenuItemType, Screen}; +use crate::ui::nav_profile::{self, NavAction, NavProfile}; impl MenuFsm { pub fn handle_input( @@ -18,6 +19,32 @@ impl MenuFsm { ev: InputEvent, domain: &DomainState, scanned: &heapless::Vec, + ) -> Intent { + let text_entry = self.is_text_entry_screen(); + let on_throttle = self.screen == Screen::Throttle; + let profile = nav_profile::active(); + let Some(action) = profile.map(ev, text_entry, on_throttle) else { + return Intent::None; + }; + match action { + NavAction::ListPrev => self.on_list_step(NavDir::Up, domain, scanned), + NavAction::ListNext => self.on_list_step(NavDir::Down, domain, scanned), + NavAction::Select => self.on_ok(domain, scanned), + NavAction::Cancel => self.on_back(domain), + NavAction::MenuEnter => self.on_menu_enter(domain, scanned), + NavAction::CharCycle(d) => self.on_char_cycle(d, domain), + NavAction::CursorMove(d) => self.on_cursor_move(d, domain), + NavAction::CaseToggle => self.on_case_toggle(), + NavAction::Digit(c) => self.on_digit(c, domain), + NavAction::PassThrough(ev) => self.handle_passthrough(ev, domain, scanned), + } + } + + fn handle_passthrough( + &mut self, + ev: InputEvent, + domain: &DomainState, + scanned: &heapless::Vec, ) -> Intent { if let Some(intent) = self.handle_global(ev, domain) { return intent; @@ -32,20 +59,189 @@ impl MenuFsm { InputEvent::EncoderClockwise => self.encoder(true, domain), InputEvent::EncoderCounterClockwise => self.encoder(false, domain), InputEvent::EncoderButton => self.encoder_button(domain), - InputEvent::EStop | InputEvent::DirectionSet(_) => Intent::None, + InputEvent::Stop => self.on_back(domain), + InputEvent::EnterProgrammingMode => Intent::EnterProgrammingMode, + InputEvent::EStop + | InputEvent::DirectionSet(_) + | InputEvent::DirectionToggle + | InputEvent::Digit(_) + | InputEvent::SpeedAbsolute(_) + | InputEvent::LocoSlot(_, _) + | InputEvent::CharCycle(_) + | InputEvent::CursorMove(_) + | InputEvent::CaseToggle => Intent::None, + } + } + + fn on_menu_enter( + &mut self, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { + if self.screen == Screen::Throttle { + return self.on_menu_key(domain); + } + if self.is_text_entry_screen() + || self.is_list_screen() + || matches!( + self.screen, + Screen::IpConfig | Screen::IpEdit | Screen::ServerEntry + ) + { + return self.on_ok(domain, scanned); + } + self.on_menu_key(domain) + } + + fn on_list_step( + &mut self, + dir: NavDir, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { + if self.is_list_screen() { + return self.list_nav(dir, domain, scanned); + } + if self.screen == Screen::Throttle && !domain.current_slot_has_loco() { + let delta = if dir == NavDir::Up { -1i8 } else { 1 }; + let _ = self.addr_kbd.char_cycle(delta); + return Intent::None; + } + Intent::None + } + + fn on_char_cycle(&mut self, delta: i8, domain: &DomainState) -> Intent { + match self.screen { + Screen::Password | Screen::DeviceNameEdit => { + let _ = self.text_kbd.char_cycle(delta); + } + Screen::ServerEntry => { + let _ = self.ip_kbd.char_cycle(delta); + } + Screen::IpEdit => { + let _ = self.net_kbd.char_cycle(delta); + } + Screen::DeviceIdEdit => { + let _ = self.id_kbd.char_cycle(delta); + } + Screen::Throttle if !domain.current_slot_has_loco() => { + let _ = self.addr_kbd.char_cycle(delta); + } + _ => {} + } + Intent::None + } + + fn on_cursor_move(&mut self, delta: i8, domain: &DomainState) -> Intent { + // Buffer cursor lives in MenuFsm later; for now Left=backspace, Right=commit. + let left = delta < 0; + match self.screen { + Screen::Password | Screen::DeviceNameEdit => { + if left { + let _ = self.text_kbd.nav_left(); + } else { + let _ = self.text_kbd.nav_right(); + } + } + Screen::ServerEntry => { + if left { + let _ = self.ip_kbd.nav_left(); + } else { + let _ = self.ip_kbd.nav_right(); + } + } + Screen::IpEdit => { + if left { + let _ = self.net_kbd.nav_left(); + } else { + let _ = self.net_kbd.nav_right(); + } + } + Screen::DeviceIdEdit => { + if left { + let _ = self.id_kbd.nav_left(); + } else { + let _ = self.id_kbd.nav_right(); + } + } + Screen::Throttle if !domain.current_slot_has_loco() => { + if left { + let _ = self.addr_kbd.nav_left(); + } else { + let _ = self.addr_kbd.nav_right(); + } + } + _ => {} + } + Intent::None + } + + fn on_case_toggle(&mut self) -> Intent { + if matches!(self.screen, Screen::Password | Screen::DeviceNameEdit) { + let _ = self.text_kbd.case_toggle(); } + Intent::None + } + + fn on_digit(&mut self, c: char, domain: &DomainState) -> Intent { + if self.screen == Screen::Menu { + if c.is_ascii_digit() { + let _ = self.menu_cmd.push(c); + } + return Intent::None; + } + match self.screen { + Screen::Throttle if !domain.current_slot_has_loco() && c.is_ascii_digit() => { + if self.addr_kbd.buffer.len() < 5 { + let _ = self.addr_kbd.buffer.push(c); + } + } + Screen::Password | Screen::DeviceNameEdit => { + if self.text_kbd.buffer.len() < 64 { + let _ = self.text_kbd.buffer.push(c); + } + } + Screen::ServerEntry if c.is_ascii_digit() => { + if self.ip_kbd.buffer.len() < 17 { + let _ = self.ip_kbd.buffer.push(c); + } + } + Screen::IpEdit if c.is_ascii_digit() => { + if self.net_kbd.buffer.len() < 12 { + let _ = self.net_kbd.buffer.push(c); + } + } + Screen::DeviceIdEdit if c.is_ascii_digit() => { + if self.id_kbd.buffer.len() < 4 { + let _ = self.id_kbd.buffer.push(c); + } + } + _ => {} + } + Intent::None } fn handle_global(&mut self, ev: InputEvent, domain: &DomainState) -> Option { match ev { InputEvent::EStop => Some(Intent::Action(Action::EStop)), - InputEvent::DirectionSet(dir) if self.screen == Screen::Throttle && domain.current_slot_has_loco() => { + // Physical Stop: EStop on throttle, otherwise fall through to Back. + InputEvent::Stop if self.screen == Screen::Throttle => { + Some(Intent::Action(Action::EStop)) + } + InputEvent::DirectionSet(dir) + if self.screen == Screen::Throttle && domain.current_slot_has_loco() => + { Some(if dir == longfred_proto::model::Direction::Forward { Intent::Action(Action::DirectionForward) } else { Intent::Action(Action::DirectionReverse) }) } + InputEvent::DirectionToggle + if self.screen == Screen::Throttle && domain.current_slot_has_loco() => + { + Some(Intent::Action(Action::DirectionToggle)) + } InputEvent::Menu if self.screen == Screen::Throttle => { self.menu_cmd.clear(); self.screen = Screen::Menu; @@ -57,13 +253,20 @@ impl MenuFsm { && domain.current_slot_has_loco() && !self.is_text_entry_screen() => { - Some(Intent::Function(buttons::FN_TO_DCC[k.min(10) as usize], true)) + Some(Intent::Function( + buttons::FN_TO_DCC[k.min(10) as usize], + true, + )) } InputEvent::FnRelease(k) if self.screen == Screen::Throttle && domain.current_slot_has_loco() => { - Some(Intent::Function(buttons::FN_TO_DCC[k.min(10) as usize], false)) + Some(Intent::Function( + buttons::FN_TO_DCC[k.min(10) as usize], + false, + )) } + InputEvent::EnterProgrammingMode => Some(Intent::EnterProgrammingMode), _ => None, } } @@ -89,7 +292,12 @@ impl MenuFsm { Intent::None } - fn on_nav(&mut self, dir: NavDir, domain: &DomainState, scanned: &heapless::Vec) -> Intent { + fn on_nav( + &mut self, + dir: NavDir, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { match self.screen { Screen::Password | Screen::DeviceNameEdit => { match dir { @@ -181,7 +389,11 @@ impl MenuFsm { Intent::None } - fn on_ok(&mut self, domain: &DomainState, scanned: &heapless::Vec) -> Intent { + fn on_ok( + &mut self, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { match self.screen { Screen::Throttle => self.ok_throttle(domain), Screen::Menu => self.ok_menu(), @@ -261,7 +473,12 @@ impl MenuFsm { } } - fn on_fn_press(&mut self, k: u8, domain: &DomainState, _scanned: &heapless::Vec) -> Intent { + fn on_fn_press( + &mut self, + k: u8, + domain: &DomainState, + _scanned: &heapless::Vec, + ) -> Intent { if self.screen == Screen::Menu && !self.menu_cmd.is_empty() { if k <= 9 { let c = (b'0' + k) as char; @@ -328,7 +545,11 @@ impl MenuFsm { ) } - fn list_count(&self, domain: &DomainState, scanned: &heapless::Vec) -> usize { + fn list_count( + &self, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> usize { match self.screen { Screen::SsidList => config::network::NETWORKS.len(), Screen::SsidScan => scanned.len().saturating_sub(self.page * 5).min(5), @@ -347,7 +568,12 @@ impl MenuFsm { } } - fn list_nav(&mut self, dir: NavDir, domain: &DomainState, scanned: &heapless::Vec) -> Intent { + fn list_nav( + &mut self, + dir: NavDir, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { let count = self.list_count(domain, scanned); if count == 0 { return Intent::None; @@ -369,7 +595,11 @@ impl MenuFsm { Intent::None } - fn list_page_next(&mut self, domain: &DomainState, scanned: &heapless::Vec) -> Intent { + fn list_page_next( + &mut self, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { match self.screen { Screen::SsidList => { self.screen = Screen::SsidScan; @@ -525,7 +755,11 @@ impl MenuFsm { } } - fn ok_ssid_scan(&mut self, domain: &DomainState, scanned: &heapless::Vec) -> Intent { + fn ok_ssid_scan( + &mut self, + domain: &DomainState, + scanned: &heapless::Vec, + ) -> Intent { self.selected_ssid_idx = self.page * 5 + self.cursor; self.selected_from_scan = true; self.selected_ssid.clear(); @@ -627,7 +861,9 @@ impl MenuFsm { fn ok_device_name(&mut self, domain: &DomainState) -> Intent { let _ = self.text_kbd.ok(); self.device_name_edit.clear(); - let _ = self.device_name_edit.push_str(self.text_kbd.buffer.as_str()); + let _ = self + .device_name_edit + .push_str(self.text_kbd.buffer.as_str()); let mut device = domain.persist.device.clone(); device.name.clear(); let _ = device.name.push_str(self.device_name_edit.as_str()); diff --git a/crates/firmware/src/ui/mod.rs b/crates/firmware/src/ui/mod.rs index 1e4863e..4bf970d 100644 --- a/crates/firmware/src/ui/mod.rs +++ b/crates/firmware/src/ui/mod.rs @@ -2,9 +2,13 @@ pub mod display; pub mod fonts; +pub mod headless_shell; pub mod i18n; pub mod keyboard; +#[cfg(feature = "variant-heiko-wifred")] +pub mod led_presenter; pub mod menu; +pub mod nav_profile; pub mod view; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; diff --git a/crates/firmware/src/ui/nav_profile.rs b/crates/firmware/src/ui/nav_profile.rs new file mode 100644 index 0000000..9449ab7 --- /dev/null +++ b/crates/firmware/src/ui/nav_profile.rs @@ -0,0 +1,130 @@ +//! Per-variant navigation profile: InputEvent → canonical NavAction. + +use crate::input::{InputEvent, NavDir}; + +/// Canonical UI navigation vocabulary (screen-agnostic). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavAction { + ListPrev, + ListNext, + Select, + Cancel, + MenuEnter, + CharCycle(i8), + CursorMove(i8), + CaseToggle, + Digit(char), + /// Domain / throttle events not remapped by the profile. + PassThrough(InputEvent), +} + +pub trait NavProfile { + /// Map an input event to a canonical navigation action. + /// + /// `text_entry` is true on password / address / IP edit screens. + /// `on_throttle` is true on the drive screen (no menu open). + fn map(&self, ev: InputEvent, text_entry: bool, on_throttle: bool) -> Option; +} + +/// LongFred standard / mini: 5-way joystick + Stop + Menu center. +#[derive(Clone, Copy, Debug, Default)] +pub struct LongFredNav; + +impl NavProfile for LongFredNav { + fn map(&self, ev: InputEvent, text_entry: bool, _on_throttle: bool) -> Option { + match ev { + InputEvent::Nav(NavDir::Up) => Some(if text_entry { + NavAction::CharCycle(-1) + } else { + NavAction::ListPrev + }), + InputEvent::Nav(NavDir::Down) => Some(if text_entry { + NavAction::CharCycle(1) + } else { + NavAction::ListNext + }), + InputEvent::Nav(NavDir::Left) => Some(if text_entry { + NavAction::CursorMove(-1) + } else { + NavAction::PassThrough(InputEvent::Nav(NavDir::Left)) + }), + InputEvent::Nav(NavDir::Right) => Some(if text_entry { + NavAction::CursorMove(1) + } else { + NavAction::PassThrough(InputEvent::Nav(NavDir::Right)) + }), + InputEvent::Ok => Some(NavAction::Select), + InputEvent::Back => Some(NavAction::Cancel), + InputEvent::Menu => Some(NavAction::MenuEnter), + InputEvent::CaseToggle => Some(NavAction::CaseToggle), + InputEvent::Digit(c) => Some(NavAction::Digit(c)), + InputEvent::CharCycle(d) => Some(NavAction::CharCycle(d)), + InputEvent::CursorMove(d) => Some(NavAction::CursorMove(d)), + // Stop / EStop / encoder / Fn / direction stay domain-visible. + other => Some(NavAction::PassThrough(other)), + } + } +} + +/// MarkWTech: encoder + keypad (`*` / `#`). +/// +/// - Encoder → ListPrev/ListNext (or CharCycle in text entry) +/// - `#` → Select +/// - `*` → MenuEnter on throttle; Cancel / backspace in menus & text +/// - Digits 0–9 → Digit +#[derive(Clone, Copy, Debug, Default)] +pub struct MarkwtechNav; + +impl NavProfile for MarkwtechNav { + fn map(&self, ev: InputEvent, text_entry: bool, on_throttle: bool) -> Option { + match ev { + InputEvent::EncoderCounterClockwise => Some(if text_entry { + NavAction::CharCycle(-1) + } else { + NavAction::ListPrev + }), + InputEvent::EncoderClockwise => Some(if text_entry { + NavAction::CharCycle(1) + } else { + NavAction::ListNext + }), + InputEvent::Digit('#') => Some(NavAction::Select), + InputEvent::Digit('*') => Some(if text_entry { + NavAction::Cancel + } else if on_throttle { + // On the drive screen, `*` opens the menu. + NavAction::MenuEnter + } else { + // Inside a menu, `*` acts as Cancel / back. + NavAction::Cancel + }), + InputEvent::Digit(c) => Some(NavAction::Digit(c)), + InputEvent::Menu => Some(NavAction::MenuEnter), + InputEvent::Back | InputEvent::Ok => Some(NavAction::Select), + other => Some(NavAction::PassThrough(other)), + } + } +} + +/// Active nav profile for this build (type differs per variant feature). +#[cfg(any( + feature = "variant-longfred-standard", + feature = "variant-longfred-mini" +))] +pub fn active() -> LongFredNav { + LongFredNav +} + +#[cfg(feature = "variant-markwtech")] +pub fn active() -> MarkwtechNav { + MarkwtechNav +} + +#[cfg(feature = "variant-heiko-wifred")] +pub fn active() -> LongFredNav { + // Heiko-wifred is headless: its ControlSurface never emits Nav/Ok/Menu + // events, so the profile is effectively dead code. We still return a + // LongFredNav so `domain::task` (which always constructs a MenuFsm) + // compiles uniformly across variants. + LongFredNav +} diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index b70ead7..cb9f974 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -4,5 +4,10 @@ version = "0.1.0" edition.workspace = true rust-version.workspace = true +[lints] +workspace = true + [dependencies] heapless = "0.9" +serde = { version = "1", default-features = false, features = ["derive"] } +serde-json-core = { version = "0.6", default-features = false } diff --git a/crates/proto/src/events.rs b/crates/proto/src/events.rs index 849556b..3954621 100644 --- a/crates/proto/src/events.rs +++ b/crates/proto/src/events.rs @@ -2,7 +2,9 @@ use crate::model::*; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServerEvent { - HeartbeatConfig { seconds: u32 }, + HeartbeatConfig { + seconds: u32, + }, Version(ShortText), ServerType(ShortText), ServerDescription(LongText), @@ -11,11 +13,28 @@ pub enum ServerEvent { WebPort(u16), TrackPower(TrackPower), - Speed { throttle: char, speed: u8 }, - DirectionLead { throttle: char, dir: Direction }, - DirectionLoco { throttle: char, addr: LocoAddr, dir: Direction }, - FunctionState { throttle: char, func: u8, on: bool }, - RosterFunctionLabels { throttle: char, labels: [ShortText; MAX_FUNCTIONS] }, + Speed { + throttle: char, + speed: u8, + }, + DirectionLead { + throttle: char, + dir: Direction, + }, + DirectionLoco { + throttle: char, + addr: LocoAddr, + dir: Direction, + }, + FunctionState { + throttle: char, + func: u8, + on: bool, + }, + RosterFunctionLabels { + throttle: char, + labels: [ShortText; MAX_FUNCTIONS], + }, RosterEntriesCount(u16), RosterEntry { @@ -39,8 +58,14 @@ pub enum ServerEvent { state: i32, }, - TurnoutAction { sys_name: ShortText, state: TurnoutState }, - RouteAction { sys_name: ShortText, state: RouteState }, + TurnoutAction { + sys_name: ShortText, + state: TurnoutState, + }, + RouteAction { + sys_name: ShortText, + state: RouteState, + }, AddressAdded { throttle: char, diff --git a/crates/proto/src/input_map.rs b/crates/proto/src/input_map.rs new file mode 100644 index 0000000..769f438 --- /dev/null +++ b/crates/proto/src/input_map.rs @@ -0,0 +1,158 @@ +//! Function-key mapping and chord hold detection (host-testable). + +/// Map a physical function key (0..=8) through optional shift layers. +/// +/// - neither shift → `key` +/// - `shift1` only → `key + 9` +/// - `shift2` (with or without `shift1`) → `key + 18` +/// +/// `key` MUST be in `0..=8`. The caller is expected to enforce this invariant +/// (e.g. via the `ButtonId::F0..=F8` enum); out-of-range input saturates in +/// release builds and trips a `debug_assert!` in debug builds. +/// +/// # Panics +/// +/// Panics in debug builds if `key > 8`. In release builds the result +/// saturates at `u8::MAX` to avoid silent overflow. +pub fn map_fn_key(key: u8, shift1: bool, shift2: bool) -> u8 { + debug_assert!(key <= 8, "function key index out of range 0..=8"); + let offset = if shift2 { + 18 + } else if shift1 { + 9 + } else { + 0 + }; + key.saturating_add(offset) +} + +/// Two-button chord hold detector. +/// +/// Returns `true` from [`ChordState::update`] exactly once when both inputs +/// have been held continuously for `hold_ms`. Resets when either input is +/// released. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ChordState { + pub both_since_ms: Option, + pub fired: bool, +} + +impl ChordState { + pub const fn new() -> Self { + Self { + both_since_ms: None, + fired: false, + } + } + + /// Update chord tracking. Returns `true` once when both `a` and `b` have + /// been held for at least `hold_ms` since they first became both-true. + pub fn update(&mut self, a: bool, b: bool, now_ms: u64, hold_ms: u64) -> bool { + if !(a && b) { + self.both_since_ms = None; + self.fired = false; + return false; + } + + let since = match self.both_since_ms { + Some(t) => t, + None => { + self.both_since_ms = Some(now_ms); + now_ms + } + }; + + if self.fired { + return false; + } + + if now_ms.saturating_sub(since) >= hold_ms { + self.fired = true; + return true; + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_fn_key_no_shift() { + for k in 0..=8 { + assert_eq!(map_fn_key(k, false, false), k); + } + } + + #[test] + fn map_fn_key_shift1() { + for k in 0..=8 { + assert_eq!(map_fn_key(k, true, false), k + 9); + } + } + + #[test] + fn map_fn_key_shift2() { + for k in 0..=8 { + assert_eq!(map_fn_key(k, false, true), k + 18); + // shift2 wins when both are pressed + assert_eq!(map_fn_key(k, true, true), k + 18); + } + } + + #[test] + fn chord_fires_once_after_hold() { + let mut c = ChordState::new(); + assert!(!c.update(true, true, 0, 1000)); + assert!(!c.update(true, true, 500, 1000)); + assert!(c.update(true, true, 1000, 1000)); + // already fired + assert!(!c.update(true, true, 1500, 1000)); + assert!(!c.update(true, true, 2000, 1000)); + } + + #[test] + fn chord_resets_on_release() { + let mut c = ChordState::new(); + assert!(!c.update(true, true, 0, 1000)); + assert!(c.update(true, true, 1000, 1000)); + assert!(!c.update(true, false, 1100, 1000)); + assert_eq!(c.both_since_ms, None); + assert!(!c.fired); + // can fire again after re-hold + assert!(!c.update(true, true, 1200, 1000)); + assert!(c.update(true, true, 2200, 1000)); + } + + #[test] + fn chord_requires_both() { + let mut c = ChordState::new(); + assert!(!c.update(true, false, 0, 100)); + assert!(!c.update(false, true, 50, 100)); + assert!(!c.update(false, false, 100, 100)); + assert!(!c.update(true, true, 200, 100)); + assert!(c.update(true, true, 300, 100)); + } + + #[test] + fn chord_zero_hold_fires_immediately() { + let mut c = ChordState::new(); + assert!(c.update(true, true, 42, 0)); + assert!(!c.update(true, true, 42, 0)); + } + + #[test] + fn chord_partial_release_aborts() { + let mut c = ChordState::new(); + assert!(!c.update(true, true, 0, 500)); + assert!(!c.update(true, true, 200, 500)); + // release one before hold expires + assert!(!c.update(false, true, 300, 500)); + // re-press — timer restarts + assert!(!c.update(true, true, 400, 500)); + assert!(!c.update(true, true, 800, 500)); + assert!(c.update(true, true, 900, 500)); + } +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index b95167f..78e1e13 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -1,15 +1,21 @@ #![cfg_attr(not(test), no_std)] //! LongFred WiThrottle protocol: wire parser + command builder (pure, host-testable). +//! +//! Public item docs are filled incrementally; CI clippy allows `missing_docs` for the +//! same reason. Prefer documenting new public API when adding it. +#![allow(missing_docs)] pub mod adapter; pub mod command; pub mod events; +pub mod input_map; pub mod mdns; pub mod menu; pub mod model; pub mod parser; pub mod persist; pub mod protocol; +pub mod provisioning; pub mod wt; pub mod z21; diff --git a/crates/proto/src/menu.rs b/crates/proto/src/menu.rs index d4092be..353e0e8 100644 --- a/crates/proto/src/menu.rs +++ b/crates/proto/src/menu.rs @@ -26,7 +26,9 @@ pub fn finish_menu(cmd: &str) -> MenuFinish { return MenuFinish::None; } let mut bytes = cmd.as_bytes().iter(); - let first = *bytes.next().unwrap() as char; + // `cmd.is_empty()` is checked above, so indexing [0] is in-bounds. + let first = cmd.as_bytes()[0] as char; + let _ = bytes.next(); let mut rest = heapless::String::<8>::new(); let _ = rest.push_str(cmd.get(1..).unwrap_or("")); match first { @@ -128,10 +130,7 @@ mod tests { #[test] fn menu_turnout_list_throw() { - assert_eq!( - finish_menu("5"), - MenuFinish::TurnoutList { throw: true } - ); + assert_eq!(finish_menu("5"), MenuFinish::TurnoutList { throw: true }); } #[test] diff --git a/crates/proto/src/parser.rs b/crates/proto/src/parser.rs index 9dc21a6..8e344e2 100644 --- a/crates/proto/src/parser.rs +++ b/crates/proto/src/parser.rs @@ -203,12 +203,7 @@ fn parse_loco_action(throttle: char, s: &str, emit: &mut impl FnMut(ServerEvent) } } 'R' => { - let dir = Direction::from_wire( - act.as_bytes() - .get(1) - .copied() - .unwrap_or(b'1') as char, - ); + let dir = Direction::from_wire(act.as_bytes().get(1).copied().unwrap_or(b'1') as char); if addr == "*" { emit(ServerEvent::DirectionLead { throttle, dir }); } else { @@ -222,11 +217,7 @@ fn parse_loco_action(throttle: char, s: &str, emit: &mut impl FnMut(ServerEvent) 'F' => { let on = act.as_bytes().get(1) == Some(&b'1'); if let Ok(func) = act[2..].parse::() { - emit(ServerEvent::FunctionState { - throttle, - func, - on, - }); + emit(ServerEvent::FunctionState { throttle, func, on }); } } // 's' speed steps — not surfaced in ServerEvent (domain uses local config). @@ -325,9 +316,7 @@ fn parse_three_segments(entry: &str) -> Option<(&str, &str, &str)> { } fn find_from(haystack: &str, needle: &str, from: usize) -> Option { - haystack[from..] - .find(needle) - .map(|pos| from + pos) + haystack[from..].find(needle).map(|pos| from + pos) } fn starts(s: &str, prefix: &str) -> bool { diff --git a/crates/proto/src/persist.rs b/crates/proto/src/persist.rs index bd71c0e..0c978f8 100644 --- a/crates/proto/src/persist.rs +++ b/crates/proto/src/persist.rs @@ -1,11 +1,14 @@ //! NVS persistence record serialization (host-testable). pub const MAGIC: u32 = 0x4C46_5031; // "LFP1" -pub const VERSION: u16 = 3; +pub const VERSION: u16 = 4; pub const MAX_CREDENTIALS: usize = 8; pub const MAX_SAVED_LOCOS: usize = 12; pub const MAX_DEVICE_NAME_LEN: usize = 32; pub const MAX_WIFI_HOSTNAME_LEN: usize = 16; +pub const MAX_BIGFRED_LOGIN_LEN: usize = 32; +pub const MAX_BIGFRED_PIN_LEN: usize = 16; +pub const MAX_STATIC_ROSTER_NAME_LEN: usize = 32; pub const WIFI_HOSTNAME_PREFIX: &str = "longred_"; pub const WIFI_HOSTNAME_SUFFIX_LEN: usize = 6; pub const DEVICE_ID_MIN: u16 = 1000; @@ -17,6 +20,9 @@ const TAG_NET: u8 = 3; const TAG_DEV: u8 = 4; const TAG_HOST: u8 = 5; const TAG_LANG: u8 = 6; +const TAG_PROG: u8 = 7; +const TAG_BIGFRED: u8 = 8; +const TAG_ROSTER: u8 = 9; /// UI language (stored in NVS). #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -55,6 +61,35 @@ pub struct SavedLoco { pub addr: heapless::String<8>, } +/// How the device obtains its loco roster. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum RosterMode { + #[default] + Auto = 0, + Static = 1, +} + +impl RosterMode { + pub fn as_u8(self) -> u8 { + self as u8 + } + + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Auto), + 1 => Some(Self::Static), + _ => None, + } + } +} + +/// Static roster entry (address + optional display name). +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct StaticRosterEntry { + pub addr: heapless::String<8>, + pub name: heapless::String, +} + /// Client IPv4 configuration (DHCP or static). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct StaticIpConfig { @@ -158,6 +193,11 @@ pub struct PersistRecord { pub device: DeviceIdentity, pub wifi_hostname: heapless::String, pub language: Language, + pub programming_mode: bool, + pub bigfred_login: heapless::String, + pub bigfred_pin: heapless::String, + pub static_roster: heapless::Vec, + pub roster_mode: RosterMode, } impl Default for PersistRecord { @@ -169,6 +209,11 @@ impl Default for PersistRecord { device: DeviceIdentity::default(), wifi_hostname: heapless::String::new(), language: Language::default(), + programming_mode: false, + bigfred_login: heapless::String::new(), + bigfred_pin: heapless::String::new(), + static_roster: heapless::Vec::new(), + roster_mode: RosterMode::default(), } } } @@ -183,7 +228,11 @@ impl PersistRecord { /// Replace existing password or append; evict oldest when full. pub fn set_password(&mut self, ssid: &str, pw: &str) { - if let Some(c) = self.credentials.iter_mut().find(|c| c.ssid.as_str() == ssid) { + if let Some(c) = self + .credentials + .iter_mut() + .find(|c| c.ssid.as_str() == ssid) + { c.password.clear(); let _ = c.password.push_str(pw); return; @@ -260,6 +309,31 @@ impl PersistRecord { off = write_u8(buf, off, TAG_LANG)?; off = write_u8(buf, off, self.language.as_u8())?; + off = write_u8(buf, off, TAG_PROG)?; + off = write_u8(buf, off, self.programming_mode as u8)?; + + if !self.bigfred_login.is_empty() || !self.bigfred_pin.is_empty() { + off = write_u8(buf, off, TAG_BIGFRED)?; + let login_len = self.bigfred_login.len() as u8; + let pin_len = self.bigfred_pin.len() as u8; + off = write_u8(buf, off, login_len)?; + off = write_u8(buf, off, pin_len)?; + off = write_bytes(buf, off, self.bigfred_login.as_bytes())?; + off = write_bytes(buf, off, self.bigfred_pin.as_bytes())?; + } + + off = write_u8(buf, off, TAG_ROSTER)?; + off = write_u8(buf, off, self.roster_mode.as_u8())?; + off = write_u8(buf, off, self.static_roster.len() as u8)?; + for e in &self.static_roster { + let addr_len = e.addr.len() as u8; + let name_len = e.name.len() as u8; + off = write_u8(buf, off, addr_len)?; + off = write_u8(buf, off, name_len)?; + off = write_bytes(buf, off, e.addr.as_bytes())?; + off = write_bytes(buf, off, e.name.as_bytes())?; + } + let crc = crc32(&buf[0..off]); off = write_u32(buf, off, crc)?; Some(off) @@ -275,7 +349,7 @@ impl PersistRecord { return None; } let version = read_u16(buf, &mut off)?; - if version != 1 && version != 2 && version != 3 { + if version != 1 && version != 2 && version != 3 && version != 4 { return None; } let cred_count = read_u16(buf, &mut off)? as usize; @@ -365,6 +439,39 @@ impl PersistRecord { let lang = read_u8(buf, &mut off)?; rec.language = Language::from_u8(lang)?; } + TAG_PROG => { + rec.programming_mode = read_u8(buf, &mut off)? != 0; + } + TAG_BIGFRED => { + let login_len = read_u8(buf, &mut off)? as usize; + let pin_len = read_u8(buf, &mut off)? as usize; + let login_bytes = read_slice(buf, &mut off, login_len)?; + let pin_bytes = read_slice(buf, &mut off, pin_len)?; + rec.bigfred_login.clear(); + let _ = rec + .bigfred_login + .push_str(core::str::from_utf8(login_bytes).ok()?); + rec.bigfred_pin.clear(); + let _ = rec + .bigfred_pin + .push_str(core::str::from_utf8(pin_bytes).ok()?); + } + TAG_ROSTER => { + let mode = read_u8(buf, &mut off)?; + rec.roster_mode = RosterMode::from_u8(mode)?; + let count = read_u8(buf, &mut off)? as usize; + rec.static_roster.clear(); + for _ in 0..count { + let addr_len = read_u8(buf, &mut off)? as usize; + let name_len = read_u8(buf, &mut off)? as usize; + let addr_bytes = read_slice(buf, &mut off, addr_len)?; + let name_bytes = read_slice(buf, &mut off, name_len)?; + let mut entry = StaticRosterEntry::default(); + let _ = entry.addr.push_str(core::str::from_utf8(addr_bytes).ok()?); + let _ = entry.name.push_str(core::str::from_utf8(name_bytes).ok()?); + let _ = rec.static_roster.push(entry); + } + } _ => return None, } } @@ -594,7 +701,10 @@ mod tests { fn wifi_hostname_from_entropy_format() { let host = wifi_hostname_from_entropy(0x1234_5678); assert!(host.starts_with(WIFI_HOSTNAME_PREFIX)); - assert_eq!(host.len(), WIFI_HOSTNAME_PREFIX.len() + WIFI_HOSTNAME_SUFFIX_LEN); + assert_eq!( + host.len(), + WIFI_HOSTNAME_PREFIX.len() + WIFI_HOSTNAME_SUFFIX_LEN + ); for c in host[WIFI_HOSTNAME_PREFIX.len()..].chars() { assert!(c.is_ascii_digit() || ('a'..='z').contains(&c)); } @@ -638,6 +748,75 @@ mod tests { let decoded = PersistRecord::decode(&buf[..off]).unwrap(); assert_eq!(decoded.language, Language::En); assert_eq!(decoded.device.id, 1234); + assert!(!decoded.programming_mode); + assert!(decoded.bigfred_login.is_empty()); + assert!(decoded.bigfred_pin.is_empty()); + assert!(decoded.static_roster.is_empty()); + assert_eq!(decoded.roster_mode, RosterMode::Auto); + } + + #[test] + fn roundtrip_programming_mode() { + let mut rec = PersistRecord::default(); + rec.programming_mode = true; + let mut buf = [0u8; 512]; + let n = rec.encode(&mut buf).unwrap(); + let decoded = PersistRecord::decode(&buf[..n]).unwrap(); + assert!(decoded.programming_mode); + } + + #[test] + fn roundtrip_bigfred_creds() { + let mut rec = PersistRecord::default(); + let _ = rec.bigfred_login.push_str("operator"); + let _ = rec.bigfred_pin.push_str("1234"); + let mut buf = [0u8; 512]; + let n = rec.encode(&mut buf).unwrap(); + let decoded = PersistRecord::decode(&buf[..n]).unwrap(); + assert_eq!(decoded.bigfred_login.as_str(), "operator"); + assert_eq!(decoded.bigfred_pin.as_str(), "1234"); + } + + #[test] + fn roundtrip_static_roster() { + let mut rec = PersistRecord::default(); + rec.roster_mode = RosterMode::Static; + let mut e = StaticRosterEntry::default(); + let _ = e.addr.push_str("L1234"); + let _ = e.name.push_str("Pacific"); + let _ = rec.static_roster.push(e); + let mut e2 = StaticRosterEntry::default(); + let _ = e2.addr.push_str("S99"); + let _ = rec.static_roster.push(e2); + let mut buf = [0u8; 512]; + let n = rec.encode(&mut buf).unwrap(); + let decoded = PersistRecord::decode(&buf[..n]).unwrap(); + assert_eq!(decoded.roster_mode, RosterMode::Static); + assert_eq!(decoded.static_roster.len(), 2); + assert_eq!(decoded.static_roster[0].addr.as_str(), "L1234"); + assert_eq!(decoded.static_roster[0].name.as_str(), "Pacific"); + assert_eq!(decoded.static_roster[1].addr.as_str(), "S99"); + assert!(decoded.static_roster[1].name.is_empty()); + } + + #[test] + fn decode_v3_missing_v4_tags_defaults() { + let mut buf = [0u8; 512]; + let mut off = 0; + off = write_u32(&mut buf, off, MAGIC).unwrap(); + off = write_u16(&mut buf, off, 3).unwrap(); + off = write_u16(&mut buf, off, 0).unwrap(); + off = write_u16(&mut buf, off, 0).unwrap(); + off = write_u8(&mut buf, off, TAG_LANG).unwrap(); + off = write_u8(&mut buf, off, Language::De.as_u8()).unwrap(); + let crc = crc32(&buf[0..off]); + off = write_u32(&mut buf, off, crc).unwrap(); + let decoded = PersistRecord::decode(&buf[..off]).unwrap(); + assert_eq!(decoded.language, Language::De); + assert!(!decoded.programming_mode); + assert!(decoded.bigfred_login.is_empty()); + assert_eq!(decoded.roster_mode, RosterMode::Auto); + assert!(decoded.static_roster.is_empty()); } #[test] diff --git a/crates/proto/src/provisioning.rs b/crates/proto/src/provisioning.rs new file mode 100644 index 0000000..1e32a50 --- /dev/null +++ b/crates/proto/src/provisioning.rs @@ -0,0 +1,481 @@ +//! HTTP provisioning settings DTOs (serde-json-core, no heapless feature). + +use crate::persist::{ + MAX_CREDENTIALS, MAX_SAVED_LOCOS, PersistRecord, RosterMode, StaticRosterEntry, +}; +#[cfg(test)] +use crate::persist::{MAX_BIGFRED_LOGIN_LEN, MAX_WIFI_HOSTNAME_LEN}; + +use serde::{Deserialize, Serialize}; + +/// Device identity in a GET settings response. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct DeviceView<'a> { + pub name: &'a str, + pub id: u16, +} + +/// Wi-Fi related fields in a GET settings response. +#[derive(Clone, Debug, Serialize)] +pub struct WifiView<'a> { + pub hostname: &'a str, + /// Saved network SSIDs (passwords are write-only). + pub networks: NetworksView<'a>, +} + +/// Serialize saved SSIDs as a JSON array (variable length). +#[derive(Clone, Copy, Debug)] +pub struct NetworksView<'a> { + pub ssids: &'a [&'a str], +} + +impl Serialize for NetworksView<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.ssids.len()))?; + for s in self.ssids { + seq.serialize_element(s)?; + } + seq.end() + } +} + +/// BigFred credentials in a GET settings response (PIN is write-only). +#[derive(Clone, Copy, Debug, Serialize)] +pub struct BigfredView<'a> { + pub login: &'a str, + /// Always empty on GET; clients may set via PUT. + pub pin_set: bool, +} + +/// One static roster entry for GET. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct RosterEntryView<'a> { + pub addr: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<&'a str>, +} + +/// Roster section for GET. +#[derive(Clone, Debug, Serialize)] +pub struct RosterView<'a> { + pub mode: RosterModeName, + pub entries: RosterEntriesView<'a>, +} + +/// Serialize roster entries as a JSON array (variable length). +#[derive(Clone, Copy, Debug)] +pub struct RosterEntriesView<'a> { + pub entries: &'a [StaticRosterEntry], +} + +impl Serialize for RosterEntriesView<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.entries.len()))?; + for e in self.entries { + let name = if e.name.is_empty() { + None + } else { + Some(e.name.as_str()) + }; + seq.serialize_element(&RosterEntryView { + addr: e.addr.as_str(), + name, + })?; + } + seq.end() + } +} + +/// Wire name for [`RosterMode`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RosterModeName { + Auto, + Static, +} + +impl From for RosterModeName { + fn from(m: RosterMode) -> Self { + match m { + RosterMode::Auto => Self::Auto, + RosterMode::Static => Self::Static, + } + } +} + +impl From for RosterMode { + fn from(m: RosterModeName) -> Self { + match m { + RosterModeName::Auto => Self::Auto, + RosterModeName::Static => Self::Static, + } + } +} + +/// Full GET `/settings` body (borrows into [`PersistRecord`] / scratch). +#[derive(Clone, Debug, Serialize)] +pub struct SettingsGet<'a> { + pub device: DeviceView<'a>, + pub wifi: WifiView<'a>, + pub bigfred: BigfredView<'a>, + pub roster: RosterView<'a>, + pub programming_mode: bool, +} + +/// Serialize current settings into `buf`. Returns bytes written. +/// +/// `network_ssids` is a scratch array of `&str` pointing at `rec.credentials[*].ssid`. +pub fn serialize_settings( + buf: &mut [u8], + rec: &PersistRecord, + network_ssids: &[&str], +) -> Result { + let view = SettingsGet { + device: DeviceView { + name: rec.device.name.as_str(), + id: rec.device.id, + }, + wifi: WifiView { + hostname: rec.wifi_hostname.as_str(), + networks: NetworksView { + ssids: network_ssids, + }, + }, + bigfred: BigfredView { + login: rec.bigfred_login.as_str(), + pin_set: !rec.bigfred_pin.is_empty(), + }, + roster: RosterView { + mode: rec.roster_mode.into(), + entries: RosterEntriesView { + entries: rec.static_roster.as_slice(), + }, + }, + programming_mode: rec.programming_mode, + }; + serde_json_core::to_slice(&view, buf) +} + +/// Helper: fill a ssid scratch buffer from `rec` then serialize. +pub fn serialize_settings_from_record( + buf: &mut [u8], + rec: &PersistRecord, +) -> Result { + let mut ssids: [&str; MAX_CREDENTIALS] = [""; MAX_CREDENTIALS]; + let n = rec.credentials.len().min(MAX_CREDENTIALS); + for (slot, cred) in ssids.iter_mut().zip(rec.credentials.iter()).take(n) { + *slot = cred.ssid.as_str(); + } + serialize_settings(buf, rec, &ssids[..n]) +} + +/// Optional Wi-Fi fields in a PUT body. +#[derive(Clone, Copy, Debug, Default, Deserialize)] +pub struct WifiPut<'a> { + #[serde(default)] + #[serde(borrow)] + pub ssid: Option<&'a str>, + #[serde(default)] + #[serde(borrow)] + pub password: Option<&'a str>, + #[serde(default)] + #[serde(borrow)] + pub hostname: Option<&'a str>, +} + +/// Optional BigFred fields in a PUT body. +#[derive(Clone, Copy, Debug, Default, Deserialize)] +pub struct BigfredPut<'a> { + #[serde(default)] + #[serde(borrow)] + pub login: Option<&'a str>, + #[serde(default)] + #[serde(borrow)] + pub pin: Option<&'a str>, +} + +/// One roster entry in a PUT body. +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct RosterEntryPut<'a> { + #[serde(borrow)] + pub addr: &'a str, + #[serde(default)] + #[serde(borrow)] + pub name: Option<&'a str>, +} + +/// PUT `/settings` body. All fields optional; missing tags leave persist unchanged. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct SettingsPut<'a> { + #[serde(default)] + #[serde(borrow)] + pub wifi: Option>, + #[serde(default)] + #[serde(borrow)] + pub bigfred: Option>, + #[serde(default)] + pub programming_mode: Option, + #[serde(default)] + pub roster_mode: Option, + /// Up to [`MAX_SAVED_LOCOS`] entries; shorter JSON arrays are accepted. + #[serde(default)] + #[serde(borrow)] + #[serde(deserialize_with = "deserialize_roster_entries")] + pub roster: [Option>; MAX_SAVED_LOCOS], +} + +fn deserialize_roster_entries<'de, D>( + deserializer: D, +) -> Result<[Option>; MAX_SAVED_LOCOS], D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = [Option>; MAX_SAVED_LOCOS]; + + fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "array of at most {MAX_SAVED_LOCOS} roster entries") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut out: [Option>; MAX_SAVED_LOCOS] = [None; MAX_SAVED_LOCOS]; + let mut i = 0usize; + while let Some(item) = seq.next_element::>()? { + if i >= MAX_SAVED_LOCOS { + return Err(serde::de::Error::custom("roster too long")); + } + out[i] = Some(item); + i += 1; + } + Ok(out) + } + } + + deserializer.deserialize_seq(Visitor) +} + +/// Deserialize a PUT body from JSON bytes. +pub fn deserialize_settings_put(buf: &[u8]) -> Result, serde_json_core::de::Error> { + let (put, _rest) = serde_json_core::from_slice(buf)?; + Ok(put) +} + +/// Error returned by [`apply_settings_put`] when a field exceeds its +/// fixed-capacity storage in [`PersistRecord`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApplyError { + /// `wifi.hostname` longer than `MAX_WIFI_HOSTNAME_LEN`. + HostnameTooLong, + /// `bigfred.login` longer than `MAX_BIGFRED_LOGIN_LEN`. + LoginTooLong, + /// `bigfred.pin` longer than `MAX_BIGFRED_PIN_LEN`. + PinTooLong, + /// A roster `addr` longer than the entry capacity. + RosterAddrTooLong, + /// A roster `name` longer than `MAX_STATIC_ROSTER_NAME_LEN`. + RosterNameTooLong, + /// More roster entries than `MAX_SAVED_LOCOS`. + RosterFull, +} + +/// Apply a PUT body onto a persist record. +/// +/// Returns `Err(ApplyError)` when a string does not fit its fixed-capacity +/// storage; in that case the record is left in a partially-updated state +/// (callers should treat the whole PUT as rejected). +pub fn apply_settings_put( + rec: &mut PersistRecord, + put: &SettingsPut<'_>, +) -> Result<(), ApplyError> { + if let Some(wifi) = &put.wifi { + if let (Some(ssid), Some(password)) = (wifi.ssid, wifi.password) { + rec.set_password(ssid, password); + } + if let Some(host) = wifi.hostname { + rec.wifi_hostname.clear(); + if rec.wifi_hostname.push_str(host).is_err() { + return Err(ApplyError::HostnameTooLong); + } + } + } + + if let Some(bf) = &put.bigfred { + if let Some(login) = bf.login { + rec.bigfred_login.clear(); + if rec.bigfred_login.push_str(login).is_err() { + return Err(ApplyError::LoginTooLong); + } + } + if let Some(pin) = bf.pin { + rec.bigfred_pin.clear(); + if rec.bigfred_pin.push_str(pin).is_err() { + return Err(ApplyError::PinTooLong); + } + } + } + + if let Some(pm) = put.programming_mode { + rec.programming_mode = pm; + } + + if let Some(mode) = put.roster_mode { + rec.roster_mode = mode.into(); + } + + // Replace roster when any entry is present. Missing `roster` key deserializes + // to all-None and leaves the existing roster unchanged. + let has_entries = put.roster.iter().any(|e| e.is_some()); + if has_entries { + rec.static_roster.clear(); + for slot in &put.roster { + let Some(e) = slot else { continue }; + let mut entry = StaticRosterEntry::default(); + if entry.addr.push_str(e.addr).is_err() { + return Err(ApplyError::RosterAddrTooLong); + } + if let Some(name) = e.name + && entry.name.push_str(name).is_err() + { + return Err(ApplyError::RosterNameTooLong); + } + if rec.static_roster.push(entry).is_err() { + return Err(ApplyError::RosterFull); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_empty_settings() { + let rec = PersistRecord::default(); + let mut buf = [0u8; 512]; + let n = serialize_settings_from_record(&mut buf, &rec).unwrap(); + let s = core::str::from_utf8(&buf[..n]).unwrap(); + assert!(s.contains("\"programming_mode\":false")); + assert!(s.contains("\"mode\":\"auto\"")); + assert!(s.contains("\"pin_set\":false")); + assert!(s.contains("\"networks\":[]")); + assert!(s.contains("\"entries\":[]")); + } + + #[test] + fn serialize_populated() { + let mut rec = PersistRecord::default(); + rec.device.name.clear(); + let _ = rec.device.name.push_str("Pilot"); + rec.device.id = 4242; + let _ = rec.wifi_hostname.push_str("longred_abc123"); + rec.set_password("Home", "secret"); + let _ = rec.bigfred_login.push_str("bob"); + let _ = rec.bigfred_pin.push_str("9999"); + rec.programming_mode = true; + rec.roster_mode = RosterMode::Static; + let mut e = StaticRosterEntry::default(); + let _ = e.addr.push_str("L1"); + let _ = e.name.push_str("One"); + let _ = rec.static_roster.push(e); + + let mut buf = [0u8; 1024]; + let n = serialize_settings_from_record(&mut buf, &rec).unwrap(); + let s = core::str::from_utf8(&buf[..n]).unwrap(); + assert!(s.contains("\"name\":\"Pilot\"")); + assert!(s.contains("\"id\":4242")); + assert!(s.contains("\"hostname\":\"longred_abc123\"")); + assert!(s.contains("\"networks\":[\"Home\"]")); + assert!(s.contains("\"login\":\"bob\"")); + assert!(s.contains("\"pin_set\":true")); + assert!(!s.contains("9999")); + assert!(s.contains("\"mode\":\"static\"")); + assert!(s.contains("\"addr\":\"L1\"")); + assert!(s.contains("\"name\":\"One\"")); + assert!(s.contains("\"programming_mode\":true")); + } + + #[test] + fn deserialize_partial_put() { + let json = br#"{"wifi":{"ssid":"Net","password":"pw"},"programming_mode":true}"#; + let put = deserialize_settings_put(json).unwrap(); + assert_eq!(put.wifi.unwrap().ssid, Some("Net")); + assert_eq!(put.wifi.unwrap().password, Some("pw")); + assert_eq!(put.programming_mode, Some(true)); + assert!(put.bigfred.is_none()); + assert!(put.roster.iter().all(|e| e.is_none())); + } + + #[test] + fn deserialize_and_apply_roster() { + let json = br#"{ + "roster_mode":"static", + "roster":[{"addr":"S42","name":"Switch"},{"addr":"L7"}], + "bigfred":{"login":"ops","pin":"1234"} + }"#; + let put = deserialize_settings_put(json).unwrap(); + let mut rec = PersistRecord::default(); + assert!(apply_settings_put(&mut rec, &put).is_ok()); + assert_eq!(rec.roster_mode, RosterMode::Static); + assert_eq!(rec.static_roster.len(), 2); + assert_eq!(rec.static_roster[0].addr.as_str(), "S42"); + assert_eq!(rec.static_roster[0].name.as_str(), "Switch"); + assert_eq!(rec.static_roster[1].addr.as_str(), "L7"); + assert!(rec.static_roster[1].name.is_empty()); + assert_eq!(rec.bigfred_login.as_str(), "ops"); + assert_eq!(rec.bigfred_pin.as_str(), "1234"); + } + + #[test] + fn apply_wifi_password() { + let json = br#"{"wifi":{"ssid":"Club","password":"x"}}"#; + let put = deserialize_settings_put(json).unwrap(); + let mut rec = PersistRecord::default(); + assert!(apply_settings_put(&mut rec, &put).is_ok()); + assert_eq!(rec.find_password("Club"), Some("x")); + } + + #[test] + fn put_missing_fields_noop() { + let put = deserialize_settings_put(br#"{}"#).unwrap(); + let mut rec = PersistRecord::default(); + let _ = rec.bigfred_login.push_str("keep"); + rec.programming_mode = true; + assert!(apply_settings_put(&mut rec, &put).is_ok()); + assert_eq!(rec.bigfred_login.as_str(), "keep"); + assert!(rec.programming_mode); + } + + #[test] + fn apply_too_long_hostname_returns_typed_error() { + let long = "x".repeat(MAX_WIFI_HOSTNAME_LEN + 1); + let json = format!(r#"{{"wifi":{{"hostname":"{long}"}}}}"#); + let put = deserialize_settings_put(json.as_bytes()).unwrap(); + let mut rec = PersistRecord::default(); + assert_eq!( + apply_settings_put(&mut rec, &put), + Err(ApplyError::HostnameTooLong) + ); + } + + #[test] + fn apply_too_long_login_returns_typed_error() { + let long = "x".repeat(MAX_BIGFRED_LOGIN_LEN + 1); + let json = format!(r#"{{"bigfred":{{"login":"{long}"}}}}"#); + let put = deserialize_settings_put(json.as_bytes()).unwrap(); + let mut rec = PersistRecord::default(); + assert_eq!( + apply_settings_put(&mut rec, &put), + Err(ApplyError::LoginTooLong) + ); + } +} diff --git a/crates/proto/src/wt.rs b/crates/proto/src/wt.rs index 994556c..d5fad80 100644 --- a/crates/proto/src/wt.rs +++ b/crates/proto/src/wt.rs @@ -17,7 +17,13 @@ pub struct WtAdapter { } impl WtAdapter { - pub fn new(name: &str, id: &str, hb_period: u32, send_leading_crlf: bool, heartbeat_enabled: bool) -> Self { + pub fn new( + name: &str, + id: &str, + hb_period: u32, + send_leading_crlf: bool, + heartbeat_enabled: bool, + ) -> Self { let mut n = heapless::String::new(); let _ = n.push_str(name); let mut i = heapless::String::new(); @@ -36,10 +42,7 @@ impl WtAdapter { pub fn on_connect(&mut self, out: &mut WireBuf, _emit: &mut dyn FnMut(ServerEvent)) { self.push_line(out, &protocol::handshake_name(self.name.as_str())); self.push_line(out, &protocol::handshake_id(self.id.as_str())); - self.push_line( - out, - &protocol::heartbeat_enable(self.heartbeat_enabled), - ); + self.push_line(out, &protocol::heartbeat_enable(self.heartbeat_enabled)); } pub fn encode( @@ -68,7 +71,11 @@ impl WtAdapter { out, &protocol::set_speed(throttle_char_u8(*throttle), *speed), ), - ClientCommand::SetDirection { throttle, loco, dir } => { + ClientCommand::SetDirection { + throttle, + loco, + dir, + } => { let owned = loco.map(|l| l.to_wire()); let addr = owned.as_ref().map(|s| s.as_str()).unwrap_or("*"); self.push_line( @@ -88,13 +95,7 @@ impl WtAdapter { let sel = if *all { "*" } else { "" }; self.push_line( out, - &protocol::set_function( - throttle_char_u8(*throttle), - sel, - *func, - *on, - false, - ), + &protocol::set_function(throttle_char_u8(*throttle), sel, *func, *on, false), ); } ClientCommand::TrackPower(on) => { diff --git a/crates/proto/src/z21.rs b/crates/proto/src/z21.rs index b44640c..d6baa29 100644 --- a/crates/proto/src/z21.rs +++ b/crates/proto/src/z21.rs @@ -3,7 +3,7 @@ use crate::adapter::WireBuf; use crate::command::{ClientCommand, LocoId}; use crate::events::ServerEvent; -use crate::model::{throttle_char, Direction, LocoAddr, LongText, TrackPower}; +use crate::model::{Direction, LocoAddr, LongText, TrackPower, throttle_char}; const HDR_XBUS: u16 = 0x0040; const MAX_LOCOS: usize = 16; @@ -61,7 +61,11 @@ fn steps_db0(steps: u8) -> u8 { /// Domain speed 0..=126 (128-step UI scale) → Z21 DB3 for the given speed-step mode. pub fn encode_db3(speed: u8, dir: Direction, steps: u8) -> u8 { - let r = if dir == Direction::Forward { 0x80 } else { 0x00 }; + let r = if dir == Direction::Forward { + 0x80 + } else { + 0x00 + }; if speed == 0 { return r; } @@ -156,11 +160,7 @@ impl Z21Adapter { emit: &mut dyn FnMut(ServerEvent), ) { match cmd { - ClientCommand::AddLoco { - throttle, - loco, - .. - } => { + ClientCommand::AddLoco { throttle, loco, .. } => { let _ = self.locos.push(Slot { throttle: *throttle, addr: loco.addr, @@ -208,7 +208,11 @@ impl Z21Adapter { ClientCommand::SetSpeed { throttle, speed } => { self.drive(*throttle, None, Some(*speed), None, out); } - ClientCommand::SetDirection { throttle, loco, dir } => { + ClientCommand::SetDirection { + throttle, + loco, + dir, + } => { self.drive(*throttle, *loco, None, Some(*dir), out); } ClientCommand::EStop { throttle } => { @@ -218,10 +222,7 @@ impl Z21Adapter { } } ClientCommand::SetFunction { - throttle, - func, - on, - .. + throttle, func, on, .. } => { for s in self.locos.iter_mut().filter(|s| s.throttle == *throttle) { let a = addr_bytes(s.addr, s.long); @@ -442,8 +443,16 @@ mod tests { put_xbus(&mut pkt, &[0x61, 0x01]); put_xbus(&mut pkt, &[0xEF, 0x00, 0x1F, 0x13, 0x80 | 40, 0x00]); adapter.decode(pkt.as_slice(), &mut emit); - assert!(events.iter().any(|e| matches!(e, ServerEvent::TrackPower(TrackPower::On)))); - assert!(events.iter().any(|e| matches!(e, ServerEvent::Speed { speed: 39, .. }))); + assert!( + events + .iter() + .any(|e| matches!(e, ServerEvent::TrackPower(TrackPower::On))) + ); + assert!( + events + .iter() + .any(|e| matches!(e, ServerEvent::Speed { speed: 39, .. })) + ); } #[test] @@ -467,8 +476,10 @@ mod tests { &mut emit, ); assert!(!out.is_empty()); - assert!(events - .iter() - .any(|e| matches!(e, ServerEvent::AddressAdded { .. }))); + assert!( + events + .iter() + .any(|e| matches!(e, ServerEvent::AddressAdded { .. })) + ); } } diff --git a/crates/proto/tests/mdns.rs b/crates/proto/tests/mdns.rs index 24fd2da..322035d 100644 --- a/crates/proto/tests/mdns.rs +++ b/crates/proto/tests/mdns.rs @@ -1,5 +1,7 @@ +//! Integration tests for WiThrottle mDNS discovery helpers. + use longfred_proto::command::Protocol; -use longfred_proto::mdns::{build_ptr_query, collect_servers, WITHROTTLE_SERVICE}; +use longfred_proto::mdns::{WITHROTTLE_SERVICE, build_ptr_query, collect_servers}; fn push_label(pkt: &mut Vec, label: &str) { pkt.push(label.len() as u8); diff --git a/crates/proto/tests/parser.rs b/crates/proto/tests/parser.rs index 815fdab..da112cf 100644 --- a/crates/proto/tests/parser.rs +++ b/crates/proto/tests/parser.rs @@ -1,6 +1,8 @@ +//! Integration tests for WiThrottle line parsing. + +use longfred_proto::ServerEvent; use longfred_proto::model::{Direction, TrackPower}; use longfred_proto::parser::parse; -use longfred_proto::ServerEvent; fn collect(line: &str) -> Vec { let mut events = Vec::new(); @@ -24,26 +26,17 @@ fn server_description() { #[test] fn heartbeat_config() { let events = collect("*10"); - assert_eq!( - events[0], - ServerEvent::HeartbeatConfig { seconds: 10 } - ); + assert_eq!(events[0], ServerEvent::HeartbeatConfig { seconds: 10 }); } #[test] fn power_on() { - assert_eq!( - collect("PPA1")[0], - ServerEvent::TrackPower(TrackPower::On) - ); + assert_eq!(collect("PPA1")[0], ServerEvent::TrackPower(TrackPower::On)); } #[test] fn power_off() { - assert_eq!( - collect("PPA0")[0], - ServerEvent::TrackPower(TrackPower::Off) - ); + assert_eq!(collect("PPA0")[0], ServerEvent::TrackPower(TrackPower::Off)); } #[test] @@ -113,10 +106,22 @@ fn roster_list() { let line = "RL2]\\[Big Boy}|{4014}|{L]\\[Shay}|{12}|{S"; let events = collect(line); assert_eq!(events[0], ServerEvent::RosterEntriesCount(2)); - assert!(matches!(events[1], ServerEvent::RosterEntry { index: 0, .. })); - assert!(matches!(events[2], ServerEvent::RosterEntry { index: 1, .. })); + assert!(matches!( + events[1], + ServerEvent::RosterEntry { index: 0, .. } + )); + assert!(matches!( + events[2], + ServerEvent::RosterEntry { index: 1, .. } + )); - if let ServerEvent::RosterEntry { name, address, length, .. } = &events[1] { + if let ServerEvent::RosterEntry { + name, + address, + length, + .. + } = &events[1] + { assert_eq!(name.as_str(), "Big Boy"); assert_eq!(*address, 4014); assert_eq!(*length, 'L'); diff --git a/crates/proto/tests/protocol.rs b/crates/proto/tests/protocol.rs index a5848e1..66e98be 100644 --- a/crates/proto/tests/protocol.rs +++ b/crates/proto/tests/protocol.rs @@ -1,3 +1,5 @@ +//! Integration tests for WiThrottle protocol framing helpers. + use longfred_proto::model::{Direction, TurnoutAction}; use longfred_proto::protocol as p; diff --git a/docs/hardware/heiko-wifred.md b/docs/hardware/heiko-wifred.md new file mode 100644 index 0000000..2641c76 --- /dev/null +++ b/docs/hardware/heiko-wifred.md @@ -0,0 +1,57 @@ +# Heiko wiFred-style (headless) + +ESP32-C6-DevKitC-1 with wiFred-like controls: speed pot, direction switch, four loco selectors, F0–F8, yellow Shift, red EStop, and three status LEDs. **No OLED and no on-device menu** — configuration is Wi‑Fi programming only. + +| Item | Value | +|------|-------| +| Cargo feature | `variant-heiko-wifred` | +| Display | none (LED presenter) | +| Expanders | 2× MCP23017 | +| Speed | ADC potentiometer | +| Programming chord | **Shift + Stop** 8 s | +| Auto-pair | yes, if NVS has no Wi‑Fi credentials | + +## Controls + +- Potentiometer → absolute speed 0–126 +- Direction switch → forward/reverse +- Four loco enable switches → slots 0–3 +- F0–F8; Shift1 raises to F9–F16 +- Red Stop; yellow Shift +- HeadlessShell ignores menu/nav events + +## LED patterns + +| Mode | STOP (red) | FORWARD (green) | REVERSE (green) | +|------|------------|-----------------|-----------------| +| Boot / connecting | blink 1 Hz | off | off | +| Entering pair (2 s) | fast blink | fast blink | fast blink | +| Pairing active | off | alternate | alternate | +| Drive forward | off | solid | off | +| Drive reverse | off | off | solid | +| EStop | solid | blink (dir) | blink (dir) | +| Server lost | blink 1 Hz | last dir held | last dir held | + +## Pin map + +| Function | GPIO / bus | +|----------|------------| +| I2C SDA / SCL | 6 / 7 | +| MCP23017 | 0x20, 0x21 | +| LED STOP / FWD / REV | 18 / 19 / 20 | +| Speed pot ADC | GPIO 1 (shared battery ADC pin — dedicated pot pin in future revisions) | + +```mermaid +flowchart LR + ESP[ESP32-C6] --- I2C[I2C] + I2C --- MCP[2x MCP23017] + MCP --- BTN[F0-F8 Shift Stop loco dir] + ESP --- POT[Speed pot ADC] + ESP --- LED[STOP FWD REV LEDs] +``` + +## Programming mode + +- Auto: first boot with empty Wi‑Fi NVS +- Manual: Shift + Stop 8 s +- Soft-AP `longfred_prog_XXXXXX` — see [provisioning.md](../provisioning.md) diff --git a/docs/hardware/longfred-mini.md b/docs/hardware/longfred-mini.md new file mode 100644 index 0000000..e2c8293 --- /dev/null +++ b/docs/hardware/longfred-mini.md @@ -0,0 +1,32 @@ +# LongFred Mini + +Same hardware family as [LongFred Standard](longfred-standard.md), but with a **0.91" SSD1306 128×32** OLED. + +| Item | Value | +|------|-------| +| Cargo feature | `variant-longfred-mini` | +| Display | SSD1306 128×32 I2C `@ 0x3C` | +| Controls / expanders / encoder | Identical to standard | +| Programming chord | **Shift1 + Stop** 8 s | + +## Software sharing + +Pinout, `ControlSurface`, `NavProfile`, and Shift layers live in one module (`board/variants/longfred_family.rs`). The only compile-time difference is `DisplayGeometry` (128×32 compact layout). + +## Compact throttle layout + +| Band | Y | Content | +|------|---|--------| +| Speed | 0–12 | Speed `8×13`, direction + loco `6×10` | +| Info | 13–22 | Button/footer line `6×10` | +| Functions | 25–30 | F0–F28 strip `4×6` | + +Menu grids use 3 rows × 2 columns (~6 lines) instead of 12. + +## Wiring + +Same as [longfred-standard.md](longfred-standard.md); swap the OLED for a 128×32 module. + +## BOM delta + +- OLED 0.91" SSD1306 128×32 I2C (e.g. Allegro 0.91" modules) instead of 128×64 diff --git a/docs/hardware/longfred-standard.md b/docs/hardware/longfred-standard.md new file mode 100644 index 0000000..e037d09 --- /dev/null +++ b/docs/hardware/longfred-standard.md @@ -0,0 +1,64 @@ +# LongFred Standard + +ESP32-C6-DevKitC-1U handheld throttle with OLED **128×64**, MCP23017 expanders, and rotary encoder. + +## Features + +| Item | Value | +|------|-------| +| MCU | ESP32-C6-DevKitC-1U | +| Display | SSD1306 128×64 I2C `@ 0x3C` | +| I/O | 2× MCP23017 (0x20, 0x21) | +| Speed | KY-040 / EC11 encoder (A/B only) | +| Cargo feature | `variant-longfred-standard` (default) | +| Programming chord | **Shift1 + Stop** held 8 s | + +## Controls + +- **STOP** — EStop on throttle screen; Cancel/Back in menus +- **Shift1 / Shift2** — F-key layers (F0–F8 / F9–F17 / F18–F26); Shift1 also toggles case in text entry +- **5-way joystick** — Up/Down/Left/Right + center = Menu (Select when already in a menu) +- **Encoder** — speed only +- **Direction** — toggle loco direction +- **F0–F8** — DCC functions (shifted via Shift1/Shift2) + +## Pin map + +```mermaid +flowchart LR + ESP[ESP32-C6] --- I2C[I2C SDA6 SCL7] + I2C --- OLED[SSD1306 0x3C] + I2C --- MCP0[MCP23017 0x20] + I2C --- MCP1[MCP23017 0x21] + ESP --- ENC[Encoder A=2 B=3] + ESP --- JOY[Joy GPIO 18-23 Menu=10] + MCP0 --- FKEYS[F0-F7 Stop] + MCP1 --- MORE[F8 Shift Direction] +``` + +| Function | Connection | +|----------|------------| +| I2C SDA / SCL | GPIO 6 / 7 | +| OLED | I2C 0x3C | +| MCP23017 #0 / #1 | I2C 0x20 / 0x21 | +| Encoder A / B | GPIO 2 / 3 | +| Joy Up/Down/Left/Right/Ok*/Menu | GPIO 18–22 / 10 | +| Battery ADC | GPIO 1 | + +\*Center of 5-way is Menu in firmware; legacy Ok GPIO maps through the surface as Menu. + +Exact MCP bit map: [`config/board.rs`](../../crates/firmware/src/config/board.rs) `BUTTON_MAP`. + +## BOM (core) + +- ESP32-C6-DevKitC-1U +- SSD1306 0.96" 128×64 OLED (I2C) +- 2× MCP23017 / Kamod IOEXP16 +- KY-040 or EC11 encoder +- Tact switches F0–F8, Stop, Shift×2, Direction +- 5-way joystick module +- LiPo + charging (board-dependent) + +## Programming mode + +Hold **Shift1 + Stop** for 8 seconds. Soft-AP `longfred_prog_XXXXXX` at `192.168.0.1`. See [provisioning.md](../provisioning.md). diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md new file mode 100644 index 0000000..3134a12 --- /dev/null +++ b/docs/hardware/markwtech.md @@ -0,0 +1,49 @@ +# MarkWTech (WiTcontroller-style) + +ESP32-C6-DevKitC-1 with 3×4 keypad, extra buttons, KY-040 encoder, and 2.42" SSD1309 OLED — inspired by [WiTcontroller](https://github.com/flash62au/WiTcontroller) / [Thingiverse 7029069](https://www.thingiverse.com/thing:7029069), with ESP32-C6 instead of LOLIN32. + +| Item | Value | +|------|-------| +| Cargo feature | `variant-markwtech` | +| Display | SSD1309/SSD1306 128×64 I2C | +| Expanders | none | +| Programming chord | **\* (Menu) + Stop** 8 s | + +## Controls + +- 3×4 keypad: digits, `*` (menu/cancel), `#` (select) +- Extra GPIO buttons (mapped as function keys) +- KY-040 encoder for speed / list scroll +- Dedicated Stop for EStop / programming chord + +## Pin map (keypad) + +| Role | GPIOs | +|------|-------| +| Keypad rows | 18, 19, 20, 21 | +| Keypad columns | 22, 23, 10 | +| I2C OLED | SDA 6, SCL 7 | +| Encoder | A 2, B 3 (shared family defaults) | + +```mermaid +flowchart LR + ESP[ESP32-C6] --- KP[Keypad 3x4] + ESP --- OLED[OLED 2.42in I2C] + ESP --- ENC[KY-040] + ESP --- STOP[Stop + extras] +``` + +Key layout constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`). + +## BOM + +- ESP32-C6-DevKitC-1 +- 2.42" OLED 128×64 SSD1309 (I2C) +- 3×4 membrane keypad +- KY-040 encoder +- Extra tact switches (Stop + up to 5 optional) +- Case: Thingiverse 7029069 (adapted) + +## Programming mode + +Hold **\* + Stop** for 8 seconds. See [provisioning.md](../provisioning.md). diff --git a/docs/provisioning.md b/docs/provisioning.md new file mode 100644 index 0000000..867fb3d --- /dev/null +++ b/docs/provisioning.md @@ -0,0 +1,70 @@ +# LongFred programming / pairing mode + +All hardware variants share the same Soft-AP provisioning API. + +## Entering + +| Variant | Chord (hold 8 s) | Auto if no Wi‑Fi creds | +|---------|------------------|------------------------| +| longfred-standard / mini | Shift1 + Stop | no | +| markwtech | `*` + Stop | no | +| heiko-wifred | Shift + Stop | **yes** | + +Firmware sets `programming_mode` in NVS and soft-resets (except auto-pair at boot, which skips STA bring-up). + +## Network + +| Setting | Value | +|---------|-------| +| SSID | `longfred_prog_XXXXXX` (6-char MAC suffix) | +| Security | open | +| AP IP | `192.168.0.1/24` | +| DHCP | **none** — client must use a static address | + +### Phone / laptop (manual static IP) + +1. Join `longfred_prog_XXXXXX` +2. Set static IPv4: address `192.168.0.50`, mask `255.255.255.0`, gateway `192.168.0.1` +3. Open `http://192.168.0.1/` + +### wireless-programmer + +Associates open, assigns `192.168.0.2/24`, talks HTTP to `192.168.0.1:80` (driver id `longfred`). + +## HTTP API + +### `GET /` + +Static HTML configuration page (inline CSS/JS). + +### `GET /api/v1/settings` + +Returns device info (including `device.variant`), Wi‑Fi SSID (no password), BigFred login (no PIN), roster, roster mode. + +### `PUT /api/v1/settings` + +Partial JSON body: + +```json +{ + "wifi": { "ssid": "layout", "password": "secret" }, + "bigfred": { "login": "user", "pin": "1234" }, + "rosterMode": "static", + "roster": [{ "addr": "3", "name": "SHUNT" }] +} +``` + +All top-level fields optional. Persisted to NVS. + +> **Note:** the server requires a `Content-Length` header and does **not** +> support `Transfer-Encoding: chunked`. Clients must send the whole body in +> a single request. Maximum body size is 1536 bytes; larger bodies are +> rejected with `400 body too large`. + +### `POST /api/v1/programming-mode/off` + +Clears the programming flag, responds 200, soft-resets after ~500 ms. + +## Cancel on device + +Press **Stop** / **EStop** while in pairing UI to clear the flag and reboot into normal operation. diff --git a/scripts/check-esp32c6-size.sh b/scripts/check-esp32c6-size.sh new file mode 100755 index 0000000..3986921 --- /dev/null +++ b/scripts/check-esp32c6-size.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Report flash + static RAM usage for each LongFred variant against ESP32-C6 limits. +# +# Flash budget: default espflash factory app partition (reported by save-image). +# RAM budget: esp-hal esp32c6 memory.x RAM LENGTH (0x6E610) — linker already enforces +# this; we re-check sections and fail if anything looks over. +# +# Usage: +# ./scripts/check-esp32c6-size.sh # build each variant, then check +# ./scripts/check-esp32c6-size.sh --check-only # check existing dist/*.elf (no cargo) +# VARIANTS="markwtech heiko-wifred" ./scripts/check-esp32c6-size.sh --check-only +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TARGET="${TARGET:-riscv32imac-unknown-none-elf}" +CHIP="${CHIP:-esp32c6}" +BIN="${BIN:-longfred}" +DIST_DIR="${DIST_DIR:-dist}" +# From esp-hal ld/esp32c6/memory.x: RAM LENGTH = 0x6E610 +RAM_LIMIT_BYTES="${RAM_LIMIT_BYTES:-$((0x6E610))}" +VARIANTS=(${VARIANTS:-longfred-standard longfred-mini markwtech heiko-wifred}) + +CHECK_ONLY=0 +for arg in "$@"; do + case "$arg" in + --check-only) CHECK_ONLY=1 ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) + echo "error: unknown argument: $arg" >&2 + exit 1 + ;; + esac +done + +if ! command -v espflash >/dev/null 2>&1; then + echo "error: espflash not found in PATH" >&2 + exit 1 +fi +if ! command -v readelf >/dev/null 2>&1; then + echo "error: readelf not found in PATH" >&2 + exit 1 +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +# On-chip RAM section usage (0x4080_0000 .. 0x4088_0000). +# Prints: static_bytes stack_bytes total_bytes +# Stack is sized by the linker to fill leftover RAM, so total ≈ RAM_LIMIT after a +# successful link; static (rwtext+data+bss+…) is the meaningful footprint. +ram_breakdown_bytes() { + local elf="$1" + readelf -SW "$elf" | awk ' + /^\s*\[[ 0-9]+\]/ { + line = $0 + sub(/^\s*\[[ 0-9]+\]\s+/, "", line) + n = split(line, a, /[[:space:]]+/) + if (n < 5) next + name = a[1] + addr = strtonum("0x" a[3]) + size = strtonum("0x" a[5]) + if (addr < 0x40800000 || addr >= 0x40880000) next + if (name == ".stack") stack += size + else static += size + } + END { printf "%d %d %d", static + 0, stack + 0, static + stack + 0 } + ' +} + +human() { + local n="${1:-0}" + if (( n >= 1048576 )); then + awk -v n="$n" 'BEGIN { printf "%.2f MiB", n/1048576 }' + elif (( n >= 1024 )); then + awk -v n="$n" 'BEGIN { printf "%.1f KiB", n/1024 }' + else + printf "%d B" "$n" + fi +} + +elf_path_for_variant() { + local variant="$1" + printf '%s/%s-%s-esp32c6.elf' "$DIST_DIR" "$BIN" "$variant" +} + +printf "%-18s %12s %12s %8s %12s %12s %12s %8s %s\n" \ + "VARIANT" "FLASH_USED" "FLASH_MAX" "FLASH%" "RAM_STATIC" "RAM_STACK" "RAM_MAX" "STATIC%" "STATUS" +printf "%-18s %12s %12s %8s %12s %12s %12s %8s %s\n" \ + "------------------" "------------" "------------" "--------" \ + "------------" "------------" "------------" "--------" "------" + +failed=0 + +for variant in "${VARIANTS[@]}"; do + elf="$(elf_path_for_variant "$variant")" + + if (( CHECK_ONLY )); then + if [[ ! -f "$elf" ]]; then + echo "error: missing ELF for ${variant}: ${elf}" >&2 + failed=1 + continue + fi + else + # Isolate artifacts per variant so feature switches cannot reuse a stale ELF. + # Same layout as Makefile TARGET_DIR=target/$(VARIANT). + target_dir="target/${variant}" + built_elf="${target_dir}/${TARGET}/release/${BIN}" + + echo "==> release build variant-${variant}" >&2 + if ! cargo build -p longfred-firmware --release --bin "$BIN" \ + --target-dir "$target_dir" \ + --no-default-features --features "variant-${variant}" \ + >"${tmpdir}/${variant}.cargo.log" 2>&1; then + echo "error: cargo build failed for variant-${variant}" >&2 + tail -n 40 "${tmpdir}/${variant}.cargo.log" >&2 + failed=1 + continue + fi + + if [[ ! -f "$built_elf" ]]; then + echo "error: missing ELF for ${variant}: ${built_elf}" >&2 + failed=1 + continue + fi + + mkdir -p "$DIST_DIR" + cp -f "$built_elf" "$elf" + fi + + img="${tmpdir}/${variant}.bin" + log="${tmpdir}/${variant}.espflash.log" + if ! ESPFLASH_SKIP_UPDATE_CHECK=true espflash save-image \ + --chip "$CHIP" --merge "$elf" "$img" >"$log" 2>&1; then + printf "%-18s %12s %12s %8s %12s %12s %12s %8s %s\n" \ + "$variant" "-" "-" "-" "-" "-" "-" "-" "FAIL (espflash)" + sed -n '1,20p' "$log" >&2 + failed=1 + continue + fi + + # e.g. "App/part. size: 764,320/4,128,768 bytes, 18.51%" + flash_line="$(grep -E 'App/part\. size:' "$log" | tail -n1 || true)" + if [[ -z "$flash_line" ]]; then + printf "%-18s %12s %12s %8s %12s %12s %12s %8s %s\n" \ + "$variant" "-" "-" "-" "-" "-" "-" "-" "FAIL (no size line)" + failed=1 + continue + fi + + flash_used="$(sed -E 's/.*App\/part\. size:[[:space:]]*([0-9,]+)\/.*/\1/; s/,//g' <<<"$flash_line")" + flash_max="$(sed -E 's/.*App\/part\. size:[[:space:]]*[0-9,]+\/([0-9,]+).*/\1/; s/,//g' <<<"$flash_line")" + flash_pct="$(awk -v u="$flash_used" -v m="$flash_max" 'BEGIN { printf "%.2f", (u*100)/m }')" + + # Prefer a portable capture; `read < <(fn)` + `set -e` aborts on some bash/pipefail combos. + ram_breakdown="$(ram_breakdown_bytes "$elf")" + ram_static="${ram_breakdown%% *}" + rest="${ram_breakdown#* }" + ram_stack="${rest%% *}" + ram_total="${rest##* }" + static_pct="$(awk -v u="$ram_static" -v m="$RAM_LIMIT_BYTES" 'BEGIN { printf "%.2f", (u*100)/m }')" + + status="OK" + if (( flash_used > flash_max )); then + status="FAIL (flash)" + failed=1 + fi + if (( ram_total > RAM_LIMIT_BYTES )); then + status="FAIL (ram)" + failed=1 + fi + + printf "%-18s %12s %12s %7s%% %12s %12s %12s %7s%% %s\n" \ + "$variant" \ + "$(human "$flash_used")" "$(human "$flash_max")" "$flash_pct" \ + "$(human "$ram_static")" "$(human "$ram_stack")" "$(human "$RAM_LIMIT_BYTES")" "$static_pct" \ + "$status" +done + +echo +echo "Limits: ESP32-C6 app partition (espflash default) + on-chip RAM 0x6E610 from esp-hal memory.x" +echo "Note: RAM_STATIC includes .bss (with 72 KiB esp_alloc heap); linker fills leftover with .stack." + +if (( failed != 0 )); then + echo "error: one or more variants exceed ESP32-C6 memory budget" >&2 + exit 1 +fi + +echo "All variants fit in ESP32-C6 flash and RAM."