From 7e7cba97ceecbc2d6afb837d195c3885919fdd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:20:22 +0200 Subject: [PATCH 1/7] Add five GPIO tact buttons to MarkWTech variant. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire left/Stop/right/Back/Menu on GPIO 11–14 and 16 with a debounced scanner, map them through MarkwtechSurface/NavProfile, and document the pinout so provisioning chord *+Stop works on hardware. Co-authored-by: Cursor --- crates/firmware/src/bin/main.rs | 6 +- crates/firmware/src/board/raw.rs | 2 + .../src/board/variants/longfred_family.rs | 1 + .../firmware/src/board/variants/markwtech.rs | 19 +++- crates/firmware/src/input/extra_buttons.rs | 92 +++++++++++++++++++ crates/firmware/src/input/mod.rs | 4 +- crates/firmware/src/ui/nav_profile.rs | 18 +++- docs/hardware/markwtech.md | 37 ++++++-- 8 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 crates/firmware/src/input/extra_buttons.rs diff --git a/crates/firmware/src/bin/main.rs b/crates/firmware/src/bin/main.rs index 9d310e1..0513f55 100644 --- a/crates/firmware/src/bin/main.rs +++ b/crates/firmware/src/bin/main.rs @@ -201,13 +201,17 @@ async fn main(spawner: Spawner) -> ! { } } - // MarkWTech: 3×4 keypad matrix (pins from markwtech constants). + // MarkWTech: 3×4 keypad matrix + extra tact cluster (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); } + let extras = input::extra_buttons::build(); + if let Ok(token) = input::extra_buttons::task(extras, raw_sender) { + spawner.spawn(token); + } } // Expanders: LongFred family + heiko-wifred. diff --git a/crates/firmware/src/board/raw.rs b/crates/firmware/src/board/raw.rs index 7adfce6..a4795a2 100644 --- a/crates/firmware/src/board/raw.rs +++ b/crates/firmware/src/board/raw.rs @@ -26,6 +26,8 @@ pub enum ButtonId { /// Keypad digit 0–9 (markwtech / heiko). KeypadDigit(u8), Menu, + /// Dedicated Back / Cancel (MarkWTech extra cluster). + Back, Hash, Star, Extra(u8), diff --git a/crates/firmware/src/board/variants/longfred_family.rs b/crates/firmware/src/board/variants/longfred_family.rs index 262c2ee..aa98e1f 100644 --- a/crates/firmware/src/board/variants/longfred_family.rs +++ b/crates/firmware/src/board/variants/longfred_family.rs @@ -126,6 +126,7 @@ impl LongFredSurface { | ButtonId::JoyRight | ButtonId::JoyMenu | ButtonId::Menu + | ButtonId::Back | ButtonId::Direction | ButtonId::EncoderButton | ButtonId::KeypadDigit(_) diff --git a/crates/firmware/src/board/variants/markwtech.rs b/crates/firmware/src/board/variants/markwtech.rs index 3340aaf..664dfc0 100644 --- a/crates/firmware/src/board/variants/markwtech.rs +++ b/crates/firmware/src/board/variants/markwtech.rs @@ -1,7 +1,7 @@ //! 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. +//! Hardware: 3×4 keypad matrix, extra tact cluster, KY-040 encoder, OLED 128×64 +//! (SSD1309), no MCP expanders. Programming chord: Star (`*`) + Stop held for 8 s. use embassy_time::Instant; @@ -10,7 +10,7 @@ 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; +use crate::input::{InputEvent, NavDir}; /// Keypad matrix row GPIOs (driven, active-low scan). pub const KEYPAD_ROW_PINS: [Gpio; 4] = [18, 19, 20, 21]; @@ -44,6 +44,16 @@ pub const KEYPAD_MAP: [[ButtonId; 3]; 4] = [ [ButtonId::Star, ButtonId::KeypadDigit(0), ButtonId::Hash], ]; +/// Extra tact switches (active-low, internal pull-up): left, Stop, right, Back, Menu. +pub const EXTRA_BUTTON_PINS: [Gpio; 5] = [11, 12, 13, 14, 16]; +pub const EXTRA_BUTTON_MAP: [ButtonId; 5] = [ + ButtonId::JoyLeft, + ButtonId::Stop, + ButtonId::JoyRight, + ButtonId::Back, + ButtonId::Menu, +]; + pub const DESCRIPTOR: VariantDescriptor = VariantDescriptor { id: "markwtech", name: "MarkWTech", @@ -91,6 +101,9 @@ impl MarkwtechSurface { ButtonId::KeypadDigit(d) if pressed && d <= 9 => { out(InputEvent::Digit((b'0' + d) as char)); } + ButtonId::JoyLeft if pressed => out(InputEvent::Nav(NavDir::Left)), + ButtonId::JoyRight if pressed => out(InputEvent::Nav(NavDir::Right)), + ButtonId::Back if pressed => out(InputEvent::Back), ButtonId::Extra(n) => { if pressed { out(InputEvent::FnPress(n)); diff --git a/crates/firmware/src/input/extra_buttons.rs b/crates/firmware/src/input/extra_buttons.rs new file mode 100644 index 0000000..04406b8 --- /dev/null +++ b/crates/firmware/src/input/extra_buttons.rs @@ -0,0 +1,92 @@ +//! Extra GPIO tact switches for MarkWTech (active-low, internal pull-up). +//! +//! Pin numbers come from [`crate::board::variants::markwtech`]. + +use embassy_time::{Duration, Timer}; +use esp_hal::gpio::{AnyPin, Input, InputConfig, Pull}; + +use crate::board::raw::{RawEvent, RawSender}; +use crate::board::variants::markwtech::{EXTRA_BUTTON_MAP, EXTRA_BUTTON_PINS}; + +const POLL_MS: u64 = 20; +const DEBOUNCE_TICKS: u8 = 2; + +pub struct Pins { + pub buttons: [Input<'static>; 5], +} + +/// Build extra-button GPIO from markwtech pin constants. +/// +/// # Safety +/// +/// Call once from `main`; pins must not overlap other drivers. +#[allow(unsafe_code)] +pub fn build() -> Pins { + let cfg = InputConfig::default().with_pull(Pull::Up); + // SAFETY: extra-button pins are reserved for this driver; single owner from `main`. + Pins { + buttons: [ + Input::new(unsafe { AnyPin::steal(EXTRA_BUTTON_PINS[0]) }, cfg), + Input::new(unsafe { AnyPin::steal(EXTRA_BUTTON_PINS[1]) }, cfg), + Input::new(unsafe { AnyPin::steal(EXTRA_BUTTON_PINS[2]) }, cfg), + Input::new(unsafe { AnyPin::steal(EXTRA_BUTTON_PINS[3]) }, cfg), + Input::new(unsafe { AnyPin::steal(EXTRA_BUTTON_PINS[4]) }, cfg), + ], + } +} + +struct Btn { + stable_high: bool, + debounce: u8, +} + +impl Btn { + fn new(initial_high: bool) -> Self { + Self { + stable_high: initial_high, + debounce: 0, + } + } + + /// 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 None; + } + self.debounce = self.debounce.saturating_add(1); + if self.debounce < DEBOUNCE_TICKS { + return None; + } + let was_high = self.stable_high; + self.stable_high = raw_high; + self.debounce = 0; + if was_high && !raw_high { + Some(true) + } else if !was_high && raw_high { + Some(false) + } else { + None + } + } +} + +#[embassy_executor::task] +pub async fn task(pins: Pins, sender: RawSender) { + let mut state = [ + Btn::new(pins.buttons[0].is_high()), + Btn::new(pins.buttons[1].is_high()), + Btn::new(pins.buttons[2].is_high()), + Btn::new(pins.buttons[3].is_high()), + Btn::new(pins.buttons[4].is_high()), + ]; + + loop { + for i in 0..5 { + if let Some(pressed) = state[i].update(pins.buttons[i].is_high()) { + let _ = sender.try_send(RawEvent::Button(EXTRA_BUTTON_MAP[i], pressed)); + } + } + 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 53a23ea..486894d 100644 --- a/crates/firmware/src/input/mod.rs +++ b/crates/firmware/src/input/mod.rs @@ -1,4 +1,4 @@ -//! Input: GPIO nav cluster, MCP23017 tact/F-keys, encoder, keypad. +//! Input: GPIO nav cluster, MCP23017 tact/F-keys, encoder, keypad, extra buttons. //! Drivers emit [`crate::board::raw::RawEvent`] to `RAW_CHANNEL`; //! the board bridge maps them to [`InputEvent`] on `INPUT_CHANNEL`. @@ -7,6 +7,8 @@ pub mod expander; pub mod gpio_nav; pub mod i2c_bus; #[cfg(feature = "variant-markwtech")] +pub mod extra_buttons; +#[cfg(feature = "variant-markwtech")] pub mod keypad; 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 index 9449ab7..2ffaec0 100644 --- a/crates/firmware/src/ui/nav_profile.rs +++ b/crates/firmware/src/ui/nav_profile.rs @@ -66,12 +66,15 @@ impl NavProfile for LongFredNav { } } -/// MarkWTech: encoder + keypad (`*` / `#`). +/// MarkWTech: encoder + keypad (`*` / `#`) + extra tact cluster. /// /// - Encoder → ListPrev/ListNext (or CharCycle in text entry) /// - `#` → Select /// - `*` → MenuEnter on throttle; Cancel / backspace in menus & text /// - Digits 0–9 → Digit +/// - Extra Back → Cancel +/// - Extra Menu → MenuEnter +/// - Extra Left/Right → CursorMove in text entry; otherwise list paging #[derive(Clone, Copy, Debug, Default)] pub struct MarkwtechNav; @@ -88,6 +91,16 @@ impl NavProfile for MarkwtechNav { } 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::Digit('#') => Some(NavAction::Select), InputEvent::Digit('*') => Some(if text_entry { NavAction::Cancel @@ -100,7 +113,8 @@ impl NavProfile for MarkwtechNav { }), InputEvent::Digit(c) => Some(NavAction::Digit(c)), InputEvent::Menu => Some(NavAction::MenuEnter), - InputEvent::Back | InputEvent::Ok => Some(NavAction::Select), + InputEvent::Back => Some(NavAction::Cancel), + InputEvent::Ok => Some(NavAction::Select), other => Some(NavAction::PassThrough(other)), } } diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index 3134a12..e1f3699 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -12,28 +12,51 @@ ESP32-C6-DevKitC-1 with 3×4 keypad, extra buttons, KY-040 encoder, and 2.42" SS ## Controls - 3×4 keypad: digits, `*` (menu/cancel), `#` (select) -- Extra GPIO buttons (mapped as function keys) +- Five extra GPIO tact switches (left / Stop / right / Back / Menu) - KY-040 encoder for speed / list scroll - Dedicated Stop for EStop / programming chord -## Pin map (keypad) +## Pin map | 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) | +| I2C OLED | SDA 6, SCL 7, address 0x3C | +| Encoder | A 2, B 3, SW 0 | +| Extra left / Stop / right / Back / Menu | 11, 12, 13, 14, 16 | + +Keypad layout (`KEYPAD_MAP`): + +```text + C0 C1 C2 +R0 1 2 3 +R1 4 5 6 +R2 7 8 9 +R3 * 0 # +``` + +Extra buttons: tact switch to **GND**, firmware pull-up, active-low. + +| # | Function | GPIO | Notes | +|---|----------|------|-------| +| 1 | Menu left | 11 | `Nav(Left)` — list page prev / cursor | +| 2 | Stop | 12 | EStop on throttle; chord with `*` | +| 3 | Menu right | 13 | `Nav(Right)` — list page next / cursor | +| 4 | Back | 14 | Cancel / back | +| 5 | Menu | 16 | Open menu / select-in-menu | + +GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 16 is U0TXD — holding Menu can interrupt the UART0 console. ```mermaid flowchart LR ESP[ESP32-C6] --- KP[Keypad 3x4] ESP --- OLED[OLED 2.42in I2C] ESP --- ENC[KY-040] - ESP --- STOP[Stop + extras] + ESP --- EXTRA[Left Stop Right Back Menu] ``` -Key layout constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`). +Constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`, `EXTRA_BUTTON_MAP`). ## BOM @@ -41,7 +64,7 @@ Key layout constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`). - 2.42" OLED 128×64 SSD1309 (I2C) - 3×4 membrane keypad - KY-040 encoder -- Extra tact switches (Stop + up to 5 optional) +- 5 tact switches (left, Stop, right, Back, Menu) - Case: Thingiverse 7029069 (adapted) ## Programming mode From 3372404de6ca288b063418f9bb07789f88f6831e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:22:48 +0200 Subject: [PATCH 2/7] Move MarkWTech Menu off UART0 TX onto GPIO 15. Keep GPIO 16 free for the serial console; GPIO 15 is strapping but idle-high with pull-up is safe if Menu is not held at reset. Co-authored-by: Cursor --- crates/firmware/src/board/variants/markwtech.rs | 3 ++- docs/hardware/markwtech.md | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/firmware/src/board/variants/markwtech.rs b/crates/firmware/src/board/variants/markwtech.rs index 664dfc0..847dc94 100644 --- a/crates/firmware/src/board/variants/markwtech.rs +++ b/crates/firmware/src/board/variants/markwtech.rs @@ -45,7 +45,8 @@ pub const KEYPAD_MAP: [[ButtonId; 3]; 4] = [ ]; /// Extra tact switches (active-low, internal pull-up): left, Stop, right, Back, Menu. -pub const EXTRA_BUTTON_PINS: [Gpio; 5] = [11, 12, 13, 14, 16]; +/// Menu is GPIO 15 (not 16/U0TXD) so UART0 console stays free. +pub const EXTRA_BUTTON_PINS: [Gpio; 5] = [11, 12, 13, 14, 15]; pub const EXTRA_BUTTON_MAP: [ButtonId; 5] = [ ButtonId::JoyLeft, ButtonId::Stop, diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index e1f3699..59e778a 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -24,7 +24,7 @@ ESP32-C6-DevKitC-1 with 3×4 keypad, extra buttons, KY-040 encoder, and 2.42" SS | Keypad columns | 22, 23, 10 | | I2C OLED | SDA 6, SCL 7, address 0x3C | | Encoder | A 2, B 3, SW 0 | -| Extra left / Stop / right / Back / Menu | 11, 12, 13, 14, 16 | +| Extra left / Stop / right / Back / Menu | 11, 12, 13, 14, 15 | Keypad layout (`KEYPAD_MAP`): @@ -44,9 +44,9 @@ Extra buttons: tact switch to **GND**, firmware pull-up, active-low. | 2 | Stop | 12 | EStop on throttle; chord with `*` | | 3 | Menu right | 13 | `Nav(Right)` — list page next / cursor | | 4 | Back | 14 | Cancel / back | -| 5 | Menu | 16 | Open menu / select-in-menu | +| 5 | Menu | 15 | Open menu / select-in-menu | -GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 16 is U0TXD — holding Menu can interrupt the UART0 console. +GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 15 is a strapping pin — do not hold Menu during reset/boot (idle HIGH via pull-up is the safe default). UART0 TX/RX stay on GPIO 16/17. ```mermaid flowchart LR From 79f47bdf2b643d841a3545c4ba62f191e58b51a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:29:05 +0200 Subject: [PATCH 3/7] Document full MarkWTech pin-by-pin wiring in hardware guide. Add per-component connection tables, a master GPIO map, and clarify GPIO 15 strapping behavior on stock DevKitC-1 boards. Co-authored-by: Cursor --- docs/hardware/markwtech.md | 110 ++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index 59e778a..9470ee2 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -46,7 +46,7 @@ Extra buttons: tact switch to **GND**, firmware pull-up, active-low. | 4 | Back | 14 | Cancel / back | | 5 | Menu | 15 | Open menu / select-in-menu | -GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 15 is a strapping pin — do not hold Menu during reset/boot (idle HIGH via pull-up is the safe default). UART0 TX/RX stay on GPIO 16/17. +GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 15 is a strapping pin (JTAG source selection when a specific eFuse is burned; on stock DevKitC-1 it is effectively ignored at boot). UART0 TX/RX stay on GPIO 16/17. ```mermaid flowchart LR @@ -58,6 +58,114 @@ flowchart LR Constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`, `EXTRA_BUTTON_MAP`). +## Full wiring (pin-by-pin) + +All modules run on **3.3 V** (not 5 V). Tie a common **GND** to every component. + +### Power + +| ESP32-C6 | Module label | Notes | +|----------|--------------|-------| +| **3V3** | `VCC` / `+` / `3.3V` | OLED, KY-040 | +| **GND** | `GND` | common ground rail | + +### OLED 2.42" I2C (SSD1309 / SSD1306) + +| ESP GPIO | Firmware | OLED pin (typical) | Notes | +|----------|----------|-------------------|-------| +| **6** | `I2C_SDA` | `SDA` / `DIN` / `DATA` | I2C data | +| **7** | `I2C_SCL` | `SCL` / `CLK` / `SCK` | I2C clock | +| **3V3** | — | `VCC` / `3.3V` | | +| **GND** | — | `GND` | | +| — | address **0x3C** | — | set `ADDR` jumper on module if present | + +Common 4-pin FPC order on cheap modules: `GND` · `VCC` · `SCL` · `SDA` (verify your module silkscreen). + +### KY-040 rotary encoder + +| ESP GPIO | Firmware | KY-040 pin | Notes | +|----------|----------|------------|-------| +| **2** | `ENCODER_A` | **`DT`** (sometimes `B`, `DATA`) | channel A | +| **3** | `ENCODER_B` | **`CLK`** (sometimes `A`) | channel B | +| **0** | `ENCODER_BUTTON` | **`SW`** / `KEY` | encoder push button | +| **3V3** | — | **`+`** / `VCC` | | +| **GND** | — | **`GND`** | | + +KY-040 boards often swap `CLK`/`DT` silkscreen labels — wire as above (`DT`→GPIO2, `CLK`→GPIO3). GPIO0 is also the boot pin; avoid holding `SW` low during reset. + +### 3×4 membrane keypad (7 pins) + +Rows are **outputs** (scanner drives one low at a time). Columns are **inputs** with internal pull-up. + +| ESP GPIO | Firmware | Keypad pin | Matrix role | +|----------|----------|------------|-------------| +| **18** | `KEYPAD_ROW_PINS[0]` | **R0** (row 1) | keys `1` `2` `3` | +| **19** | `KEYPAD_ROW_PINS[1]` | **R1** (row 2) | keys `4` `5` `6` | +| **20** | `KEYPAD_ROW_PINS[2]` | **R2** (row 3) | keys `7` `8` `9` | +| **21** | `KEYPAD_ROW_PINS[3]` | **R3** (row 4) | keys `*` `0` `#` | +| **22** | `KEYPAD_COL_PINS[0]` | **C0** (col 1) | keys `1` `4` `7` `*` | +| **23** | `KEYPAD_COL_PINS[1]` | **C1** (col 2) | keys `2` `5` `8` `0` | +| **10** | `KEYPAD_COL_PINS[2]` | **C2** (col 3) | keys `3` `6` `9` `#` | + +**Keypad FPC pin order is not standardized** (e.g. `R1 R2 R3 R4 C1 C2 C3` or other). Identify which physical pin is each row/column with a multimeter (pressed key = row shorted to column). If digits are scrambled, swap row/column assignments on the connector — do not change firmware GPIO numbers. + +### Five extra tact switches (active-low) + +Each switch: one leg → **GPIO**, other leg → **GND**. Firmware enables internal pull-up (pressed = LOW). + +| ESP GPIO | Label | UI function | Suggested silkscreen | +|----------|-------|-------------|----------------------| +| **11** | Menu left | list page prev / cursor left | `◀` / `LEFT` | +| **12** | **Stop** | EStop on throttle; `*`+Stop chord (8 s) | `STOP` / `E-STOP` | +| **13** | Menu right | list page next / cursor right | `▶` / `RIGHT` | +| **14** | Back | cancel / back | `BACK` / `ESC` | +| **15** | Menu | open menu / select in menu | `MENU` / `OK` | + +```text +GPIOx ────[ tact switch ]──── GND + (MCU pull-up) +``` + +### Battery ADC (optional — not in BOM) + +| ESP GPIO | Firmware | Connection | Notes | +|----------|----------|------------|-------| +| **1** | `BATTERY_ADC` | voltage divider from LiPo | extra hardware required; leave **unconnected** if not used | + +Firmware has `USE_BATTERY_TEST = true` globally; without a divider on GPIO1 the reading is meaningless but harmless. + +### Master table (one wire per row) + +| ESP GPIO | DevKit silk | Component | Component pin | Direction | +|----------|-------------|-----------|---------------|-----------| +| 0 | GPIO0 | KY-040 | `SW` | input, active-low | +| 2 | GPIO2 | KY-040 | `DT` | encoder A | +| 3 | GPIO3 | KY-040 | `CLK` | encoder B | +| 6 | GPIO6 | OLED | `SDA` | I2C data | +| 7 | GPIO7 | OLED | `SCL` | I2C clock | +| 10 | GPIO10 | Keypad | `C2` | matrix column | +| 11 | GPIO11 | Tact | Menu left | → GND | +| 12 | GPIO12 | Tact | Stop | → GND | +| 13 | GPIO13 | Tact | Menu right | → GND | +| 14 | GPIO14 | Tact | Back | → GND | +| 15 | GPIO15 | Tact | Menu | → GND | +| 18 | GPIO18 | Keypad | `R0` | matrix row (output) | +| 19 | GPIO19 | Keypad | `R1` | matrix row (output) | +| 20 | GPIO20 | Keypad | `R2` | matrix row (output) | +| 21 | GPIO21 | Keypad | `R3` | matrix row (output) | +| 22 | GPIO22 | Keypad | `C0` | matrix column | +| 23 | GPIO23 | Keypad | `C1` | matrix column | +| 3V3 | 3V3 | OLED, KY-040 | `VCC` / `+` | power | +| GND | GND | all | `GND` | ground | + +### Unused / reserved GPIO + +Not used by MarkWTech: **1** (optional battery ADC), **4, 5, 8, 9, 16, 17** and any GPIO not listed above. **GPIO 16/17** are UART0 (serial console via the USB-UART bridge) — leave free for debug. + +### Flashing + +Flash firmware over the DevKit USB port (UART or USB-JTAG) — no extra wiring. Enter provisioning: hold **`*`** (keypad) + **Stop** (GPIO 12) for **8 s**. + ## BOM - ESP32-C6-DevKitC-1 From ff77bde85dbd1a32a1f91a69d2690a9e84a7a464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:53:37 +0200 Subject: [PATCH 4/7] fix(markwtech): move extra buttons off unavailable and USB pins GPIO 14 is not broken out on the ESP32-C6-WROOM-1 module, so the Back button had no header to connect to. Move it to GPIO 5 and Menu-right from USB_D+ (13) to GPIO 4, keeping the native USB pair intact apart from Stop on USB_D-. Document the ESP32-C6-DevKitC-1 J1/J3 header positions for every wire, and add the battery hardware the DevKit lacks compared to the LOLIN32 the original WiTcontroller uses: charger, 3.3 V regulator and the 47k/47k divider feeding the ADC on GPIO 1. Co-authored-by: Cursor --- .../firmware/src/board/variants/markwtech.rs | 7 +- docs/hardware/markwtech.md | 130 ++++++++++++------ 2 files changed, 95 insertions(+), 42 deletions(-) diff --git a/crates/firmware/src/board/variants/markwtech.rs b/crates/firmware/src/board/variants/markwtech.rs index 847dc94..1e75ae6 100644 --- a/crates/firmware/src/board/variants/markwtech.rs +++ b/crates/firmware/src/board/variants/markwtech.rs @@ -45,8 +45,11 @@ pub const KEYPAD_MAP: [[ButtonId; 3]; 4] = [ ]; /// Extra tact switches (active-low, internal pull-up): left, Stop, right, Back, Menu. -/// Menu is GPIO 15 (not 16/U0TXD) so UART0 console stays free. -pub const EXTRA_BUTTON_PINS: [Gpio; 5] = [11, 12, 13, 14, 15]; +/// +/// ESP32-C6-WROOM-1 does not expose GPIO 14, and GPIO 1 is the battery ADC, so the +/// cluster uses 4/5 (strapping, harmless with default eFuses) and 12 (USB_D-). +/// UART0 (16/17) stays free for the serial console. +pub const EXTRA_BUTTON_PINS: [Gpio; 5] = [11, 12, 4, 5, 15]; pub const EXTRA_BUTTON_MAP: [ButtonId; 5] = [ ButtonId::JoyLeft, ButtonId::Stop, diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index 9470ee2..00c0084 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -24,7 +24,8 @@ ESP32-C6-DevKitC-1 with 3×4 keypad, extra buttons, KY-040 encoder, and 2.42" SS | Keypad columns | 22, 23, 10 | | I2C OLED | SDA 6, SCL 7, address 0x3C | | Encoder | A 2, B 3, SW 0 | -| Extra left / Stop / right / Back / Menu | 11, 12, 13, 14, 15 | +| Extra left / Stop / right / Back / Menu | 11, 12, 4, 5, 15 | +| Battery ADC | 1 | Keypad layout (`KEYPAD_MAP`): @@ -42,11 +43,17 @@ Extra buttons: tact switch to **GND**, firmware pull-up, active-low. |---|----------|------|-------| | 1 | Menu left | 11 | `Nav(Left)` — list page prev / cursor | | 2 | Stop | 12 | EStop on throttle; chord with `*` | -| 3 | Menu right | 13 | `Nav(Right)` — list page next / cursor | -| 4 | Back | 14 | Cancel / back | +| 3 | Menu right | 4 | `Nav(Right)` — list page next / cursor | +| 4 | Back | 5 | Cancel / back | | 5 | Menu | 15 | Open menu / select-in-menu | -GPIO 12/13 are USB D−/D+ (fine when flashing via the USB-UART bridge). GPIO 15 is a strapping pin (JTAG source selection when a specific eFuse is burned; on stock DevKitC-1 it is effectively ignored at boot). UART0 TX/RX stay on GPIO 16/17. +Pin choice is constrained — ESP32-C6-WROOM-1 exposes only GPIO `0–13, 15–23`: + +- **GPIO 14 does not exist on the module.** It is present on the bare SoC, so `AnyPin::steal(14)` still compiles, but nothing is routed to the headers. +- **GPIO 4, 5, 15** are strapping pins. Boot mode is decided solely by GPIO 8/9; with default eFuses these three have no effect at reset, and the internal pull-up keeps them idle HIGH. +- **GPIO 12** is `USB_D−`. Wiring Stop here rules out the native USB Serial/JTAG port. Flashing and logs go through the **USB-UART** port instead, which uses GPIO 16/17 — left free on purpose. +- **GPIO 8** drives the on-board RGB LED and **GPIO 9** is the BOOT button, so neither is usable. +- **GPIO 1** is reserved for the battery divider. ```mermaid flowchart LR @@ -91,7 +98,7 @@ Common 4-pin FPC order on cheap modules: `GND` · `VCC` · `SCL` · `SDA` (verif | **3V3** | — | **`+`** / `VCC` | | | **GND** | — | **`GND`** | | -KY-040 boards often swap `CLK`/`DT` silkscreen labels — wire as above (`DT`→GPIO2, `CLK`→GPIO3). GPIO0 is also the boot pin; avoid holding `SW` low during reset. +KY-040 boards often swap `CLK`/`DT` silkscreen labels — wire as above (`DT`→GPIO2, `CLK`→GPIO3). GPIO0 doubles as the deep-sleep wake pin, so the encoder button also wakes the throttle. ### 3×4 membrane keypad (7 pins) @@ -113,58 +120,93 @@ Rows are **outputs** (scanner drives one low at a time). Columns are **inputs** Each switch: one leg → **GPIO**, other leg → **GND**. Firmware enables internal pull-up (pressed = LOW). -| ESP GPIO | Label | UI function | Suggested silkscreen | -|----------|-------|-------------|----------------------| -| **11** | Menu left | list page prev / cursor left | `◀` / `LEFT` | -| **12** | **Stop** | EStop on throttle; `*`+Stop chord (8 s) | `STOP` / `E-STOP` | -| **13** | Menu right | list page next / cursor right | `▶` / `RIGHT` | -| **14** | Back | cancel / back | `BACK` / `ESC` | -| **15** | Menu | open menu / select in menu | `MENU` / `OK` | +| ESP GPIO | Header | Label | UI function | Suggested silkscreen | +|----------|--------|-------|-------------|----------------------| +| **11** | J1-11 | Menu left | list page prev / cursor left | `◀` / `LEFT` | +| **12** | J3-14 | **Stop** | EStop on throttle; `*`+Stop chord (8 s) | `STOP` / `E-STOP` | +| **4** | J1-3 | Menu right | list page next / cursor right | `▶` / `RIGHT` | +| **5** | J1-4 | Back | cancel / back | `BACK` / `ESC` | +| **15** | J3-4 | Menu | open menu / select in menu | `MENU` / `OK` | ```text GPIOx ────[ tact switch ]──── GND (MCU pull-up) ``` -### Battery ADC (optional — not in BOM) +### Battery -| ESP GPIO | Firmware | Connection | Notes | -|----------|----------|------------|-------| -| **1** | `BATTERY_ADC` | voltage divider from LiPo | extra hardware required; leave **unconnected** if not used | +The DevKit has **no LiPo charger and no battery connector** — unlike the LOLIN32 Lite the original WiTcontroller is built on, which provides both. Running MarkWTech on a cell therefore needs external parts. + +| Part | Purpose | +|------|---------| +| LiPo cell 3.7 V (e.g. 1200 mAh, 503759) | power source; ~400 mAh gives roughly 6 h, so 1200 mAh lasts most of a day | +| TP4056 module with protection | USB charging plus over-charge / over-discharge cut-off | +| 3.3 V LDO (HT7333, ME6211 or similar) | cell is 3.0–4.2 V; the WROOM-1 module needs 3.0–3.6 V, so 4.2 V must not reach `3V3` directly | +| 2× 47 kΩ resistor | measurement divider into GPIO 1 | +| Power switch on the cell positive lead | the divider draws current continuously | + +Divider (same values as the original project): + +```text +Cell + ──┬── 47k ──┬── 47k ── GND + │ │ + (to LDO in) └── GPIO 1 (ADC) +``` -Firmware has `USE_BATTERY_TEST = true` globally; without a divider on GPIO1 the reading is meaningless but harmless. +A 1:2 divider turns a full 4.2 V cell into ~2.1 V at the pin, inside the ADC range. + +| ESP GPIO | Header | Firmware | Connection | +|----------|--------|----------|------------| +| **1** | J1-8 | `BATTERY_ADC` | divider midpoint | + +If the cell has a third **NTC** lead (thermistor), leave it unconnected — basic TP4056 modules ignore it. + +**Calibration is required.** `BATTERY_CONVERSION_FACTOR` in [`config/power.rs`](../../crates/firmware/src/config/power.rs) is inherited from the original project, where it was tuned against the classic ESP32 ADC. ESP32-C6 has different ADC characteristics, so charge the cell fully, read the reported percentage, and scale the constant until a full cell shows 100 %. + +Leaving GPIO 1 unconnected is harmless — the reading is then meaningless noise and the battery icon can be hidden from the menu. ### Master table (one wire per row) -| ESP GPIO | DevKit silk | Component | Component pin | Direction | -|----------|-------------|-----------|---------------|-----------| -| 0 | GPIO0 | KY-040 | `SW` | input, active-low | -| 2 | GPIO2 | KY-040 | `DT` | encoder A | -| 3 | GPIO3 | KY-040 | `CLK` | encoder B | -| 6 | GPIO6 | OLED | `SDA` | I2C data | -| 7 | GPIO7 | OLED | `SCL` | I2C clock | -| 10 | GPIO10 | Keypad | `C2` | matrix column | -| 11 | GPIO11 | Tact | Menu left | → GND | -| 12 | GPIO12 | Tact | Stop | → GND | -| 13 | GPIO13 | Tact | Menu right | → GND | -| 14 | GPIO14 | Tact | Back | → GND | -| 15 | GPIO15 | Tact | Menu | → GND | -| 18 | GPIO18 | Keypad | `R0` | matrix row (output) | -| 19 | GPIO19 | Keypad | `R1` | matrix row (output) | -| 20 | GPIO20 | Keypad | `R2` | matrix row (output) | -| 21 | GPIO21 | Keypad | `R3` | matrix row (output) | -| 22 | GPIO22 | Keypad | `C0` | matrix column | -| 23 | GPIO23 | Keypad | `C1` | matrix column | -| 3V3 | 3V3 | OLED, KY-040 | `VCC` / `+` | power | -| GND | GND | all | `GND` | ground | +Header numbering follows the [ESP32-C6-DevKitC-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html): **J1** is the side carrying `3V3`/`RST`/`5V`, **J3** the side carrying `TX`/`RX`. + +| ESP GPIO | Header | Component | Component pin | Direction | +|----------|--------|-----------|---------------|-----------| +| 0 | J1-7 | KY-040 | `SW` | input, active-low | +| 1 | J1-8 | Battery | divider midpoint | ADC input | +| 2 | J1-12 | KY-040 | `DT` | encoder A | +| 3 | J1-13 | KY-040 | `CLK` | encoder B | +| 4 | J1-3 | Tact | Menu right | → GND | +| 5 | J1-4 | Tact | Back | → GND | +| 6 | J1-5 | OLED | `SDA` | I2C data | +| 7 | J1-6 | OLED | `SCL` | I2C clock | +| 10 | J1-10 | Keypad | `C2` | matrix column | +| 11 | J1-11 | Tact | Menu left | → GND | +| 12 | J3-14 | Tact | Stop | → GND | +| 15 | J3-4 | Tact | Menu | → GND | +| 18 | J3-10 | Keypad | `R0` | matrix row (output) | +| 19 | J3-9 | Keypad | `R1` | matrix row (output) | +| 20 | J3-8 | Keypad | `R2` | matrix row (output) | +| 21 | J3-7 | Keypad | `R3` | matrix row (output) | +| 22 | J3-6 | Keypad | `C0` | matrix column | +| 23 | J3-5 | Keypad | `C1` | matrix column | +| 3V3 | J1-1 | OLED, KY-040 | `VCC` / `+` | power | +| GND | J1-15 / J3-1 | all | `GND` | ground | ### Unused / reserved GPIO -Not used by MarkWTech: **1** (optional battery ADC), **4, 5, 8, 9, 16, 17** and any GPIO not listed above. **GPIO 16/17** are UART0 (serial console via the USB-UART bridge) — leave free for debug. +| GPIO | Header | Why it is left alone | +|------|--------|----------------------| +| 8 | J1-9 | drives the on-board addressable RGB LED | +| 9 | J3-11 | on-board BOOT button; boot-mode strapping pin | +| 13 | J3-13 | `USB_D+` — keep paired with 12 rather than half-breaking the port | +| 16 | J3-2 | `U0TXD` — serial console out | +| 17 | J3-3 | `U0RXD` — serial console in | + +GPIO **14** is not listed because the ESP32-C6-WROOM-1 module does not break it out; only `0–13` and `15–23` reach the headers. ### Flashing -Flash firmware over the DevKit USB port (UART or USB-JTAG) — no extra wiring. Enter provisioning: hold **`*`** (keypad) + **Stop** (GPIO 12) for **8 s**. +Use the **USB Type-C to UART** port (the one wired to the on-board bridge) — no extra wiring. The other Type-C port is the chip's native USB, which is unavailable because Stop occupies `USB_D−`. Enter provisioning: hold **`*`** (keypad) + **Stop** (GPIO 12) for **8 s**. ## BOM @@ -175,6 +217,14 @@ Flash firmware over the DevKit USB port (UART or USB-JTAG) — no extra wiring. - 5 tact switches (left, Stop, right, Back, Menu) - Case: Thingiverse 7029069 (adapted) +Battery (optional, see [Battery](#battery)): + +- LiPo cell 3.7 V, 1200 mAh (503759) or larger +- TP4056 charging module with protection +- 3.3 V LDO (HT7333 / ME6211) +- 2× 47 kΩ resistor +- Power switch + ## Programming mode Hold **\* + Stop** for 8 seconds. See [provisioning.md](../provisioning.md). From 8a6497633b0a56981f56bd3134373bf34d4dbd69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:37:23 +0200 Subject: [PATCH 5/7] Add dual-slot HTTP firmware OTA over Soft-AP and layout Wi-Fi. Serve POST /api/v1/firmware from the pairing AP (with DHCP) and from the Extras firmware-update screen on STA, writing the inactive OTA slot. Co-authored-by: Cursor --- .cargo/config.toml | 2 +- .github/workflows/ci.yml | 8 +- Cargo.lock | 55 +++++ crates/firmware/.cargo/config.toml | 2 +- crates/firmware/Cargo.toml | 3 +- crates/firmware/src/bin/main.rs | 25 +- crates/firmware/src/config/sizes.rs | 10 +- crates/firmware/src/domain/task.rs | 46 ++-- crates/firmware/src/net/mdns.rs | 74 +++++- crates/firmware/src/net/mod.rs | 25 ++ .../src/net/provisioning/http_server.rs | 228 ++++++++++++++---- .../firmware/src/net/provisioning/index.html | 18 ++ crates/firmware/src/net/provisioning/mod.rs | 78 +++++- crates/firmware/src/net/provisioning/ota.rs | 160 ++++++++++++ crates/firmware/src/net/wifi.rs | 4 + crates/firmware/src/storage/mod.rs | 36 ++- crates/firmware/src/ui/i18n.rs | 28 +++ crates/firmware/src/ui/menu.rs | 48 +++- crates/firmware/src/ui/menu_nav.rs | 20 +- crates/firmware/src/ui/view.rs | 3 + crates/proto/src/image.rs | 109 +++++++++ crates/proto/src/lib.rs | 1 + crates/proto/src/mdns.rs | 145 +++++++++++ crates/proto/src/provisioning.rs | 19 +- docs/hardware/heiko-wifred.md | 3 +- docs/hardware/longfred-mini.md | 4 + docs/hardware/longfred-standard.md | 2 +- docs/hardware/markwtech.md | 2 +- docs/provisioning.md | 55 ++++- partitions.csv | 6 + scripts/check-esp32c6-size.sh | 12 +- 31 files changed, 1097 insertions(+), 134 deletions(-) create mode 100644 crates/firmware/src/net/provisioning/ota.rs create mode 100644 crates/proto/src/image.rs create mode 100644 partitions.csv diff --git a/.cargo/config.toml b/.cargo/config.toml index 21b0117..5f722b2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,7 +2,7 @@ include = ["../crates/firmware/.cargo/esp-config.toml"] [target.riscv32imac-unknown-none-elf] -runner = "espflash flash --monitor --chip esp32c6" +runner = "espflash flash --monitor --chip esp32c6 --partition-table partitions.csv" [build] target = "riscv32imac-unknown-none-elf" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9858ee0..e54e959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,12 +135,16 @@ jobs: ELF="target/riscv32imac-unknown-none-elf/release/longfred" cp "$ELF" "dist/longfred-${VARIANT}-esp32c6.elf" - espflash save-image --chip esp32c6 --merge "$ELF" \ + espflash save-image --chip esp32c6 --partition-table partitions.csv --merge "$ELF" \ "dist/longfred-${VARIANT}-esp32c6.bin" + espflash save-image --chip esp32c6 --partition-table partitions.csv "$ELF" \ + "dist/longfred-${VARIANT}-esp32c6.app.bin" ( cd dist - sha256sum "longfred-${VARIANT}-esp32c6.elf" "longfred-${VARIANT}-esp32c6.bin" > SHA256SUMS + sha256sum "longfred-${VARIANT}-esp32c6.elf" \ + "longfred-${VARIANT}-esp32c6.bin" \ + "longfred-${VARIANT}-esp32c6.app.bin" > SHA256SUMS ) - name: Upload firmware diff --git a/Cargo.lock b/Cargo.lock index c00e86d..3ff86b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,24 @@ dependencies = [ "litrs", ] +[[package]] +name = "edge-dhcp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0b32c831ced877a78378312fe0b6f7cdd5759f3ba272578f582ff9bba5291d" +dependencies = [ + "edge-raw", + "heapless 0.9.3", + "num_enum", + "rand_core 0.9.5", +] + +[[package]] +name = "edge-raw" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466dfce9c2172a4e947b81b556f1f07a86029fbac679e323cfb66c738cc2faea" + [[package]] name = "embassy-embedded-hal" version = "0.6.0" @@ -787,6 +805,21 @@ dependencies = [ "xtensa-lx-rt", ] +[[package]] +name = "esp-hal-dhcp-server" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15dbfc886c70ad3f81ea6eaec936e41b95fff21279620371715f5635df85f5ff" +dependencies = [ + "edge-dhcp", + "embassy-futures", + "embassy-net", + "embassy-sync 0.8.0", + "embassy-time", + "heapless 0.9.3", + "log", +] + [[package]] name = "esp-hal-procmacros" version = "0.22.0" @@ -1348,6 +1381,7 @@ dependencies = [ "esp-backtrace", "esp-bootloader-esp-idf", "esp-hal", + "esp-hal-dhcp-server", "esp-println", "esp-radio", "esp-rtos", @@ -1466,6 +1500,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "object" version = "0.37.3" diff --git a/crates/firmware/.cargo/config.toml b/crates/firmware/.cargo/config.toml index 665e44a..43af3cb 100644 --- a/crates/firmware/.cargo/config.toml +++ b/crates/firmware/.cargo/config.toml @@ -2,7 +2,7 @@ include = ["esp-config.toml"] [target.riscv32imac-unknown-none-elf] -runner = "espflash flash --monitor --chip esp32c6" +runner = "espflash flash --monitor --chip esp32c6 --partition-table partitions.csv" [build] target = "riscv32imac-unknown-none-elf" diff --git a/crates/firmware/Cargo.toml b/crates/firmware/Cargo.toml index a75b801..a5aaf27 100644 --- a/crates/firmware/Cargo.toml +++ b/crates/firmware/Cargo.toml @@ -49,11 +49,12 @@ embassy-time = { version = "0.5.0", features = ["log"] } ssd1306 = { version = "0.10", default-features = false, features = ["graphics"] } embedded-graphics = "0.8" embedded-hal = "1.0" +embassy-embedded-hal = "0.6" log = "0.4" heapless = "0.9" nb = "1.1" static_cell = "2.1" -embassy-embedded-hal = "0.6" +esp-hal-dhcp-server = { version = "0.4.0", default-features = false, features = ["log"] } embedded-hal-async = "1.0" critical-section = "1.2" diff --git a/crates/firmware/src/bin/main.rs b/crates/firmware/src/bin/main.rs index 0513f55..8854a97 100644 --- a/crates/firmware/src/bin/main.rs +++ b/crates/firmware/src/bin/main.rs @@ -3,6 +3,8 @@ //! LongFred firmware entry point: HAL init, task spawn, and Soft-AP programming mode. use embassy_executor::Spawner; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; use embassy_time::{Duration, Timer}; use esp_backtrace as _; #[cfg(not(feature = "sim"))] @@ -27,7 +29,7 @@ use longfred_firmware::{board, config, domain, input, storage, ui}; esp_bootloader_esp_idf::esp_app_desc!(); -static FLASH: StaticCell = StaticCell::new(); +static FLASH: StaticCell>> = StaticCell::new(); #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { @@ -50,13 +52,24 @@ async fn main(spawner: Spawner) -> ! { let rng = Rng::new(); let boot_entropy = rng.random(); - let flash = FLASH.init(FlashStorage::new(peripherals.FLASH)); - let boot = storage::ensure_boot(flash, boot_entropy); + let mut flash_dev = FlashStorage::new(peripherals.FLASH); + let boot = storage::ensure_boot(&mut flash_dev, boot_entropy); info!("wifi hostname: {}", boot.wifi_hostname.as_str()); + #[cfg(not(feature = "sim"))] + net::provisioning::ota::mark_running_slot_valid(&mut flash_dev); let enter_programming = boot.programming_mode || (board::active_variant().auto_pair_when_unconfigured && !boot.has_wifi_credentials); + let mut prog_rec = boot.record.clone(); + if enter_programming { + prog_rec.programming_mode = true; + if !boot.programming_mode { + storage::write_record(&mut flash_dev, &prog_rec); + } + } + let flash = FLASH.init(Mutex::new(flash_dev)); + #[cfg(not(feature = "sim"))] { let seed = ((rng.random() as u64) << 32) | rng.random() as u64; @@ -71,15 +84,12 @@ async fn main(spawner: Spawner) -> ! { ); 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, + flash, ); } else { let controller = match WifiController::new(peripherals.WIFI, Default::default()) { @@ -120,6 +130,7 @@ async fn main(spawner: Spawner) -> ! { if let Ok(token) = net::session::task(stack) { spawner.spawn(token); } + net::provisioning::spawn_sta_http(&spawner, stack, boot.record.clone(), flash); } } diff --git a/crates/firmware/src/config/sizes.rs b/crates/firmware/src/config/sizes.rs index e9c1851..56fbfbc 100644 --- a/crates/firmware/src/config/sizes.rs +++ b/crates/firmware/src/config/sizes.rs @@ -12,5 +12,11 @@ pub const MAX_ROUTE_LIST: usize = 60; pub const MAX_SSID_LEN: usize = 32; pub const MAX_PASSWORD_LEN: usize = 64; -/// embassy-net socket count (DHCP + DNS + mDNS UDP + session TCP/UDP). -pub const NET_SOCKETS: usize = 5; +/// embassy-net socket count (DHCP + DNS + mDNS UDP + session TCP/UDP + HTTP OTA). +pub const NET_SOCKETS: usize = 6; + +/// Soft-AP programming stack: HTTP TCP + DHCP UDP + spare. +pub const PROG_NET_SOCKETS: usize = 4; + +/// Inactive OTA app slot size from `partitions.csv` (`ota_0` / `ota_1`). +pub const OTA_SLOT_BYTES: u32 = 0x3C_0000; diff --git a/crates/firmware/src/domain/task.rs b/crates/firmware/src/domain/task.rs index 57db9b5..b5f68cb 100644 --- a/crates/firmware/src/domain/task.rs +++ b/crates/firmware/src/domain/task.rs @@ -81,6 +81,9 @@ fn publish_view( ip_formatted, broadcast: state.active_broadcast(), battery, + sta_ipv4: net::sta_ipv4(), + http_ota: net::http_ota_enabled(), + http_ota_busy: net::http_ota_busy(), }; ui_tx.send(fsm.view(&ctx)); } @@ -127,6 +130,7 @@ fn interpret( Intent::None => {} Intent::Action(Action::ShowHideBattery) => fsm.cycle_battery_mode(), Intent::Action(Action::Sleep) => { + net::set_http_ota_enabled(false); SLEEP_CTRL.signal(SleepReason::Command); } Intent::Action(a) => { @@ -219,7 +223,10 @@ fn interpret( } Intent::DropBeforeAcquireToggle => state.toggle_drop_before_acquire(), Intent::HashFunctionsToggle => fsm.toggle_hash_functions(), - Intent::Sleep => SLEEP_CTRL.signal(SleepReason::Command), + Intent::Sleep => { + net::set_http_ota_enabled(false); + SLEEP_CTRL.signal(SleepReason::Command); + } Intent::SaveLocos => { let locos = state.collect_saved_locos(); let _ = storage_tx.try_send(StorageCmd::SaveLocos(locos)); @@ -256,6 +263,9 @@ fn interpret( // Handled eagerly in the input loop (persist + software_reset). log::info!("domain: EnterProgrammingMode intent (already applied)"); } + Intent::SetHttpOta(on) => { + net::set_http_ota_enabled(on); + } } } @@ -357,29 +367,35 @@ pub async fn task() { spdt_direction = dir; } out.clear(); - let intent = fsm.handle(ev, &state, &scanned); - interpret( - &mut fsm, - &mut state, - intent, - spdt_direction, - &mut out, - &scanned, - &servers, - &wifi_tx, - &srv_tx, - &storage_tx, - ); + if net::http_ota_busy() { + // Ignore navigation while an image is streaming to flash. + } else { + let intent = fsm.handle(ev, &state, &scanned); + interpret( + &mut fsm, + &mut state, + intent, + spdt_direction, + &mut out, + &scanned, + &servers, + &wifi_tx, + &srv_tx, + &storage_tx, + ); + } } Either3::Second(sev) => { out.clear(); let _ = state.apply_event(sev, &mut out); } Either3::Third(_) => { - if power::AUTO_SLEEP_INACTIVITY_MS > 0 + if !net::http_ota_busy() + && power::AUTO_SLEEP_INACTIVITY_MS > 0 && conn != ConnState::Connected && last_activity.elapsed().as_millis() > power::AUTO_SLEEP_INACTIVITY_MS { + net::set_http_ota_enabled(false); SLEEP_CTRL.signal(SleepReason::Inactivity); } } diff --git a/crates/firmware/src/net/mdns.rs b/crates/firmware/src/net/mdns.rs index 5cf782f..3421dea 100644 --- a/crates/firmware/src/net/mdns.rs +++ b/crates/firmware/src/net/mdns.rs @@ -12,7 +12,10 @@ use longfred_proto::mdns::{ }; use crate::config::{network, sizes}; -use crate::net::{FOUND_SERVERS, MDNS_CTRL, NetStatus, SERVER, STATE, ServerEndpoint}; +use crate::net::{ + FOUND_SERVERS, HTTP_OTA_ENABLE, MDNS_CTRL, NetStatus, SERVER, STATE, ServerEndpoint, + WIFI_HOSTNAME, +}; const MAX_SERVERS: usize = sizes::MAX_FOUND_SERVERS; @@ -171,6 +174,10 @@ pub async fn task(stack: Stack<'static>, ssid: &'static str) { let mdns_rx = MDNS_CTRL.receiver(); loop { + if HTTP_OTA_ENABLE.try_get() == Some(true) { + Timer::after(Duration::from_millis(500)).await; + continue; + } let servers = run_discovery(stack, ssid).await; maybe_auto_connect(&servers); FOUND_SERVERS.signal(servers); @@ -181,3 +188,68 @@ pub async fn task(stack: Stack<'static>, ssid: &'static str) { } } } + +/// Advertise `_longfred-ota._tcp.local` while STA HTTP OTA is enabled. +#[embassy_executor::task] +pub async fn ota_announce_task(stack: Stack<'static>) { + loop { + wait_ota_enabled().await; + let Some(ip) = crate::net::sta_ipv4() else { + Timer::after(Duration::from_millis(200)).await; + continue; + }; + let hostname = WIFI_HOSTNAME.try_get().filter(|h| !h.is_empty()).unwrap_or_else(|| { + let mut s = heapless::String::new(); + let _ = s.push_str("longfred"); + s + }); + + let mut rx_meta = [PacketMetadata::EMPTY; 4]; + let mut rx_buf = [0u8; 512]; + let mut tx_meta = [PacketMetadata::EMPTY; 4]; + let mut tx_buf = [0u8; 512]; + let mut sock = UdpSocket::new(stack, &mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf); + let group = IpAddress::v4( + MDNS_MULTICAST_V4[0], + MDNS_MULTICAST_V4[1], + MDNS_MULTICAST_V4[2], + MDNS_MULTICAST_V4[3], + ); + let _ = stack.join_multicast_group(group); + if sock.bind(MDNS_PORT).is_err() { + warn!("ota-mdns: bind 5353 failed"); + Timer::after(Duration::from_secs(1)).await; + continue; + } + let dst = IpEndpoint::new(group, MDNS_PORT); + info!("ota-mdns: announcing {} at {:?}:80", hostname.as_str(), ip); + while crate::net::http_ota_enabled() { + let mut pkt = [0u8; 512]; + let n = longfred_proto::mdns::build_ota_announce(hostname.as_str(), ip, 80, &mut pkt); + let _ = sock.send_to(&pkt[..n], dst).await; + Timer::after(Duration::from_secs(2)).await; + } + let _ = stack.leave_multicast_group(group); + info!("ota-mdns: stopped"); + } +} + +async fn wait_ota_enabled() { + if HTTP_OTA_ENABLE.try_get() == Some(true) { + return; + } + if let Some(mut rx) = HTTP_OTA_ENABLE.receiver() { + loop { + if rx.try_get() == Some(true) { + return; + } + rx.changed().await; + } + } + loop { + Timer::after(Duration::from_millis(200)).await; + if HTTP_OTA_ENABLE.try_get() == Some(true) { + return; + } + } +} diff --git a/crates/firmware/src/net/mod.rs b/crates/firmware/src/net/mod.rs index 5118b0d..182571e 100644 --- a/crates/firmware/src/net/mod.rs +++ b/crates/firmware/src/net/mod.rs @@ -112,6 +112,31 @@ pub static WIFI_HOSTNAME: Watch, 2 /// Live IPv4 stack configuration (domain → config_task). pub static NET_CONFIG_CTRL: Signal = Signal::new(); +/// STA IPv4 once DHCP/static config is up (UI + mDNS OTA announce). +pub static STA_IPV4: Watch, 2> = Watch::new_with(None); + +/// User-enabled STA HTTP OTA server (menu screen). +pub static HTTP_OTA_ENABLE: Watch = Watch::new_with(false); + +/// Firmware POST in progress (OLED "Updating"). +pub static HTTP_OTA_BUSY: Watch = Watch::new_with(false); + +pub fn http_ota_enabled() -> bool { + HTTP_OTA_ENABLE.try_get().unwrap_or(false) +} + +pub fn http_ota_busy() -> bool { + HTTP_OTA_BUSY.try_get().unwrap_or(false) +} + +pub fn sta_ipv4() -> Option<[u8; 4]> { + STA_IPV4.try_get().flatten() +} + +pub fn set_http_ota_enabled(on: bool) { + HTTP_OTA_ENABLE.sender().send(on); +} + // Legacy type aliases for gradual migration. pub type WitEndpoint = ServerEndpoint; pub type WitConnState = ConnState; diff --git a/crates/firmware/src/net/provisioning/http_server.rs b/crates/firmware/src/net/provisioning/http_server.rs index cf2281a..14afd01 100644 --- a/crates/firmware/src/net/provisioning/http_server.rs +++ b/crates/firmware/src/net/provisioning/http_server.rs @@ -1,4 +1,4 @@ -//! Minimal HTTP/1.1 server for Soft-AP provisioning (manual TcpSocket parser). +//! Minimal HTTP/1.1 server for Soft-AP provisioning and STA firmware OTA. use embassy_net::Stack; use embassy_net::tcp::TcpSocket; @@ -12,7 +12,12 @@ use longfred_proto::provisioning::{ }; use crate::net::provisioning::exit_programming_mode; -use crate::storage::{STORAGE_ACK, STORAGE_CTRL, StorageCmd}; +use crate::net::provisioning::ota; +use crate::net::{self, HTTP_OTA_BUSY}; +use crate::storage::{STORAGE_ACK, STORAGE_CTRL, SharedFlash, StorageCmd}; +use crate::ui::i18n; +use crate::ui::view::{GridView, UiView}; +use crate::ui::UI_VIEW; const INDEX_HTML: &str = include_str!("index.html"); @@ -20,49 +25,135 @@ const RX_BUF: usize = 2048; const TX_BUF: usize = 4096; const BODY_MAX: usize = 1536; const JSON_MAX: usize = 1536; +const FW_TIMEOUT_SECS: u64 = 120; + +/// Which routes the HTTP server exposes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HttpMode { + /// Soft-AP: settings + firmware + exit programming mode. + Ap, + /// STA: firmware upload only (plus GET settings / page). + Sta, +} #[embassy_executor::task] -pub async fn task( +pub async fn task_ap( stack: Stack<'static>, rec: &'static Mutex, + flash: &'static SharedFlash, ) { info!("programming: HTTP listening on :80"); + serve_loop(stack, Some(rec), flash, HttpMode::Ap, false).await; +} + +#[embassy_executor::task] +pub async fn task_sta( + stack: Stack<'static>, + rec: &'static Mutex, + flash: &'static SharedFlash, +) { + info!("http-ota: STA server idle until enabled"); + serve_loop(stack, Some(rec), flash, HttpMode::Sta, true).await; +} + +async fn serve_loop( + stack: Stack<'static>, + rec: Option<&'static Mutex>, + flash: &'static SharedFlash, + mode: HttpMode, + gated: bool, +) { loop { + if gated { + wait_enabled().await; + } 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))); + let timeout = if gated { + Duration::from_secs(2) + } else { + Duration::from_secs(20) + }; + sock.set_timeout(Some(timeout)); - if sock.accept(80).await.is_err() { - warn!("programming: accept failed"); - Timer::after(Duration::from_millis(100)).await; + if gated && !is_enabled() { + sock.abort(); continue; } - if let Err(e) = handle_client(&mut sock, rec).await { - warn!("programming: request error: {}", e); + match sock.accept(80).await { + Ok(()) => {} + Err(_) => { + Timer::after(Duration::from_millis(50)).await; + continue; + } + } + + if let Err(e) = handle_client(&mut sock, rec, flash, mode).await { + warn!("http: request error: {}", e); } sock.abort(); let _ = sock.flush().await; } } +fn is_enabled() -> bool { + net::http_ota_enabled() +} + +fn show_updating() { + let mut grid = GridView::new(); + grid.set(0, i18n::tr().msg_fw_updating, false); + UI_VIEW.sender().send(UiView::Grid(grid)); +} + +async fn wait_enabled() { + if is_enabled() { + return; + } + if let Some(mut rx) = crate::net::HTTP_OTA_ENABLE.receiver() { + loop { + if rx.try_get() == Some(true) { + return; + } + rx.changed().await; + } + } + loop { + Timer::after(Duration::from_millis(200)).await; + if is_enabled() { + return; + } + } +} + async fn handle_client( sock: &mut TcpSocket<'_>, - rec: &'static Mutex, + rec: Option<&'static Mutex>, + flash: &'static SharedFlash, + mode: HttpMode, ) -> 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 header_end = find_header_end(&hdr[..n]).ok_or("bad headers")?; + let already = n.saturating_sub(header_end); + let already_bytes = if already > 0 { + &hdr[header_end..header_end + already] + } else { + &[][..] + }; + + if method == "POST" && path == "/api/v1/firmware" { + return handle_firmware(sock, flash, already_bytes, content_len).await; + } let mut body_buf = [0u8; BODY_MAX]; let body = if content_len > 0 { if content_len > BODY_MAX { - return Err("body too large"); + return respond(sock, 400, "text/plain", b"body too large").await; } - // 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"); } @@ -80,56 +171,94 @@ async fn handle_client( &[][..] }; - match (method, path) { - ("GET", "/") => respond(sock, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes()).await, - ("GET", "/api/v1/settings") => { + match (method, path, mode) { + ("GET", "/", _) => respond(sock, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes()).await, + ("GET", "/api/v1/settings", _) => { + let Some(rec) = rec else { + return respond(sock, 503, "text/plain", b"no settings").await; + }; let guard = rec.lock().await; let mut json = [0u8; JSON_MAX]; - match serialize_settings_from_record(&mut json, &*guard) { + match serialize_settings_from_record(&mut json, &*guard, i18n::FW_VERSION) { 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", + ("PUT", "/api/v1/settings", HttpMode::Ap) => { + let Some(rec) = rec else { + return respond(sock, 503, "text/plain", b"no settings").await; }; - 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 + handle_settings_put(sock, rec, body).await } - ("POST", "/api/v1/programming-mode/off") => { + ("POST", "/api/v1/programming-mode/off", HttpMode::Ap) => { 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 handle_settings_put( + sock: &mut TcpSocket<'_>, + rec: &'static Mutex, + body: &[u8], +) -> Result<(), &'static str> { + 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 +} + +async fn handle_firmware( + sock: &mut TcpSocket<'_>, + flash: &'static SharedFlash, + already: &[u8], + content_len: usize, +) -> Result<(), &'static str> { + if content_len == 0 { + return respond(sock, 400, "text/plain", b"empty image").await; + } + sock.set_timeout(Some(Duration::from_secs(FW_TIMEOUT_SECS))); + HTTP_OTA_BUSY.sender().send(true); + show_updating(); + let result = { + let mut g = flash.lock().await; + ota::flash_from_socket(&mut g, sock, already, content_len).await + }; + HTTP_OTA_BUSY.sender().send(false); + match result { + Ok(()) => { + respond(sock, 200, "application/json", b"{\"ok\":true}").await?; + let _ = sock.flush().await; + Timer::after(Duration::from_millis(500)).await; + esp_hal::system::software_reset(); + } + Err(msg) => { + warn!("ota: {msg}"); + respond(sock, 400, "text/plain", msg.as_bytes()).await + } + } +} + async fn read_headers(sock: &mut TcpSocket<'_>, buf: &mut [u8]) -> Result { let mut n = 0usize; loop { @@ -161,7 +290,6 @@ fn parse_request(buf: &[u8]) -> Result<(&str, &str, usize), &'static str> { 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; diff --git a/crates/firmware/src/net/provisioning/index.html b/crates/firmware/src/net/provisioning/index.html index 349ebc6..969bdbc 100644 --- a/crates/firmware/src/net/provisioning/index.html +++ b/crates/firmware/src/net/provisioning/index.html @@ -36,6 +36,14 @@

