Compare commits
5 Commits
61806a5fa2
...
e0b8acd55c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0b8acd55c | ||
|
|
c04109a76c | ||
|
|
f0c9ed4e7f | ||
|
|
3fa8077b81 | ||
|
|
7f0714914f |
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
Software/MainBoard/rust/.idea/vcs.xml
generated
1
Software/MainBoard/rust/.idea/vcs.xml
generated
@@ -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>
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?))
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user