diff --git a/Software/CAN_Sensor/build.rs b/Software/CAN_Sensor/build.rs index 77208e1..c08c92d 100644 --- a/Software/CAN_Sensor/build.rs +++ b/Software/CAN_Sensor/build.rs @@ -8,4 +8,15 @@ fn main() { std::fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap(); println!("cargo:rustc-link-search={}", out_dir.display()); println!("cargo:rerun-if-changed=memory.x"); + + // Embed firmware build timestamp as minutes since Unix epoch (4 bytes, big-endian). + // Dropping sub-minute precision keeps it in 4 bytes for many years. + let build_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("System time before UNIX_EPOCH") + .as_secs(); + let build_minutes = (build_seconds / 60) as u32; + let bytes = build_minutes.to_be_bytes(); + std::fs::write(out_dir.join("build_minutes.bin"), bytes).unwrap(); + println!("cargo:rerun-if-changed=build.rs"); } diff --git a/Software/CAN_Sensor/src/main.rs b/Software/CAN_Sensor/src/main.rs index dd4ddcc..7995def 100644 --- a/Software/CAN_Sensor/src/main.rs +++ b/Software/CAN_Sensor/src/main.rs @@ -3,7 +3,7 @@ extern crate alloc; use crate::hal::peripherals::CAN1; -use canapi::id::{plant_id, IDENTIFY_CMD_OFFSET, MOISTURE_DATA_OFFSET}; +use canapi::id::{plant_id, FIRMWARE_BUILD_OFFSET, IDENTIFY_CMD_OFFSET, MOISTURE_DATA_OFFSET}; use canapi::SensorSlot; use ch32_hal::adc::{Adc, SampleTime, ADC_MAX}; use ch32_hal::{pac}; @@ -47,6 +47,10 @@ static CAN_TX_CH: Channel = Channel::new() static BEACON: AtomicBool = AtomicBool::new(false); +/// Firmware build timestamp in minutes since Unix epoch, embedded at compile time. +const FIRMWARE_BUILD_MINUTES: u32 = + u32::from_be_bytes(*include_bytes!(concat!(env!("OUT_DIR"), "/build_minutes.bin"))); + #[embassy_executor::main(entry = "qingke_rt::entry")] async fn main(spawner: Spawner) { ch32_hal::pac::AFIO.pcfr1().write(|w| w.set_can1_rm(2)); @@ -111,6 +115,7 @@ async fn main(spawner: Spawner) { } let moisture_id = plant_id(MOISTURE_DATA_OFFSET, slot, addr as u16); let identify_id = plant_id(IDENTIFY_CMD_OFFSET, slot, addr as u16); + let firmware_build_id = plant_id(FIRMWARE_BUILD_OFFSET, slot, addr as u16); let standard_identify_id = StandardId::new(identify_id).unwrap(); //is any floating, or invalid addr (only 1-8 are valid) @@ -269,8 +274,9 @@ async fn main(spawner: Spawner) { // filter.get(0).unwrap().set(Id::Standard(standard_identify_id), Default::default()); // can.add_filter(filter); let standard_moisture_id = StandardId::new(moisture_id).unwrap(); + let standard_firmware_build_id = StandardId::new(firmware_build_id).unwrap(); spawner - .spawn(can_task(can,info, warn, standard_identify_id, standard_moisture_id)) + .spawn(can_task(can, info, warn, standard_identify_id, standard_moisture_id, standard_firmware_build_id)) .unwrap(); // move Q output, LED, ADC and analog input into worker task @@ -282,6 +288,7 @@ async fn main(spawner: Spawner) { ain, standard_moisture_id, standard_identify_id, + standard_firmware_build_id, )) .unwrap(); } @@ -362,6 +369,7 @@ async fn can_task( warn: &'static mut Output<'static>, identify_id: StandardId, moisture_id: StandardId, + firmware_build_id: StandardId, ) { // Non-blocking beacon blink timing. // We keep this inside the CAN task so it can't stall other tasks (like `worker`) with `await`s. @@ -460,6 +468,7 @@ async fn worker( mut ain: hal::peripherals::PA1, moisture_id: StandardId, identify_id: StandardId, + firmware_build_id: StandardId, ) { // 555 emulation state: Q initially Low let mut q_high = false; @@ -540,6 +549,12 @@ async fn worker( let moisture = CanFrame::new(moisture_id, &(freq_hz as u32).to_be_bytes()).unwrap(); CAN_TX_CH.send(moisture).await; + + // Send firmware build timestamp after each measurement so the controller + // always has up-to-date build info without requiring an identify request. + if let Some(build_frame) = CanFrame::new(firmware_build_id, &FIRMWARE_BUILD_MINUTES.to_be_bytes()) { + CAN_TX_CH.send(build_frame).await; + } } } diff --git a/Software/MainBoard/rust/src/hal/mod.rs b/Software/MainBoard/rust/src/hal/mod.rs index 75641c2..1a31a4a 100644 --- a/Software/MainBoard/rust/src/hal/mod.rs +++ b/Software/MainBoard/rust/src/hal/mod.rs @@ -170,10 +170,20 @@ pub trait BoardInteraction<'a> { async fn backup_info(&mut self) -> FatResult; // Return JSON string with autodetected sensors per plant. Default: not supported. - async fn detect_sensors(&mut self, _request: Detection) -> FatResult { + async fn detect_sensors(&mut self, _request: DetectionRequest) -> FatResult { bail!("Autodetection is only available on v4 HAL with CAN bus"); } + /// Return the last known firmware build timestamps per sensor, set during detect_sensors. + fn get_sensor_build_minutes( + &self, + ) -> ( + [Option; PLANT_COUNT], + [Option; PLANT_COUNT], + ) { + ([None; PLANT_COUNT], [None; PLANT_COUNT]) + } + async fn progress(&mut self, counter: u32) { // Indicate progress is active to suppress default wait_infinity blinking PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed); @@ -657,14 +667,34 @@ pub fn next_partition(current: AppPartitionSubType) -> FatResult; PLANT_COUNT], pub sensor_b_hz: [Option; PLANT_COUNT], + pub sensor_a_build_minutes: [Option; PLANT_COUNT], + pub sensor_b_build_minutes: [Option; PLANT_COUNT], } +/// Request: which sensors to send IDENTIFY_CMD to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DetectionRequest { + pub plant: [SensorRequest; PLANT_COUNT], +} + +/// Per-sensor portion of a detection request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SensorRequest { + pub sensor_a: bool, + pub sensor_b: bool, +} + +/// Response: detection result per plant. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Detection { - plant: [DetectionSensorResult; PLANT_COUNT], + pub plant: [DetectionSensorResult; PLANT_COUNT], } + +/// Per-sensor detection result. +/// `Some(build_minutes)` = sensor responded; value is its firmware build timestamp +/// (minutes since Unix epoch, or 0 if not reported). `None` = not detected. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DetectionSensorResult { - sensor_a: bool, - sensor_b: bool, + pub sensor_a: Option, + pub sensor_b: Option, } diff --git a/Software/MainBoard/rust/src/hal/v4_hal.rs b/Software/MainBoard/rust/src/hal/v4_hal.rs index 327e521..757ac50 100644 --- a/Software/MainBoard/rust/src/hal/v4_hal.rs +++ b/Software/MainBoard/rust/src/hal/v4_hal.rs @@ -6,7 +6,8 @@ use crate::hal::esp::{hold_disable, hold_enable, Esp}; use crate::hal::rtc::{BackupHeader, RTCModuleInteraction, EEPROM_PAGE, X25}; use crate::hal::water::TankSensor; use crate::hal::{ - BoardInteraction, Detection, FreePeripherals, Moistures, Sensor, I2C_DRIVER, PLANT_COUNT, + BoardInteraction, Detection, DetectionRequest, FreePeripherals, Moistures, Sensor, I2C_DRIVER, + PLANT_COUNT, }; use crate::log::{log, LogMessage}; use alloc::boxed::Box; @@ -143,6 +144,11 @@ pub struct V4<'a> { extra1: Output<'a>, extra2: Output<'a>, twai_config: Option>, + + /// Last known firmware build timestamps per sensor (minutes since Unix epoch). + /// Updated during detect_sensors; preserved across normal measurement cycles. + sensor_a_build_minutes: [Option; PLANT_COUNT], + sensor_b_build_minutes: [Option; PLANT_COUNT], } pub(crate) async fn create_v4( @@ -272,6 +278,8 @@ pub(crate) async fn create_v4( extra2, can_power, twai_config, + sensor_a_build_minutes: [None; PLANT_COUNT], + sensor_b_build_minutes: [None; PLANT_COUNT], }; Ok(Box::new(v)) } @@ -393,6 +401,20 @@ impl<'a> BoardInteraction<'a> for V4<'a> { self.twai_config.replace(config); self.can_power.set_low(); + // Persist any firmware build timestamps received alongside moisture data. + if let Ok(ref moistures) = res { + for (i, v) in moistures.sensor_a_build_minutes.iter().enumerate() { + if v.is_some() { + self.sensor_a_build_minutes[i] = *v; + } + } + for (i, v) in moistures.sensor_b_build_minutes.iter().enumerate() { + if v.is_some() { + self.sensor_b_build_minutes[i] = *v; + } + } + } + res } @@ -535,7 +557,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { Ok(header) } - async fn detect_sensors(&mut self, request: Detection) -> FatResult { + async fn detect_sensors(&mut self, request: DetectionRequest) -> FatResult { self.can_power.set_high(); Timer::after_millis(500).await; let config = self.twai_config.take().context("twai config not set")?; @@ -558,6 +580,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { } else { request.plant[plant].sensor_b }; + if !detect { continue; } @@ -602,7 +625,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { let result: Detection = moistures.into(); info!("Autodetection result: {result:?}"); - Ok(result) + Ok((result, moistures.sensor_a_build_minutes, moistures.sensor_b_build_minutes)) }) .await; @@ -610,7 +633,20 @@ impl<'a> BoardInteraction<'a> for V4<'a> { self.twai_config.replace(config); self.can_power.set_low(); - res + match res { + Ok((detection, a_builds, b_builds)) => { + self.sensor_a_build_minutes = a_builds; + self.sensor_b_build_minutes = b_builds; + Ok(detection) + } + Err(e) => Err(e), + } + } + + fn get_sensor_build_minutes( + &self, + ) -> ([Option; PLANT_COUNT], [Option; PLANT_COUNT]) { + (self.sensor_a_build_minutes, self.sensor_b_build_minutes) } } @@ -631,10 +667,10 @@ async fn wait_for_can_measurements( "received message of kind {:?} (plant: {}, sensor: {:?})", msg.0, msg.1, msg.2 ); + let plant = msg.1 as usize; + let sensor = msg.2; + let data = can_frame.data(); if msg.0 == MessageKind::MoistureData { - let plant = msg.1 as usize; - let sensor = msg.2; - let data = can_frame.data(); info!("Received moisture data: {:?}", data); if let Ok(bytes) = data.try_into() { let frequency = u32::from_be_bytes(bytes); @@ -651,6 +687,23 @@ async fn wait_for_can_measurements( } else { error!("Received moisture data with invalid length: {} (expected 4)", data.len()); } + } else if msg.0 == MessageKind::FirmwareBuild { + info!("Received firmware build data: {:?}", data); + if let Ok(bytes) = data.try_into() { + let build_minutes = u32::from_be_bytes(bytes); + match sensor { + SensorSlot::A => { + moistures.sensor_a_build_minutes[plant - 1] = + Some(build_minutes); + } + SensorSlot::B => { + moistures.sensor_b_build_minutes[plant - 1] = + Some(build_minutes); + } + } + } else { + error!("Received firmware build data with invalid length: {} (expected 4)", data.len()); + } } } } @@ -677,10 +730,17 @@ impl From for Detection { fn from(value: Moistures) -> Self { let mut result = Detection::default(); for (plant, sensor) in value.sensor_a_hz.iter().enumerate() { - result.plant[plant].sensor_a = sensor.is_some(); + if sensor.is_some() { + // Sensor responded; include build timestamp (0 = timestamp not reported) + result.plant[plant].sensor_a = + Some(value.sensor_a_build_minutes[plant].unwrap_or(0)); + } } for (plant, sensor) in value.sensor_b_hz.iter().enumerate() { - result.plant[plant].sensor_b = sensor.is_some(); + if sensor.is_some() { + result.plant[plant].sensor_b = + Some(value.sensor_b_build_minutes[plant].unwrap_or(0)); + } } result } diff --git a/Software/MainBoard/rust/src/plant_state.rs b/Software/MainBoard/rust/src/plant_state.rs index d179106..e612895 100644 --- a/Software/MainBoard/rust/src/plant_state.rs +++ b/Software/MainBoard/rust/src/plant_state.rs @@ -84,6 +84,11 @@ pub struct PlantState { pub sensor_a: MoistureSensorState, pub sensor_b: MoistureSensorState, pub pump: PumpState, + /// Last known firmware build timestamp for sensor A (minutes since Unix epoch). + /// Set during sensor detection; None if detection has not been run yet. + pub sensor_a_firmware_build_minutes: Option, + /// Last known firmware build timestamp for sensor B. + pub sensor_b_firmware_build_minutes: Option, } fn map_range_moisture( @@ -157,6 +162,7 @@ impl PlantState { let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id); let consecutive_pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id); + let (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes(); let state = Self { sensor_a, sensor_b, @@ -164,6 +170,8 @@ impl PlantState { consecutive_pump_count, previous_pump, }, + sensor_a_firmware_build_minutes: a_builds[plant_id], + sensor_b_firmware_build_minutes: b_builds[plant_id], }; if state.is_err() { let _ = board.board_hal.fault(plant_id, true).await; @@ -286,6 +294,8 @@ impl PlantState { } else { None }, + sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes, + sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes, } } } @@ -314,4 +324,8 @@ pub struct PlantInfo<'a> { last_pump: Option>, /// next time when pump should activate next_pump: Option>, + /// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown + sensor_a_firmware_build_minutes: Option, + /// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown + sensor_b_firmware_build_minutes: Option, } diff --git a/Software/MainBoard/rust/src/webserver/post_json.rs b/Software/MainBoard/rust/src/webserver/post_json.rs index 0220c54..31b8493 100644 --- a/Software/MainBoard/rust/src/webserver/post_json.rs +++ b/Software/MainBoard/rust/src/webserver/post_json.rs @@ -1,6 +1,6 @@ use crate::config::PlantControllerConfig; use crate::fat_error::FatResult; -use crate::hal::Detection; +use crate::hal::DetectionRequest; use crate::webserver::read_up_to_bytes_from_request; use crate::{do_secure_pump, BOARD_ACCESS}; use alloc::borrow::ToOwned; @@ -64,7 +64,7 @@ where T: Read + Write, { let actual_data = read_up_to_bytes_from_request(request, None).await?; - let detect: Detection = serde_json::from_slice(&actual_data)?; + let detect: DetectionRequest = serde_json::from_slice(&actual_data)?; let mut board = BOARD_ACCESS.get().await.lock().await; let result = board.board_hal.detect_sensors(detect).await?; let json = serde_json::to_string(&result)?; diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index a080cbf..7e2aa68 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -199,9 +199,22 @@ export interface BatteryState { state_of_health: string } -export interface DetectionPlant { +/// Request: which sensors to send IDENTIFY_CMD to. +export interface SensorRequest { sensor_a: boolean, - sensor_b: boolean + sensor_b: boolean, +} + +export interface DetectionRequest { + plant: SensorRequest[] +} + +/// Response: detection result per plant. +/// sensor_a / sensor_b: firmware build timestamp in minutes since Unix epoch, +/// or null if the sensor did not respond. +export interface DetectionPlant { + sensor_a: number | null, + sensor_b: number | null, } export interface Detection { diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 388e639..7103d24 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -29,7 +29,7 @@ import { SetTime, SSIDList, TankInfo, TestPump, VersionInfo, - SaveInfo, SolarState, PumpTestResult, Detection, CanPower + SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower } from "./api"; import {SolarView} from "./solarview"; import {toast} from "./toast"; @@ -339,7 +339,7 @@ export class Controller { ) } - async detectSensors(detection: Detection, silent: boolean = false) { + async detectSensors(detection: DetectionRequest, silent: boolean = false) { let counter = 0 let limit = 5 if (!silent) { @@ -577,7 +577,7 @@ export class Controller { this.hardwareView = new HardwareConfigView(this) this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement this.detectBtn.onclick = () => { - const detection: Detection = { + const detection: DetectionRequest = { plant: Array.from({length: PLANT_COUNT}, () => ({ sensor_a: true, sensor_b: true, @@ -615,7 +615,7 @@ export class Controller { try { await this.measure_moisture(true); - const detection: Detection = { + const detection: DetectionRequest = { plant: Array.from({length: PLANT_COUNT}, () => ({ sensor_a: true, sensor_b: true, diff --git a/Software/MainBoard/rust/src_webpack/src/plant.html b/Software/MainBoard/rust/src_webpack/src/plant.html index 0e0e715..1ab0213 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.html +++ b/Software/MainBoard/rust/src_webpack/src/plant.html @@ -133,10 +133,18 @@ Sensor A: not measured +
+ Sensor A FW: + unknown +
Sensor B:
not measured
+
+ Sensor B FW: + unknown +
Max Current
not_tested diff --git a/Software/MainBoard/rust/src_webpack/src/plant.ts b/Software/MainBoard/rust/src_webpack/src/plant.ts index 702e282..19c234e 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.ts +++ b/Software/MainBoard/rust/src_webpack/src/plant.ts @@ -1,7 +1,14 @@ -import {DetectionPlant, Detection, PlantConfig, PumpTestResult} from "./api"; +import {Detection, DetectionPlant, DetectionRequest, PlantConfig, PumpTestResult} from "./api"; export const PLANT_COUNT = 8; +/** Format a firmware build timestamp (minutes since Unix epoch) as a human-readable date/time. */ +function formatBuildMinutes(buildMinutes: number | null): string { + if (buildMinutes === null) return "not detected"; + if (buildMinutes === 0) return "detected (no timestamp)"; + const ms = buildMinutes * 60 * 1000; + return new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC"; +} import {Controller} from "./main"; @@ -79,6 +86,8 @@ export class PlantView { private readonly mode: HTMLSelectElement; private readonly moistureA: HTMLElement; private readonly moistureB: HTMLElement; + private readonly sensorAFwBuild: HTMLElement; + private readonly sensorBFwBuild: HTMLElement; private readonly maxConsecutivePumpCount: HTMLInputElement; private readonly minPumpCurrentMa: HTMLInputElement; private readonly maxPumpCurrentMa: HTMLInputElement; @@ -109,6 +118,8 @@ export class PlantView { this.moistureA = document.getElementById("plant_" + plantId + "_moisture_a")! as HTMLElement; this.moistureB = document.getElementById("plant_" + plantId + "_moisture_b")! as HTMLElement; + this.sensorAFwBuild = document.getElementById("plant_" + plantId + "_sensor_a_fw_build")! as HTMLElement; + this.sensorBFwBuild = document.getElementById("plant_" + plantId + "_sensor_b_fw_build")! as HTMLElement; this.pump_test_current_max = document.getElementById("plant_" + plantId + "_pump_test_current_max")! as HTMLElement; this.pump_test_current_min = document.getElementById("plant_" + plantId + "_pump_test_current_min")! as HTMLElement; @@ -124,7 +135,7 @@ export class PlantView { this.testSensorAButton = document.getElementById("plant_" + plantId + "_test_sensor_a")! as HTMLButtonElement; this.testSensorAButton.onclick = () => { - const detection: Detection = { + const detection: DetectionRequest = { plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({ sensor_a: idx === plantId, sensor_b: false, @@ -135,7 +146,7 @@ export class PlantView { this.testSensorBButton = document.getElementById("plant_" + plantId + "_test_sensor_b")! as HTMLButtonElement; this.testSensorBButton.onclick = () => { - const detection: Detection = { + const detection: DetectionRequest = { plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({ sensor_a: false, sensor_b: idx === plantId, @@ -360,19 +371,23 @@ export class PlantView { } setDetectionResult(plantResult: DetectionPlant) { - console.log("setDetectionResult plantResult: " + plantResult.sensor_a + " " + plantResult.sensor_b) + const sensorADetected = plantResult.sensor_a !== null; + const sensorBDetected = plantResult.sensor_b !== null; + console.log("setDetectionResult plantResult: a=" + plantResult.sensor_a + " b=" + plantResult.sensor_b); var changed = false; - if (this.sensorAInstalled.checked != plantResult.sensor_a) { + if (this.sensorAInstalled.checked != sensorADetected) { changed = true; - this.sensorAInstalled.checked = plantResult.sensor_a; + this.sensorAInstalled.checked = sensorADetected; } - if (this.sensorBInstalled.checked != plantResult.sensor_b) { + if (this.sensorBInstalled.checked != sensorBDetected) { changed = true; - this.sensorBInstalled.checked = plantResult.sensor_b; + this.sensorBInstalled.checked = sensorBDetected; } if (changed) { this.controller.configChanged(); } + this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a); + this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b); } } \ No newline at end of file diff --git a/Software/Shared/canapi/src/lib.rs b/Software/Shared/canapi/src/lib.rs index 9a119c3..c06c248 100644 --- a/Software/Shared/canapi/src/lib.rs +++ b/Software/Shared/canapi/src/lib.rs @@ -43,6 +43,7 @@ pub mod id { // Message group base offsets relative to SENSOR_BASE_ADDRESS pub const MOISTURE_DATA_OFFSET: u16 = 0; // periodic data from sensor (sensor -> controller) pub const IDENTIFY_CMD_OFFSET: u16 = 32; // identify LED command (controller -> sensor) + pub const FIRMWARE_BUILD_OFFSET: u16 = 64; // firmware build timestamp (sensor -> controller, sent after identify) #[inline] pub const fn plant_id(message_type_offset: u16, sensor: SensorSlot, plant: u16) -> u16 { @@ -55,8 +56,9 @@ pub mod id { /// Kinds of message spaces recognized by the addressing scheme. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageKind { - MoistureData, // sensor -> controller - IdentifyCmd, // controller -> sensor + MoistureData, // sensor -> controller + IdentifyCmd, // controller -> sensor + FirmwareBuild, // sensor -> controller, sent after receiving identify cmd } /// Try to classify a received 11-bit standard ID into a known message kind and extract plant and sensor slot. @@ -93,6 +95,9 @@ pub mod id { if let Some((plant, slot)) = decode_in_group(rel, IDENTIFY_CMD_OFFSET) { return Some((MessageKind::IdentifyCmd, plant, slot)); } + if let Some((plant, slot)) = decode_in_group(rel, FIRMWARE_BUILD_OFFSET) { + return Some((MessageKind::FirmwareBuild, plant, slot)); + } None }