feat: OTA firmware update via CDC serial (Settings tab)

OTA protocol (text-based, uses existing keyboard connection):
1. Send "OTA <size>"
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) <noreply@anthropic.com>
This commit is contained in:
Mae PUGIN 2026-04-07 13:45:12 +02:00
parent 2f53119178
commit 70fa72ddb7
3 changed files with 219 additions and 41 deletions

View file

@ -22,8 +22,10 @@ enum BgMsg {
BigramLines(Vec<String>), BigramLines(Vec<String>),
LayoutJson(Vec<KeycapPos>), LayoutJson(Vec<KeycapPos>),
MacroList(Vec<logic::parsers::MacroEntry>), MacroList(Vec<logic::parsers::MacroEntry>),
FlashProgress(f32, String), // progress 0-1, status message FlashProgress(f32, String),
FlashDone(Result<(), String>), FlashDone(Result<(), String>),
OtaProgress(f32, String),
OtaDone(Result<(), String>),
} }
fn build_keycap_model(keys: &[KeycapPos]) -> Rc<VecModel<KeycapData>> { fn build_keycap_model(keys: &[KeycapPos]) -> Rc<VecModel<KeycapData>> {
@ -1326,6 +1328,111 @@ fn main() {
}); });
} }
// --- OTA: browse ---
{
let window_weak = window.as_weak();
window.global::<SettingsBridge>().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::<SettingsBridge>().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::<SettingsBridge>().on_ota_start(move || {
let Some(w) = window_weak.upgrade() else { return };
let settings = w.global::<SettingsBridge>();
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 --- // --- Flasher: refresh prog ports ---
{ {
let window_weak = window.as_weak(); let window_weak = window.as_weak();
@ -1726,6 +1833,24 @@ fn main() {
_ => {} _ => {}
} }
} }
BgMsg::OtaProgress(progress, msg) => {
let s = window.global::<SettingsBridge>();
s.set_ota_progress(progress);
s.set_ota_status(SharedString::from(msg));
}
BgMsg::OtaDone(result) => {
let s = window.global::<SettingsBridge>();
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) => { BgMsg::MacroList(macros) => {
let model: Vec<MacroData> = macros.iter().map(|m| { let model: Vec<MacroData> = macros.iter().map(|m| {
let steps_str: Vec<String> = m.steps.iter().map(|s| { let steps_str: Vec<String> = m.steps.iter().map(|s| {

View file

@ -74,6 +74,13 @@ export global SettingsBridge {
in property <[string]> available-layouts; in property <[string]> available-layouts;
in-out property <int> selected-layout-index: 0; in-out property <int> selected-layout-index: 0;
callback change-layout(int); callback change-layout(int);
// OTA
in-out property <string> ota-path: "";
in property <float> ota-progress: 0;
in property <string> ota-status: "";
in property <bool> ota-flashing: false;
callback ota-browse();
callback ota-start();
} }
// ---- Stats ---- // ---- Stats ----

View file

@ -1,6 +1,7 @@
import { Theme } from "../theme.slint"; import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkComboBox } from "../components/dark_combo_box.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 { export component TabSettings inherits Rectangle {
background: Theme.bg-primary; background: Theme.bg-primary;
@ -17,29 +18,19 @@ export component TabSettings inherits Rectangle {
font-weight: 700; font-weight: 700;
} }
// Keyboard layout section // Keyboard layout
Rectangle { Rectangle {
background: Theme.bg-secondary; background: Theme.bg-secondary;
border-radius: 8px; border-radius: 8px;
height: 80px;
HorizontalLayout { HorizontalLayout {
padding: 16px; padding: 16px;
spacing: 12px; spacing: 12px;
alignment: start;
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
Text { Text { text: "Keyboard Layout"; color: Theme.fg-primary; font-size: 14px; }
text: "Keyboard Layout"; Text { text: "Controls how keycodes are displayed"; color: Theme.fg-secondary; font-size: 11px; }
color: Theme.fg-primary;
font-size: 14px;
}
Text {
text: "Controls how keycodes are displayed (label remapping)";
color: Theme.fg-secondary;
font-size: 11px;
}
} }
Rectangle { horizontal-stretch: 1; } Rectangle { horizontal-stretch: 1; }
@ -50,15 +41,88 @@ export component TabSettings inherits Rectangle {
width: 200px; width: 200px;
model: SettingsBridge.available-layouts; model: SettingsBridge.available-layouts;
current-index <=> SettingsBridge.selected-layout-index; current-index <=> SettingsBridge.selected-layout-index;
selected(value) => { selected(value) => { SettingsBridge.change-layout(self.current-index); }
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 { Rectangle {
background: Theme.bg-secondary; background: Theme.bg-secondary;
border-radius: 8px; border-radius: 8px;
@ -67,31 +131,13 @@ export component TabSettings inherits Rectangle {
padding: 16px; padding: 16px;
spacing: 8px; spacing: 8px;
Text { Text { text: "About"; color: Theme.fg-primary; font-size: 14px; font-weight: 600; }
text: "About"; Text { text: "KeSp Controller v0.6.0"; color: Theme.fg-secondary; font-size: 12px; }
color: Theme.fg-primary; Text { text: "Split keyboard configurator"; color: Theme.fg-secondary; font-size: 12px; }
font-size: 14px; Text { text: "Made with Slint"; color: Theme.accent-purple; font-size: 11px; }
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;
}
} }
} }
// Spacer
Rectangle { vertical-stretch: 1; } Rectangle { vertical-stretch: 1; }
} }
} }