5 Commits

22 changed files with 601 additions and 152 deletions

View File

@@ -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");
}

View File

@@ -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<CriticalSectionRawMutex, CanFrame, 4> = 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,65 +468,80 @@ 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;
let low_th: u16 = (ADC_MAX as u16) / 3; // ~1/3 Vref
let high_th: u16 = ((ADC_MAX as u32 * 2) / 3) as u16; // ~2/3 Vref
const AVG_WINDOWS: u32 = 4;
const YIELD_EVERY: u32 = 64;
let probe_duration = Duration::from_millis(100);
loop {
// Count rising edges of Q in a 100 ms window
let start = Instant::now();
let mut pulses: u32 = 0;
let mut last_q = q_high;
let mut total_pulses: u32 = 0;
probe_gnd.set_as_output(Speed::Low);
probe_gnd.set_low();
let probe_duration = Duration::from_millis(100);
while Instant::now()
.checked_duration_since(start)
.unwrap_or(Duration::from_millis(0))
< probe_duration
{
// Sample the analog input (Threshold/Trigger on A1)
let val: u16 = adc.convert(&mut ain, SampleTime::CYCLES28_5);
for _ in 0..AVG_WINDOWS {
// Count rising edges of Q in a 100 ms window
let start = Instant::now();
let mut pulses: u32 = 0;
let mut last_q = q_high;
let mut iter_count: u32 = 0;
// 555 core behavior:
// - If input <= 1/3 Vref => set Q high (trigger)
// - If input >= 2/3 Vref => set Q low (threshold)
// - Otherwise keep previous Q state (hysteresis)
if val <= low_th {
q_high = true;
} else if val >= high_th {
q_high = false;
probe_gnd.set_as_output(Speed::Low);
probe_gnd.set_low();
while Instant::now()
.checked_duration_since(start)
.unwrap_or(Duration::from_millis(0))
< probe_duration
{
// Sample the analog input (Threshold/Trigger on A1)
let val: u16 = adc.convert(&mut ain, SampleTime::CYCLES28_5);
// 555 core behavior:
// - If input <= 1/3 Vref => set Q high (trigger)
// - If input >= 2/3 Vref => set Q low (threshold)
// - Otherwise keep previous Q state (hysteresis)
if val <= low_th {
q_high = true;
} else if val >= high_th {
q_high = false;
}
// Drive output pin accordingly
if q_high {
q.set_high();
} else {
q.set_low();
}
// Count rising edges
if !last_q && q_high {
pulses = pulses.saturating_add(1);
}
last_q = q_high;
// Yield every YIELD_EVERY samples to keep USB alive without
// disrupting per-sample timing
iter_count += 1;
if iter_count % YIELD_EVERY == 0 {
yield_now().await;
}
}
// Drive output pin accordingly
if q_high {
q.set_high();
} else {
q.set_low();
}
// Count rising edges
if !last_q && q_high {
pulses = pulses.saturating_add(1);
}
last_q = q_high;
// Yield to allow USB and other tasks to run
yield_now().await;
probe_gnd.set_as_input(Pull::None);
total_pulses = total_pulses.saturating_add(pulses);
}
probe_gnd.set_as_input(Pull::None);
let freq_hz: u32 = pulses * (1000 / probe_duration.as_millis()) as u32; // pulses per 0.1s => Hz
let avg_pulses = total_pulses / AVG_WINDOWS;
let freq_hz: u32 = avg_pulses * (1000 / probe_duration.as_millis()) as u32;
let mut msg: heapless::String<128> = heapless::String::new();
let _ = write!(
&mut msg,
"555 window={}ms pulses={} freq={} Hz (A1->Q on PB0) id={:?}\r\n",
probe_duration.as_millis(),
pulses,
"555 window={}ms avg_pulses={} freq={} Hz (A1->Q on PB0) id={:?}\r\n",
probe_duration.as_millis() * AVG_WINDOWS as u64,
avg_pulses,
freq_hz,
identify_id.as_raw()
);
@@ -526,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;
}
}
}

View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component>
</project>

View File

