From 90c8e869cdd107dc35fba9f874e5a5c8a3faab56 Mon Sep 17 00:00:00 2001 From: Cameron Brooks Date: Fri, 17 Jul 2026 20:19:44 -0400 Subject: [PATCH] feat: command watchdog - relays off when the bus goes silent Implement the disarmed-by-default bus-liveness watchdog (#5) against bread-crumbs-contracts 0.4.5's shared vocabulary, mirroring the Slice_DCMT implementation (feastorg/Slice_DCMT#8): - BREAD_OP_SET_WATCHDOG handler arms/disarms ([timeout_ms:u16], 0 = disarm); firmware boots disarmed unless -DRLHT_WATCHDOG_BOOT_MS is set, so standalone/serial use is unaffected by default. - Liveness stamping: CRUMBS on_message callback, GET_WATCHDOG reply build, and serial CLI commands refresh wdLastRxMs; a valid command frame clears a trip. - watchdogLogic() in the main loop: on expiry, drive both relays LOW and zero setpoints/on-times (same safe-state set as processEStop, estop untouched), latch tripped, bump trip_count; relayControlLogic() holds the e-stop-equivalent safe state while tripped. - BREAD_OP_GET_WATCHDOG reply reports [armed][timeout_ms][tripped][trip_count]; caps now advertise RLHT_CAP_CMD_WATCHDOG (bit 6). - Serial CLI: WDOG= (0 = off); status output gains WDOG/WDTRIP. Masked windows stay within the short-cli-window discipline (feastorg/Slice_DCMT#3). Builds verified on all four envs. Closes #5 --- firmware/include/globals.h | 11 ++++ firmware/platformio.ini | 4 +- firmware/src/main.cpp | 73 +++++++++++++++++++++++++- firmware/src/printSerialOutputRLHT.cpp | 20 ++++++- firmware/src/rlht_handlers.cpp | 31 ++++++++++- firmware/src/serialCommands.cpp | 28 ++++++++++ 6 files changed, 163 insertions(+), 4 deletions(-) diff --git a/firmware/include/globals.h b/firmware/include/globals.h index 462bac8..4f2fda7 100644 --- a/firmware/include/globals.h +++ b/firmware/include/globals.h @@ -55,11 +55,20 @@ extern Timing timing; extern PID relay1PID; extern PID relay2PID; +// Command watchdog (BREAD_OP_SET/GET_WATCHDOG): boots disarmed (timeout 0) +// unless RLHT_WATCHDOG_BOOT_MS is defined. ISR handlers write these; main-loop +// access goes through short masked windows (multi-byte volatiles on AVR). +extern volatile uint16_t wdTimeoutMs; +extern volatile unsigned long wdLastRxMs; +extern volatile bool wdTripped; +extern volatile uint8_t wdTripCount; + void setupSlice(); void setupRLHT(); void pollEStop(); void estopISR(); void processEStop(); +void watchdogLogic(); void measureThermocouples(); void relayControlLogic(); void setRelayPeriod(uint8_t relayId, uint16_t periodMs); @@ -74,8 +83,10 @@ void handler_set_pid(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, void handler_set_periods(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data); void handler_set_tc_select(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data); void handler_set_open_duty(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data); +void handler_set_watchdog(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data); void reply_version(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_data); void reply_get_state(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_data); void reply_get_caps(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_data); +void reply_get_watchdog(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_data); #endif // GLOBALS_H diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 4830a61..bc5a1e5 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -12,7 +12,7 @@ monitor_speed = 115200 lib_deps = fastled/FastLED @ ^3.6.0 cameronbrooks11/CRUMBS @ ^0.12.4 - cameronbrooks11/bread-crumbs-contracts @ ^0.4.3 + cameronbrooks11/bread-crumbs-contracts @ ^0.4.5 br3ttb/PID @ ^1.2.1 https://github.com/adafruit/MAX6675-library.git build_flags = @@ -21,6 +21,8 @@ build_flags = ; -DI2C_ADR=10 ; enable debug serial output: ; -DSLICE_DEBUG=1 + ; boot with the command watchdog armed (ms); default: boots disarmed: + ; -DRLHT_WATCHDOG_BOOT_MS=5000 [env:nano_base] platform = atmelavr diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 3871df3..d31b42d 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -20,6 +20,13 @@ static crumbs_context_t ctx; volatile bool estopTriggered = false; CRGB led; +// Command watchdog state (see globals.h). ISR-written; main loop snapshots +// under short masked windows. +volatile uint16_t wdTimeoutMs = 0; +volatile unsigned long wdLastRxMs = 0; +volatile bool wdTripped = false; +volatile uint8_t wdTripCount = 0; + static const unsigned long ESTOP_DEBOUNCE_MS = 25; static bool estopDebouncePending = false; static unsigned long estopDebounceStartMs = 0; @@ -168,12 +175,58 @@ void loop() { wdt_reset(); pollEStop(); + watchdogLogic(); measureThermocouples(); relayControlLogic(); serialCommands(); printSerialOutput(); } +// Fires for every CRC-valid inbound command frame (SET_REPLY excluded by +// CRUMBS): any valid command proves a live master and clears a trip. +static void on_crumbs_message(crumbs_context_t *c, const crumbs_message_t *msg) +{ + (void)c; + (void)msg; + wdLastRxMs = millis(); + wdTripped = false; +} + +void watchdogLogic() +{ + uint16_t timeout; + unsigned long lastRx; + bool tripped; + + noInterrupts(); + timeout = wdTimeoutMs; + lastRx = wdLastRxMs; + tripped = wdTripped; + interrupts(); + + if (timeout == 0 || tripped) + return; // relayControlLogic holds relays LOW while tripped + + if (millis() - lastRx < timeout) + return; + + digitalWrite(RELAY1, LOW); + digitalWrite(RELAY2, LOW); + + // Same safe-state fields as processEStop, without touching eStop. + noInterrupts(); + wdTripped = true; + wdTripCount++; + slice.relayHeater1.setpointTemperature = 0; + slice.relayHeater2.setpointTemperature = 0; + slice.relayHeater1.relayOnTime = 0; + slice.relayHeater2.relayOnTime = 0; + slice.relay1State = false; + slice.relay2State = false; + interrupts(); + SLICE_DEBUG_PRINTLN(F("WATCHDOG TRIPPED: bus silent, relays off")); +} + void setupSlice() { int rc; @@ -181,6 +234,7 @@ void setupSlice() Serial.begin(115200); crumbs_arduino_init_peripheral(&ctx, I2C_ADR); + crumbs_set_callbacks(&ctx, on_crumbs_message, nullptr, nullptr); rc = crumbs_register_handler(&ctx, RLHT_OP_SET_MODE, handler_set_mode, nullptr); if (rc != 0) @@ -206,6 +260,10 @@ void setupSlice() if (rc != 0) SLICE_DEBUG_PRINTLN(F("CRUMBS: Failed to register RLHT_OP_SET_OPEN_DUTY")); + rc = crumbs_register_handler(&ctx, BREAD_OP_SET_WATCHDOG, handler_set_watchdog, nullptr); + if (rc != 0) + SLICE_DEBUG_PRINTLN(F("CRUMBS: Failed to register BREAD_OP_SET_WATCHDOG")); + rc = crumbs_register_reply_handler(&ctx, 0x00, reply_version, nullptr); if (rc != 0) SLICE_DEBUG_PRINTLN(F("CRUMBS: Failed to register version reply handler")); @@ -218,6 +276,17 @@ void setupSlice() if (rc != 0) SLICE_DEBUG_PRINTLN(F("CRUMBS: Failed to register BREAD_OP_GET_CAPS reply handler")); + rc = crumbs_register_reply_handler(&ctx, BREAD_OP_GET_WATCHDOG, reply_get_watchdog, nullptr); + if (rc != 0) + SLICE_DEBUG_PRINTLN(F("CRUMBS: Failed to register BREAD_OP_GET_WATCHDOG reply handler")); + +#ifdef RLHT_WATCHDOG_BOOT_MS + // Integration opt-in: come up armed (e.g. e-stop wirings that power-cycle + // the board). Default builds boot disarmed. + wdTimeoutMs = (uint16_t)RLHT_WATCHDOG_BOOT_MS; + wdLastRxMs = millis(); +#endif + #if RLHT_HAS_STATUS_LED FastLED.addLeds(&led, 1); FastLED.setBrightness(50); @@ -359,7 +428,7 @@ void relayControlLogic() // one window so a heater's tunings are never torn; a torn view across // windows self-corrects next iteration. noInterrupts(); - bool localEStop = slice.eStop; + bool localEStop = slice.eStop || wdTripped; ControlMode localMode = slice.mode; double sp1 = slice.relayHeater1.setpointTemperature; double sp2 = slice.relayHeater2.setpointTemperature; @@ -393,6 +462,8 @@ void relayControlLogic() slice.relayHeater1.thermocoupleSelect = tc1; slice.relayHeater2.thermocoupleSelect = tc2; + // A tripped command watchdog holds the same safe state as e-stop until + // fresh traffic clears the trip (ISR side). if (localEStop) { digitalWrite(RELAY1, LOW); diff --git a/firmware/src/printSerialOutputRLHT.cpp b/firmware/src/printSerialOutputRLHT.cpp index 16d6724..4e7d95b 100644 --- a/firmware/src/printSerialOutputRLHT.cpp +++ b/firmware/src/printSerialOutputRLHT.cpp @@ -46,7 +46,25 @@ void printSliceState(Print &out) out.print(F(", Thermo Select Relay 2:")); out.print(slice.relayHeater2.thermocoupleSelect); out.print(F(", ESTOP:")); - out.println(slice.eStop); + out.print(slice.eStop); + + out.print(F(", WDOG:")); + uint16_t wdTimeout; + bool wdTrip; + uint8_t wdTrips; + noInterrupts(); + wdTimeout = wdTimeoutMs; + wdTrip = wdTripped; + wdTrips = wdTripCount; + interrupts(); + if (wdTimeout == 0) + out.print(F("off")); + else + out.print(wdTimeout); + out.print(F(", WDTRIP:")); + out.print(wdTrip); + out.print(F("/")); + out.println(wdTrips); } void printSerialOutput() diff --git a/firmware/src/rlht_handlers.cpp b/firmware/src/rlht_handlers.cpp index 3d0f4af..746617a 100644 --- a/firmware/src/rlht_handlers.cpp +++ b/firmware/src/rlht_handlers.cpp @@ -27,6 +27,34 @@ static double deci_c_to_temp(int16_t t) return ((double)t) / 10.0; } +void handler_set_watchdog(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data) +{ + uint16_t timeout_ms = 0; + (void)ctx; + (void)opcode; + (void)user_data; + + if (crumbs_msg_read_u16(data, data_len, 0, &timeout_ms) != 0) + return; + + wdTimeoutMs = timeout_ms; + wdLastRxMs = millis(); + wdTripped = false; +} + +void reply_get_watchdog(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_data) +{ + uint16_t timeout_ms = wdTimeoutMs; + (void)ctx; + (void)user_data; + + (void)bread_watchdog_build_reply(reply, RLHT_TYPE_ID, + timeout_ms != 0 ? 1 : 0, timeout_ms, + wdTripped ? 1 : 0, wdTripCount); + // A reply build proves a live master too. + wdLastRxMs = millis(); +} + void handler_set_mode(crumbs_context_t *ctx, uint8_t opcode, const uint8_t *data, uint8_t data_len, void *user_data) { uint8_t mode = RLHT_MODE_CLOSED_LOOP; @@ -218,5 +246,6 @@ void reply_get_caps(crumbs_context_t *ctx, crumbs_message_t *reply, void *user_d (void)ctx; (void)user_data; - (void)bread_caps_build_reply(reply, RLHT_TYPE_ID, RLHT_CAP_LEVEL_1, RLHT_CAP_BASELINE_FLAGS); + (void)bread_caps_build_reply(reply, RLHT_TYPE_ID, RLHT_CAP_LEVEL_1, + RLHT_CAP_BASELINE_FLAGS | RLHT_CAP_CMD_WATCHDOG); } diff --git a/firmware/src/serialCommands.cpp b/firmware/src/serialCommands.cpp index 33d3fd9..a5b2bb3 100644 --- a/firmware/src/serialCommands.cpp +++ b/firmware/src/serialCommands.cpp @@ -39,6 +39,12 @@ static void processCommand(char *cmd) if (*cmd == '\0') return; + // A serial operator is a live master too: feed the command watchdog. + noInterrupts(); + wdLastRxMs = millis(); + wdTripped = false; + interrupts(); + if (starts_with_P(cmd, PSTR("MODE="))) { char *mode = (char *)after_prefix_P(cmd, PSTR("MODE=")); @@ -290,6 +296,27 @@ static void processCommand(char *cmd) Serial.println(F("Period range 100-10000ms")); } } + else if (starts_with_P(cmd, PSTR("WDOG="))) + { + long v = atol(after_prefix_P(cmd, PSTR("WDOG="))); + if (v < 0) + v = 0; + if (v > 65535) + v = 65535; + noInterrupts(); + wdTimeoutMs = (uint16_t)v; + wdLastRxMs = millis(); + wdTripped = false; + interrupts(); + Serial.print(F("WDOG-> ")); + if (v == 0) + Serial.println(F("disarmed")); + else + { + Serial.print(v); + Serial.println(F(" ms")); + } + } else if (starts_with_P(cmd, PSTR("HELP")) || starts_with_P(cmd, PSTR("?"))) { Serial.println(F("Commands:")); @@ -304,6 +331,7 @@ static void processCommand(char *cmd) Serial.println(F("R2KP/KI/KD= - R2 PID")); Serial.println(F("R1PERIOD= - R1 period ms")); Serial.println(F("R2PERIOD= - R2 period ms")); + Serial.println(F("WDOG= - command watchdog (0=off)")); } else {