Add firmware build timestamp support for sensors; update detection workflows and UI accordingly.

This commit is contained in:
Kai Börnert
2026-04-27 16:46:24 +02:00
parent c04109a76c
commit e0b8acd55c
11 changed files with 204 additions and 33 deletions

View File

@@ -8,4 +8,15 @@ fn main() {
std::fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap(); std::fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out_dir.display()); println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rerun-if-changed=memory.x"); 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; extern crate alloc;
use crate::hal::peripherals::CAN1; 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 canapi::SensorSlot;
use ch32_hal::adc::{Adc, SampleTime, ADC_MAX}; use ch32_hal::adc::{Adc, SampleTime, ADC_MAX};
use ch32_hal::{pac}; use ch32_hal::{pac};
@@ -47,6 +47,10 @@ static CAN_TX_CH: Channel<CriticalSectionRawMutex, CanFrame, 4> = Channel::new()
static BEACON: AtomicBool = AtomicBool::new(false); 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")] #[embassy_executor::main(entry = "qingke_rt::entry")]
async fn main(spawner: Spawner) { async fn main(spawner: Spawner) {
ch32_hal::pac::AFIO.pcfr1().write(|w| w.set_can1_rm(2)); 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 moisture_id = plant_id(MOISTURE_DATA_OFFSET, slot, addr as u16);
let identify_id = plant_id(IDENTIFY_CMD_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(); let standard_identify_id = StandardId::new(identify_id).unwrap();
//is any floating, or invalid addr (only 1-8 are valid) //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()); // filter.get(0).unwrap().set(Id::Standard(standard_identify_id), Default::default());
// can.add_filter(filter); // can.add_filter(filter);
let standard_moisture_id = StandardId::new(moisture_id).unwrap(); let standard_moisture_id = StandardId::new(moisture_id).unwrap();
let standard_firmware_build_id = StandardId::new(firmware_build_id).unwrap();
spawner 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(); .unwrap();
// move Q output, LED, ADC and analog input into worker task // move Q output, LED, ADC and analog input into worker task
@@ -282,6 +288,7 @@ async fn main(spawner: Spawner) {
ain, ain,
standard_moisture_id, standard_moisture_id,
standard_identify_id, standard_identify_id,
standard_firmware_build_id,
)) ))
.unwrap(); .unwrap();
} }
@@ -362,6 +369,7 @@ async fn can_task(
warn: &'static mut Output<'static>, warn: &'static mut Output<'static>,
identify_id: StandardId, identify_id: StandardId,
moisture_id: StandardId, moisture_id: StandardId,
firmware_build_id: StandardId,
) { ) {
// Non-blocking beacon blink timing. // Non-blocking beacon blink timing.
// We keep this inside the CAN task so it can't stall other tasks (like `worker`) with `await`s. // 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, mut ain: hal::peripherals::PA1,
moisture_id: StandardId, moisture_id: StandardId,
identify_id: StandardId, identify_id: StandardId,
firmware_build_id: StandardId,
) { ) {
// 555 emulation state: Q initially Low // 555 emulation state: Q initially Low
let mut q_high = false; 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(); let moisture = CanFrame::new(moisture_id, &(freq_hz as u32).to_be_bytes()).unwrap();
CAN_TX_CH.send(moisture).await; 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

@@ -170,10 +170,20 @@ pub trait BoardInteraction<'a> {
async fn backup_info(&mut self) -> FatResult<BackupHeader>; async fn backup_info(&mut self) -> FatResult<BackupHeader>;
// Return JSON string with autodetected sensors per plant. Default: not supported. // 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"); 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) { async fn progress(&mut self, counter: u32) {
// Indicate progress is active to suppress default wait_infinity blinking // Indicate progress is active to suppress default wait_infinity blinking
PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed); 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 struct Moistures {
pub sensor_a_hz: [Option<f32>; PLANT_COUNT], pub sensor_a_hz: [Option<f32>; PLANT_COUNT],
pub sensor_b_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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Detection { 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DetectionSensorResult { pub struct DetectionSensorResult {
sensor_a: bool, pub sensor_a: Option<u32>,
sensor_b: bool, 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::rtc::{BackupHeader, RTCModuleInteraction, EEPROM_PAGE, X25};
use crate::hal::water::TankSensor; use crate::hal::water::TankSensor;
use crate::hal::{ 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 crate::log::{log, LogMessage};
use alloc::boxed::Box; use alloc::boxed::Box;
@@ -143,6 +144,11 @@ pub struct V4<'a> {
extra1: Output<'a>, extra1: Output<'a>,
extra2: Output<'a>, extra2: Output<'a>,
twai_config: Option<TwaiConfiguration<'static, Blocking>>, 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( pub(crate) async fn create_v4(
@@ -272,6 +278,8 @@ pub(crate) async fn create_v4(
extra2, extra2,
can_power, can_power,
twai_config, twai_config,
sensor_a_build_minutes: [None; PLANT_COUNT],
sensor_b_build_minutes: [None; PLANT_COUNT],
}; };
Ok(Box::new(v)) Ok(Box::new(v))
} }
@@ -393,6 +401,20 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
self.twai_config.replace(config); self.twai_config.replace(config);
self.can_power.set_low(); 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 res
} }
@@ -535,7 +557,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
Ok(header) 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(); self.can_power.set_high();
Timer::after_millis(500).await; Timer::after_millis(500).await;
let config = self.twai_config.take().context("twai config not set")?; let config = self.twai_config.take().context("twai config not set")?;
@@ -558,6 +580,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
} else { } else {
request.plant[plant].sensor_b request.plant[plant].sensor_b
}; };
if !detect { if !detect {
continue; continue;
} }
@@ -602,7 +625,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
let result: Detection = moistures.into(); let result: Detection = moistures.into();
info!("Autodetection result: {result:?}"); info!("Autodetection result: {result:?}");
Ok(result) Ok((result, moistures.sensor_a_build_minutes, moistures.sensor_b_build_minutes))
}) })
.await; .await;
@@ -610,7 +633,20 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
self.twai_config.replace(config); self.twai_config.replace(config);
self.can_power.set_low(); 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: {:?})", "received message of kind {:?} (plant: {}, sensor: {:?})",
msg.0, msg.1, msg.2 msg.0, msg.1, msg.2
); );
if msg.0 == MessageKind::MoistureData {
let plant = msg.1 as usize; let plant = msg.1 as usize;
let sensor = msg.2; let sensor = msg.2;
let data = can_frame.data(); let data = can_frame.data();
if msg.0 == MessageKind::MoistureData {
info!("Received moisture data: {:?}", data); info!("Received moisture data: {:?}", data);
if let Ok(bytes) = data.try_into() { if let Ok(bytes) = data.try_into() {
let frequency = u32::from_be_bytes(bytes); let frequency = u32::from_be_bytes(bytes);
@@ -651,6 +687,23 @@ async fn wait_for_can_measurements(
} else { } else {
error!("Received moisture data with invalid length: {} (expected 4)", data.len()); 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 { fn from(value: Moistures) -> Self {
let mut result = Detection::default(); let mut result = Detection::default();
for (plant, sensor) in value.sensor_a_hz.iter().enumerate() { 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() { 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 result
} }

View File

@@ -84,6 +84,11 @@ pub struct PlantState {
pub sensor_a: MoistureSensorState, pub sensor_a: MoistureSensorState,
pub sensor_b: MoistureSensorState, pub sensor_b: MoistureSensorState,
pub pump: PumpState, 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( fn map_range_moisture(
@@ -157,6 +162,7 @@ impl PlantState {
let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id); 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 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 { let state = Self {
sensor_a, sensor_a,
sensor_b, sensor_b,
@@ -164,6 +170,8 @@ impl PlantState {
consecutive_pump_count, consecutive_pump_count,
previous_pump, previous_pump,
}, },
sensor_a_firmware_build_minutes: a_builds[plant_id],
sensor_b_firmware_build_minutes: b_builds[plant_id],
}; };
if state.is_err() { if state.is_err() {
let _ = board.board_hal.fault(plant_id, true).await; let _ = board.board_hal.fault(plant_id, true).await;
@@ -286,6 +294,8 @@ impl PlantState {
} else { } else {
None 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>>, last_pump: Option<DateTime<Tz>>,
/// next time when pump should activate /// next time when pump should activate
next_pump: Option<DateTime<Tz>>, 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

@@ -1,6 +1,6 @@
use crate::config::PlantControllerConfig; use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult; use crate::fat_error::FatResult;
use crate::hal::Detection; use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request; use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS}; use crate::{do_secure_pump, BOARD_ACCESS};
use alloc::borrow::ToOwned; use alloc::borrow::ToOwned;
@@ -64,7 +64,7 @@ where
T: Read + Write, T: Read + Write,
{ {
let actual_data = read_up_to_bytes_from_request(request, None).await?; 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 mut board = BOARD_ACCESS.get().await.lock().await;
let result = board.board_hal.detect_sensors(detect).await?; let result = board.board_hal.detect_sensors(detect).await?;
let json = serde_json::to_string(&result)?; let json = serde_json::to_string(&result)?;

View File

@@ -199,9 +199,22 @@ export interface BatteryState {
state_of_health: string state_of_health: string
} }
export interface DetectionPlant { /// Request: which sensors to send IDENTIFY_CMD to.
export interface SensorRequest {
sensor_a: boolean, 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 { export interface Detection {

View File

@@ -29,7 +29,7 @@ import {
SetTime, SSIDList, TankInfo, SetTime, SSIDList, TankInfo,
TestPump, TestPump,
VersionInfo, VersionInfo,
SaveInfo, SolarState, PumpTestResult, Detection, CanPower SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
} from "./api"; } from "./api";
import {SolarView} from "./solarview"; import {SolarView} from "./solarview";
import {toast} from "./toast"; 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 counter = 0
let limit = 5 let limit = 5
if (!silent) { if (!silent) {
@@ -577,7 +577,7 @@ export class Controller {
this.hardwareView = new HardwareConfigView(this) this.hardwareView = new HardwareConfigView(this)
this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement
this.detectBtn.onclick = () => { this.detectBtn.onclick = () => {
const detection: Detection = { const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, () => ({ plant: Array.from({length: PLANT_COUNT}, () => ({
sensor_a: true, sensor_a: true,
sensor_b: true, sensor_b: true,
@@ -615,7 +615,7 @@ export class Controller {
try { try {
await this.measure_moisture(true); await this.measure_moisture(true);
const detection: Detection = { const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, () => ({ plant: Array.from({length: PLANT_COUNT}, () => ({
sensor_a: true, sensor_a: true,
sensor_b: true, sensor_b: true,

View File

@@ -133,10 +133,18 @@
<span class="plantsensorkey">Sensor A:</span> <span class="plantsensorkey">Sensor A:</span>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_a">not measured</span> <span class="plantsensorvalue" id="plant_${plantId}_moisture_a">not measured</span>
</div> </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="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="plantsensorkey">Sensor B:</div> <div class="plantsensorkey">Sensor B:</div>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_b">not measured</span> <span class="plantsensorvalue" id="plant_${plantId}_moisture_b">not measured</span>
</div> </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="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="plantsensorkey">Max Current</div> <div class="plantsensorkey">Max Current</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_max">not_tested</span> <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; 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"; import {Controller} from "./main";
@@ -79,6 +86,8 @@ export class PlantView {
private readonly mode: HTMLSelectElement; private readonly mode: HTMLSelectElement;
private readonly moistureA: HTMLElement; private readonly moistureA: HTMLElement;
private readonly moistureB: HTMLElement; private readonly moistureB: HTMLElement;
private readonly sensorAFwBuild: HTMLElement;
private readonly sensorBFwBuild: HTMLElement;
private readonly maxConsecutivePumpCount: HTMLInputElement; private readonly maxConsecutivePumpCount: HTMLInputElement;
private readonly minPumpCurrentMa: HTMLInputElement; private readonly minPumpCurrentMa: HTMLInputElement;
private readonly maxPumpCurrentMa: HTMLInputElement; private readonly maxPumpCurrentMa: HTMLInputElement;
@@ -109,6 +118,8 @@ export class PlantView {
this.moistureA = document.getElementById("plant_" + plantId + "_moisture_a")! as HTMLElement; this.moistureA = document.getElementById("plant_" + plantId + "_moisture_a")! as HTMLElement;
this.moistureB = document.getElementById("plant_" + plantId + "_moisture_b")! 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_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; 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 = document.getElementById("plant_" + plantId + "_test_sensor_a")! as HTMLButtonElement;
this.testSensorAButton.onclick = () => { this.testSensorAButton.onclick = () => {
const detection: Detection = { const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({ plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({
sensor_a: idx === plantId, sensor_a: idx === plantId,
sensor_b: false, sensor_b: false,
@@ -135,7 +146,7 @@ export class PlantView {
this.testSensorBButton = document.getElementById("plant_" + plantId + "_test_sensor_b")! as HTMLButtonElement; this.testSensorBButton = document.getElementById("plant_" + plantId + "_test_sensor_b")! as HTMLButtonElement;
this.testSensorBButton.onclick = () => { this.testSensorBButton.onclick = () => {
const detection: Detection = { const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({ plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({
sensor_a: false, sensor_a: false,
sensor_b: idx === plantId, sensor_b: idx === plantId,
@@ -360,19 +371,23 @@ export class PlantView {
} }
setDetectionResult(plantResult: DetectionPlant) { 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; var changed = false;
if (this.sensorAInstalled.checked != plantResult.sensor_a) { if (this.sensorAInstalled.checked != sensorADetected) {
changed = true; 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; changed = true;
this.sensorBInstalled.checked = plantResult.sensor_b; this.sensorBInstalled.checked = sensorBDetected;
} }
if (changed) { if (changed) {
this.controller.configChanged(); 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 // Message group base offsets relative to SENSOR_BASE_ADDRESS
pub const MOISTURE_DATA_OFFSET: u16 = 0; // periodic data from sensor (sensor -> controller) 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 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] #[inline]
pub const fn plant_id(message_type_offset: u16, sensor: SensorSlot, plant: u16) -> u16 { pub const fn plant_id(message_type_offset: u16, sensor: SensorSlot, plant: u16) -> u16 {
@@ -57,6 +58,7 @@ pub mod id {
pub enum MessageKind { pub enum MessageKind {
MoistureData, // sensor -> controller MoistureData, // sensor -> controller
IdentifyCmd, // controller -> sensor 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. /// 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) { if let Some((plant, slot)) = decode_in_group(rel, IDENTIFY_CMD_OFFSET) {
return Some((MessageKind::IdentifyCmd, plant, slot)); 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 None
} }