use crate::tio::proto::{DeviceRoute, Payload, RpcErrorCode};
use crate::tio::proxy::{Port, RecvError, RpcError};
use crate::tio::util::PacketBuilder;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(feature = "firmware-update")]
pub mod github;
const UPLOAD_CHUNK_SIZE: usize = 288;
const MAX_CHUNKS_IN_FLIGHT: u16 = 2;
const COMMIT_SETTLE_TIME: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct FirmwareDate {
pub year: u16,
pub month: u8,
pub day: u8,
}
impl FirmwareDate {
pub fn parse(s: &str) -> Option<Self> {
let mut parts = s.split('-');
let year = parts.next()?.parse().ok()?;
let month = parts.next()?.parse().ok()?;
let day = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(Self { year, month, day })
}
}
impl std::fmt::Display for FirmwareDate {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[derive(Debug, Clone)]
pub struct InstalledFirmware {
pub name: String,
pub revision: String,
pub build_date: Option<FirmwareDate>,
pub hash: Option<String>,
pub serial: Option<String>,
pub is_development: bool,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareRelease {
pub name: String,
pub revision: String,
pub date: FirmwareDate,
pub short_hash: String,
pub filename: String,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateStatus {
UpToDate,
UpdateAvailable,
NoPublishedFirmware,
DevelopmentBuild,
Unknown,
}
#[derive(Debug, Clone)]
pub struct UpdateReport {
pub installed: InstalledFirmware,
pub releases: Vec<FirmwareRelease>,
pub latest: Option<FirmwareRelease>,
pub status: UpdateStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopOutcome {
Stopped,
AlreadyStopped,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlashEvent {
Stopping,
Stopped(StopOutcome),
Uploading { chunk: usize, total: usize },
Committing,
Finalizing,
Complete,
}
#[derive(Debug, thiserror::Error)]
pub enum FirmwareError {
#[error("device RPC failed: {0}")]
Rpc(#[from] RpcError),
#[error("could not determine installed firmware: {0}")]
Parse(String),
#[error("firmware catalog error: {0}")]
Catalog(String),
#[error("firmware cache I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("firmware upload failed: {0}")]
Upload(String),
}
pub trait FirmwareCatalog {
fn list_releases(
&self,
name: &str,
revision: &str,
) -> Result<Vec<FirmwareRelease>, FirmwareError>;
fn download(&self, release: &FirmwareRelease) -> Result<Vec<u8>, FirmwareError>;
}
fn paren_content(desc: &str) -> Option<&str> {
let start = desc.find('(')?;
let end = desc.rfind(')')?;
(end > start).then(|| &desc[start + 1..end])
}
fn bracket_content(desc: &str) -> Option<&str> {
let start = desc.rfind('[')?;
let rest = &desc[start + 1..];
let end = rest.find(']')?;
Some(&rest[..end])
}
fn parse_installed(desc: &str) -> InstalledFirmware {
let header_end = desc.find(|c| c == '(' || c == '[').unwrap_or(desc.len());
let header_tokens: Vec<&str> = desc[..header_end].split_whitespace().collect();
let name = header_tokens
.get(1)
.map(|s| s.to_string())
.unwrap_or_default();
let revision = header_tokens
.get(2)
.map(|s| s.to_string())
.unwrap_or_default();
let serial = paren_content(desc)
.map(|s| s.trim_matches(|c| c == '(' || c == ')'))
.filter(|s| !s.is_empty() && !s.eq_ignore_ascii_case("null"))
.map(str::to_string);
let (build_date, hash) = match bracket_content(desc) {
Some(inside) => {
let date = inside.split('/').next().and_then(FirmwareDate::parse);
let build = inside
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.map(str::to_string);
(date, build)
}
None => (None, None),
};
let is_development = hash
.as_deref()
.is_some_and(|h| h.to_ascii_uppercase().contains("DEV"));
InstalledFirmware {
name,
revision,
build_date,
hash,
serial,
is_development,
description: desc.to_string(),
}
}
pub fn query_installed(device: &Port) -> Result<InstalledFirmware, FirmwareError> {
let desc: String = device.rpc("dev.desc", ())?;
Ok(parse_installed(&desc))
}
pub fn latest_release(mut releases: Vec<FirmwareRelease>) -> Option<FirmwareRelease> {
releases.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then_with(|| a.filename.cmp(&b.filename))
});
releases.pop()
}
fn compare(installed: &InstalledFirmware, latest: &FirmwareRelease) -> UpdateStatus {
if let Some(installed_hash) = &installed.hash {
if installed_hash.eq_ignore_ascii_case(&latest.short_hash) {
return UpdateStatus::UpToDate;
}
}
match installed.build_date {
Some(installed_date) if latest.date > installed_date => UpdateStatus::UpdateAvailable,
Some(_) => UpdateStatus::UpToDate,
None => UpdateStatus::Unknown,
}
}
pub fn check_for_update(
installed: InstalledFirmware,
catalog: &dyn FirmwareCatalog,
) -> Result<UpdateReport, FirmwareError> {
if installed.is_development {
return Ok(UpdateReport {
installed,
releases: Vec::new(),
latest: None,
status: UpdateStatus::DevelopmentBuild,
});
}
if installed.name.is_empty() || installed.revision.is_empty() {
return Err(FirmwareError::Parse(format!(
"could not determine sensor name and revision from dev.desc: {:?}",
installed.description
)));
}
let mut releases = catalog.list_releases(&installed.name, &installed.revision)?;
releases.sort_by(|a, b| {
b.date
.cmp(&a.date)
.then_with(|| b.filename.cmp(&a.filename))
});
let latest = releases.first().cloned();
let status = match &latest {
None => UpdateStatus::NoPublishedFirmware,
Some(release) => compare(&installed, release),
};
Ok(UpdateReport {
installed,
releases,
latest,
status,
})
}
pub fn default_cache_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|b| b.cache_dir().join("twinleaf").join("firmware"))
}
pub fn cache_path(cache_root: &Path, release: &FirmwareRelease) -> PathBuf {
cache_root
.join(&release.name)
.join(&release.revision)
.join(&release.filename)
}
pub fn download_cached(
catalog: &dyn FirmwareCatalog,
release: &FirmwareRelease,
cache_root: &Path,
) -> Result<Vec<u8>, FirmwareError> {
let path = cache_path(cache_root, release);
if let Ok(meta) = std::fs::metadata(&path) {
if meta.is_file() && meta.len() > 0 {
return Ok(std::fs::read(&path)?);
}
}
let data = catalog.download(release)?;
if data.is_empty() {
return Err(FirmwareError::Catalog(format!(
"downloaded firmware {} is empty",
release.filename
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("part");
std::fs::write(&tmp, &data)?;
std::fs::rename(&tmp, &path)?;
Ok(data)
}
pub fn flash(
device: &Port,
firmware_data: &[u8],
mut on_event: impl FnMut(FlashEvent),
) -> Result<(), FirmwareError> {
on_event(FlashEvent::Stopping);
let stop_outcome = match device.action("dev.stop") {
Ok(()) => StopOutcome::Stopped,
Err(RpcError::ExecError(ref e)) if matches!(e.error, RpcErrorCode::NotFound) => {
StopOutcome::Unsupported
}
Err(RpcError::ExecError(ref e)) if matches!(e.error, RpcErrorCode::WrongDeviceState) => {
StopOutcome::AlreadyStopped
}
Err(e) => return Err(e.into()),
};
on_event(FlashEvent::Stopped(stop_outcome));
let total_chunks = firmware_data.len().div_ceil(UPLOAD_CHUNK_SIZE);
let mut next_send_chunk: u16 = 0;
let mut next_ack_chunk: u16 = 0;
let mut more_to_send = true;
while more_to_send || (next_ack_chunk != next_send_chunk) {
if more_to_send && ((next_send_chunk - next_ack_chunk) < MAX_CHUNKS_IN_FLIGHT) {
let offset = usize::from(next_send_chunk) * UPLOAD_CHUNK_SIZE;
let chunk_end = (offset + UPLOAD_CHUNK_SIZE).min(firmware_data.len());
device
.send(PacketBuilder::make_rpc_request(
"dev.firmware.upload",
&firmware_data[offset..chunk_end],
next_send_chunk,
DeviceRoute::root(),
))
.map_err(|e| {
FirmwareError::Upload(format!(
"failed to send firmware chunk {}/{}: {}",
next_send_chunk + 1,
total_chunks,
e
))
})?;
next_send_chunk += 1;
more_to_send = chunk_end < firmware_data.len();
}
let pkt = if more_to_send && ((next_send_chunk - next_ack_chunk) < MAX_CHUNKS_IN_FLIGHT) {
match device.try_recv() {
Ok(pkt) => pkt,
Err(RecvError::WouldBlock) => continue,
Err(e) => {
return Err(FirmwareError::Upload(format!(
"failed to receive firmware upload ack: {}",
e
)))
}
}
} else {
device.recv().map_err(|e| {
FirmwareError::Upload(format!("failed to receive firmware upload ack: {}", e))
})?
};
match pkt.payload {
Payload::RpcReply(rep) => {
if rep.id != next_ack_chunk {
return Err(FirmwareError::Upload(format!(
"firmware chunk ack out of order (expected {}, got {})",
next_ack_chunk, rep.id
)));
}
next_ack_chunk += 1;
on_event(FlashEvent::Uploading {
chunk: next_ack_chunk as usize,
total: total_chunks,
});
}
Payload::RpcError(err) => {
return Err(FirmwareError::Upload(format!(
"device rejected firmware chunk {}/{}: {}",
next_ack_chunk + 1,
total_chunks,
err.error
)));
}
_ => continue,
}
}
on_event(FlashEvent::Committing);
device.action("dev.firmware.upgrade")?;
on_event(FlashEvent::Finalizing);
std::thread::sleep(COMMIT_SETTLE_TIME);
on_event(FlashEvent::Complete);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn installed(date: Option<&str>, hash: Option<&str>) -> InstalledFirmware {
InstalledFirmware {
name: "ASM".into(),
revision: "R6".into(),
build_date: date.and_then(FirmwareDate::parse),
hash: hash.map(str::to_string),
serial: None,
is_development: false,
description: String::new(),
}
}
fn release(date: &str, hash: &str) -> FirmwareRelease {
FirmwareRelease {
name: "ASM".into(),
revision: "R6".into(),
date: FirmwareDate::parse(date).unwrap(),
short_hash: hash.into(),
filename: format!("ASM-R6-firmware-{date}-{hash}.bin"),
url: String::new(),
}
}
#[test]
fn firmware_dates_order_chronologically() {
assert!(
FirmwareDate::parse("2026-03-17").unwrap() > FirmwareDate::parse("2026-01-10").unwrap()
);
assert!(
FirmwareDate::parse("2026-01-10").unwrap() > FirmwareDate::parse("2025-12-31").unwrap()
);
assert_eq!(
FirmwareDate::parse("2026-3-7"),
Some(FirmwareDate {
year: 2026,
month: 3,
day: 7
})
);
assert_eq!(FirmwareDate::parse("not-a-date"), None);
assert_eq!(FirmwareDate::parse("2026-03"), None);
}
#[test]
fn parses_installed_from_desc() {
let fw = parse_installed("Twinleaf ASM R6 ((null)) [2026-06-04/022547]");
assert_eq!(fw.name, "ASM");
assert_eq!(fw.revision, "R6");
assert_eq!(fw.build_date, FirmwareDate::parse("2026-06-04"));
assert_eq!(fw.hash.as_deref(), Some("022547")); assert_eq!(fw.serial, None); assert!(!fw.is_development);
let fw = parse_installed(
"HUB-USB-RS422 (010000003D003B001850453657353320) [2026-05-28/4b13b1-DEV]",
);
assert_eq!(fw.name, ""); assert_eq!(fw.revision, "");
assert_eq!(fw.build_date, FirmwareDate::parse("2026-05-28"));
assert_eq!(fw.hash.as_deref(), Some("4b13b1-DEV"));
assert_eq!(
fw.serial.as_deref(),
Some("010000003D003B001850453657353320")
);
assert!(fw.is_development);
let fw = parse_installed("Twinleaf ASM R6 (5d1494) [2026-03-17/abc123-dev]");
assert_eq!((fw.name.as_str(), fw.revision.as_str()), ("ASM", "R6"));
assert_eq!(fw.serial.as_deref(), Some("5d1494"));
assert!(fw.is_development); }
struct PanicCatalog;
impl FirmwareCatalog for PanicCatalog {
fn list_releases(&self, _: &str, _: &str) -> Result<Vec<FirmwareRelease>, FirmwareError> {
panic!("catalog must not be queried for a development build");
}
fn download(&self, _: &FirmwareRelease) -> Result<Vec<u8>, FirmwareError> {
panic!("catalog must not be downloaded for a development build");
}
}
struct ListCatalog(Vec<FirmwareRelease>);
impl FirmwareCatalog for ListCatalog {
fn list_releases(&self, _: &str, _: &str) -> Result<Vec<FirmwareRelease>, FirmwareError> {
Ok(self.0.clone())
}
fn download(&self, _: &FirmwareRelease) -> Result<Vec<u8>, FirmwareError> {
Ok(Vec::new())
}
}
#[test]
fn report_lists_releases_newest_first() {
let installed = parse_installed("Twinleaf ASM R6 (000000) [2026-02-01/000000]");
let catalog = ListCatalog(vec![
release("2026-01-10", "aaaaaa"),
release("2026-03-17", "5d1494"),
release("2025-12-31", "bbbbbb"),
]);
let report = check_for_update(installed, &catalog).unwrap();
let dates: Vec<String> = report.releases.iter().map(|r| r.date.to_string()).collect();
assert_eq!(dates, ["2026-03-17", "2026-01-10", "2025-12-31"]);
assert_eq!(report.latest.as_ref().unwrap().short_hash, "5d1494");
assert_eq!(report.status, UpdateStatus::UpdateAvailable);
}
#[test]
fn dev_build_refuses_without_touching_catalog() {
let fw = parse_installed("HUB-USB-RS422 (0100ABCD) [2026-05-28/4b13b1-DEV]");
let report = check_for_update(fw, &PanicCatalog).unwrap();
assert_eq!(report.status, UpdateStatus::DevelopmentBuild);
assert!(report.latest.is_none());
}
#[test]
fn missing_name_revision_errors_for_release() {
let fw = parse_installed("HUB-USB-RS422 (0100ABCD) [2026-05-28/4b13b1]");
assert!(check_for_update(fw, &PanicCatalog).is_err());
}
#[test]
fn latest_release_picks_newest_date() {
let latest = latest_release(vec![
release("2026-01-10", "aaaaaa"),
release("2026-03-17", "5d1494"),
release("2025-12-31", "bbbbbb"),
])
.unwrap();
assert_eq!(latest.date, FirmwareDate::parse("2026-03-17").unwrap());
assert_eq!(latest.short_hash, "5d1494");
}
#[test]
fn update_available_only_when_strictly_newer() {
assert_eq!(
compare(
&installed(Some("2026-01-10"), None),
&release("2026-03-17", "5d1494")
),
UpdateStatus::UpdateAvailable
);
assert_eq!(
compare(
&installed(Some("2026-06-04"), None),
&release("2026-03-17", "5d1494")
),
UpdateStatus::UpToDate
);
assert_eq!(
compare(
&installed(Some("2026-03-17"), None),
&release("2026-03-17", "5d1494")
),
UpdateStatus::UpToDate
);
assert_eq!(
compare(&installed(None, None), &release("2026-03-17", "5d1494")),
UpdateStatus::Unknown
);
assert_eq!(
compare(
&installed(Some("2026-01-10"), Some("5d1494")),
&release("2026-03-17", "5d1494")
),
UpdateStatus::UpToDate
);
}
}