diff --git a/fan-controller/TODO.md b/fan-controller/TODO.md index a06d94e..b89dab0 100644 --- a/fan-controller/TODO.md +++ b/fan-controller/TODO.md @@ -383,7 +383,7 @@ urgent if subscriptions ever become dynamic. |---|---| | `src/mqtt/packet/publish.rs:158` | Validate there is enough space left in the buffer | | `src/mqtt/packet/publish.rs:146` | Validate the topic name contains no MQTT wildcard characters | -| `src/mqtt/packet/publish.rs:66`, `src/mqtt/mod.rs:54` | Set flags | +| `src/mqtt/packet/publish.rs:66`, `src/mqtt/mod.rs:54` | Set the duplicate and QoS flags. Retain is done — the reset cause is published with it | | `src/mqtt/packet/connect.rs:40` | Check the fixed header can even be written | | `src/mqtt/packet/subscribe.rs:127` | Support packet identifiers greater than `u8::MAX` | | `src/mqtt/packet/subscribe_acknowledgement.rs:18` | Convert to the decode trait | diff --git a/fan-controller/src/main.rs b/fan-controller/src/main.rs index d420721..04d7e93 100644 --- a/fan-controller/src/main.rs +++ b/fan-controller/src/main.rs @@ -40,6 +40,7 @@ mod debounce; mod fan; mod modbus; mod mqtt; +mod reset_cause; mod task; bind_interrupts!(struct Irqs { @@ -540,6 +541,9 @@ const _: () = core::assert!( enum OutgoingPublish { Discovery, + /// Why the controller last reset. Published once per boot, retained, because the resets worth + /// diagnosing happen when nobody is watching + ResetCause(reset_cause::ResetCause), UpdateSpeed { fan: Fan, payload: UpdateSpeedPayload, @@ -561,6 +565,7 @@ impl Publish for OutgoingPublish { fn topic(&self) -> &str { match self { OutgoingPublish::Discovery => topic::fan_controller::DISCOVERY, + OutgoingPublish::ResetCause(_) => topic::fan_controller::RESET_CAUSE, OutgoingPublish::UpdateSpeed { fan: Fan::One, payload: _, @@ -589,6 +594,7 @@ impl Publish for OutgoingPublish { fn payload(&self) -> &[u8] { match self { OutgoingPublish::Discovery => DISCOVERY_PAYLOAD, + OutgoingPublish::ResetCause(cause) => cause.as_str().as_bytes(), OutgoingPublish::UpdateSpeed { fan: _, payload } => { // set_point.0.to_be_bytes() payload.0.as_bytes() @@ -600,6 +606,10 @@ impl Publish for OutgoingPublish { OutgoingPublish::UpdateSensors { fan: _, payload } => payload.as_bytes(), } } + + fn is_retained(&self) -> bool { + matches!(self, OutgoingPublish::ResetCause(_)) + } } /// Sets up and manages the MQTT connection like keeping it alive @@ -1235,6 +1245,10 @@ const CHANNEL_SIZE: usize = 8; #[embassy_executor::main] async fn main(spawner: Spawner) { + // Read before anything else touches the chip, so nothing here can be mistaken for the reason + // the last boot ended + let boot_reset_cause = reset_cause::read(); + let Peripherals { PIN_23: pin_23, PIN_25: pin_25, @@ -1329,6 +1343,11 @@ async fn main(spawner: Spawner) { info!("[Main] Seinding discovery"); sender_out.send(OutgoingPublish::Discovery).await; info!("[Main] Sent out discocery"); + + info!("[Main] Reset cause: {}", boot_reset_cause); + sender_out + .send(OutgoingPublish::ResetCause(boot_reset_cause)) + .await; unwrap!(spawner.spawn(display_routine(display_receivers, &LED_STATE, sender_out))); static FAN_ONE_STATE: SetPointSignal = Signal::new(); diff --git a/fan-controller/src/mqtt/mod.rs b/fan-controller/src/mqtt/mod.rs index b430ac6..d773b1d 100644 --- a/fan-controller/src/mqtt/mod.rs +++ b/fan-controller/src/mqtt/mod.rs @@ -51,8 +51,8 @@ impl TryEncode for T { } // Fixed header - //TODO set flags - buffer[*offset] = Self::TYPE << 4; + //TODO set the duplicate and QoS flags + buffer[*offset] = (Self::TYPE << 4) | u8::from(self.is_retained()); *offset += 1; let topic_name = self.topic(); diff --git a/fan-controller/src/reset_cause.rs b/fan-controller/src/reset_cause.rs new file mode 100644 index 0000000..196f01a --- /dev/null +++ b/fan-controller/src/reset_cause.rs @@ -0,0 +1,65 @@ +//! Why the RP2040 last came up. +//! +//! A fan controller that reboots on its own looks identical from the outside to one that was +//! power cycled, and the two want completely different fixes. The chip records the answer across +//! the reset, so read it before anything else has a chance to overwrite it. + +use embassy_rp::pac; + +#[derive(Clone, Copy, defmt::Format)] +pub enum ResetCause { + /// The supply dropped below the brown-out threshold, or power was applied. A controller that + /// reports this without anyone touching the plug browned out. + PowerOnOrBrownOut, + /// The RUN pin was pulled low, which is the reset button and the probe's reset line. + RunPin, + /// The watchdog was not fed in time. + WatchdogTimeout, + /// Something asked for the reset deliberately — a debugger, or `probe-rs run`. + Forced, + /// The power-on state machine restarted without any of the above, which is what a debugger + /// attaching usually looks like. + DebuggerRestart, + /// None of the bits were set. Documented as the plain hardware reset case. + Unknown, +} + +impl ResetCause { + /// The string published to MQTT. Kept short and stable so it can be grepped and compared + /// across boots. + pub fn as_str(self) -> &'static str { + match self { + ResetCause::PowerOnOrBrownOut => "power-on-or-brown-out", + ResetCause::RunPin => "run-pin", + ResetCause::WatchdogTimeout => "watchdog-timeout", + ResetCause::Forced => "forced", + ResetCause::DebuggerRestart => "debugger-restart", + ResetCause::Unknown => "unknown", + } + } +} + +/// Read why the chip last reset. +/// +/// The watchdog reason is checked first: a watchdog reset runs through the same power-on state +/// machine, so `CHIP_RESET` alone would report it as a restart and hide the real cause. +pub fn read() -> ResetCause { + let watchdog_reason = pac::WATCHDOG.reason().read(); + if watchdog_reason.timer() { + return ResetCause::WatchdogTimeout; + } + if watchdog_reason.force() { + return ResetCause::Forced; + } + + let chip_reset = pac::VREG_AND_CHIP_RESET.chip_reset().read(); + if chip_reset.had_por() { + ResetCause::PowerOnOrBrownOut + } else if chip_reset.had_run() { + ResetCause::RunPin + } else if chip_reset.had_psm_restart() { + ResetCause::DebuggerRestart + } else { + ResetCause::Unknown + } +} diff --git a/fan-controller/src/task.rs b/fan-controller/src/task.rs index 5847a8a..360ba2c 100644 --- a/fan-controller/src/task.rs +++ b/fan-controller/src/task.rs @@ -470,6 +470,12 @@ pub(super) trait Publish { /// For example, they can be defined as an enum and match internally to provide the appropriate string for the enum variant. fn topic(&self) -> &str; fn payload(&self) -> &[u8]; + /// Whether the broker should keep this message and hand it to whoever subscribes next. + /// Almost nothing wants this: state that is published on every change is better re-read than + /// remembered. A message about an event nobody was watching for is the exception. + fn is_retained(&self) -> bool { + false + } } pub(super) async fn set_up_network_stack( diff --git a/topic/src/lib.rs b/topic/src/lib.rs index e14524f..534c109 100644 --- a/topic/src/lib.rs +++ b/topic/src/lib.rs @@ -12,6 +12,11 @@ pub mod fan_controller { pub const DISCOVERY: &str = formatcp!("{DISCOVERY_PREFIX}/{COMPONENT}/{OBJECT_ID}/config"); + /// Why the controller last reset, published retained on boot. Retained because the resets + /// worth diagnosing are rare and nobody is subscribed when they happen — the answer has to + /// still be there whenever someone next looks. + pub const RESET_CAUSE: &str = formatcp!("{OBJECT_ID}/reset-cause"); + /// The topic to publish the on/off state of the fan controller. pub const STATE: &str = formatcp!("{OBJECT_ID}/on/state"); /// The topic to subscribe to for setting the on/off state of the fan controller.