@@ -170,10 +170,20 @@ pub trait BoardInteraction<'a> {
async fn backup_info(&mut self) -> FatResult<BackupHeader>;
// Return JSON string with autodetected sensors per plant. Default: not supported.
async fn detect_sensors(&mut self, _request: Detection) -> FatResult<Detection> {
async fn detect_sensors(&mut self, _request: DetectionRequest) -> FatResult<Detection> {
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<u32>; PLANT_COUNT],
[Option<u32>; 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<AppPartitionSub
pub struct Moistures {
pub sensor_a_hz: [Option<f32>; PLANT_COUNT],
pub sensor_b_hz: [Option<f32>; PLANT_COUNT],
pub sensor_a_build_minutes: [Option<u32>; PLANT_COUNT],
pub sensor_b_build_minutes: [Option<u32>; 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<u32>,
pub sensor_b: Option<u32>,
}

View File

@@ -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<TwaiConfiguration<'static, Blocking>>,
/// 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<u32>; PLANT_COUNT],
sensor_b_build_minutes: [Option<u32>; 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<Detection> {
async fn detect_sensors(&mut self, request: DetectionRequest) -> FatResult<Detection> {
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<u32>; PLANT_COUNT], [Option<u32>; 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<Moistures> 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
}

View File

@@ -88,15 +88,33 @@ impl<'a> TankSensor<'a> {
//multisample should be moved to water_temperature_c
let mut attempt = 1;
let mut delay = Delay::new();
self.one_wire_bus.reset(&mut delay)?;
let presence = self.one_wire_bus.reset(&mut delay)?;
println!("OneWire: reset presence pulse = {}", presence);
if !presence {
println!("OneWire: no device responded to reset — check pull-up resistor and wiring");
}
let mut search = DeviceSearch::new();
let mut water_temp_sensor: Option<Device> = None;
let mut devices_found = 0u8;
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
devices_found += 1;
println!(
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
devices_found, device.address[0], device.address
);
if device.address[0] == ds18b20::FAMILY_CODE {
water_temp_sensor = Some(device);
break;
} else {
println!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE);
}
}
if devices_found == 0 {
println!("OneWire: search found zero devices on the bus");
}
match water_temp_sensor {
Some(device) => {
println!("Found one wire device: {:?}", device);

View File

@@ -2,42 +2,84 @@ use alloc::string::String;
use alloc::vec::Vec;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
use embassy_sync::mutex::Mutex;
use log::{error, LevelFilter, Log, Metadata, Record};
use log::{LevelFilter, Log, Metadata, Record};
const MAX_LIVE_LOG_ENTRIES: usize = 64;
struct LiveLogBuffer {
entries: Vec<(u64, String)>,
next_seq: u64,
}
impl LiveLogBuffer {
const fn new() -> Self {
Self {
entries: Vec::new(),
next_seq: 0,
}
}
fn push(&mut self, text: String) {
if self.entries.len() >= MAX_LIVE_LOG_ENTRIES {
self.entries.remove(0);
}
self.entries.push((self.next_seq, text));
self.next_seq += 1;
}
fn get_after(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
let next_seq = self.next_seq;
match after {
None => (self.entries.clone(), false, next_seq),
Some(after_seq) => {
let result: Vec<_> = self.entries
.iter()
.filter(|(seq, _)| *seq > after_seq)
.cloned()
.collect();
// Dropped if there are entries that should exist (seq > after_seq) but
// the oldest retained entry has a higher seq than after_seq + 1.
let dropped = if next_seq > after_seq.saturating_add(1) {
if let Some((oldest_seq, _)) = self.entries.first() {
*oldest_seq > after_seq.saturating_add(1)
} else {
// Buffer empty but entries were written — all dropped
true
}
} else {
false
};
(result, dropped, next_seq)
}
}
}
}
pub struct InterceptorLogger {
// Async mutex for start/stop capture from async context
async_capture: Mutex<CriticalSectionRawMutex, ()>,
// Blocking mutex for the actual data to be used in sync log()
sync_capture: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<Option<Vec<String>>>>,
live_log: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<LiveLogBuffer>>,
}
impl InterceptorLogger {
pub const fn new() -> Self {
Self {
async_capture: Mutex::new(()),
sync_capture: BlockingMutex::new(core::cell::RefCell::new(None)),
live_log: BlockingMutex::new(core::cell::RefCell::new(LiveLogBuffer::new())),
}
}
pub async fn start_capture(&self) {
let _guard = self.async_capture.lock().await;
self.sync_capture.lock(|capture| {
*capture.borrow_mut() = Some(Vec::new());
});
}
pub async fn stop_capture(&self) -> Option<Vec<String>> {
let _guard = self.async_capture.lock().await;
self.sync_capture
.lock(|capture| capture.borrow_mut().take())
/// Returns (entries_after, dropped, next_seq).
/// Pass `after = None` to retrieve the entire current buffer.
/// Pass `after = Some(seq)` to retrieve only entries with seq > that value.
pub fn get_live_logs(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
self.live_log.lock(|buf| buf.borrow().get_after(after))
}
pub fn init(&'static self) {
match log::set_logger(self).map(|()| log::set_max_level(LevelFilter::Info)) {
Ok(()) => {}
Err(e) => {
error!("Logger already set: {}", e);
Err(_e) => {
esp_println::println!("ERROR: Logger already set");
}
}
}
@@ -50,16 +92,14 @@ impl Log for InterceptorLogger {
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
let message = alloc::format!("{}", record.args());
let message = alloc::format!("{}: {}", record.level(), record.args());
// Print to serial using esp_println
esp_println::println!("{}: {}", record.level(), message);
// Print to serial
esp_println::println!("{}", message);
// Capture if active
self.sync_capture.lock(|capture| {
if let Some(ref mut buffer) = *capture.borrow_mut() {
buffer.push(alloc::format!("{}: {}", record.level(), message));
}
// Store in live log ring buffer
self.live_log.lock(|buf| {
buf.borrow_mut().push(message);
});
}
}

View File

@@ -1327,12 +1327,17 @@ async fn get_version(
let hash = &env!("VERGEN_GIT_SHA")[0..8];
let board = board.board_hal.get_esp();
let heap = esp_alloc::HEAP.stats();
VersionInfo {
git_hash: branch + "@" + hash,
build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(),
current: format!("{:?}", board.current),
slot0_state: format!("{:?}", board.slot0_state),
slot1_state: format!("{:?}", board.slot1_state),
heap_total: heap.size,
heap_used: heap.current_usage,
heap_free: heap.size.saturating_sub(heap.current_usage),
heap_max_used: heap.max_usage,
}
}
@@ -1343,4 +1348,8 @@ struct VersionInfo {
current: String,
slot0_state: String,
slot1_state: String,
heap_total: usize,
heap_used: usize,
heap_free: usize,
heap_max_used: usize,
}

View File

@@ -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<u32>,
/// Last known firmware build timestamp for sensor B.
pub sensor_b_firmware_build_minutes: Option<u32>,
}
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<DateTime<Tz>>,
/// next time when pump should activate
next_pump: Option<DateTime<Tz>>,
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
sensor_a_firmware_build_minutes: Option<u32>,
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
sensor_b_firmware_build_minutes: Option<u32>,
}

View File

@@ -109,7 +109,7 @@ pub(crate) async fn get_solar_state<T, const N: usize>(
Ok(Some(serde_json::to_string(&state)?))
}
pub(crate) async fn get_version_web<T, const N: usize>(
pub(crate) async fn get_firmware_info_web<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;

View File

@@ -1,7 +1,10 @@
use crate::fat_error::FatResult;
use crate::log::LOG_ACCESS;
use alloc::string::String;
use alloc::vec::Vec;
use edge_http::io::server::Connection;
use edge_nal::io::{Read, Write};
use serde::Serialize;
pub(crate) async fn get_log<T, const N: usize>(
conn: &mut Connection<'_, T, N>,
@@ -34,3 +37,29 @@ where
conn.write_all("]".as_bytes()).await?;
Ok(Some(200))
}
#[derive(Serialize)]
struct LiveLogEntry {
seq: u64,
text: String,
}
#[derive(Serialize)]
struct LiveLogResponse {
entries: Vec<LiveLogEntry>,
dropped: bool,
next_seq: u64,
}
pub(crate) async fn get_live_log(after: Option<u64>) -> FatResult<Option<String>> {
let (raw_entries, dropped, next_seq) = crate::log::INTERCEPTOR.get_live_logs(after);
let response = LiveLogResponse {
entries: raw_entries
.into_iter()
.map(|(seq, text)| LiveLogEntry { seq, text })
.collect(),
dropped,
next_seq,
};
Ok(Some(serde_json::to_string(&response)?))
}

View File

@@ -11,9 +11,9 @@ use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
get_solar_state, get_time, get_timezones, get_version_web, list_saves, tank_info,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
};
use crate::webserver::get_log::get_log;
use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
use crate::webserver::ota::ota_operations;
use crate::webserver::post_json::{
@@ -64,7 +64,6 @@ impl Handler for HTTPRequestRouter {
e
})?
} else {
crate::log::INTERCEPTOR.start_capture().await;
match method {
Method::Get => match path {
"/favicon.ico" => serve_favicon(conn).await?,
@@ -74,7 +73,7 @@ impl Handler for HTTPRequestRouter {
"/get_backup_config" => get_backup_config(conn).await?,
&_ => {
let json = match path {
"/version" => Some(get_version_web(conn).await),
"/firmware_info" => Some(get_firmware_info_web(conn).await),
"/time" => Some(get_time(conn).await),
"/battery" => Some(get_battery_state(conn).await),
"/solar" => Some(get_solar_state(conn).await),
@@ -84,6 +83,14 @@ impl Handler for HTTPRequestRouter {
"/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(conn).await),
"/list_saves" => Some(list_saves(conn).await),
// /live_log accepts an optional ?after=N query parameter
p if p == "/live_log" || p.starts_with("/live_log?") => {
let after: Option<u64> = p
.find("after=")
.and_then(|pos| p[pos + 6..].split('&').next())
.and_then(|s| s.parse().ok());
Some(get_live_log(after).await)
}
// /get_config accepts an optional ?saveidx=N query parameter
p if p == "/get_config" || p.starts_with("/get_config?") => {
let saveidx: Option<usize> = p
@@ -167,7 +174,6 @@ impl Handler for HTTPRequestRouter {
let response_time = Instant::now().duration_since(start).as_millis();
info!("\"{method} {path}\" {code} {response_time}ms");
crate::log::INTERCEPTOR.stop_capture().await;
Ok(())
}
}
@@ -265,17 +271,9 @@ where
}
},
Err(err) => {
let mut error_text = err.to_string();
let error_text = err.to_string();
info!("error handling process {error_text}");
if let Some(logs) = crate::log::INTERCEPTOR.stop_capture().await {
error_text.push_str("\n\nCaptured Logs:\n");
for log in logs {
error_text.push_str(&log);
error_text.push('\n');
}
}
conn.initiate_response(
500,
Some("OK"),

View File

@@ -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)?;

View File

@@ -1,6 +1,17 @@
export interface LogArray extends Array<LogEntry> {
}
export interface LiveLogEntry {
seq: number,
text: string,
}
export interface LiveLogResponse {
entries: LiveLogEntry[],
dropped: boolean,
next_seq: number,
}
export interface LogEntry {
timestamp: string,
message_id: number,
@@ -172,6 +183,10 @@ export interface VersionInfo {
current: string,
slot0_state: string,
slot1_state: string,
heap_total: number,
heap_used: number,
heap_free: number,
heap_max_used: number,
}
export interface BatteryState {
@@ -184,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 {

View File

@@ -1,7 +1,48 @@
<style>
#livelogpanel {
font-family: monospace;
font-size: 0.85em;
background: #1a1a1a;
color: #d4d4d4;
padding: 8px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #444;
white-space: pre-wrap;
word-break: break-all;
}
.livelog-dropped {
color: #f0a500;
font-style: italic;
}
.log-accordion-header {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.log-accordion-header::before {
content: "▶";
font-size: 0.75em;
transition: transform 0.15s;
display: inline-block;
}
.log-accordion-header.open::before {
transform: rotate(90deg);
}
#logpanel {
display: none;
}
</style>
<button id="loadLog">Load Logs</button>
<div id="logpanel">
</div>
<h4 id="logAccordionHeader" class="log-accordion-header">Application Log</h4>
<div id="logpanel"></div>
<h4>Live Log</h4>
<div id="livelogpanel"></div>

View File

@@ -1,19 +1,38 @@
import { Controller } from "./main";
import {LogArray, LogLocalisation} from "./api";
import {LiveLogResponse, LogArray, LogLocalisation} from "./api";
const LIVE_LOG_POLL_INTERVAL_MS = 2000;
export class LogView {
private readonly logpanel: HTMLElement;
private readonly loadLog: HTMLButtonElement;
private readonly livelogpanel: HTMLElement;
private readonly accordionHeader: HTMLElement;
loglocale: LogLocalisation | undefined;
private liveLogNextSeq: number | undefined = undefined;
private liveLogTimer: ReturnType<typeof setTimeout> | undefined = undefined;
private structuredLogLoaded = false;
constructor(controller: Controller) {
(document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string;
this.logpanel = document.getElementById("logpanel") as HTMLElement
this.loadLog = document.getElementById("loadLog") as HTMLButtonElement
this.logpanel = document.getElementById("logpanel") as HTMLElement;
this.livelogpanel = document.getElementById("livelogpanel") as HTMLElement;
this.accordionHeader = document.getElementById("logAccordionHeader") as HTMLElement;
this.loadLog.onclick = () => {
controller.loadLog();
}
this.accordionHeader.onclick = () => {
const isOpen = this.logpanel.style.display !== "none";
if (isOpen) {
this.logpanel.style.display = "none";
this.accordionHeader.classList.remove("open");
} else {
this.logpanel.style.display = "";
this.accordionHeader.classList.add("open");
if (!this.structuredLogLoaded) {
this.structuredLogLoaded = true;
controller.loadLog();
}
}
};
}
setLogLocalisation(loglocale: LogLocalisation) {
@@ -21,10 +40,10 @@ export class LogView {
}
setLog(logs: LogArray) {
this.logpanel.textContent = ""
this.logpanel.textContent = "";
logs.forEach(entry => {
let message = this.loglocale!![entry.message_id];
let template = message.message
let template = message.message;
template = template.replace("${number_a}", entry.a.toString());
template = template.replace("${number_b}", entry.b.toString());
template = template.replace("${txt_short}", entry.txt_short.toString());
@@ -32,15 +51,67 @@ export class LogView {
let ts = new Date(entry.timestamp);
let div = document.createElement("div")
let timestampDiv = document.createElement("div")
let messageDiv = document.createElement("div")
let div = document.createElement("div");
let timestampDiv = document.createElement("div");
let messageDiv = document.createElement("div");
timestampDiv.innerText = ts.toISOString();
messageDiv.innerText = template;
div.appendChild(timestampDiv)
div.appendChild(messageDiv)
this.logpanel.appendChild(div)
}
)
div.appendChild(timestampDiv);
div.appendChild(messageDiv);
this.logpanel.appendChild(div);
});
}
}
startLivePoll(publicUrl: string) {
if (this.liveLogTimer !== undefined) {
return;
}
const poll = async () => {
try {
const url = this.liveLogNextSeq !== undefined
? `${publicUrl}/live_log?after=${this.liveLogNextSeq}`
: `${publicUrl}/live_log`;
const response = await fetch(url);
const data = await response.json() as LiveLogResponse;
this.appendLiveLog(data);
} catch (_e) {
// network error — silently ignore, will retry next interval
}
this.liveLogTimer = setTimeout(poll, LIVE_LOG_POLL_INTERVAL_MS);
};
// Kick off immediately
this.liveLogTimer = setTimeout(poll, 0);
}
stopLivePoll() {
if (this.liveLogTimer !== undefined) {
clearTimeout(this.liveLogTimer);
this.liveLogTimer = undefined;
}
}
private appendLiveLog(data: LiveLogResponse) {
const panel = this.livelogpanel;
const wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 4;
if (data.dropped) {
const marker = document.createElement("div");
marker.className = "livelog-dropped";
marker.textContent = "[..]";
panel.appendChild(marker);
}
for (const entry of data.entries) {
const line = document.createElement("div");
line.textContent = entry.text;
panel.appendChild(line);
}
this.liveLogNextSeq = data.next_seq;
// Auto-scroll to bottom only if user was already at the bottom
if (wasAtBottom) {
panel.scrollTop = panel.scrollHeight;
}
}
}

View File

@@ -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";
@@ -194,7 +194,7 @@ export class Controller {
async version(): Promise<void> {
controller.progressview.addIndeterminate("version", "Getting buildVersion")
const response = await fetch(PUBLIC_URL + "/version");
const response = await fetch(PUBLIC_URL + "/firmware_info");
const json = await response.json();
const versionInfo = json as VersionInfo;
controller.progressview.removeProgress("version");
@@ -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) {
@@ -499,7 +499,7 @@ export class Controller {
waitForReboot() {
console.log("Check if controller online again")
fetch(PUBLIC_URL + "/version", {
fetch(PUBLIC_URL + "/firmware_info", {
method: "GET",
signal: AbortSignal.timeout(5000)
}).then(response => {
@@ -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,
@@ -668,7 +668,8 @@ async function executeTasksSequentially() {
}
executeTasksSequentially().then(() => {
controller.progressview.removeProgress("initial")
controller.progressview.removeProgress("initial");
controller.logView.startLivePoll(PUBLIC_URL);
});
controller.progressview.removeProgress("rebooting");

View File

@@ -21,6 +21,7 @@
<div class="subtitle">
Current Firmware
</div>
<button style="margin-left: auto;" type="button" id="refresh_firmware_info">Refresh</button>
</div>
<div class="flexcontainer">
<span class="otakey">Buildtime:</span>
@@ -42,12 +43,35 @@
<span class="otakey">State1:</span>
<span class="otavalue" id="firmware_state1"></span>
</div>
<div class="flexcontainer">
<form class="otaform" id="upload_form" method="post">
<input class="otachooser" type="file" name="file1" id="firmware_file"><br>
</form>
</div>
<div class="flexcontainer">
<div class="subtitle">
Heap Memory
</div>
<div></div>
</div>
<div class="flexcontainer">
<span class="otakey">Free:</span>
<span class="otavalue" id="heap_free"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Used:</span>
<span class="otavalue" id="heap_used"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Total:</span>
<span class="otavalue" id="heap_total"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Peak used:</span>
<span class="otavalue" id="heap_max_used"></span>
</div>
<div class="display:flex">
<button style="margin-left: 16px; margin-top: 8px;" class="col-6" type="button" id="test">Self-Test</button>
</div>

View File

@@ -1,6 +1,10 @@
import {Controller} from "./main";
import {VersionInfo} from "./api";
function fmtBytes(n: number): string {
return `${n} B (${(n / 1024).toFixed(1)} KiB)`;
}
export class OTAView {
readonly file1Upload: HTMLInputElement;
readonly firmware_buildtime: HTMLDivElement;
@@ -8,19 +12,26 @@ export class OTAView {
readonly firmware_partition: HTMLDivElement;
readonly firmware_state0: HTMLDivElement;
readonly firmware_state1: HTMLDivElement;
readonly heap_free: HTMLDivElement;
readonly heap_used: HTMLDivElement;
readonly heap_total: HTMLDivElement;
readonly heap_max_used: HTMLDivElement;
constructor(controller: Controller) {
(document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html")
let test = document.getElementById("test") as HTMLButtonElement;
let refresh = document.getElementById("refresh_firmware_info") as HTMLButtonElement;
this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement;
this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement;
this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement;
this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement;
this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement;
this.heap_free = document.getElementById("heap_free") as HTMLDivElement;
this.heap_used = document.getElementById("heap_used") as HTMLDivElement;
this.heap_total = document.getElementById("heap_total") as HTMLDivElement;
this.heap_max_used = document.getElementById("heap_max_used") as HTMLDivElement;
const file = document.getElementById("firmware_file") as HTMLInputElement;
this.file1Upload = file
@@ -36,6 +47,10 @@ export class OTAView {
test.onclick = () => {
controller.selfTest();
}
refresh.onclick = () => {
controller.version();
}
}
setVersion(versionInfo: VersionInfo) {
@@ -44,5 +59,9 @@ export class OTAView {
this.firmware_partition.innerText = versionInfo.current;
this.firmware_state0.innerText = versionInfo.slot0_state;
this.firmware_state1.innerText = versionInfo.slot1_state;
this.heap_free.innerText = fmtBytes(versionInfo.heap_free);
this.heap_used.innerText = fmtBytes(versionInfo.heap_used);
this.heap_total.innerText = fmtBytes(versionInfo.heap_total);
this.heap_max_used.innerText = fmtBytes(versionInfo.heap_max_used);
}
}

View File

@@ -126,17 +126,25 @@
<div class="subtitle">Live:</div>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<button class="subtitle" id="plant_${plantId}_test_sensor_a">Test Sensor A</button>
<button class="subtitle" id="plant_${plantId}_test_sensor_b">Test Sensor B</button>
<button class="subtitle" id="plant_${plantId}_test_sensor_a">Identify Sensor A</button>
<button class="subtitle" id="plant_${plantId}_test_sensor_b">Identify Sensor B</button>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<span class="plantsensorkey">Sensor A:</span>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_a">not measured</span>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<span class="plantsensorkey">Sensor A FW:</span>
<span class="plantsensorvalue" id="plant_${plantId}_sensor_a_fw_build">unknown</span>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="plantsensorkey">Sensor B:</div>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_b">not measured</span>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<span class="plantsensorkey">Sensor B FW:</span>
<span class="plantsensorvalue" id="plant_${plantId}_sensor_b_fw_build">unknown</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="plantsensorkey">Max Current</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_max">not_tested</span>

View File

@@ -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);
}
}

View File

@@ -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
}