LongFred pairing

+
+Firmware + + + + + +
@@ -97,6 +105,16 @@

LongFred pairing

status('Done — device rebooting',true); }catch(e){status('Exit failed: '+e,false)} } +async function uploadFw(){ + const f=$('fw').files[0]; + if(!f){status('Choose a .app.bin file',false);return;} + status('Uploading firmware…'); + try{ + const r=await fetch('/api/v1/firmware',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:f}); + if(!r.ok)throw new Error(await r.text()||r.status); + status('Firmware written — device rebooting',true); + }catch(e){status('Upload failed: '+e,false)} +} load(); diff --git a/crates/firmware/src/net/provisioning/mod.rs b/crates/firmware/src/net/provisioning/mod.rs index f38e8d5..b7cd02f 100644 --- a/crates/firmware/src/net/provisioning/mod.rs +++ b/crates/firmware/src/net/provisioning/mod.rs @@ -1,6 +1,7 @@ //! Soft-AP programming / pairing mode (HTTP provisioning). mod http_server; +pub mod ota; use embassy_net::{ Config as NetConfig, Ipv4Address, Ipv4Cidr, Stack, StackResources, StaticConfigV4, @@ -21,7 +22,7 @@ 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::storage::{PERSIST_LOADED, STORAGE_ACK, STORAGE_CTRL, SharedFlash, StorageCmd}; use crate::ui::UI_VIEW; use crate::ui::view::{GridView, UiView}; @@ -30,6 +31,7 @@ const AP_PREFIX: u8 = 24; const SSID_PREFIX: &str = "longfred_prog_"; static PROG_REC: StaticCell> = StaticCell::new(); +static STA_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> { @@ -46,7 +48,7 @@ pub fn ap_ssid_from_mac(mac: &[u8; 6]) -> String<32> { fn static_ap_config() -> NetConfig { NetConfig::ipv4_static(StaticConfigV4 { address: Ipv4Cidr::new(AP_IP, AP_PREFIX), - gateway: None, + gateway: Some(AP_IP), dns_servers: Default::default(), }) } @@ -95,12 +97,14 @@ pub async fn ap_hold_task(controller: WifiController<'static>) { #[embassy_executor::task] pub async fn pairing_ui_task(ssid: String<32>) { let desc = board::active_variant(); + let mut pairing_grid = None; 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)); + UI_VIEW.sender().send(UiView::Grid(grid.clone())); + pairing_grid = Some(grid); info!("programming: display shows Pairing mode"); } #[cfg(feature = "variant-heiko-wifred")] @@ -114,8 +118,16 @@ pub async fn pairing_ui_task(ssid: String<32>) { if desc.display.is_none() { info!("programming: pairing active (no display/LEDs)"); } + let mut was_busy = false; loop { - Timer::after(Duration::from_secs(30)).await; + Timer::after(Duration::from_millis(250)).await; + let busy = crate::net::http_ota_busy(); + if was_busy && !busy { + if let Some(ref g) = pairing_grid { + UI_VIEW.sender().send(UiView::Grid(g.clone())); + } + } + was_busy = busy; } } @@ -145,6 +157,7 @@ pub fn spawn_programming_net( wifi: esp_hal::peripherals::WIFI<'static>, seed: u64, initial: PersistRecord, + flash: &'static SharedFlash, ) -> bool { let mac = efuse::interface_mac_address(InterfaceMacAddress::AccessPoint); let mut mac_bytes = [0u8; 6]; @@ -162,7 +175,7 @@ pub fn spawn_programming_net( return false; }; - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); let resources = RESOURCES.init(StackResources::new()); let (stack, runner) = embassy_net::new(iface, static_ap_config(), resources, seed); @@ -174,7 +187,10 @@ pub fn spawn_programming_net( if let Ok(token) = crate::net::wifi::net_task(runner) { spawner.spawn(token); } - if let Ok(token) = http_server::task(stack, rec) { + if let Ok(token) = http_server::task_ap(stack, rec, flash) { + spawner.spawn(token); + } + if let Ok(token) = dhcp_task(stack) { spawner.spawn(token); } if let Ok(token) = pairing_ui_task(ssid) { @@ -212,3 +228,53 @@ pub async fn exit_programming_mode(delay_ms: u64) -> ! { /// Used by HTTP server / tests: re-export stack type. pub type ProgStack = Stack<'static>; + +/// STA HTTP OTA + mDNS announce (gated by [`crate::net::HTTP_OTA_ENABLE`]). +pub fn spawn_sta_http( + spawner: &embassy_executor::Spawner, + stack: Stack<'static>, + initial: PersistRecord, + flash: &'static SharedFlash, +) { + let rec = STA_REC.init(Mutex::new(initial)); + if let Ok(token) = http_server::task_sta(stack, rec, flash) { + spawner.spawn(token); + } + if let Ok(token) = sync_persist_task(rec) { + spawner.spawn(token); + } + if let Ok(token) = crate::net::mdns::ota_announce_task(stack) { + spawner.spawn(token); + } +} + +#[embassy_executor::task] +async fn dhcp_task(stack: Stack<'static>) { + use esp_hal_dhcp_server::simple_leaser::SimpleDhcpLeaser; + use esp_hal_dhcp_server::structs::DhcpServerConfig; + use esp_hal_dhcp_server::{Ipv4Addr, run_dhcp_server}; + + let ip = Ipv4Addr::new(192, 168, 0, 1); + let gw = [ip]; + let dns = [ip]; + let config = DhcpServerConfig { + ip, + lease_time: Duration::from_secs(3600), + gateways: &gw, + subnet: Some(Ipv4Addr::new(255, 255, 255, 0)), + dns: &dns, + use_captive_portal: false, + }; + let mut leaser = SimpleDhcpLeaser { + start: Ipv4Addr::new(192, 168, 0, 50), + end: Ipv4Addr::new(192, 168, 0, 200), + leases: Default::default(), + }; + info!("programming: DHCP pool 192.168.0.50-200"); + if let Err(e) = run_dhcp_server(stack, config, &mut leaser).await { + warn!("programming: DHCP server failed: {:?}", e); + loop { + Timer::after(Duration::from_secs(60)).await; + } + } +} diff --git a/crates/firmware/src/net/provisioning/ota.rs b/crates/firmware/src/net/provisioning/ota.rs new file mode 100644 index 0000000..ffb2698 --- /dev/null +++ b/crates/firmware/src/net/provisioning/ota.rs @@ -0,0 +1,160 @@ +//! Dual-slot OTA via `esp-bootloader-esp-idf` `OtaUpdater`. + +use embedded_storage::nor_flash::NorFlash; +use embassy_net::tcp::TcpSocket; +use esp_bootloader_esp_idf::ota::OtaImageState; +use esp_bootloader_esp_idf::ota_updater::OtaUpdater; +use esp_bootloader_esp_idf::partitions::FlashRegion; +use esp_storage::FlashStorage; +use log::{info, warn}; +use longfred_proto::image::{ + ESP_IMAGE_HEADER_LEN, ImageError, validate_esp32c6_app_image, +}; + +const SECTOR: usize = 4096; +const PT_BUF: usize = 3072; + +/// Mark the running slot Valid after a successful OTA boot (no-op if otadata is empty). +pub fn mark_running_slot_valid(flash: &mut FlashStorage<'_>) { + let mut buf = [0u8; PT_BUF]; + let Ok(mut ota) = OtaUpdater::new(flash, &mut buf) else { + return; + }; + match ota.current_ota_state() { + Ok(OtaImageState::New | OtaImageState::PendingVerify) => { + if ota.set_current_ota_state(OtaImageState::Valid).is_ok() { + info!("ota: running slot marked Valid"); + } + } + Err(e) => warn!("ota: current state: {:?}", e), + Ok(_) => {} + } +} + +fn image_err_msg(e: ImageError) -> &'static str { + match e { + ImageError::Truncated => "image header truncated", + ImageError::BadMagic => "not an ESP app image (use .app.bin, not merged)", + ImageError::WrongChip => "image is not for ESP32-C6", + ImageError::TooSmall => "image too small", + ImageError::TooLarge => "image larger than OTA slot", + } +} + +fn write_sector( + region: &mut FlashRegion<'_, FlashStorage<'_>>, + offset: &mut u32, + sector: &mut [u8; SECTOR], + filled: &mut usize, + chunk: &[u8], +) -> Result<(), &'static str> { + let mut rest = chunk; + while !rest.is_empty() { + let room = SECTOR - *filled; + let n = rest.len().min(room); + sector[*filled..*filled + n].copy_from_slice(&rest[..n]); + *filled += n; + rest = &rest[n..]; + if *filled == SECTOR { + region.write(*offset, sector).map_err(|_| "flash write")?; + *offset += SECTOR as u32; + *filled = 0; + *sector = [0xFFu8; SECTOR]; + } + } + Ok(()) +} + +/// Stream `content_len` bytes from `sock` into the inactive OTA slot. +pub async fn flash_from_socket( + flash: &mut FlashStorage<'_>, + sock: &mut TcpSocket<'_>, + already: &[u8], + content_len: usize, +) -> Result<(), &'static str> { + let mut buf = [0u8; PT_BUF]; + let mut ota = OtaUpdater::new(flash, &mut buf).map_err(|_| "ota partitions missing")?; + + let mut header = [0u8; ESP_IMAGE_HEADER_LEN]; + let mut header_got = already.len().min(ESP_IMAGE_HEADER_LEN); + if header_got > 0 { + header[..header_got].copy_from_slice(&already[..header_got]); + } + while header_got < ESP_IMAGE_HEADER_LEN && header_got < content_len { + let end = ESP_IMAGE_HEADER_LEN.min(content_len); + match sock.read(&mut header[header_got..end]).await { + Ok(0) => return Err("eof body"), + Ok(k) => header_got += k, + Err(_) => return Err("read body"), + } + } + + let slot_len; + { + let (region, subtype) = ota.next_partition().map_err(|_| "no next ota slot")?; + info!("ota: writing slot {:?}", subtype); + slot_len = region.partition_size(); + } + + if already.len() > content_len { + return Err("bad body framing"); + } + validate_esp32c6_app_image( + &header[..header_got.min(ESP_IMAGE_HEADER_LEN)], + content_len, + slot_len, + ) + .map_err(image_err_msg)?; + + let mut offset: u32 = 0; + let mut sector = [0xFFu8; SECTOR]; + let mut filled = 0usize; + let mut written = 0usize; + + { + let (mut region, _) = ota.next_partition().map_err(|_| "no next ota slot")?; + write_sector( + &mut region, + &mut offset, + &mut sector, + &mut filled, + &header[..header_got], + )?; + written += header_got; + if already.len() > header_got { + write_sector( + &mut region, + &mut offset, + &mut sector, + &mut filled, + &already[header_got..], + )?; + written += already.len() - header_got; + } + + let mut tmp = [0u8; 1024]; + while written < content_len { + let want = (content_len - written).min(tmp.len()); + match sock.read(&mut tmp[..want]).await { + Ok(0) => return Err("eof body"), + Ok(k) => { + write_sector(&mut region, &mut offset, &mut sector, &mut filled, &tmp[..k])?; + written += k; + } + Err(_) => return Err("read body"), + } + } + if filled > 0 { + let padded = filled.div_ceil(4) * 4; + region + .write(offset, §or[..padded]) + .map_err(|_| "flash write")?; + } + } + + ota.activate_next_partition() + .map_err(|_| "activate ota slot")?; + let _ = ota.set_current_ota_state(OtaImageState::New); + info!("ota: slot activated ({written} bytes)"); + Ok(()) +} diff --git a/crates/firmware/src/net/wifi.rs b/crates/firmware/src/net/wifi.rs index c66b5ee..7811ff7 100644 --- a/crates/firmware/src/net/wifi.rs +++ b/crates/firmware/src/net/wifi.rs @@ -130,9 +130,13 @@ pub async fn status_task(stack: Stack<'static>) { if let Some(cfg) = stack.config_v4() { info!("net ready: ip={}", cfg.address); sender.send(NetStatus::Ready); + let oct = cfg.address.address().octets(); + crate::net::STA_IPV4.sender().send(Some(oct)); } stack.wait_link_down().await; warn!("net link down"); + crate::net::STA_IPV4.sender().send(None); + crate::net::HTTP_OTA_ENABLE.sender().send(false); } } diff --git a/crates/firmware/src/storage/mod.rs b/crates/firmware/src/storage/mod.rs index b8d2572..1766bf5 100644 --- a/crates/firmware/src/storage/mod.rs +++ b/crates/firmware/src/storage/mod.rs @@ -2,6 +2,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; +use embassy_sync::mutex::Mutex; use embassy_sync::signal::Signal; use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; use esp_bootloader_esp_idf::partitions::{ @@ -39,6 +40,9 @@ pub enum StorageCmd { pub static STORAGE_CTRL: Channel = Channel::new(); +/// Flash mutex shared by NVS persistence and HTTP OTA. +pub type SharedFlash = Mutex>; + /// Boot-time NVS snapshot used to choose STA vs programming path. #[derive(Clone)] pub struct BootState { @@ -88,6 +92,11 @@ fn persist(flash: &mut FlashStorage<'_>, rec: &PersistRecord) { } } +async fn persist_shared(flash: &SharedFlash, rec: &PersistRecord) { + let mut g = flash.lock().await; + persist(&mut g, rec); +} + /// Synchronous NVS write (boot path before the storage task runs). pub fn write_record(flash: &mut FlashStorage<'_>, rec: &PersistRecord) { persist(flash, rec); @@ -146,8 +155,11 @@ pub fn ensure_boot_hostname( } #[embassy_executor::task] -pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) { - let mut rec = load(flash).unwrap_or_default(); +pub async fn task(flash: &'static SharedFlash, boot_entropy: u32) { + let mut rec = { + let mut g = flash.lock().await; + load(&mut g).unwrap_or_default() + }; let mut dirty = false; if rec.device.id == 0 { ensure_device_id(&mut rec, boot_entropy); @@ -158,7 +170,7 @@ pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) dirty = true; } if dirty { - persist(flash, &rec); + persist_shared(flash, &rec).await; } info!("wifi hostname: {}", rec.wifi_hostname.as_str()); PERSIST_LOADED.signal(rec.clone()); @@ -168,37 +180,37 @@ pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) match rx.receive().await { StorageCmd::SavePassword { ssid, password } => { rec.set_password(ssid.as_str(), password.as_str()); - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::SaveLocos(locos) => { rec.locos = locos; - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::SaveNetwork(cfg) => { rec.network = Some(cfg); - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::SaveDevice(device) => { rec.device = device; - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::RegenerateDeviceId => { regenerate_device_id(&mut rec); - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::SaveLanguage(lang) => { rec.language = lang; - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } StorageCmd::SetProgrammingMode(on) => { rec.programming_mode = on; - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); STORAGE_ACK.signal(()); } @@ -210,7 +222,7 @@ pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) if rec.wifi_hostname.is_empty() { ensure_wifi_hostname(&mut rec, boot_entropy); } - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); STORAGE_ACK.signal(()); } @@ -218,7 +230,7 @@ pub async fn task(flash: &'static mut FlashStorage<'static>, boot_entropy: u32) rec = PersistRecord::default(); ensure_device_id(&mut rec, boot_entropy); ensure_wifi_hostname(&mut rec, boot_entropy); - persist(flash, &rec); + persist_shared(flash, &rec).await; PERSIST_LOADED.signal(rec.clone()); } } diff --git a/crates/firmware/src/ui/i18n.rs b/crates/firmware/src/ui/i18n.rs index abad8c4..2a1e5bd 100644 --- a/crates/firmware/src/ui/i18n.rs +++ b/crates/firmware/src/ui/i18n.rs @@ -68,6 +68,12 @@ pub struct Strings { pub hint_list: &'static str, pub hint_language: &'static str, pub hint_extras_cmd: &'static str, + pub msg_fw_update: &'static str, + pub msg_fw_http_on: &'static str, + pub msg_fw_http_off: &'static str, + pub msg_fw_updating: &'static str, + pub msg_fw_no_ip: &'static str, + pub hint_fw_update: &'static str, pub menu_fn: &'static str, pub menu_add: &'static str, pub menu_drop: &'static str, @@ -88,6 +94,7 @@ pub struct Strings { pub extras_off_sleep: &'static str, pub extras_one_loco_tgl: &'static str, pub extras_save_locos: &'static str, + pub extras_firmware: &'static str, pub proto_wit: &'static str, pub proto_z21: &'static str, pub device_name_id: &'static str, @@ -146,6 +153,12 @@ pub const EN: Strings = Strings { hint_list: "Nav OK > Pg Back", hint_language: "Nav OK Back", hint_extras_cmd: "Nav OK Back", + msg_fw_update: "Firmware update", + msg_fw_http_on: "HTTP: on", + msg_fw_http_off: "HTTP: off", + msg_fw_updating: "Updating...", + msg_fw_no_ip: "No IP yet", + hint_fw_update: "OK HTTP Back off", menu_fn: "Function", menu_add: "Add Loco", menu_drop: "Drop Loco", @@ -166,6 +179,7 @@ pub const EN: Strings = Strings { extras_off_sleep: "7 OFF / Sleep", extras_one_loco_tgl: "8 1 Loco Tgl", extras_save_locos: "9 Save Locos", + extras_firmware: "Firmware update", proto_wit: "0 WiThrottle", proto_z21: "1 Z21", device_name_id: "0 Name 1 ID", @@ -224,6 +238,12 @@ pub const PL: Strings = Strings { hint_list: "Nav OK > Str Wst", hint_language: "Nav OK Wst", hint_extras_cmd: "Nav OK Wst", + msg_fw_update: "Aktualizacja FW", + msg_fw_http_on: "HTTP: wl", + msg_fw_http_off: "HTTP: wyl", + msg_fw_updating: "Wgrywanie...", + msg_fw_no_ip: "Brak IP", + hint_fw_update: "OK HTTP Wst wyl", menu_fn: "Funkcja", menu_add: "Dodaj lok", menu_drop: "Zwolnij lok", @@ -244,6 +264,7 @@ pub const PL: Strings = Strings { extras_off_sleep: "7 Wyl / Sen", extras_one_loco_tgl: "8 1 Lok Przel", extras_save_locos: "9 Zapisz loki", + extras_firmware: "Aktualizacja FW", proto_wit: "0 WiThrottle", proto_z21: "1 Z21", device_name_id: "0 Nazwa 1 ID", @@ -302,6 +323,12 @@ pub const DE: Strings = Strings { hint_list: "Nav OK > Seite Zur", hint_language: "Nav OK Zur", hint_extras_cmd: "Nav OK Zur", + msg_fw_update: "Firmware-Update", + msg_fw_http_on: "HTTP: an", + msg_fw_http_off: "HTTP: aus", + msg_fw_updating: "Aktualisiere...", + msg_fw_no_ip: "Keine IP", + hint_fw_update: "OK HTTP Zur aus", menu_fn: "Funktion", menu_add: "Lok hinzu", menu_drop: "Lok frei", @@ -322,6 +349,7 @@ pub const DE: Strings = Strings { extras_off_sleep: "7 AUS / Schlaf", extras_one_loco_tgl: "8 1 Lok Ums", extras_save_locos: "9 Loks speichern", + extras_firmware: "Firmware-Update", proto_wit: "0 WiThrottle", proto_z21: "1 Z21", device_name_id: "0 Name 1 ID", diff --git a/crates/firmware/src/ui/menu.rs b/crates/firmware/src/ui/menu.rs index 6eb21fb..335a155 100644 --- a/crates/firmware/src/ui/menu.rs +++ b/crates/firmware/src/ui/menu.rs @@ -40,6 +40,7 @@ pub enum Screen { DeviceNameEdit, DeviceIdEdit, Language, + FirmwareUpdate, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -75,6 +76,7 @@ pub enum Intent { RegenerateDeviceId, SetLanguage(Language), EnterProgrammingMode, + SetHttpOta(bool), } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -225,6 +227,7 @@ impl MenuFsm { | Screen::DeviceNameEdit | Screen::DeviceIdEdit | Screen::Language + | Screen::FirmwareUpdate ) { self.screen = Screen::Throttle; } @@ -899,17 +902,40 @@ impl MenuFsm { g.set(5, i18n::tr().hint_menu, false); } Screen::Extras => { - g.set(0, i18n::tr().extras_net_config, false); - g.set(1, i18n::tr().extras_device, false); - g.set(2, i18n::tr().extras_fnc_key_tgl, false); - g.set(3, i18n::tr().extras_heartbt_tgl, false); - g.set(4, i18n::tr().extras_throttles_plus, false); - g.set(6, i18n::tr().extras_throttles_minus, false); - g.set(7, i18n::tr().extras_off_sleep, false); - g.set(8, i18n::tr().extras_one_loco_tgl, false); - g.set(9, i18n::tr().extras_save_locos, false); - g.set(10, i18n::tr().extras_language, false); - g.set(5, i18n::tr().hint_extras_cmd, false); + g.set(0, i18n::tr().extras_net_config, self.cursor == 0); + g.set(1, i18n::tr().extras_device, self.cursor == 1); + g.set(2, i18n::tr().extras_fnc_key_tgl, self.cursor == 2); + g.set(3, i18n::tr().extras_heartbt_tgl, self.cursor == 3); + g.set(4, i18n::tr().extras_throttles_plus, self.cursor == 4); + g.set(6, i18n::tr().extras_throttles_minus, self.cursor == 5); + g.set(7, i18n::tr().extras_off_sleep, self.cursor == 6); + g.set(8, i18n::tr().extras_one_loco_tgl, self.cursor == 7); + g.set(9, i18n::tr().extras_save_locos, self.cursor == 8); + g.set(10, i18n::tr().extras_language, self.cursor == 9); + g.set(5, i18n::tr().extras_firmware, self.cursor == 10); + } + Screen::FirmwareUpdate => { + g.set(0, i18n::tr().msg_fw_update, false); + if let Some(ip) = ctx.sta_ipv4 { + let mut line = Line::new(); + write_ip_line(&mut line, ip); + g.set(1, line.as_str(), false); + } else { + g.set(1, i18n::tr().msg_fw_no_ip, false); + } + g.set( + 2, + if ctx.http_ota { + i18n::tr().msg_fw_http_on + } else { + i18n::tr().msg_fw_http_off + }, + false, + ); + if ctx.http_ota_busy { + g.set(3, i18n::tr().msg_fw_updating, false); + } + g.set(5, i18n::tr().hint_fw_update, false); } Screen::IpConfig => { g.set(0, i18n::tr().msg_net_config, false); diff --git a/crates/firmware/src/ui/menu_nav.rs b/crates/firmware/src/ui/menu_nav.rs index c5232a0..594fc14 100644 --- a/crates/firmware/src/ui/menu_nav.rs +++ b/crates/firmware/src/ui/menu_nav.rs @@ -85,7 +85,7 @@ impl MenuFsm { || self.is_list_screen() || matches!( self.screen, - Screen::IpConfig | Screen::IpEdit | Screen::ServerEntry + Screen::IpConfig | Screen::IpEdit | Screen::ServerEntry | Screen::FirmwareUpdate ) { return self.on_ok(domain, scanned); @@ -283,13 +283,18 @@ impl MenuFsm { } fn on_menu_key(&mut self, domain: &DomainState) -> Intent { + let leave_fw = self.screen == Screen::FirmwareUpdate; if self.screen == Screen::Throttle && !domain.current_slot_has_loco() { return Intent::None; } self.menu_cmd.clear(); self.screen = Screen::Menu; self.cursor = 0; - Intent::None + if leave_fw { + Intent::SetHttpOta(false) + } else { + Intent::None + } } fn on_nav( @@ -417,6 +422,7 @@ impl MenuFsm { Screen::DeviceNameEdit => self.ok_device_name(domain), Screen::DeviceIdEdit => self.ok_device_id(domain), Screen::Language => self.ok_language(), + Screen::FirmwareUpdate => Intent::SetHttpOta(!crate::net::http_ota_enabled()), Screen::DirectCommands => self.ok_direct(self.cursor), _ => Intent::None, } @@ -458,6 +464,10 @@ impl MenuFsm { self.screen = Screen::Extras; Intent::None } + Screen::FirmwareUpdate => { + self.screen = Screen::Extras; + Intent::SetHttpOta(false) + } Screen::RosterList | Screen::FunctionList | Screen::TurnoutList @@ -556,7 +566,7 @@ impl MenuFsm { Screen::ServerList => 5, Screen::ServerProto => 2, Screen::Menu => 10, - Screen::Extras => 10, + Screen::Extras => 11, Screen::RosterList => domain.roster.len().saturating_sub(self.page * 5).min(5), Screen::FunctionList => 10, Screen::TurnoutList => domain.turnouts.len().saturating_sub(self.page * 10).min(10), @@ -740,6 +750,10 @@ impl MenuFsm { self.cursor = 0; Intent::None } + 10 => { + self.screen = Screen::FirmwareUpdate; + Intent::None + } _ => Intent::None, } } diff --git a/crates/firmware/src/ui/view.rs b/crates/firmware/src/ui/view.rs index 19d459c..9b2cb1f 100644 --- a/crates/firmware/src/ui/view.rs +++ b/crates/firmware/src/ui/view.rs @@ -112,4 +112,7 @@ pub struct ViewCtx<'a> { pub ip_formatted: &'a str, pub broadcast: Option<&'a str>, pub battery: Option, + pub sta_ipv4: Option<[u8; 4]>, + pub http_ota: bool, + pub http_ota_busy: bool, } diff --git a/crates/proto/src/image.rs b/crates/proto/src/image.rs new file mode 100644 index 0000000..be679ec --- /dev/null +++ b/crates/proto/src/image.rs @@ -0,0 +1,109 @@ +//! ESP-IDF application image header checks (host-testable). + +/// ESP-IDF app image magic (`esp_image_header_t.magic`). +pub const ESP_IMAGE_MAGIC: u8 = 0xE9; + +/// ESP32-C6 chip id in the app image header (`chip_id`, little-endian). +pub const ESP32C6_CHIP_ID: u16 = 0x000D; + +/// Size of `esp_image_header_t`. +pub const ESP_IMAGE_HEADER_LEN: usize = 24; + +/// Offset of `chip_id` in the app image header. +const CHIP_ID_OFF: usize = 12; + +/// Why an uploaded image was rejected. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageError { + /// Fewer than [`ESP_IMAGE_HEADER_LEN`] bytes. + Truncated, + /// Magic byte is not [`ESP_IMAGE_MAGIC`]. + BadMagic, + /// `chip_id` is not ESP32-C6. + WrongChip, + /// Declared length is smaller than the header. + TooSmall, + /// Declared length exceeds the inactive OTA slot. + TooLarge, +} + +/// Validate an ESP32-C6 app image (not a merged flash dump). +pub fn validate_esp32c6_app_image( + prefix: &[u8], + content_len: usize, + slot_len: usize, +) -> Result<(), ImageError> { + if content_len < ESP_IMAGE_HEADER_LEN { + return Err(ImageError::TooSmall); + } + if content_len > slot_len { + return Err(ImageError::TooLarge); + } + if prefix.len() < ESP_IMAGE_HEADER_LEN { + return Err(ImageError::Truncated); + } + if prefix[0] != ESP_IMAGE_MAGIC { + return Err(ImageError::BadMagic); + } + let chip = u16::from_le_bytes([prefix[CHIP_ID_OFF], prefix[CHIP_ID_OFF + 1]]); + if chip != ESP32C6_CHIP_ID { + return Err(ImageError::WrongChip); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(magic: u8, chip: u16) -> [u8; ESP_IMAGE_HEADER_LEN] { + let mut h = [0u8; ESP_IMAGE_HEADER_LEN]; + h[0] = magic; + let b = chip.to_le_bytes(); + h[CHIP_ID_OFF] = b[0]; + h[CHIP_ID_OFF + 1] = b[1]; + h + } + + #[test] + fn accepts_c6_app_header() { + let h = header(ESP_IMAGE_MAGIC, ESP32C6_CHIP_ID); + assert_eq!(validate_esp32c6_app_image(&h, 4096, 0x3C0000), Ok(())); + } + + #[test] + fn rejects_wrong_magic() { + let h = header(0x00, ESP32C6_CHIP_ID); + assert_eq!( + validate_esp32c6_app_image(&h, 4096, 0x3C0000), + Err(ImageError::BadMagic) + ); + } + + #[test] + fn rejects_wrong_chip() { + let h = header(ESP_IMAGE_MAGIC, 0x0005); + assert_eq!( + validate_esp32c6_app_image(&h, 4096, 0x3C0000), + Err(ImageError::WrongChip) + ); + } + + #[test] + fn rejects_oversized() { + let h = header(ESP_IMAGE_MAGIC, ESP32C6_CHIP_ID); + assert_eq!( + validate_esp32c6_app_image(&h, 0x3C0001, 0x3C0000), + Err(ImageError::TooLarge) + ); + } + + #[test] + fn rejects_truncated_prefix() { + let h = header(ESP_IMAGE_MAGIC, ESP32C6_CHIP_ID); + assert_eq!( + validate_esp32c6_app_image(&h[..8], 4096, 0x3C0000), + Err(ImageError::Truncated) + ); + } +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 78e1e13..d5a36e1 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -8,6 +8,7 @@ pub mod adapter; pub mod command; pub mod events; +pub mod image; pub mod input_map; pub mod mdns; pub mod menu; diff --git a/crates/proto/src/mdns.rs b/crates/proto/src/mdns.rs index 3f77c0b..42df56f 100644 --- a/crates/proto/src/mdns.rs +++ b/crates/proto/src/mdns.rs @@ -5,6 +5,8 @@ use crate::command::Protocol; pub const WITHROTTLE_SERVICE: &str = "_withrottle._tcp.local"; pub const Z21_SERVICE: &str = "_z21._udp.local"; +/// Advertised while STA HTTP OTA is enabled. +pub const OTA_HTTP_SERVICE: &str = "_longfred-ota._tcp.local"; pub const MDNS_MULTICAST_V4: [u8; 4] = [224, 0, 0, 251]; pub const MDNS_PORT: u16 = 5353; @@ -168,6 +170,137 @@ pub fn collect_servers( servers } +fn mdns_put_byte(buf: &mut [u8], n: &mut usize, b: u8) { + if *n < buf.len() { + buf[*n] = b; + } + *n += 1; +} + +fn mdns_put_slice(buf: &mut [u8], n: &mut usize, s: &[u8]) { + for &b in s { + mdns_put_byte(buf, n, b); + } +} + +fn mdns_put_name(buf: &mut [u8], n: &mut usize, labels: &[&str]) { + for lab in labels { + mdns_put_byte(buf, n, lab.len() as u8); + mdns_put_slice(buf, n, lab.as_bytes()); + } + mdns_put_byte(buf, n, 0); +} + +/// Unsolicited mDNS announcement for `_longfred-ota._tcp` (PTR + SRV + A). +pub fn build_ota_announce(hostname: &str, ipv4: [u8; 4], port: u16, buf: &mut [u8]) -> usize { + let mut n = 0usize; + + // Header: response, authoritative, 0 questions, 3 answers. + mdns_put_slice(buf, &mut n, &[0, 0, 0x84, 0, 0, 0, 0, 3, 0, 0, 0, 0]); + + // PTR _longfred-ota._tcp.local -> {hostname}._longfred-ota._tcp.local + mdns_put_name(buf, &mut n, &["_longfred-ota", "_tcp", "local"]); + mdns_put_slice(buf, &mut n, &[0, 12, 0, 1, 0, 0, 0, 120]); + let instance_len = + 1 + hostname.len() + 1 + "_longfred-ota".len() + 1 + "_tcp".len() + 1 + "local".len() + 1; + mdns_put_slice( + buf, + &mut n, + &u16::try_from(instance_len).unwrap_or(0).to_be_bytes(), + ); + mdns_put_name( + buf, + &mut n, + &[hostname, "_longfred-ota", "_tcp", "local"], + ); + + // SRV {hostname}._longfred-ota._tcp.local -> {hostname}.local:port + mdns_put_name( + buf, + &mut n, + &[hostname, "_longfred-ota", "_tcp", "local"], + ); + mdns_put_slice(buf, &mut n, &[0, 33, 0, 1, 0, 0, 0, 120]); + let target_len = 6 + 1 + hostname.len() + 1 + "local".len() + 1; + mdns_put_slice( + buf, + &mut n, + &u16::try_from(target_len).unwrap_or(0).to_be_bytes(), + ); + mdns_put_slice(buf, &mut n, &[0, 0, 0, 0]); + mdns_put_slice(buf, &mut n, &port.to_be_bytes()); + mdns_put_name(buf, &mut n, &[hostname, "local"]); + + // A {hostname}.local + mdns_put_name(buf, &mut n, &[hostname, "local"]); + mdns_put_slice(buf, &mut n, &[0, 1, 0, 1, 0, 0, 0, 120, 0, 4]); + mdns_put_slice(buf, &mut n, &ipv4); + + n.min(buf.len()) +} + +/// Hosts advertising `_longfred-ota._tcp` (A records in a response). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OtaHost { + pub hostname: heapless::String<32>, + pub ipv4: [u8; 4], + pub port: u16, +} + +/// Collect A records from an mDNS packet (used by LAN firmware discovery). +pub fn collect_ota_hosts(pkt: &[u8]) -> heapless::Vec { + let mut out: heapless::Vec = heapless::Vec::new(); + if pkt.len() < 12 { + return out; + } + let an = be16(pkt, 6).unwrap_or(0); + let ns = be16(pkt, 8).unwrap_or(0); + let ar = be16(pkt, 10).unwrap_or(0); + let mut off = 12usize; + let mut port = 80u16; + for _ in 0..an.saturating_add(ns).saturating_add(ar) { + let Some((name, nend)) = read_name(pkt, off) else { + break; + }; + off = nend; + let Some(typ) = be16(pkt, off) else { break }; + off += 8; + let Some(rdlen) = be16(pkt, off) else { break }; + off += 2; + let rdata = off; + off = off.saturating_add(rdlen as usize); + if typ == TYPE_SRV && rdlen >= 6 { + if let Some(p) = be16(pkt, rdata + 4) { + port = p; + } + } + if typ == TYPE_A && rdlen == 4 { + if let (Some(a), Some(b), Some(c), Some(d)) = ( + pkt.get(rdata), + pkt.get(rdata + 1), + pkt.get(rdata + 2), + pkt.get(rdata + 3), + ) { + let host = name + .split('.') + .next() + .unwrap_or("longfred"); + let mut hostname = heapless::String::new(); + let _ = hostname.push_str(host); + let _ = out.push(OtaHost { + hostname, + ipv4: [*a, *b, *c, *d], + port, + }); + } + } + if out.is_full() { + break; + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -181,4 +314,16 @@ mod tests { assert!(z > 12); assert_ne!(w, z); } + + #[test] + fn ota_announce_roundtrip_a_record() { + let mut buf = [0u8; 512]; + let n = build_ota_announce("longred_ab12cd", [192, 168, 1, 40], 80, &mut buf); + assert!(n > 40); + let hosts = collect_ota_hosts::<4>(&buf[..n]); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].ipv4, [192, 168, 1, 40]); + assert_eq!(hosts[0].port, 80); + assert_eq!(hosts[0].hostname.as_str(), "longred_ab12cd"); + } } diff --git a/crates/proto/src/provisioning.rs b/crates/proto/src/provisioning.rs index 0be0ce5..61ca8b6 100644 --- a/crates/proto/src/provisioning.rs +++ b/crates/proto/src/provisioning.rs @@ -122,6 +122,13 @@ pub struct SettingsGet<'a> { pub bigfred: BigfredView<'a>, pub roster: RosterView<'a>, pub programming_mode: bool, + pub firmware: FirmwareView<'a>, +} + +/// Firmware identity in a GET settings response. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct FirmwareView<'a> { + pub version: &'a str, } /// Serialize current settings into `buf`. Returns bytes written. @@ -131,6 +138,7 @@ pub fn serialize_settings( buf: &mut [u8], rec: &PersistRecord, network_ssids: &[&str], + firmware_version: &str, ) -> Result { let view = SettingsGet { device: DeviceView { @@ -154,6 +162,9 @@ pub fn serialize_settings( }, }, programming_mode: rec.programming_mode, + firmware: FirmwareView { + version: firmware_version, + }, }; serde_json_core::to_slice(&view, buf) } @@ -162,13 +173,14 @@ pub fn serialize_settings( pub fn serialize_settings_from_record( buf: &mut [u8], rec: &PersistRecord, + firmware_version: &str, ) -> 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]) + serialize_settings(buf, rec, &ssids[..n], firmware_version) } /// Optional Wi-Fi fields in a PUT body. @@ -362,9 +374,10 @@ mod tests { fn serialize_empty_settings() { let rec = PersistRecord::default(); let mut buf = [0u8; 512]; - let n = serialize_settings_from_record(&mut buf, &rec).unwrap(); + let n = serialize_settings_from_record(&mut buf, &rec, "0.1.0").unwrap(); let s = core::str::from_utf8(&buf[..n]).unwrap(); assert!(s.contains("\"programming_mode\":false")); + assert!(s.contains("\"version\":\"0.1.0\"")); assert!(s.contains("\"mode\":\"auto\"")); assert!(s.contains("\"pin_set\":false")); assert!(s.contains("\"networks\":[]")); @@ -389,7 +402,7 @@ mod tests { let _ = rec.static_roster.push(e); let mut buf = [0u8; 1024]; - let n = serialize_settings_from_record(&mut buf, &rec).unwrap(); + let n = serialize_settings_from_record(&mut buf, &rec, "0.1.0").unwrap(); let s = core::str::from_utf8(&buf[..n]).unwrap(); assert!(s.contains("\"name\":\"Pilot\"")); assert!(s.contains("\"id\":4242")); diff --git a/docs/hardware/heiko-wifred.md b/docs/hardware/heiko-wifred.md index 2641c76..af304e5 100644 --- a/docs/hardware/heiko-wifred.md +++ b/docs/hardware/heiko-wifred.md @@ -54,4 +54,5 @@ flowchart LR - Auto: first boot with empty Wi‑Fi NVS - Manual: Shift + Stop 8 s -- Soft-AP `longfred_prog_XXXXXX` — see [provisioning.md](../provisioning.md) +- Soft-AP `longfred_prog_XXXXXX` — DHCP, then `http://192.168.0.1/` (see [provisioning.md](../provisioning.md)) +- Firmware OTA: Soft-AP only (no on-device menu) diff --git a/docs/hardware/longfred-mini.md b/docs/hardware/longfred-mini.md index e2c8293..945eb86 100644 --- a/docs/hardware/longfred-mini.md +++ b/docs/hardware/longfred-mini.md @@ -27,6 +27,10 @@ Menu grids use 3 rows × 2 columns (~6 lines) instead of 12. Same as [longfred-standard.md](longfred-standard.md); swap the OLED for a 128×32 module. +## Programming mode + +Hold **Shift1 + Stop** for 8 seconds. Soft-AP `longfred_prog_XXXXXX` at `192.168.0.1` (DHCP). Firmware OTA: pairing page, or Extras → Firmware update on layout Wi‑Fi. See [provisioning.md](../provisioning.md). + ## 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 index e037d09..c6d1b5c 100644 --- a/docs/hardware/longfred-standard.md +++ b/docs/hardware/longfred-standard.md @@ -61,4 +61,4 @@ Exact MCP bit map: [`config/board.rs`](../../crates/firmware/src/config/board.rs ## Programming mode -Hold **Shift1 + Stop** for 8 seconds. Soft-AP `longfred_prog_XXXXXX` at `192.168.0.1`. See [provisioning.md](../provisioning.md). +Hold **Shift1 + Stop** for 8 seconds. Soft-AP `longfred_prog_XXXXXX` at `192.168.0.1` (DHCP). Firmware OTA: pairing page, or Extras → Firmware update on layout Wi‑Fi. See [provisioning.md](../provisioning.md). diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index 00c0084..e3b137e 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -227,4 +227,4 @@ Battery (optional, see [Battery](#battery)): ## Programming mode -Hold **\* + Stop** for 8 seconds. See [provisioning.md](../provisioning.md). +Hold **\* + Stop** for 8 seconds. Soft-AP gets DHCP; firmware OTA from the pairing page or Extras → Firmware update on layout Wi‑Fi. See [provisioning.md](../provisioning.md). diff --git a/docs/provisioning.md b/docs/provisioning.md index 867fb3d..59c92d2 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -1,8 +1,8 @@ # LongFred programming / pairing mode -All hardware variants share the same Soft-AP provisioning API. +All hardware variants share the same Soft-AP provisioning API. Firmware can also be uploaded over HTTP while the throttle is already on the layout Wi‑Fi (STA), from the Extras menu. -## Entering +## Entering Soft-AP mode | Variant | Chord (hold 8 s) | Auto if no Wi‑Fi creds | |---------|------------------|------------------------| @@ -12,38 +12,63 @@ All hardware variants share the same Soft-AP provisioning API. Firmware sets `programming_mode` in NVS and soft-resets (except auto-pair at boot, which skips STA bring-up). -## Network +## Network (Soft-AP) | 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 | +| DHCP | pool `192.168.0.50`–`192.168.0.200`, lease ~1 h, gateway/DNS `192.168.0.1` | -### Phone / laptop (manual static IP) +`192.168.0.2` is **outside** the DHCP pool so `wireless-programmer` can keep using that static address. + +### Phone / laptop 1. Join `longfred_prog_XXXXXX` -2. Set static IPv4: address `192.168.0.50`, mask `255.255.255.0`, gateway `192.168.0.1` +2. Wait for DHCP (or set a static IPv4 in `192.168.0.0/24`, not `.1` / `.2`) 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`). +## Firmware update over HTTP + +Use the **app image** (`*.app.bin` from CI — `espflash save-image` **without** `--merge`). Merged flash dumps are rejected. + +The first install of the dual-slot partition table (`partitions.csv`) must be done over **USB** (`espflash flash`). Later updates can use HTTP OTA. + +```bash +curl -T dist/longfred-markwtech-esp32c6.app.bin \ + http://192.168.0.1/api/v1/firmware +``` + +### Soft-AP path + +Upload from the pairing page or `POST /api/v1/firmware`. After a successful write the device reboots **back into Soft-AP** (`programming_mode` stays set) so you can confirm the new version. + +### STA / LAN path (not heiko-wifred) + +On variants with a menu: **Extras → Firmware update** (encoder; digits 0–9 stay on the other extras items). OK toggles HTTP on the layout IPv4, port 80. The device announces `_longfred-ota._tcp.local`. Open `http:///` and upload `.app.bin`. **Back** (and sleep) turn HTTP and mDNS off. After STA OTA the device reboots onto layout Wi‑Fi (`programming_mode` stays false). + +heiko-wifred has no menu — firmware OTA is Soft-AP only. + ## HTTP API ### `GET /` -Static HTML configuration page (inline CSS/JS). +Static HTML configuration page (inline CSS/JS), including firmware file upload. + +On STA this page is served only while Firmware update HTTP is enabled. `PUT` settings and `POST …/programming-mode/off` are Soft-AP only. ### `GET /api/v1/settings` -Returns device info (including `device.variant`), Wi‑Fi SSID (no password), BigFred login (no PIN), roster, roster mode. +Returns device info (including `device.variant` and `firmware.version`), Wi‑Fi SSID (no password), BigFred login (no PIN), roster, roster mode. ### `PUT /api/v1/settings` -Partial JSON body: +Soft-AP only. Partial JSON body: ```json { @@ -57,13 +82,17 @@ Partial JSON body: 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`. +> support `Transfer-Encoding: chunked`. Settings bodies are limited to 1536 bytes +> (`400 body too large`). Firmware upload streams the body and is not subject to +> that cap (timeout ~120 s; image must fit the inactive OTA slot, 0x3C0000 bytes). + +### `POST /api/v1/firmware` + +Raw `application/octet-stream` ESP32-C6 **app** image (`Content-Length` required). Validates header magic `0xE9` and chip id `0x000D`, writes the inactive OTA slot, responds 200, then soft-resets. ### `POST /api/v1/programming-mode/off` -Clears the programming flag, responds 200, soft-resets after ~500 ms. +Soft-AP only. Clears the programming flag, responds 200, soft-resets after ~500 ms. ## Cancel on device diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..6a2d57a --- /dev/null +++ b/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x10000, 0x3C0000, +ota_1, app, ota_1, 0x3D0000,0x3C0000, diff --git a/scripts/check-esp32c6-size.sh b/scripts/check-esp32c6-size.sh index 3986921..9d7026b 100755 --- a/scripts/check-esp32c6-size.sh +++ b/scripts/check-esp32c6-size.sh @@ -1,7 +1,7 @@ #!/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). +# Flash budget: dual-slot OTA app partition in partitions.csv (ota_0 / ota_1 = 0x3C0000). # RAM budget: esp-hal esp32c6 memory.x RAM LENGTH (0x6E610) — linker already enforces # this; we re-check sections and fail if anything looks over. # @@ -20,6 +20,8 @@ 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))}" +OTA_SLOT_BYTES="${OTA_SLOT_BYTES:-$((0x3C0000))}" +PARTITION_TABLE="${PARTITION_TABLE:-$ROOT/partitions.csv}" VARIANTS=(${VARIANTS:-longfred-standard longfred-mini markwtech heiko-wifred}) CHECK_ONLY=0 @@ -135,7 +137,7 @@ for variant in "${VARIANTS[@]}"; do 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 + --chip "$CHIP" --partition-table "$PARTITION_TABLE" --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 @@ -169,6 +171,10 @@ for variant in "${VARIANTS[@]}"; do status="FAIL (flash)" failed=1 fi + if (( flash_used > OTA_SLOT_BYTES )); then + status="FAIL (ota slot)" + failed=1 + fi if (( ram_total > RAM_LIMIT_BYTES )); then status="FAIL (ram)" failed=1 @@ -182,7 +188,7 @@ for variant in "${VARIANTS[@]}"; do done echo -echo "Limits: ESP32-C6 app partition (espflash default) + on-chip RAM 0x6E610 from esp-hal memory.x" +echo "Limits: ESP32-C6 OTA app slot 0x3C0000 (partitions.csv) + 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 From 0df459a6b193a8b5f41119a604c311e042466ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:58:14 +0200 Subject: [PATCH 6/7] Document USB firmware updates through wireless-programmer. Point the first dual-slot install at update-firmware --mode usb as well as a raw espflash invocation. Co-authored-by: Cursor --- docs/provisioning.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/provisioning.md b/docs/provisioning.md index 59c92d2..468235d 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -37,7 +37,13 @@ Associates open, assigns `192.168.0.2/24`, talks HTTP to `192.168.0.1:80` (drive Use the **app image** (`*.app.bin` from CI — `espflash save-image` **without** `--merge`). Merged flash dumps are rejected. -The first install of the dual-slot partition table (`partitions.csv`) must be done over **USB** (`espflash flash`). Later updates can use HTTP OTA. +The first install of the dual-slot partition table (`partitions.csv`) must be done over **USB** (`espflash flash`, or `wireless-programmer update-firmware --mode usb`). Later updates can use HTTP OTA or USB. + +```bash +# First install (ELF + partition table), or a merged `.bin` from CI: +wireless-programmer update-firmware --mode usb --port /dev/ttyUSB0 \ + --file dist/longfred-markwtech-esp32c6.elf --partition-table partitions.csv +``` ```bash curl -T dist/longfred-markwtech-esp32c6.app.bin \ From 71ef0cd214c48552bbab29a26ba928e4444d0bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:21:47 +0200 Subject: [PATCH 7/7] Expand MarkWTech assembly docs and set battery sleep defaults. Add an English step-by-step build plus a Polish translation, log a suggested ADC factor for first-power calibration, and enable 4-minute inactivity sleep with a 5% low-battery cutoff. Co-authored-by: Cursor --- crates/firmware/src/config/power.rs | 11 +- crates/firmware/src/power/battery.rs | 9 + docs/hardware/markwtech.md | 424 ++++++++++++++++++----- docs/hardware/markwtech_pl.md | 492 +++++++++++++++++++++++++++ 4 files changed, 850 insertions(+), 86 deletions(-) create mode 100644 docs/hardware/markwtech_pl.md diff --git a/crates/firmware/src/config/power.rs b/crates/firmware/src/config/power.rs index a9d4098..6d9cf56 100644 --- a/crates/firmware/src/config/power.rs +++ b/crates/firmware/src/config/power.rs @@ -6,18 +6,19 @@ pub const USE_BATTERY_TEST: bool = true; /// ADC-to-voltage scaling factor (hardware calibration, stage 11). pub const BATTERY_CONVERSION_FACTOR: f32 = 1.7; -/// Default display mode: icon + percent (when USE_BATTERY_TEST). +/// Default display mode: icon + percent (when `USE_BATTERY_TEST`). pub const USE_BATTERY_PERCENT_WITH_ICON: bool = false; /// Auto deep sleep when % < threshold; 0 = disabled. -pub const USE_BATTERY_SLEEP_AT_PERCENT: u8 = 0; +pub const USE_BATTERY_SLEEP_AT_PERCENT: u8 = 5; pub const BATTERY_POLL_S: u64 = 10; pub const ADC_READS: usize = 20; -/// Inactivity auto-off (no WiThrottle server connection), ms. -/// Set 0 to disable (recommended under Wokwi — deep sleep appears as reboot loop). -pub const AUTO_SLEEP_INACTIVITY_MS: u64 = 0; +/// Inactivity auto-off (no `WiThrottle` server connection), ms. +/// 0 = disabled. The `sim` build does not spawn `sleep::task`, so this is a +/// no-op under Wokwi regardless of the value. +pub const AUTO_SLEEP_INACTIVITY_MS: u64 = 240_000; /// Sleep screen delay before deep sleep, ms. pub const SLEEP_SCREEN_DELAY_MS: u64 = 2_000; diff --git a/crates/firmware/src/power/battery.rs b/crates/firmware/src/power/battery.rs index 3e7b72f..b8fdc03 100644 --- a/crates/firmware/src/power/battery.rs +++ b/crates/firmware/src/power/battery.rs @@ -47,6 +47,15 @@ pub async fn task( let raw = sum / count; let volts = raw as f32 * power::BATTERY_CONVERSION_FACTOR / 1000.0; let percent = volts_to_percent(volts); + // Full cell = 4.2 V. With the 1:2 divider that is 2.1 V at GPIO 1. + // Suggested factor makes `raw * factor / 1000 == 4.2` on a full cell. + if raw > 0 { + let suggested = 4200.0 / raw as f32; + let current = power::BATTERY_CONVERSION_FACTOR; + log::info!( + "battery: raw={raw} volts={volts:.3} percent={percent} suggested_factor={suggested:.4} (current={current})" + ); + } tx.send(Some(percent)); if power::USE_BATTERY_SLEEP_AT_PERCENT > 0 && percent < power::USE_BATTERY_SLEEP_AT_PERCENT diff --git a/docs/hardware/markwtech.md b/docs/hardware/markwtech.md index e3b137e..e7399df 100644 --- a/docs/hardware/markwtech.md +++ b/docs/hardware/markwtech.md @@ -1,5 +1,7 @@ # MarkWTech (WiTcontroller-style) +> Polish version: [markwtech_pl.md](markwtech_pl.md) + 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 | @@ -47,34 +49,59 @@ Extra buttons: tact switch to **GND**, firmware pull-up, active-low. | 4 | Back | 5 | Cancel / back | | 5 | Menu | 15 | Open menu / select-in-menu | -Pin choice is constrained — ESP32-C6-WROOM-1 exposes only GPIO `0–13, 15–23`: - -- **GPIO 14 does not exist on the module.** It is present on the bare SoC, so `AnyPin::steal(14)` still compiles, but nothing is routed to the headers. -- **GPIO 4, 5, 15** are strapping pins. Boot mode is decided solely by GPIO 8/9; with default eFuses these three have no effect at reset, and the internal pull-up keeps them idle HIGH. -- **GPIO 12** is `USB_D−`. Wiring Stop here rules out the native USB Serial/JTAG port. Flashing and logs go through the **USB-UART** port instead, which uses GPIO 16/17 — left free on purpose. -- **GPIO 8** drives the on-board RGB LED and **GPIO 9** is the BOOT button, so neither is usable. -- **GPIO 1** is reserved for the battery divider. - ```mermaid flowchart LR ESP[ESP32-C6] --- KP[Keypad 3x4] ESP --- OLED[OLED 2.42in I2C] ESP --- ENC[KY-040] ESP --- EXTRA[Left Stop Right Back Menu] + ESP --- BAT[Battery divider] ``` Constants: `board/variants/markwtech.rs` (`KEYPAD_MAP`, `EXTRA_BUTTON_MAP`). -## Full wiring (pin-by-pin) +## Pin budget -All modules run on **3.3 V** (not 5 V). Tie a common **GND** to every component. +Sources: [ESP32-C6-DevKitC-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html) (Header Block) and [ESP32-C6 Datasheet v1.5](https://documentation.espressif.com/esp32-c6_datasheet_en.html) (chapter 3, Boot Configurations). + +The headers expose **23 GPIO** in total — J1 carries `4, 5, 6, 7, 0, 1, 8, 10, 11, 2, 3` and J3 carries `16, 17, 15, 23, 22, 21, 20, 19, 18, 9, 13, 12`. **GPIO 14 is absent**: J3 jumps straight from 18 to 9, then to 13 and 12. + +| Class | GPIOs | Count | +|-------|-------|-------| +| Fully free | 0, 1, 2, 3, 6, 7, 10, 11, 18, 19, 20, 21, 22, 23 | 14 | +| Strapping, verified harmless | 4, 5, 15 | 3 | +| Costs the native USB port | 12, 13 | 2 | +| Costs the UART console | 16, 17 | 2 | +| Blocked | 8 (RGB LED), 9 (BOOT button) | 2 | -### Power +MarkWTech needs **18** lines (keypad 7 + encoder 3 + I2C 2 + buttons 5 + battery ADC 1). Free plus strapping-safe gives only 17, so exactly one pin must come from the USB pair — Stop takes **GPIO 12**. That leaves **GPIO 13 spare at no extra cost**, because the native USB port is already forfeited by using its `D−` line. -| ESP32-C6 | Module label | Notes | -|----------|--------------|-------| -| **3V3** | `VCC` / `+` / `3.3V` | OLED, KY-040 | -| **GND** | `GND` | common ground rail | +### Why the risky pins are safe + +- **GPIO 4 (MTMS) and GPIO 5 (MTDI)** — per datasheet Table 3-4 their strapping value only selects the sampling/driving clock edge of the **SDIO slave** interface, which this project never uses. Both float by default. +- **GPIO 15** — per Table 3-7, with factory eFuses (`DIS_PAD_JTAG=0`, `DIS_USB_JTAG=0`, `JTAG_SEL_ENABLE=0`) the pin is explicitly listed as **Ignored**. The datasheet warning against leaving it high-impedance only applies once `EFUSE_JTAG_SEL_ENABLE` has been burned, which is a deliberate and irreversible act. +- Holding any of these buttons during reset therefore **cannot change the boot mode**. Boot mode is decided by GPIO 8/9 alone (Table 3-3). +- **GPIO 12** works as a plain input because esp-hal calls `disable_usb_pads()` from `init_gpio()` before any input/output use, clearing `usb_pad_enable` and the D+/D− pull resistors (`esp-hal-1.1.1/src/gpio/mod.rs:1669-1709`). + +### Deep-sleep wake + +Only **GPIO 0–7** belong to the LP power domain (`LP_GPIO0..7`) and can wake the chip from deep sleep. The encoder `SW` on GPIO 0 is the wake source. Menu (15), Menu left (11) and Stop (12) **cannot** wake the throttle. + +### Unused / reserved GPIO + +| GPIO | Header | Why it is left alone | +|------|--------|----------------------| +| 8 | J1-9 | drives the on-board addressable RGB LED | +| 9 | J3-11 | on-board BOOT button; boot-mode strapping pin | +| 13 | J3-13 | `USB_D+` — spare, free to use if another input is ever needed | +| 16 | J3-2 | `U0TXD` — serial console out | +| 17 | J3-3 | `U0RXD` — serial console in | + +The **KAmod MCP23017** expander is owned but deliberately not used: all 18 lines fit directly on the DevKit, and keeping **Stop on a direct GPIO** means an I2C bus fault cannot disable the emergency stop and the display at the same time. + +## Full wiring (pin-by-pin) + +All modules run on **3.3 V** (not 5 V). Tie a common **GND** to every component. ### OLED 2.42" I2C (SSD1309 / SSD1306) @@ -86,7 +113,7 @@ All modules run on **3.3 V** (not 5 V). Tie a common **GND** to every component. | **GND** | — | `GND` | | | — | address **0x3C** | — | set `ADDR` jumper on module if present | -Common 4-pin FPC order on cheap modules: `GND` · `VCC` · `SCL` · `SDA` (verify your module silkscreen). +Common 4-pin order on cheap modules: `GND` · `VCC` · `SCL` · `SDA` (verify your module silkscreen). ### KY-040 rotary encoder @@ -114,17 +141,17 @@ Rows are **outputs** (scanner drives one low at a time). Columns are **inputs** | **23** | `KEYPAD_COL_PINS[1]` | **C1** (col 2) | keys `2` `5` `8` `0` | | **10** | `KEYPAD_COL_PINS[2]` | **C2** (col 3) | keys `3` `6` `9` `#` | -**Keypad FPC pin order is not standardized** (e.g. `R1 R2 R3 R4 C1 C2 C3` or other). Identify which physical pin is each row/column with a multimeter (pressed key = row shorted to column). If digits are scrambled, swap row/column assignments on the connector — do not change firmware GPIO numbers. +**Keypad pin order is not standardized.** Identify each row/column with a multimeter (a pressed key shorts its row to its column). If digits come out scrambled, swap wires on the connector — do not change firmware GPIO numbers. ### Five extra tact switches (active-low) -Each switch: one leg → **GPIO**, other leg → **GND**. Firmware enables internal pull-up (pressed = LOW). +Each switch: one leg → **GPIO**, other leg → **GND**. Firmware enables the internal pull-up (pressed = LOW). | ESP GPIO | Header | Label | UI function | Suggested silkscreen | |----------|--------|-------|-------------|----------------------| -| **11** | J1-11 | Menu left | list page prev / cursor left | `◀` / `LEFT` | +| **11** | J1-11 | Menu left | list page prev / cursor left | `LEFT` | | **12** | J3-14 | **Stop** | EStop on throttle; `*`+Stop chord (8 s) | `STOP` / `E-STOP` | -| **4** | J1-3 | Menu right | list page next / cursor right | `▶` / `RIGHT` | +| **4** | J1-3 | Menu right | list page next / cursor right | `RIGHT` | | **5** | J1-4 | Back | cancel / back | `BACK` / `ESC` | | **15** | J3-4 | Menu | open menu / select in menu | `MENU` / `OK` | @@ -139,91 +166,326 @@ The DevKit has **no LiPo charger and no battery connector** — unlike the LOLIN | Part | Purpose | |------|---------| -| LiPo cell 3.7 V (e.g. 1200 mAh, 503759) | power source; ~400 mAh gives roughly 6 h, so 1200 mAh lasts most of a day | -| TP4056 module with protection | USB charging plus over-charge / over-discharge cut-off | -| 3.3 V LDO (HT7333, ME6211 or similar) | cell is 3.0–4.2 V; the WROOM-1 module needs 3.0–3.6 V, so 4.2 V must not reach `3V3` directly | +| LiPo cell 3.7 V, 1200 mAh (503759) | power source; the original gets ~6 h from 400 mAh, so 1200 mAh lasts a full session | +| TP4056 module with protection (`DW01A` + `8205A`) | USB-C charging plus over-charge / over-discharge cut-off | +| **Pololu S7V8F3** buck-boost regulator | cell swings 3.0–4.2 V; this holds a solid 3.3 V across the whole range | | 2× 47 kΩ resistor | measurement divider into GPIO 1 | -| Power switch on the cell positive lead | the divider draws current continuously | +| 100 nF capacitor (recommended) | ADC noise filter, see below | +| KCD1 rocker switch, bistable | master ON/OFF in series with `OUT+` | -Divider (same values as the original project): +**Do not use an AMS1117 or LD1117.** Those need roughly 4.5 V at the input to hold 3.3 V out; a LiPo never gets there. A true low-dropout part (ME6211, AP2112K, XC6220, MCP1826) would work down to about 3.5 V, but the buck-boost is better still because it keeps regulating below 3.3 V and squeezes the last ~35 % out of the cell. + +Power chain: ```text -Cell + ──┬── 47k ──┬── 47k ── GND - │ │ - (to LDO in) └── GPIO 1 (ADC) +USB-C (TP4056) --charges--> B+ / B- <-- LiPo 1200 mAh + | + OUT+ / OUT- + | + [KCD1 rocker, in series on OUT+] + | + +----------------+----------------+ + | | + S7V8F3 VIN 47k --+-- 47k -- GND + | | + S7V8F3 VOUT ---> 3V3 (J1-1) GPIO 1 (J1-8) + | + 100 nF to GND + S7V8F3 GND ---> common ground ``` -A 1:2 divider turns a full 4.2 V cell into ~2.1 V at the pin, inside the ADC range. +Take the load from **`OUT+` / `OUT−`**, never from `B+` / `B−` — the protection MOSFETs sit between `B` and `OUT`, so a load on `B` bypasses the over-discharge cut-off. + +#### Why the divider is needed and correctly sized + +Per the [ESP hardware design guidelines](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32c6/schematic-checklist.html), the calibrated ADC range at ATTEN=3 (`Attenuation::_11dB`, what the firmware uses) is **0–3300 mV** with ±40 mV total error. A 4.2 V cell would exceed that and damage the input. The 1:2 divider maps a full cell to **2.10 V** and an empty one to **1.50 V**, both comfortably inside range. GPIO 1 is `ADC1_CH1`, a valid ADC channel. -| ESP GPIO | Header | Firmware | Connection | -|----------|--------|----------|------------| -| **1** | J1-8 | `BATTERY_ADC` | divider midpoint | +Divider current is `4.2 V / 94 kΩ ≈ 45 µA`, and it sits behind the rocker switch, so it drains nothing when the throttle is off. -If the cell has a third **NTC** lead (thermistor), leave it unconnected — basic TP4056 modules ignore it. +Espressif recommends a **0.1 µF capacitor from the ADC pin to ground**. It matters more here than usual because the S7V8F3 is a switching regulator and puts ripple on the rail. The firmware averages `ADC_READS` samples, so the reading works without it, but it will be steadier with it. -**Calibration is required.** `BATTERY_CONVERSION_FACTOR` in [`config/power.rs`](../../crates/firmware/src/config/power.rs) is inherited from the original project, where it was tuned against the classic ESP32 ADC. ESP32-C6 has different ADC characteristics, so charge the cell fully, read the reported percentage, and scale the constant until a full cell shows 100 %. +If the cell has a third **NTC** lead, leave it unconnected — TP4056 modules ignore it. + +#### Calibration + +`BATTERY_CONVERSION_FACTOR` in [`config/power.rs`](../../crates/firmware/src/config/power.rs) is inherited from the original project, where it was tuned against the classic ESP32 ADC. Charge the cell fully and read the UART log line `battery: raw=… suggested_factor=…`. Copy `suggested_factor` into the constant. Expect roughly `2600` raw and a factor near `1.6`. + +With a working cell the firmware also auto-sleeps: deep sleep below **5 %** charge, and after **4 minutes** of inactivity with no WiThrottle server. Leaving GPIO 1 unconnected is harmless — the reading is then meaningless noise and the battery icon can be hidden from the menu. -### Master table (one wire per row) - -Header numbering follows the [ESP32-C6-DevKitC-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html): **J1** is the side carrying `3V3`/`RST`/`5V`, **J3** the side carrying `TX`/`RX`. - -| ESP GPIO | Header | Component | Component pin | Direction | -|----------|--------|-----------|---------------|-----------| -| 0 | J1-7 | KY-040 | `SW` | input, active-low | -| 1 | J1-8 | Battery | divider midpoint | ADC input | -| 2 | J1-12 | KY-040 | `DT` | encoder A | -| 3 | J1-13 | KY-040 | `CLK` | encoder B | -| 4 | J1-3 | Tact | Menu right | → GND | -| 5 | J1-4 | Tact | Back | → GND | -| 6 | J1-5 | OLED | `SDA` | I2C data | -| 7 | J1-6 | OLED | `SCL` | I2C clock | -| 10 | J1-10 | Keypad | `C2` | matrix column | -| 11 | J1-11 | Tact | Menu left | → GND | -| 12 | J3-14 | Tact | Stop | → GND | -| 15 | J3-4 | Tact | Menu | → GND | -| 18 | J3-10 | Keypad | `R0` | matrix row (output) | -| 19 | J3-9 | Keypad | `R1` | matrix row (output) | -| 20 | J3-8 | Keypad | `R2` | matrix row (output) | -| 21 | J3-7 | Keypad | `R3` | matrix row (output) | -| 22 | J3-6 | Keypad | `C0` | matrix column | -| 23 | J3-5 | Keypad | `C1` | matrix column | -| 3V3 | J1-1 | OLED, KY-040 | `VCC` / `+` | power | -| GND | J1-15 / J3-1 | all | `GND` | ground | +## Master connection table + +Header numbering follows the [ESP32-C6-DevKitC-1 user guide](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html): **J1** is the side carrying `3V3`/`RST`/`5V`, **J3** the side carrying `TX`/`RX`. Ground is available on J1-15, J3-1, J3-12 and J3-15. + +**41 wires in total.** `VBAT_SW` and `MID` are junction points, not physical parts — several wires meet there. -### Unused / reserved GPIO +### A. Power (15 wires) -| GPIO | Header | Why it is left alone | -|------|--------|----------------------| -| 8 | J1-9 | drives the on-board addressable RGB LED | -| 9 | J3-11 | on-board BOOT button; boot-mode strapping pin | -| 13 | J3-13 | `USB_D+` — keep paired with 12 rather than half-breaking the port | -| 16 | J3-2 | `U0TXD` — serial console out | -| 17 | J3-3 | `U0RXD` — serial console in | +| # | From (component + pin) | To (component + pin) | Purpose | +|---|------------------------|----------------------|---------| +| 1 | LiPo **+** (measured; often red, not always) | TP4056 `B+` | cell into the charger | +| 2 | LiPo **−** (measured; often black, not always) | TP4056 `B−` | cell into the charger | +| — | LiPo — white lead (NTC) | *leave unconnected, insulate* | module has no thermistor input | +| 3 | TP4056 `OUT+` | KCD1 rocker — measured pin A | master switch, in series | +| 4 | KCD1 rocker — measured pin B | junction `VBAT_SW` | switched battery rail | +| 5 | `VBAT_SW` | S7V8F3 `VIN` | feeds the regulator | +| 6 | `VBAT_SW` | R1 47 kΩ — leg 1 | top of the divider | +| 7 | R1 47 kΩ — leg 2 | junction `MID` | divider midpoint | +| 8 | `MID` | R2 47 kΩ — leg 1 | bottom of the divider | +| 9 | R2 47 kΩ — leg 2 | common ground | closes the divider | +| 10 | `MID` | DevKit `1` (J1-8) | battery voltage into the ADC | +| 11 | 100 nF — leg 1 | `MID` / GPIO 1 | ADC noise filter (recommended) | +| 12 | 100 nF — leg 2 | common ground | ADC noise filter (recommended) | +| 13 | TP4056 `OUT−` | common ground | current return | +| 14 | S7V8F3 `GND` | common ground | current return | +| 15 | S7V8F3 `VOUT` | DevKit `3V3` (J1-1) | 3.3 V into the board | +| — | S7V8F3 `SHDN` | *leave unconnected* | internal pull-up keeps it enabled | + +### B. OLED 2.42" (4 wires) + +| # | From | To | Purpose | +|---|------|-----|---------| +| 16 | OLED `VCC` | DevKit `3V3` (J1-1) | power | +| 17 | OLED `GND` | common ground | power return | +| 18 | OLED `SDA` | DevKit `6` (J1-5) | I2C data | +| 19 | OLED `SCL` | DevKit `7` (J1-6) | I2C clock | + +### C. KY-040 encoder (5 wires) + +| # | From | To | Purpose | +|---|------|-----|---------| +| 20 | KY-040 `+` | DevKit `3V3` (J1-1) | power | +| 21 | KY-040 `GND` | common ground | power return | +| 22 | KY-040 `DT` | DevKit `2` (J1-12) | encoder channel A | +| 23 | KY-040 `CLK` | DevKit `3` (J1-13) | encoder channel B | +| 24 | KY-040 `SW` | DevKit `0` (J1-7) | push button + deep-sleep wake | + +### D. 3×4 keypad (7 wires) + +| # | From | To | Purpose | +|---|------|-----|---------| +| 25 | Keypad `R0` | DevKit `18` (J3-10) | matrix row (output) | +| 26 | Keypad `R1` | DevKit `19` (J3-9) | matrix row (output) | +| 27 | Keypad `R2` | DevKit `20` (J3-8) | matrix row (output) | +| 28 | Keypad `R3` | DevKit `21` (J3-7) | matrix row (output) | +| 29 | Keypad `C0` | DevKit `22` (J3-6) | matrix column (input) | +| 30 | Keypad `C1` | DevKit `23` (J3-5) | matrix column (input) | +| 31 | Keypad `C2` | DevKit `10` (J1-10) | matrix column (input) | + +### E. Five buttons (10 wires) + +| # | From | To | Purpose | +|---|------|-----|---------| +| 32 | Button "Menu left" — leg 1 | DevKit `11` (J1-11) | input | +| 33 | Button "Menu left" — leg 2 | common ground | pressed = LOW | +| 34 | Button "Stop" — leg 1 | DevKit `12` (J3-14) | input | +| 35 | Button "Stop" — leg 2 | common ground | pressed = LOW | +| 36 | Button "Menu right" — leg 1 | DevKit `4` (J1-3) | input | +| 37 | Button "Menu right" — leg 2 | common ground | pressed = LOW | +| 38 | Button "Back" — leg 1 | DevKit `5` (J1-4) | input | +| 39 | Button "Back" — leg 2 | common ground | pressed = LOW | +| 40 | Button "Menu" — leg 1 | DevKit `15` (J3-4) | input | +| 41 | Button "Menu" — leg 2 | common ground | pressed = LOW | + +## Assembly, step by step + +This section assumes **no electronics background**. Every step says what to pick up, where to put it, and why. Wire numbers in parentheses refer to the [master connection table](#master-connection-table). + +### Before you start + +Get a **multimeter**. It is needed several times, and without it two of the steps are guesswork. The cheapest one will do, as long as it can measure DC voltage (marked `V` with a straight line) and has a continuity mode (a sound-wave or diode symbol). + +A few terms that keep coming up: + +- **Ground** (`GND`, minus) is the shared reference point for the whole circuit. Every electrical signal is really a **voltage difference against ground** — without a shared ground there is nothing to measure against and the circuit behaves erratically. +- A **pin** is a single leg or hole in a header. The ESP32 board has two headers, **J1** and **J3**, as used in the tables. +- **In series** means "one after the other, current flows through both" — that is how a switch is wired in. +- **Polarity** is which lead is plus (`+`) and which is minus (`−`). Wiring a LiPo backwards destroys the charger and often the cell. + +**Check the cell polarity before anything is connected.** Cheap LiPo packs frequently reverse the usual colours, so **do not trust red = plus and black = minus**. Measure: + +1. Set the multimeter to DC voltage (`V` with a straight line). +2. Touch the **red** probe to one cell lead and the **black** probe to the other. Do not connect the cell to anything yet. +3. If the display shows a **positive** number (about 3.7–4.2 V): the lead under the red probe is **plus (`+`)**, the lead under the black probe is **minus (`−`)**. +4. If the display shows a **negative** number (a minus sign in front): the leads are the other way around — the lead under the red probe is **minus**, the lead under the black probe is **plus**. +5. Mark the plus lead (a piece of tape is enough) and use that mark, not the factory colour, in every later step. -GPIO **14** is not listed because the ESP32-C6-WROOM-1 module does not break it out; only `0–13` and `15–23` reach the headers. +The third lead, if present, is the NTC thermistor (usually white or yellow). It is neither plus nor minus — leave it alone. -### Flashing +### Step 1 — establish a common ground -Use the **USB Type-C to UART** port (the one wired to the on-board bridge) — no extra wiring. The other Type-C port is the chip's native USB, which is unavailable because Stop occupies `USB_D−`. Enter provisioning: hold **`*`** (keypad) + **Stop** (GPIO 12) for **8 s**. +Before connecting anything else, plan a **single common ground point**. The simplest approach: pick a `G` pin on the ESP32 board (J1-15 or J3-1) and run ground from there to every component. + +Ground goes to: `OUT−` on the charger, `GND` on the regulator, `GND` on the display, `GND` on the encoder, the bottom resistor of the divider, the capacitor, and **all five buttons** — one leg each. *(wires 9, 12, 13, 14, 17, 21, 33, 35, 37, 39, 41)* + +**Why:** this is the most common source of trouble in a first build. A button whose ground comes from somewhere other than the board may work once every few presses, or trigger by itself. The ESP32 board has four `G` pins and they are all connected internally, so any of them will do. + +### Step 2 — build the power chain + +Order matters, because each element protects the next one. + +1. **Cell into the charger — follow the measured polarity, not the colour.** The cell **plus (`+`)** lead — *often* red, but only if the check in [Before you start](#before-you-start) confirmed it — goes to pad `B+` on the TP4056. The cell **minus (`−`)** lead — *often* black — goes to pad `B−`. Reverse polarity here destroys the module and often the cell. *(wires 1, 2)* +2. **Leave the white lead loose** and tape it over. It is a temperature sensor (thermistor) this module does not support. A loose, uninsulated lead can touch something and short. +3. **Switch on the charger output.** From the `OUT+` pad to one pin of the rocker switch, from the other rocker pin onward into the circuit. *(wires 3, 4)* +4. **Regulator.** Run the point after the switch to the `VIN` pin of the S7V8F3. Its `GND` pin goes to ground. *(wires 5, 14)* +5. **Regulator output.** The `VOUT` pin goes to the `3V3` pin of the ESP32 board (J1-1). *(wire 15)* +6. **Leave the `SHDN` pin unconnected.** It has an internal pull-up that keeps the regulator enabled by default. Shorting it to ground would switch it off. + +**Why take the load from `OUT` and not `B`:** the charger module has built-in over-discharge protection (the `DW01A` and `8205A` chips). That protection sits **between** the `B` pads and the `OUT` pads. Drawing current from `B+` would bypass it and allow the cell to be discharged below its safe threshold, which damages it permanently. + +**Why a switching regulator and not a plain one:** a LiPo cell drops from 4.2 V to about 3.0 V as it discharges. A plain regulator such as an AMS1117 needs roughly 4.5 V at its input to produce 3.3 V, so it would essentially never work here. The S7V8F3 is a **buck-boost** type: when the cell is above 3.3 V it steps down, and when it falls below it steps up. That way the full capacity of the cell is used instead of losing the last ~35 %. + +**Why 3.3 V and not 5 V:** the ESP32-C6 module runs on 3.3 V and feeding 5 V into the `3V3` pin will destroy it. The board does have a `5V` pin, but it leads into the on-board regulator — using it would mean two voltage conversions instead of one and needless losses. + +### Step 3 — build the battery measurement divider + +You need two 47 kΩ resistors. Twist or solder them together **end to end**, producing one longer element with three connection points: a start, a **midpoint** (where they join) and an end. + +1. **Start** of the divider to the point **after the switch** (the same one that feeds `VIN` on the regulator). *(wire 6)* +2. **End** of the divider to ground. *(wire 9)* +3. **Midpoint** of the divider to pin `1` on the ESP32 board (J1-8). *(wire 10)* +4. If you have the 100 nF capacitor, connect it between the **midpoint** and ground. Polarity does not matter. *(wires 11, 12)* + +**Why the divider is necessary:** the ESP32 measurement input accepts at most **3.3 V**, while a fully charged cell sits at **4.2 V**. Feeding 4.2 V straight in would damage the input. Two equal resistors split the voltage exactly **in half**, so the pin sees 2.1 V on a full cell and 1.5 V on an empty one — both safely in range. The firmware knows about this split and converts the reading back. + +**Why the divider sits after the switch:** a tiny current flows through it continuously (about 45 microamps). Putting it after the switch means the cell does not discharge at all once the throttle is off. + +**What the capacitor is for:** the regulator works by switching rapidly and introduces small disturbances onto the supply. The capacitor smooths them out so the battery percentage does not jump around. The circuit works without it — the reading is simply less steady. + +### Step 4 — test the power BEFORE connecting anything else + +This is a separate step because a mistake in the previous two can destroy the display and the ESP32 board at the same time. + +1. **Do not connect** the display, encoder, keypad or buttons yet. +2. Set the rocker switch to ON. +3. With the multimeter in DC voltage mode, touch the black probe to ground and the red probe to the `3V3` pin (J1-1). +4. **You must see a value between 3.2 and 3.4 V.** + +If you see 0 V, check the switch and the cell polarity. If you see the cell voltage (about 3.7–4.2 V), the regulator is being bypassed or is miswired and you **must not** continue. If everything checks out, switch the rocker off and move on. + +While you are there, measure the voltage on pin `1` (J1-8) — it should be roughly **half** the cell voltage. That confirms the divider works. + +### Step 5 — connect the OLED display + +Four wires. *(wires 16–19)* + +| Display pin | Goes to | +|---|---| +| `VCC` | `3V3` on the board (J1-1) | +| `GND` | ground | +| `SDA` | pin `6` (J1-5) | +| `SCL` | pin `7` (J1-6) | + +**Watch the pin order.** On cheap modules it is often `GND · VCC · SCL · SDA`, meaning the **power pins are reversed** relative to intuition. Read the labels printed on the module rather than assuming an order. + +**Why only two signal wires:** the display talks over an I2C bus, where one line (`SDA`) carries data and the other (`SCL`) clocks it. That allows the display to be driven with just two pins instead of a dozen. + +### Step 6 — connect the KY-040 encoder + +Five wires. *(wires 20–24)* + +| Encoder pin | Goes to | +|---|---| +| `+` | `3V3` on the board (J1-1) | +| `GND` | ground | +| `DT` | pin `2` (J1-12) | +| `CLK` | pin `3` (J1-13) | +| `SW` | pin `0` (J1-7) | + +**Note:** KY-040 manufacturers routinely swap the `CLK` and `DT` labels. Wire it as in the table, and if the knob turns out to work backwards after power-up, swap those two wires. Nothing gets damaged by this. + +**Why `SW` goes to pin 0 specifically:** only pins 0 through 7 can wake the chip from deep sleep. Putting the encoder button on pin 0 makes it double as the "wake the throttle" button. None of the other five buttons can do this. + +### Step 7 — connect the keypad + +The keypad has **7 leads**: four for rows and three for columns. The catch is that **the lead order is not standardized** and varies between units, so you have to work it out yourself. + +**How to do it with a multimeter:** + +1. Set the multimeter to continuity mode (the beeper). +2. Press and hold key **`1`**. Look for the pair of leads that makes the meter beep — those are row R0 and column C0 for that key. +3. Repeat for key **`2`**: the lead shared with the previous test is row R0, the new one is column C1. +4. Working through keys `3`, `4`, `7` and `*` maps out all seven leads. + +Then wire it according to the table. *(wires 25–31)* + +| Lead | Goes to | Keys | +|---|---|---| +| `R0` | pin `18` (J3-10) | `1` `2` `3` | +| `R1` | pin `19` (J3-9) | `4` `5` `6` | +| `R2` | pin `20` (J3-8) | `7` `8` `9` | +| `R3` | pin `21` (J3-7) | `*` `0` `#` | +| `C0` | pin `22` (J3-6) | `1` `4` `7` `*` | +| `C1` | pin `23` (J3-5) | `2` `5` `8` `0` | +| `C2` | pin `10` (J1-10) | `3` `6` `9` `#` | + +**If the digits come out wrong after power-up, move the wires — do not change the firmware.** The pin numbers are baked into the code, and changing them makes the documentation disagree with reality. + +**Why 7 pins are enough for 12 keys:** the keys are arranged in a grid. The board activates one row at a time and checks which column responds. The intersection of the active row and the responding column identifies the pressed key unambiguously. That is why 4 + 3 pins suffice instead of 12 separate ones. + +### Step 8 — connect the five buttons + +Each button has **two legs** and is wired identically: **one leg to its assigned pin, the other to ground**. *(wires 32–41)* + +| Button | Board pin | Header | +|---|---|---| +| Menu left | `11` | J1-11 | +| **Stop** | `12` | J3-14 | +| Menu right | `4` | J1-3 | +| Back | `5` | J1-4 | +| Menu | `15` | J3-4 | + +**How to pick the right legs:** 12 mm buttons usually have exactly two terminals, in which case there is nothing to choose. If yours has more, set the multimeter to continuity and find the pair that **beeps only while the button is pressed** and stays silent at rest. + +**Why one leg goes to ground:** the board enables an internal pull-up resistor that holds the pin high while nothing is happening. Pressing the button connects the pin to ground and pulls it low, and that transition is what the firmware reads as a press. No external resistors are needed. + +**Why these particular pins:** all of them were checked against their special functions. Pins 4, 5 and 15 are so-called strapping pins, but with factory chip settings their state at start-up affects nothing that matters here — you can hold these buttons while powering up and the board will boot normally. Pin 12 is a native USB line, so that port will not work; the firmware is flashed through the second USB port, which is entirely sufficient. + +### Step 9 — first power-up and battery calibration + +1. **Set the rocker to OFF.** This matters: never power the board from the battery and USB at the same time. +2. Connect the computer to the **USB-to-UART** port on the board (the one wired to the bridge chip, not the native one) and flash the firmware. +3. Disconnect USB, set the rocker to ON, and check that the display lights up, the keypad responds and the knob changes values. +4. **Battery calibration:** charge the cell fully through the USB-C port on the charger module (the LED on it will change colour). From the UART log, copy `suggested_factor` out of the line `battery: raw=… suggested_factor=…` into `BATTERY_CONVERSION_FACTOR` in [`config/power.rs`](../../crates/firmware/src/config/power.rs). Expect a reading around 2600 and a factor close to 1.6. +5. Flash the firmware again (rocker off once more) and check that a full cell reads 100 %. + +### Safety warnings + +- **Never power the board over USB with the rocker switched on.** That would put two voltage sources on the same 3.3 V rail, fighting each other. Switch the rocker off before every firmware flash. +- **Do not swap `B+` and `B−`** on the charger module, and **do not trust the cell wire colours**. Measure polarity first ([Before you start](#before-you-start)). Reverse polarity destroys the module and often the cell as well. +- **The white cell lead (NTC) stays unconnected and insulated.** Do not connect it anywhere "just in case". +- **LiPo cells are delicate.** Do not bend, puncture, or solder directly to the cell terminals. Retire a swollen or damaged cell immediately. +- **Leave the regulator `SHDN` pin free.** It has an internal pull-up, and an accidental short to ground will cut the power. +- The ESP32 board has a **power indicator LED** that draws current continuously whenever the circuit is on. If you want maximum run time it can be desoldered — nothing depends on it. +- **Before the first power-up, check with the multimeter that `3V3` and ground are not shorted.** A beep between those points means an assembly error, and switching on in that state will damage the regulator. ## BOM -- ESP32-C6-DevKitC-1 -- 2.42" OLED 128×64 SSD1309 (I2C) -- 3×4 membrane keypad -- KY-040 encoder -- 5 tact switches (left, Stop, right, Back, Menu) +Core: + +- ESP32-C6-DevKitC-1 V1.4 (ESP32-C6-WROOM-1, 8 MB flash) +- 2.42" OLED 128×64 SSD1309, I2C, 4-pin +- 3×4 membrane keypad, 7-pin +- KY-040 rotary encoder +- 5× momentary panel push button, 12 mm - Case: Thingiverse 7029069 (adapted) -Battery (optional, see [Battery](#battery)): +Battery: -- LiPo cell 3.7 V, 1200 mAh (503759) or larger -- TP4056 charging module with protection -- 3.3 V LDO (HT7333 / ME6211) +- LiPo cell 3.7 V, 1200 mAh, format 503759 (5.0 × 37 × 59 mm), with NTC lead +- TP4056 charging module, USB-C, with `DW01A` + `8205A` protection +- Pololu S7V8F3 buck-boost regulator (2.7–11.8 V in, 3.3 V out, up to 1 A) - 2× 47 kΩ resistor -- Power switch +- 1× 100 nF capacitor (recommended, ADC filter) +- KCD1 rocker switch, 21 × 15 mm, bistable ON/OFF + +Owned but not used: + +- KAmod I2C-IOexp16 (MCP23017) — see [Pin budget](#pin-budget) + +## Flashing + +Use the **USB Type-C to UART** port (the one wired to the on-board bridge) — no extra wiring. The other Type-C port is the chip's native USB, which is unavailable because Stop occupies `USB_D−`. Switch the battery **off** before connecting USB. ## Programming mode diff --git a/docs/hardware/markwtech_pl.md b/docs/hardware/markwtech_pl.md new file mode 100644 index 0000000..419492f --- /dev/null +++ b/docs/hardware/markwtech_pl.md @@ -0,0 +1,492 @@ +# MarkWTech (w stylu WiTcontroller) + +> English version: [markwtech.md](markwtech.md) + +ESP32-C6-DevKitC-1 z klawiaturą 3×4, dodatkowymi przyciskami, enkoderem KY-040 i wyświetlaczem OLED 2,42" SSD1309 — inspirowany projektem [WiTcontroller](https://github.com/flash62au/WiTcontroller) / [Thingiverse 7029069](https://www.thingiverse.com/thing:7029069), z układem ESP32-C6 zamiast LOLIN32. + +| Pozycja | Wartość | +|---------|---------| +| Feature Cargo | `variant-markwtech` | +| Wyświetlacz | SSD1309/SSD1306 128×64 I2C | +| Ekspandery | brak | +| Skrót do trybu programowania | **\* (Menu) + Stop** przez 8 s | + +## Sterowanie + +- Klawiatura 3×4: cyfry, `*` (menu/anuluj), `#` (wybierz) +- Pięć dodatkowych przycisków na GPIO (w lewo / Stop / w prawo / Cofnij / Menu) +- Enkoder KY-040 do prędkości i przewijania list +- Dedykowany Stop do zatrzymania awaryjnego i skrótu programowania + +## Mapa pinów + +| Rola | GPIO | +|------|------| +| Wiersze klawiatury | 18, 19, 20, 21 | +| Kolumny klawiatury | 22, 23, 10 | +| OLED po I2C | SDA 6, SCL 7, adres 0x3C | +| Enkoder | A 2, B 3, SW 0 | +| Przyciski: w lewo / Stop / w prawo / Cofnij / Menu | 11, 12, 4, 5, 15 | +| ADC baterii | 1 | + +Układ klawiatury (`KEYPAD_MAP`): + +```text + C0 C1 C2 +R0 1 2 3 +R1 4 5 6 +R2 7 8 9 +R3 * 0 # +``` + +Dodatkowe przyciski: styk do **GND**, podciąganie włączone w firmware, stan aktywny niski. + +| # | Funkcja | GPIO | Uwagi | +|---|---------|------|-------| +| 1 | Menu w lewo | 11 | `Nav(Left)` — poprzednia strona listy / kursor | +| 2 | Stop | 12 | Zatrzymanie awaryjne; skrót z `*` | +| 3 | Menu w prawo | 4 | `Nav(Right)` — następna strona listy / kursor | +| 4 | Cofnij | 5 | Anuluj / wstecz | +| 5 | Menu | 15 | Otwórz menu / wybierz w menu | + +```mermaid +flowchart LR + ESP[ESP32-C6] --- KP[Klawiatura 3x4] + ESP --- OLED[OLED 2.42in I2C] + ESP --- ENC[KY-040] + ESP --- EXTRA[Lewo Stop Prawo Cofnij Menu] + ESP --- BAT[Dzielnik baterii] +``` + +Stałe: `board/variants/markwtech.rs` (`KEYPAD_MAP`, `EXTRA_BUTTON_MAP`). + +## Budżet pinów + +Źródła: [user guide ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html) (sekcja Header Block) oraz [ESP32-C6 Datasheet v1.5](https://documentation.espressif.com/esp32-c6_datasheet_en.html) (rozdział 3, Boot Configurations). + +Listwy wyprowadzają łącznie **23 GPIO** — na J1 znajdują się `4, 5, 6, 7, 0, 1, 8, 10, 11, 2, 3`, a na J3 `16, 17, 15, 23, 22, 21, 20, 19, 18, 9, 13, 12`. **GPIO 14 nie istnieje**: J3 przeskakuje z 18 na 9, a potem na 13 i 12. + +| Kategoria | GPIO | Liczba | +|-----------|------|--------| +| W pełni wolne | 0, 1, 2, 3, 6, 7, 10, 11, 18, 19, 20, 21, 22, 23 | 14 | +| Strappingowe, zweryfikowane jako bezpieczne | 4, 5, 15 | 3 | +| Kosztują natywny port USB | 12, 13 | 2 | +| Kosztują konsolę UART | 16, 17 | 2 | +| Zablokowane | 8 (dioda RGB), 9 (przycisk BOOT) | 2 | + +MarkWTech potrzebuje **18** linii (klawiatura 7 + enkoder 3 + I2C 2 + przyciski 5 + ADC baterii 1). Piny wolne wraz z bezpiecznymi strappingowymi dają tylko 17, więc dokładnie jeden musi pochodzić z pary USB — Stop zajmuje **GPIO 12**. Dzięki temu **GPIO 13 zostaje wolny bez żadnego dodatkowego kosztu**, bo natywny port USB jest już poświęcony przez wykorzystanie jego linii `D−`. + +### Dlaczego ryzykowne piny są bezpieczne + +- **GPIO 4 (MTMS) i GPIO 5 (MTDI)** — według tabeli 3-4 z datasheetu ich stan strappingowy wybiera wyłącznie zbocze próbkowania i wystawiania danych interfejsu **SDIO slave**, którego ten projekt w ogóle nie używa. Oba są domyślnie w stanie nieustalonym. +- **GPIO 15** — według tabeli 3-7, przy fabrycznych bezpiecznikach eFuse (`DIS_PAD_JTAG=0`, `DIS_USB_JTAG=0`, `JTAG_SEL_ENABLE=0`) pin jest wprost oznaczony jako **ignorowany**. Ostrzeżenie z datasheetu przed pozostawieniem go w stanie wysokiej impedancji dotyczy dopiero sytuacji po wypaleniu `EFUSE_JTAG_SEL_ENABLE`, co jest czynnością świadomą i nieodwracalną. +- Trzymanie któregokolwiek z tych przycisków podczas resetu **nie może więc zmienić trybu bootowania**. O trybie decydują wyłącznie GPIO 8 i 9 (tabela 3-3). +- **GPIO 12** działa jako zwykłe wejście, ponieważ esp-hal wywołuje `disable_usb_pads()` z funkcji `init_gpio()` przed każdym użyciem pinu, czyszcząc `usb_pad_enable` oraz rezystory podciągające linii D+/D− (`esp-hal-1.1.1/src/gpio/mod.rs:1669-1709`). + +### Wybudzanie z głębokiego uśpienia + +Tylko **GPIO 0–7** należą do domeny niskiego poboru (`LP_GPIO0..7`) i mogą wybudzić układ z głębokiego uśpienia. Źródłem wybudzenia jest `SW` enkodera na GPIO 0. Przyciski Menu (15), Menu w lewo (11) i Stop (12) **nie wybudzą** manipulatora. + +### Piny nieużywane i zarezerwowane + +| GPIO | Listwa | Dlaczego zostaje wolny | +|------|--------|------------------------| +| 8 | J1-9 | steruje wbudowaną adresowalną diodą RGB | +| 9 | J3-11 | wbudowany przycisk BOOT; pin strappingowy trybu startu | +| 13 | J3-13 | `USB_D+` — zapas, do wykorzystania jeśli kiedyś zabraknie wejścia | +| 16 | J3-2 | `U0TXD` — wyjście konsoli szeregowej | +| 17 | J3-3 | `U0RXD` — wejście konsoli szeregowej | + +Ekspander **KAmod MCP23017** jest w zestawie, ale świadomie nieużywany: wszystkie 18 linii mieści się bezpośrednio na płytce, a pozostawienie **przycisku Stop na bezpośrednim GPIO** sprawia, że awaria magistrali I2C nie unieruchomi jednocześnie zatrzymania awaryjnego i wyświetlacza. + +## Pełne okablowanie (pin po pinie) + +Wszystkie moduły pracują na **3,3 V** (nie 5 V). Do każdego elementu doprowadź wspólną **masę**. + +### OLED 2,42" I2C (SSD1309 / SSD1306) + +| GPIO | Firmware | Pin wyświetlacza (typowy) | Uwagi | +|------|----------|---------------------------|-------| +| **6** | `I2C_SDA` | `SDA` / `DIN` / `DATA` | dane I2C | +| **7** | `I2C_SCL` | `SCL` / `CLK` / `SCK` | zegar I2C | +| **3V3** | — | `VCC` / `3.3V` | | +| **GND** | — | `GND` | | +| — | adres **0x3C** | — | ustaw zworkę `ADDR` na module, jeśli jest | + +Typowa kolejność czterech pinów na tanich modułach: `GND` · `VCC` · `SCL` · `SDA` (sprawdź opisy na swoim module). + +### Enkoder obrotowy KY-040 + +| GPIO | Firmware | Pin KY-040 | Uwagi | +|------|----------|------------|-------| +| **2** | `ENCODER_A` | **`DT`** (czasem `B`, `DATA`) | kanał A | +| **3** | `ENCODER_B` | **`CLK`** (czasem `A`) | kanał B | +| **0** | `ENCODER_BUTTON` | **`SW`** / `KEY` | przycisk enkodera | +| **3V3** | — | **`+`** / `VCC` | | +| **GND** | — | **`GND`** | | + +Moduły KY-040 często mają zamienione opisy `CLK` i `DT` — podłącz jak w tabeli (`DT`→GPIO 2, `CLK`→GPIO 3). GPIO 0 pełni jednocześnie rolę pinu wybudzania, więc przycisk enkodera budzi manipulator. + +### Klawiatura membranowa 3×4 (7 pinów) + +Wiersze są **wyjściami** (skaner ściąga po kolei jeden do stanu niskiego). Kolumny są **wejściami** z wewnętrznym podciąganiem. + +| GPIO | Firmware | Pin klawiatury | Rola w matrycy | +|------|----------|----------------|----------------| +| **18** | `KEYPAD_ROW_PINS[0]` | **R0** (wiersz 1) | klawisze `1` `2` `3` | +| **19** | `KEYPAD_ROW_PINS[1]` | **R1** (wiersz 2) | klawisze `4` `5` `6` | +| **20** | `KEYPAD_ROW_PINS[2]` | **R2** (wiersz 3) | klawisze `7` `8` `9` | +| **21** | `KEYPAD_ROW_PINS[3]` | **R3** (wiersz 4) | klawisze `*` `0` `#` | +| **22** | `KEYPAD_COL_PINS[0]` | **C0** (kolumna 1) | klawisze `1` `4` `7` `*` | +| **23** | `KEYPAD_COL_PINS[1]` | **C1** (kolumna 2) | klawisze `2` `5` `8` `0` | +| **10** | `KEYPAD_COL_PINS[2]` | **C2** (kolumna 3) | klawisze `3` `6` `9` `#` | + +**Kolejność wyprowadzeń klawiatury nie jest ustandaryzowana.** Wyznacz każdy wiersz i kolumnę multimetrem (wciśnięty klawisz zwiera swój wiersz z kolumną). Jeśli cyfry się mylą, przełóż przewody na złączu — nie zmieniaj numerów GPIO w firmware. + +### Pięć dodatkowych przycisków (stan aktywny niski) + +Każdy przycisk: jedna nóżka → **GPIO**, druga → **GND**. Firmware włącza wewnętrzne podciąganie (wciśnięty = stan niski). + +| GPIO | Listwa | Etykieta | Funkcja w interfejsie | Sugerowany opis | +|------|--------|----------|-----------------------|-----------------| +| **11** | J1-11 | Menu w lewo | poprzednia strona listy / kursor w lewo | `LEFT` | +| **12** | J3-14 | **Stop** | zatrzymanie awaryjne; skrót `*`+Stop (8 s) | `STOP` / `E-STOP` | +| **4** | J1-3 | Menu w prawo | następna strona listy / kursor w prawo | `RIGHT` | +| **5** | J1-4 | Cofnij | anuluj / wstecz | `BACK` / `ESC` | +| **15** | J3-4 | Menu | otwórz menu / wybierz w menu | `MENU` / `OK` | + +```text +GPIOx ────[ przycisk ]──── GND + (podciąganie w MCU) +``` + +### Bateria + +Płytka **nie ma ładowarki LiPo ani złącza akumulatora** — w przeciwieństwie do LOLIN32 Lite, na której zbudowano oryginalny WiTcontroller i która ma jedno i drugie. Zasilanie MarkWTech z ogniwa wymaga więc zewnętrznych elementów. + +| Element | Przeznaczenie | +|---------|---------------| +| Ogniwo LiPo 3,7 V, 1200 mAh (503759) | źródło zasilania; oryginał osiąga ~6 h z 400 mAh, więc 1200 mAh starcza na całą sesję | +| Moduł TP4056 z zabezpieczeniem (`DW01A` + `8205A`) | ładowanie z USB-C oraz ochrona przed prze- i niedoładowaniem | +| Przetwornica **Pololu S7V8F3** | ogniwo waha się w zakresie 3,0–4,2 V; przetwornica utrzymuje stabilne 3,3 V w całym tym zakresie | +| 2× rezystor 47 kΩ | dzielnik pomiarowy na GPIO 1 | +| Kondensator 100 nF (zalecany) | filtr zakłóceń dla ADC, patrz niżej | +| Przełącznik kołyskowy KCD1, bistabilny | główny wyłącznik szeregowo z `OUT+` | + +**Nie używaj układów AMS1117 ani LD1117.** Potrzebują one około 4,5 V na wejściu, żeby wydać 3,3 V, a ogniwo LiPo nigdy tyle nie ma. Prawdziwy stabilizator o niskim spadku (ME6211, AP2112K, XC6220, MCP1826) poradziłby sobie do około 3,5 V, ale przetwornica buck-boost jest jeszcze lepsza, bo reguluje również poniżej 3,3 V i wyciąga z ogniwa ostatnie ~35 % energii. + +Tor zasilania: + +```text +USB-C (TP4056) --ładuje--> B+ / B- <-- LiPo 1200 mAh + | + OUT+ / OUT- + | + [kołyskowy KCD1, szeregowo na OUT+] + | + +----------------+----------------+ + | | + S7V8F3 VIN 47k --+-- 47k -- GND + | | + S7V8F3 VOUT ---> 3V3 (J1-1) GPIO 1 (J1-8) + | + 100 nF do masy + S7V8F3 GND ---> masa wspólna +``` + +Obciążenie bierz z **`OUT+` / `OUT−`**, nigdy z `B+` / `B−` — tranzystory ochronne siedzą pomiędzy `B` a `OUT`, więc obciążenie na `B` omija zabezpieczenie przed nadmiernym rozładowaniem. + +#### Dlaczego dzielnik jest konieczny i dobrze dobrany + +Według [wytycznych projektowych Espressif](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32c6/schematic-checklist.html) skalibrowany zakres ADC przy ATTEN=3 (`Attenuation::_11dB`, którego używa firmware) wynosi **0–3300 mV** przy błędzie całkowitym ±40 mV. Ogniwo 4,2 V przekroczyłoby ten zakres i uszkodziło wejście. Dzielnik 1:2 odwzorowuje pełne ogniwo na **2,10 V**, a rozładowane na **1,50 V** — obie wartości z zapasem mieszczą się w zakresie. GPIO 1 to `ADC1_CH1`, czyli prawidłowy kanał przetwornika. + +Prąd dzielnika wynosi `4,2 V / 94 kΩ ≈ 45 µA`, a sam dzielnik siedzi za wyłącznikiem, więc przy wyłączonym manipulatorze nie pobiera nic. + +Espressif zaleca **kondensator 0,1 µF między pinem ADC a masą**. Tutaj ma to większe znaczenie niż zwykle, bo S7V8F3 jest przetwornicą impulsową i wprowadza tętnienia na szynę zasilania. Firmware uśrednia `ADC_READS` próbek, więc pomiar działa i bez kondensatora, ale z nim jest stabilniejszy. + +Jeśli ogniwo ma trzeci przewód **NTC**, zostaw go niepodłączony — moduły TP4056 go ignorują. + +#### Kalibracja + +Stała `BATTERY_CONVERSION_FACTOR` w [`config/power.rs`](../../crates/firmware/src/config/power.rs) pochodzi z oryginalnego projektu, gdzie dobrano ją pod klasyczne ESP32. Naładuj ogniwo do pełna i odczytaj z logu UART linię `battery: raw=… suggested_factor=…`. Wartość `suggested_factor` wpisz do stałej. Spodziewaj się odczytu około `2600` i współczynnika bliskiego `1,6`. + +Przy działającym ogniwie firmware sam usypia manipulator: głębokie uśpienie poniżej **5 %** naładowania oraz po **4 minutach** bezczynności bez serwera WiThrottle. + +Pozostawienie GPIO 1 niepodłączonego jest nieszkodliwe — odczyt jest wtedy bezsensownym szumem, a ikonę baterii można ukryć w menu. + +## Tabela połączeń + +Numeracja listew według [user guide ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/user_guide.html): **J1** to strona z pinami `3V3`/`RST`/`5V`, **J3** to strona z `TX`/`RX`. Masa jest dostępna na J1-15, J3-1, J3-12 i J3-15. + +**Łącznie 41 przewodów.** `VBAT_SW` i `MID` to punkty węzłowe, a nie fizyczne elementy — schodzi się w nich po kilka przewodów. + +### A. Zasilanie (15 przewodów) + +| # | Skąd (element + pin) | Dokąd (element + pin) | Po co | +|---|----------------------|-----------------------|-------| +| 1 | LiPo **+** (zmierzony; często czerwony, nie zawsze) | TP4056 `B+` | ogniwo do ładowarki | +| 2 | LiPo **−** (zmierzony; często czarny, nie zawsze) | TP4056 `B−` | ogniwo do ładowarki | +| — | LiPo — przewód biały (NTC) | *zostaw wolny, zaizoluj* | moduł nie ma wejścia termistora | +| 3 | TP4056 `OUT+` | kołyskowy KCD1 — zmierzony pin A | wyłącznik główny, szeregowo | +| 4 | kołyskowy KCD1 — zmierzony pin B | węzeł `VBAT_SW` | przełączana szyna baterii | +| 5 | `VBAT_SW` | S7V8F3 `VIN` | zasilanie przetwornicy | +| 6 | `VBAT_SW` | R1 47 kΩ — nóżka 1 | góra dzielnika | +| 7 | R1 47 kΩ — nóżka 2 | węzeł `MID` | środek dzielnika | +| 8 | `MID` | R2 47 kΩ — nóżka 1 | dół dzielnika | +| 9 | R2 47 kΩ — nóżka 2 | masa wspólna | zamknięcie dzielnika | +| 10 | `MID` | płytka, pin `1` (J1-8) | napięcie ogniwa do przetwornika | +| 11 | 100 nF — nóżka 1 | `MID` / GPIO 1 | filtr zakłóceń ADC (zalecany) | +| 12 | 100 nF — nóżka 2 | masa wspólna | filtr zakłóceń ADC (zalecany) | +| 13 | TP4056 `OUT−` | masa wspólna | powrót prądu | +| 14 | S7V8F3 `GND` | masa wspólna | powrót prądu | +| 15 | S7V8F3 `VOUT` | płytka, pin `3V3` (J1-1) | 3,3 V do płytki | +| — | S7V8F3 `SHDN` | *zostaw niepodłączony* | wewnętrzne podciąganie trzyma włączone | + +### B. OLED 2,42" (4 przewody) + +| # | Skąd | Dokąd | Po co | +|---|------|-------|-------| +| 16 | OLED `VCC` | płytka, pin `3V3` (J1-1) | zasilanie | +| 17 | OLED `GND` | masa wspólna | powrót zasilania | +| 18 | OLED `SDA` | płytka, pin `6` (J1-5) | dane I2C | +| 19 | OLED `SCL` | płytka, pin `7` (J1-6) | zegar I2C | + +### C. Enkoder KY-040 (5 przewodów) + +| # | Skąd | Dokąd | Po co | +|---|------|-------|-------| +| 20 | KY-040 `+` | płytka, pin `3V3` (J1-1) | zasilanie | +| 21 | KY-040 `GND` | masa wspólna | powrót zasilania | +| 22 | KY-040 `DT` | płytka, pin `2` (J1-12) | kanał A enkodera | +| 23 | KY-040 `CLK` | płytka, pin `3` (J1-13) | kanał B enkodera | +| 24 | KY-040 `SW` | płytka, pin `0` (J1-7) | przycisk + wybudzanie | + +### D. Klawiatura 3×4 (7 przewodów) + +| # | Skąd | Dokąd | Po co | +|---|------|-------|-------| +| 25 | Klawiatura `R0` | płytka, pin `18` (J3-10) | wiersz matrycy (wyjście) | +| 26 | Klawiatura `R1` | płytka, pin `19` (J3-9) | wiersz matrycy (wyjście) | +| 27 | Klawiatura `R2` | płytka, pin `20` (J3-8) | wiersz matrycy (wyjście) | +| 28 | Klawiatura `R3` | płytka, pin `21` (J3-7) | wiersz matrycy (wyjście) | +| 29 | Klawiatura `C0` | płytka, pin `22` (J3-6) | kolumna matrycy (wejście) | +| 30 | Klawiatura `C1` | płytka, pin `23` (J3-5) | kolumna matrycy (wejście) | +| 31 | Klawiatura `C2` | płytka, pin `10` (J1-10) | kolumna matrycy (wejście) | + +### E. Pięć przycisków (10 przewodów) + +| # | Skąd | Dokąd | Po co | +|---|------|-------|-------| +| 32 | Przycisk „Menu w lewo" — nóżka 1 | płytka, pin `11` (J1-11) | wejście | +| 33 | Przycisk „Menu w lewo" — nóżka 2 | masa wspólna | wciśnięty = stan niski | +| 34 | Przycisk „Stop" — nóżka 1 | płytka, pin `12` (J3-14) | wejście | +| 35 | Przycisk „Stop" — nóżka 2 | masa wspólna | wciśnięty = stan niski | +| 36 | Przycisk „Menu w prawo" — nóżka 1 | płytka, pin `4` (J1-3) | wejście | +| 37 | Przycisk „Menu w prawo" — nóżka 2 | masa wspólna | wciśnięty = stan niski | +| 38 | Przycisk „Cofnij" — nóżka 1 | płytka, pin `5` (J1-4) | wejście | +| 39 | Przycisk „Cofnij" — nóżka 2 | masa wspólna | wciśnięty = stan niski | +| 40 | Przycisk „Menu" — nóżka 1 | płytka, pin `15` (J3-4) | wejście | +| 41 | Przycisk „Menu" — nóżka 2 | masa wspólna | wciśnięty = stan niski | + +## Montaż krok po kroku + +Ta część zakłada **zero wiedzy elektronicznej**. Każdy krok mówi wprost co wziąć do ręki, gdzie to wetknąć i dlaczego akurat tak. Numery przewodów w nawiasach odsyłają do [tabeli połączeń](#tabela-połączeń) powyżej. + +### Zanim zaczniesz + +Przygotuj **multimetr** — będzie potrzebny kilka razy i bez niego dwa kroki są zgadywanką. Wystarczy najtańszy, byle miał tryb pomiaru napięcia stałego (oznaczenie `V` z prostą kreską) i tryb „przejścia" z brzęczykiem (symbol fali dźwiękowej albo diody). + +Kilka pojęć, które będą się powtarzać: + +- **Masa** (`GND`, minus) to wspólny punkt odniesienia dla całego układu. Każdy sygnał elektryczny to tak naprawdę **różnica napięć względem masy** — bez wspólnej masy układ nie ma względem czego mierzyć i zachowuje się losowo. +- **Pin** to pojedyncza nóżka albo otwór w listwie. Na płytce ESP32 listwy są dwie: **J1** i **J3**, opisane w tabelach. +- **Szeregowo** znaczy „jedno za drugim, prąd płynie przez oba po kolei" — tak wpina się wyłącznik. +- **Polaryzacja** to to, który przewód jest plusem (`+`), a który minusem (`−`). Podłączenie ogniwa LiPo odwrotnie niszczy ładowarkę, a często i ogniwo. + +**Zanim cokolwiek podepniesz, zmierz polaryzację ogniwa.** Tanie pakiety LiPo często mają kolory przewodów na odwrót, więc **nie ufaj, że czerwony to plus, a czarny to minus**. Zmierz: + +1. Ustaw multimetr na napięcie stałe (`V` z prostą kreską). +2. Dotknij **czerwoną** sondą jednego przewodu ogniwa, a **czarną** sondą drugiego. Ogniwa jeszcze nigdzie nie podłączaj. +3. Jeśli na wyświetlaczu jest liczba **dodatnia** (około 3,7–4,2 V): przewód pod czerwoną sondą to **plus (`+`)**, przewód pod czarną sondą to **minus (`−`)**. +4. Jeśli na wyświetlaczu jest liczba **ujemna** (minus z przodu): przewody są na odwrót — pod czerwoną sondą jest **minus**, pod czarną **plus**. +5. Oznacz przewód plusa (wystarczy kawałek taśmy) i od tej pory kieruj się tym oznaczeniem, a nie fabrycznym kolorem. + +Trzeci przewód, jeśli jest, to termistor NTC (zwykle biały albo żółty). To ani plus, ani minus — zostaw go w spokoju. + +### Krok 1 — zrób wspólną masę + +Zanim podłączysz cokolwiek innego, zaplanuj **jeden wspólny punkt masy**. Najprościej: wybierz pin `G` na płytce ESP32 (J1-15 albo J3-1) i od niego poprowadź masę do wszystkich elementów. + +Do masy trafiają: `OUT−` z ładowarki, `GND` przetwornicy, `GND` wyświetlacza, `GND` enkodera, dolny rezystor dzielnika, kondensator i **wszystkie pięć przycisków** — po jednej nóżce z każdego. *(przewody 9, 12, 13, 14, 17, 21, 33, 35, 37, 39, 41)* + +**Dlaczego:** to najczęstsze źródło problemów przy pierwszym montażu. Jeśli przycisk ma masę wziętą skądinąd niż płytka, potrafi działać raz na kilka naciśnięć albo wyzwalać się sam. Płytka ESP32 ma cztery piny `G` i wszystkie są ze sobą połączone wewnątrz — możesz korzystać z dowolnego. + +### Krok 2 — złóż zasilanie + +Kolejność ma znaczenie, bo każdy kolejny element zabezpiecza następny. + +1. **Ogniwo do ładowarki — kieruj się zmierzoną polaryzacją, nie kolorem.** Przewód **plus (`+`)** ogniwa — *często* czerwony, ale tylko jeśli potwierdził to pomiar z [Zanim zaczniesz](#zanim-zaczniesz) — idzie do pola `B+` na module TP4056. Przewód **minus (`−`)** — *często* czarny — idzie do pola `B−`. Odwrotna polaryzacja niszczy tu moduł, a często i ogniwo. *(przewody 1, 2)* +2. **Biały przewód ogniwa zostaw luzem** i zaizoluj taśmą. To termistor (czujnik temperatury), którego ten moduł nie obsługuje. Luźny, nieizolowany przewód może się o coś oprzeć i zewrzeć. +3. **Wyłącznik na wyjściu ładowarki.** Z pola `OUT+` przewód do jednego pinu kołyskowego, z drugiego pinu kołyskowego dalej w głąb układu. *(przewody 3, 4)* +4. **Przetwornica.** Punkt za wyłącznikiem prowadzisz do pinu `VIN` przetwornicy S7V8F3. Pin `GND` przetwornicy do masy. *(przewody 5, 14)* +5. **Wyjście przetwornicy.** Pin `VOUT` do pinu `3V3` płytki ESP32 (J1-1). *(przewód 15)* +6. **Pin `SHDN` przetwornicy zostaw niepodłączony.** Ma wewnętrzne podciągnięcie, dzięki czemu przetwornica jest domyślnie włączona. Zwarcie go do masy by ją wyłączyło. + +**Dlaczego obciążenie z `OUT`, a nie z `B`:** moduł ładowarki ma wbudowaną ochronę przed nadmiernym rozładowaniem ogniwa (układy `DW01A` i `8205A`). Ta ochrona siedzi **pomiędzy** polami `B` a `OUT`. Gdybyś pobierał prąd z `B+`, ochrona zostałaby ominięta i dałoby się rozładować ogniwo poniżej bezpiecznego progu, co trwale je uszkadza. + +**Dlaczego przetwornica, a nie zwykły stabilizator:** ogniwo LiPo w trakcie pracy schodzi z 4,2 V do około 3,0 V. Zwykły stabilizator (np. AMS1117) potrzebuje na wejściu około 4,5 V, żeby wydać 3,3 V — z ogniwem nie zadziała praktycznie nigdy. Przetwornica S7V8F3 jest typu **buck-boost**: gdy napięcie ogniwa jest wyższe od 3,3 V, obniża je, a gdy spadnie poniżej — podnosi. Dzięki temu wykorzystujesz całą pojemność ogniwa zamiast tracić ostatnie ~35 %. + +**Dlaczego 3,3 V, a nie 5 V:** moduł ESP32-C6 pracuje na 3,3 V i podanie mu 5 V na pin `3V3` go zniszczy. Pin `5V` na płytce istnieje, ale prowadzi do wbudowanego stabilizatora — nie używamy go, bo mielibyśmy dwie konwersje napięcia zamiast jednej i niepotrzebne straty. + +### Krok 3 — zbuduj dzielnik napięcia do pomiaru baterii + +Potrzebujesz dwóch rezystorów 47 kΩ. Skręć albo zlutuj je ze sobą **końcami**, tak żeby powstał jeden dłuższy element z trzema wyprowadzeniami: początek, **środek** (miejsce ich połączenia) i koniec. + +1. **Początek** dzielnika do punktu **za wyłącznikiem** (tego samego, z którego idzie `VIN` przetwornicy). *(przewód 6)* +2. **Koniec** dzielnika do masy. *(przewód 9)* +3. **Środek** dzielnika do pinu `1` płytki ESP32 (J1-8). *(przewód 10)* +4. Jeśli masz kondensator 100 nF, wepnij go między **środek** dzielnika a masę. Biegunowość nie ma znaczenia. *(przewody 11, 12)* + +**Dlaczego dzielnik jest konieczny:** wejście pomiarowe ESP32 przyjmuje maksymalnie **3,3 V**, a naładowane ogniwo ma **4,2 V**. Podanie 4,2 V wprost uszkodziłoby wejście. Dwa jednakowe rezystory dzielą napięcie dokładnie **na pół**, więc na pinie pojawia się 2,1 V przy pełnym ogniwie i 1,5 V przy rozładowanym — obie wartości bezpiecznie w zakresie. Firmware zna ten podział i przelicza wynik z powrotem. + +**Dlaczego dzielnik jest za wyłącznikiem:** przez dzielnik cały czas płynie mikroskopijny prąd (około 45 mikroamperów). Umieszczenie go za wyłącznikiem sprawia, że po wyłączeniu manipulatora ogniwo nie rozładowuje się w ogóle. + +**Po co kondensator:** przetwornica pracuje impulsowo i wprowadza na zasilanie drobne zakłócenia. Kondensator je wygładza, dzięki czemu wskazanie procentu baterii nie skacze. Bez niego układ też działa — odczyt będzie po prostu bardziej rozchwiany. + +### Krok 4 — sprawdź zasilanie ZANIM podłączysz cokolwiek innego + +To osobny krok, bo błąd w poprzednich dwóch potrafi zniszczyć jednocześnie wyświetlacz i płytkę ESP32. + +1. Do płytki ESP32 **nie podłączaj jeszcze** wyświetlacza, enkodera, klawiatury ani przycisków. +2. Ustaw kołyskowy w pozycję ON. +3. Multimetrem w trybie napięcia stałego dotknij czarną sondą masy, a czerwoną pinu `3V3` (J1-1). +4. **Musisz zobaczyć wartość między 3,2 a 3,4 V.** + +Jeśli widzisz 0 V — sprawdź wyłącznik i polaryzację ogniwa. Jeśli widzisz napięcie ogniwa (około 3,7–4,2 V) — przetwornica jest pominięta albo źle podłączona i **nie wolno iść dalej**. Jeśli wszystko się zgadza, wyłącz kołyskowy i przejdź dalej. + +Przy okazji zmierz napięcie na pinie `1` (J1-8) — powinno wynosić mniej więcej **połowę** napięcia ogniwa. To potwierdza, że dzielnik działa. + +### Krok 5 — podłącz wyświetlacz OLED + +Cztery przewody. *(przewody 16–19)* + +| Pin wyświetlacza | Gdzie | +|---|---| +| `VCC` | `3V3` płytki (J1-1) | +| `GND` | masa | +| `SDA` | pin `6` (J1-5) | +| `SCL` | pin `7` (J1-6) | + +**Uwaga na kolejność pinów.** Na tanich modułach kolejność bywa `GND · VCC · SCL · SDA`, czyli **zasilanie na odwrót** względem intuicji. Przeczytaj opisy nadrukowane na module, nie zakładaj kolejności. + +**Dlaczego akurat dwa przewody sygnałowe:** wyświetlacz komunikuje się magistralą I2C, w której jedna linia (`SDA`) przenosi dane, a druga (`SCL`) taktuje ich odczyt. To pozwala obsłużyć wyświetlacz zaledwie dwoma pinami zamiast kilkunastu. + +### Krok 6 — podłącz enkoder KY-040 + +Pięć przewodów. *(przewody 20–24)* + +| Pin enkodera | Gdzie | +|---|---| +| `+` | `3V3` płytki (J1-1) | +| `GND` | masa | +| `DT` | pin `2` (J1-12) | +| `CLK` | pin `3` (J1-13) | +| `SW` | pin `0` (J1-7) | + +**Uwaga:** producenci modułów KY-040 notorycznie zamieniają opisy `CLK` i `DT` miejscami. Podłącz zgodnie z tabelą, a jeśli po uruchomieniu okaże się, że pokrętło działa w odwrotną stronę — zamień te dwa przewody miejscami. Nic się przez to nie zepsuje. + +**Dlaczego `SW` trafia akurat na pin 0:** tylko piny od 0 do 7 potrafią wybudzić układ z głębokiego uśpienia. Przycisk enkodera na pinie 0 jest więc jednocześnie przyciskiem „obudź manipulator". Żaden z pięciu pozostałych przycisków tego nie potrafi. + +### Krok 7 — podłącz klawiaturę + +Klawiatura ma **7 wyprowadzeń**: cztery odpowiadają rzędom, trzy kolumnom. Problem w tym, że **kolejność wyprowadzeń nie jest ustandaryzowana** i różni się między egzemplarzami — trzeba ją wyznaczyć samodzielnie. + +**Jak to zrobić multimetrem:** + +1. Ustaw multimetr w tryb „przejścia" (brzęczyk). +2. Wciśnij i przytrzymaj klawisz **`1`**. Szukaj pary wyprowadzeń, między którymi multimetr zapiszczy — to rząd R0 i kolumna C0 tego klawisza. +3. Powtórz dla klawisza **`2`**: wspólne z poprzednim będzie wyprowadzenie rzędu R0, nowe to kolumna C1. +4. Idąc dalej po klawiszach `3`, `4`, `7`, `*` rozpiszesz wszystkie siedem wyprowadzeń. + +Następnie podłącz według tabeli. *(przewody 25–31)* + +| Wyprowadzenie | Gdzie | Klawisze | +|---|---|---| +| `R0` | pin `18` (J3-10) | `1` `2` `3` | +| `R1` | pin `19` (J3-9) | `4` `5` `6` | +| `R2` | pin `20` (J3-8) | `7` `8` `9` | +| `R3` | pin `21` (J3-7) | `*` `0` `#` | +| `C0` | pin `22` (J3-6) | `1` `4` `7` `*` | +| `C1` | pin `23` (J3-5) | `2` `5` `8` `0` | +| `C2` | pin `10` (J1-10) | `3` `6` `9` `#` | + +**Jeśli po uruchomieniu cyfry się mylą — przełóż przewody, nie zmieniaj firmware.** Numery pinów są zaszyte w kodzie i zmienianie ich rozjeżdża dokumentację z rzeczywistością. + +**Dlaczego klawiatura potrzebuje tylko 7 pinów na 12 klawiszy:** klawisze są ułożone w siatkę. Płytka po kolei „odpytuje" każdy rząd i sprawdza, w której kolumnie pojawi się odpowiedź. Przecięcie odpytywanego rzędu z odpowiadającą kolumną jednoznacznie wskazuje wciśnięty klawisz. Dzięki temu wystarczy 4 + 3 zamiast 12 osobnych pinów. + +### Krok 8 — podłącz pięć przycisków + +Każdy przycisk ma **dwie nóżki** i podłącza się identycznie: **jedna nóżka do wyznaczonego pinu, druga do masy**. *(przewody 32–41)* + +| Przycisk | Pin płytki | Listwa | +|---|---|---| +| Menu w lewo | `11` | J1-11 | +| **Stop** | `12` | J3-14 | +| Menu w prawo | `4` | J1-3 | +| Cofnij | `5` | J1-4 | +| Menu | `15` | J3-4 | + +**Jak wybrać właściwe nóżki:** przyciski 12 mm mają zwykle dokładnie dwa wyprowadzenia i wtedy nie ma czego wybierać. Jeśli twój egzemplarz ma ich więcej, ustaw multimetr na brzęczyk i znajdź parę, która **piszczy dopiero po wciśnięciu** przycisku, a w spoczynku milczy. + +**Dlaczego jedna nóżka idzie do masy:** płytka włącza wewnętrzny rezystor podciągający, który utrzymuje pin w stanie wysokim, dopóki nic się nie dzieje. Wciśnięcie przycisku zwiera pin do masy i ściąga go w stan niski — i to właśnie firmware rozpoznaje jako naciśnięcie. Dzięki temu nie potrzebujesz żadnych zewnętrznych rezystorów. + +**Dlaczego akurat te piny:** wszystkie zostały sprawdzone pod kątem funkcji specjalnych. Piny 4, 5 i 15 są tak zwanymi pinami strappingowymi, ale przy fabrycznych ustawieniach układu ich stan przy starcie nie wpływa na nic istotnego — możesz trzymać te przyciski podczas włączania i płytka wystartuje normalnie. Pin 12 to linia natywnego portu USB, dlatego ten port nie będzie działał; firmware wgrywa się drugim portem USB i to w zupełności wystarcza. + +### Krok 9 — pierwsze uruchomienie i kalibracja baterii + +1. **Ustaw kołyskowy na OFF.** To ważne: nigdy nie zasilaj płytki jednocześnie z baterii i z USB. +2. Podłącz komputer kablem USB do portu **USB-to-UART** płytki (tego wpiętego w mostek, nie natywnego) i wgraj firmware. +3. Odłącz USB, ustaw kołyskowy na ON i sprawdź, czy wyświetlacz się zapala, klawiatura reaguje, a pokrętło zmienia wartości. +4. **Kalibracja baterii:** naładuj ogniwo do pełna przez port USB-C ładowarki (dioda na module zmieni kolor). Z logu UART skopiuj `suggested_factor` z linii `battery: raw=… suggested_factor=…` do `BATTERY_CONVERSION_FACTOR` w [`config/power.rs`](../../crates/firmware/src/config/power.rs). Spodziewaj się odczytu w okolicach 2600 i współczynnika bliskiego 1,6. +5. Wgraj firmware ponownie (znów przy kołyskowym na OFF) i sprawdź, czy pełne ogniwo pokazuje 100 %. + +### Ostrzeżenia + +- **Nigdy nie zasilaj płytki z USB przy włączonym kołyskowym.** Byłyby wtedy dwa źródła napięcia na tej samej szynie 3,3 V, walczące ze sobą. Przed każdym wgrywaniem firmware wyłączaj kołyskowy. +- **Nie zamieniaj `B+` z `B−`** na module ładowarki i **nie ufaj kolorom przewodów ogniwa**. Najpierw zmierz polaryzację ([Zanim zaczniesz](#zanim-zaczniesz)). Odwrotna polaryzacja niszczy moduł, a często i ogniwo. +- **Biały przewód ogniwa (NTC) zostaje niepodłączony i zaizolowany.** Nie wpinaj go „na wszelki wypadek" nigdzie. +- **Ogniwa LiPo są wrażliwe.** Nie zginaj, nie przekłuwaj, nie lutuj bezpośrednio do styków ogniwa. Spuchnięte albo uszkodzone ogniwo natychmiast wycofaj z użycia. +- **Pin `SHDN` przetwornicy zostaw wolny.** Ma wewnętrzne podciągnięcie i przypadkowe zwarcie do masy wyłączy zasilanie. +- Płytka ESP32 ma **diodę sygnalizującą zasilanie**, która pobiera prąd cały czas, gdy układ jest włączony. Jeśli zależy ci na maksymalnym czasie pracy, można ją wylutować — nie jest do niczego potrzebna. +- **Przed pierwszym włączeniem sprawdź multimetrem, czy `3V3` i masa nie są zwarte.** Brzęczyk multimetru między tymi punktami oznacza błąd montażu; włączenie zasilania w takim stanie uszkodzi przetwornicę. + +## Lista części + +Podstawa: + +- ESP32-C6-DevKitC-1 V1.4 (moduł ESP32-C6-WROOM-1, 8 MB flash) +- OLED 2,42" 128×64 SSD1309, I2C, 4 piny +- Klawiatura membranowa 3×4, 7 wyprowadzeń +- Enkoder obrotowy KY-040 +- 5× przycisk monostabilny panelowy, 12 mm +- Obudowa: Thingiverse 7029069 (zaadaptowana) + +Bateria: + +- Ogniwo LiPo 3,7 V, 1200 mAh, format 503759 (5,0 × 37 × 59 mm), z przewodem NTC +- Moduł ładowania TP4056, USB-C, z zabezpieczeniem `DW01A` + `8205A` +- Przetwornica buck-boost Pololu S7V8F3 (wejście 2,7–11,8 V, wyjście 3,3 V, do 1 A) +- 2× rezystor 47 kΩ +- 1× kondensator 100 nF (zalecany, filtr ADC) +- Przełącznik kołyskowy KCD1, 21 × 15 mm, bistabilny ON/OFF + +Kupione, ale nieużywane: + +- KAmod I2C-IOexp16 (MCP23017) — patrz [Budżet pinów](#budżet-pinów) + +## Wgrywanie firmware + +Użyj portu **USB Type-C to UART** (tego wpiętego we wbudowany mostek) — nie wymaga żadnego dodatkowego okablowania. Drugi port Type-C to natywne USB układu, niedostępne, bo przycisk Stop zajmuje linię `USB_D−`. Przed podłączeniem USB **wyłącz** zasilanie bateryjne. + +## Tryb programowania + +Przytrzymaj **\* + Stop** przez 8 sekund. Soft-AP ma DHCP; aktualizacja firmware ze strony parowania albo Extras → Aktualizacja FW w sieci layoutu. Zobacz [provisioning.md](../provisioning.md).