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:
parent
2f53119178
commit
70fa72ddb7
3 changed files with 219 additions and 41 deletions
127
src/main.rs
127
src/main.rs
|
|
@ -22,8 +22,10 @@ enum BgMsg {
|
|||
BigramLines(Vec<String>),
|
||||
LayoutJson(Vec<KeycapPos>),
|
||||
MacroList(Vec<logic::parsers::MacroEntry>),
|
||||
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<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 ---
|
||||
{
|
||||
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) => {
|
||||
let model: Vec<MacroData> = macros.iter().map(|m| {
|
||||
let steps_str: Vec<String> = m.steps.iter().map(|s| {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ export global SettingsBridge {
|
|||
in property <[string]> available-layouts;
|
||||
in-out property <int> selected-layout-index: 0;
|
||||
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 ----
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue