From 70fa72ddb73e4e7486c1515ae92341f8b8108d78 Mon Sep 17 00:00:00 2001 From: Mae PUGIN <48982737+mornepousse@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:45:12 +0200 Subject: [PATCH] feat: OTA firmware update via CDC serial (Settings tab) OTA protocol (text-based, uses existing keyboard connection): 1. Send "OTA " 2. Wait for firmware OTA_READY 3. Send firmware in 4096-byte chunks 4. Progress bar with chunk counter 5. OTA_DONE on completion No programming cable needed - uses the same USB port as keyboard communication. Browse for .bin file, click Flash OTA. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 127 ++++++++++++++++++++++++++++++++++++- ui/globals.slint | 7 ++ ui/tabs/tab_settings.slint | 126 ++++++++++++++++++++++++------------ 3 files changed, 219 insertions(+), 41 deletions(-) diff --git a/src/main.rs b/src/main.rs index 199e432..2cd3bc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,10 @@ enum BgMsg { BigramLines(Vec), LayoutJson(Vec), MacroList(Vec), - FlashProgress(f32, String), // progress 0-1, status message + FlashProgress(f32, String), FlashDone(Result<(), String>), + OtaProgress(f32, String), + OtaDone(Result<(), String>), } fn build_keycap_model(keys: &[KeycapPos]) -> Rc> { @@ -1326,6 +1328,111 @@ fn main() { }); } + // --- OTA: browse --- + { + let window_weak = window.as_weak(); + window.global::().on_ota_browse(move || { + let window_weak = window_weak.clone(); + std::thread::spawn(move || { + let file = rfd::FileDialog::new() + .add_filter("Firmware", &["bin"]) + .pick_file(); + if let Some(path) = file { + let path_str = path.to_string_lossy().to_string(); + let _ = slint::invoke_from_event_loop(move || { + if let Some(w) = window_weak.upgrade() { + w.global::().set_ota_path(SharedString::from(path_str.as_str())); + } + }); + } + }); + }); + } + + // --- OTA: start --- + { + let serial = serial.clone(); + let tx = bg_tx.clone(); + let window_weak = window.as_weak(); + + window.global::().on_ota_start(move || { + let Some(w) = window_weak.upgrade() else { return }; + let settings = w.global::(); + let path = settings.get_ota_path().to_string(); + if path.is_empty() { return; } + + let firmware = match std::fs::read(&path) { + Ok(data) => data, + Err(e) => { + let _ = tx.send(BgMsg::OtaDone(Err(format!("Cannot read {}: {}", path, e)))); + return; + } + }; + + settings.set_ota_flashing(true); + settings.set_ota_progress(0.0); + settings.set_ota_status(SharedString::from("Starting OTA...")); + + let serial = serial.clone(); + let tx = tx.clone(); + std::thread::spawn(move || { + let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); + let total = firmware.len(); + let chunk_size = 4096usize; + + // Step 1: Send OTA command + let cmd = format!("OTA {}", total); + if let Err(e) = ser.send_command(&cmd) { + let _ = tx.send(BgMsg::OtaDone(Err(format!("Send OTA cmd failed: {}", e)))); + return; + } + + // Step 2: Wait for OTA_READY + let _ = tx.send(BgMsg::OtaProgress(0.0, "Waiting for OTA_READY...".into())); + let ready = ser.query_command("").unwrap_or_default(); + let got_ready = ready.iter().any(|l| l.contains("OTA_READY")); + if !got_ready { + // Try reading more + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + // Step 3: Send chunks + let port = match ser.port_mut() { + Some(p) => p, + None => { + let _ = tx.send(BgMsg::OtaDone(Err("Port not available".into()))); + return; + } + }; + + use std::io::Write; + let num_chunks = (total + chunk_size - 1) / chunk_size; + for (i, chunk) in firmware.chunks(chunk_size).enumerate() { + if let Err(e) = port.write_all(chunk) { + let _ = tx.send(BgMsg::OtaDone(Err(format!("Write chunk {} failed: {}", i, e)))); + return; + } + let _ = port.flush(); + + let progress = (i + 1) as f32 / num_chunks as f32; + let msg = format!("Chunk {}/{} ({} KB / {} KB)", + i + 1, num_chunks, + ((i + 1) * chunk_size).min(total) / 1024, + total / 1024); + let _ = tx.send(BgMsg::OtaProgress(progress * 0.95, msg)); + + // Wait for ACK (read with timeout) + std::thread::sleep(std::time::Duration::from_millis(50)); + let mut buf = [0u8; 256]; + let _ = port.read(&mut buf); + } + + let _ = tx.send(BgMsg::OtaProgress(1.0, "OTA complete, rebooting...".into())); + let _ = tx.send(BgMsg::OtaDone(Ok(()))); + }); + }); + } + // --- Flasher: refresh prog ports --- { let window_weak = window.as_weak(); @@ -1726,6 +1833,24 @@ fn main() { _ => {} } } + BgMsg::OtaProgress(progress, msg) => { + let s = window.global::(); + s.set_ota_progress(progress); + s.set_ota_status(SharedString::from(msg)); + } + BgMsg::OtaDone(result) => { + let s = window.global::(); + s.set_ota_flashing(false); + match result { + Ok(()) => { + s.set_ota_progress(1.0); + s.set_ota_status(SharedString::from("OTA complete!")); + } + Err(e) => { + s.set_ota_status(SharedString::from(format!("OTA error: {}", e))); + } + } + } BgMsg::MacroList(macros) => { let model: Vec = macros.iter().map(|m| { let steps_str: Vec = m.steps.iter().map(|s| { diff --git a/ui/globals.slint b/ui/globals.slint index 3a2fe71..2c35262 100644 --- a/ui/globals.slint +++ b/ui/globals.slint @@ -74,6 +74,13 @@ export global SettingsBridge { in property <[string]> available-layouts; in-out property selected-layout-index: 0; callback change-layout(int); + // OTA + in-out property ota-path: ""; + in property ota-progress: 0; + in property ota-status: ""; + in property ota-flashing: false; + callback ota-browse(); + callback ota-start(); } // ---- Stats ---- diff --git a/ui/tabs/tab_settings.slint b/ui/tabs/tab_settings.slint index 18f9869..1b6a332 100644 --- a/ui/tabs/tab_settings.slint +++ b/ui/tabs/tab_settings.slint @@ -1,6 +1,7 @@ import { Theme } from "../theme.slint"; +import { DarkButton } from "../components/dark_button.slint"; import { DarkComboBox } from "../components/dark_combo_box.slint"; -import { SettingsBridge } from "../globals.slint"; +import { SettingsBridge, AppState, ConnectionState } from "../globals.slint"; export component TabSettings inherits Rectangle { background: Theme.bg-primary; @@ -17,29 +18,19 @@ export component TabSettings inherits Rectangle { font-weight: 700; } - // Keyboard layout section + // Keyboard layout Rectangle { background: Theme.bg-secondary; border-radius: 8px; - height: 80px; HorizontalLayout { padding: 16px; spacing: 12px; - alignment: start; VerticalLayout { alignment: center; - Text { - text: "Keyboard Layout"; - color: Theme.fg-primary; - font-size: 14px; - } - Text { - text: "Controls how keycodes are displayed (label remapping)"; - color: Theme.fg-secondary; - font-size: 11px; - } + Text { text: "Keyboard Layout"; color: Theme.fg-primary; font-size: 14px; } + Text { text: "Controls how keycodes are displayed"; color: Theme.fg-secondary; font-size: 11px; } } Rectangle { horizontal-stretch: 1; } @@ -50,15 +41,88 @@ export component TabSettings inherits Rectangle { width: 200px; model: SettingsBridge.available-layouts; current-index <=> SettingsBridge.selected-layout-index; - selected(value) => { - SettingsBridge.change-layout(self.current-index); - } + selected(value) => { SettingsBridge.change-layout(self.current-index); } } } } } - // About section + // OTA Firmware Update + Rectangle { + background: Theme.bg-secondary; + border-radius: 8px; + + VerticalLayout { + padding: 16px; + spacing: 10px; + + Text { text: "OTA Firmware Update"; color: Theme.accent-cyan; font-size: 14px; font-weight: 600; } + Text { text: "Update firmware via USB (no programming cable needed)"; color: Theme.fg-secondary; font-size: 11px; } + + HorizontalLayout { + spacing: 8px; + + Text { + horizontal-stretch: 1; + text: SettingsBridge.ota-path != "" ? SettingsBridge.ota-path : "No firmware file selected"; + color: SettingsBridge.ota-path != "" ? Theme.fg-primary : Theme.fg-secondary; + font-size: 12px; + vertical-alignment: center; + overflow: elide; + } + + DarkButton { + text: "Browse..."; + clicked => { SettingsBridge.ota-browse(); } + } + } + + HorizontalLayout { + spacing: 12px; + + DarkButton { + text: SettingsBridge.ota-flashing ? "Flashing..." : "Flash OTA"; + primary: true; + enabled: !SettingsBridge.ota-flashing + && SettingsBridge.ota-path != "" + && AppState.connection == ConnectionState.connected; + clicked => { SettingsBridge.ota-start(); } + } + + Text { + text: SettingsBridge.ota-status; + color: Theme.fg-primary; + font-size: 12px; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + if SettingsBridge.ota-flashing || SettingsBridge.ota-progress > 0 : Rectangle { + height: 20px; + background: Theme.bg-primary; + border-radius: 4px; + + Rectangle { + x: 0; + width: parent.width * clamp(SettingsBridge.ota-progress, 0, 1); + height: 100%; + background: SettingsBridge.ota-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple; + border-radius: 4px; + } + + Text { + text: round(SettingsBridge.ota-progress * 100) + "%"; + color: Theme.fg-primary; + font-size: 11px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + + // About Rectangle { background: Theme.bg-secondary; border-radius: 8px; @@ -67,31 +131,13 @@ export component TabSettings inherits Rectangle { padding: 16px; spacing: 8px; - Text { - text: "About"; - color: Theme.fg-primary; - font-size: 14px; - font-weight: 600; - } - Text { - text: "KaSe Controller v0.6.0"; - color: Theme.fg-secondary; - font-size: 12px; - } - Text { - text: "Split keyboard configurator — Slint UI port"; - color: Theme.fg-secondary; - font-size: 12px; - } - Text { - text: "Made with Slint"; - color: Theme.accent-purple; - font-size: 11px; - } + Text { text: "About"; color: Theme.fg-primary; font-size: 14px; font-weight: 600; } + Text { text: "KeSp Controller v0.6.0"; color: Theme.fg-secondary; font-size: 12px; } + Text { text: "Split keyboard configurator"; color: Theme.fg-secondary; font-size: 12px; } + Text { text: "Made with Slint"; color: Theme.accent-purple; font-size: 11px; } } } - // Spacer Rectangle { vertical-stretch: 1; } } }