diff --git a/.changes/estop-latching-switch.md b/.changes/estop-latching-switch.md new file mode 100644 index 00000000..72f73c1a --- /dev/null +++ b/.changes/estop-latching-switch.md @@ -0,0 +1,13 @@ +--- +kind: added +--- +Support latching (twist-to-release) E-Stop switches + +## Release Note +The E-Stop can now be a latching switch, such as a twist-to-release mushroom button, instead of a momentary push button + +A latching E-Stop activates when the switch is engaged and clears as soon as it is released again, instead of needing the button held for 5 seconds. A software-triggered E-Stop is cleared by cycling the switch: engage it, then release it. The switch type is set over serial with `estop latching true` (or `false` for a momentary button); the portal does not expose E-Stop settings yet. + +- Add the `latching` key (u8) to the `estop` NVS section and `latching` to its jsonconfig section; it defaults to the board's `OPENSHOCK_ESTOP_LATCHING` +- Add `OPENSHOCK_ESTOP_LATCHING` to board fragments, stamped into the board descriptor as `OPENSHOCK_BOARD_FLAG_ESTOP_LATCHING` +- Add the `estop latching` serial command; changing the switch type is refused while the E-Stop is active diff --git a/components/common/include/BoardDescriptor.h b/components/common/include/BoardDescriptor.h index 828dc873..2af06947 100644 --- a/components/common/include/BoardDescriptor.h +++ b/components/common/include/BoardDescriptor.h @@ -38,7 +38,8 @@ #define OPENSHOCK_BOARD_DESCRIPTOR_IMAGE_OFFSET 0x120u // sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t) // BoardDescriptor::flags bits. -#define OPENSHOCK_BOARD_FLAG_LED_SWAP_RG (1u << 0) // The WS2812B expects RGB rather than GRB order +#define OPENSHOCK_BOARD_FLAG_LED_SWAP_RG (1u << 0) // The WS2812B expects RGB rather than GRB order +#define OPENSHOCK_BOARD_FLAG_ESTOP_LATCHING (1u << 1) // The E-Stop is a latching switch by default rather than a momentary button namespace OpenShock { struct alignas(8) BoardDescriptor { diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index bdd1400a..b4b96b33 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -35,7 +35,7 @@ namespace ConfigTest { config.backend = Config::BackendConfig("api.example.org", "tok_0123456789abcdef"); config.serialInput = Config::SerialInputConfig(false); config.otaUpdate = Config::OtaUpdateConfig(false, "fw.example.org", OtaUpdateChannel::Develop, true, true, 120, false, true, 4242, OtaUpdateStep::Validating); - config.estop = Config::EStopConfig(true, GPIO_NUM_13); + config.estop = Config::EStopConfig(true, GPIO_NUM_13, true); return config; } @@ -135,5 +135,6 @@ namespace ConfigTest { TEST_ASSERT_EQUAL_MESSAGE(expected.estop.enabled, actual.estop.enabled, "estop.enabled"); TEST_ASSERT_EQUAL_INT_MESSAGE(expected.estop.gpioPin, actual.estop.gpioPin, "estop.gpioPin"); + TEST_ASSERT_EQUAL_MESSAGE(expected.estop.latching, actual.estop.latching, "estop.latching"); } } // namespace ConfigTest diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h index 1f980bf6..30a28c84 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -49,8 +49,9 @@ namespace ConfigTest { inline constexpr const char* kKeepAlive = "keepalive"; } // namespace Rf namespace Estop { - inline constexpr const char* kEnabled = "enabled"; - inline constexpr const char* kGpio = "gpio"; + inline constexpr const char* kEnabled = "enabled"; + inline constexpr const char* kGpio = "gpio"; + inline constexpr const char* kLatching = "latching"; } // namespace Estop namespace Ota { inline constexpr const char* kUpdateId = "update_id"; diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index e8fc8114..10bf08b6 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -56,6 +56,7 @@ TEST_CASE("Default values come from Kconfig and the board descriptor", "[config] // The host-test board has no E-Stop pin, so E-Stop defaults to off. TEST_ASSERT_EQUAL_INT(OPENSHOCK_ESTOP_PIN, config.estop.gpioPin); TEST_ASSERT_FALSE(config.estop.enabled); + TEST_ASSERT_FALSE(config.estop.latching); // OPENSHOCK_ESTOP_LATCHING is 0 on the host-test board } TEST_CASE("An empty store reads as the defaults and Init writes nothing", "[config][defaults]") @@ -182,12 +183,14 @@ TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") auto estop = Open(Keys::Ns::kEstop); TEST_ASSERT_TRUE(estop.SetBool(Keys::Estop::kEnabled, true)); TEST_ASSERT_TRUE(estop.SetI8(Keys::Estop::kGpio, 0)); // strapping pin: not a valid E-Stop input + TEST_ASSERT_TRUE(estop.SetBool(Keys::Estop::kLatching, true)); TEST_ASSERT_TRUE(estop.Commit()); } Reload(); TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, Config::GetRFConfigTxPin()); TEST_ASSERT_FALSE(Config::GetEStopEnabled()); + TEST_ASSERT_TRUE(Config::GetEStopLatching()); // Disabling the E-Stop keeps its switch type // Negative pin (the old "invalid" marker) also falls back. WipeStore(); diff --git a/components/device_control/host_test/main/test_estop_state_machine.cpp b/components/device_control/host_test/main/test_estop_state_machine.cpp index d468f90e..22d4f8e7 100644 --- a/components/device_control/host_test/main/test_estop_state_machine.cpp +++ b/components/device_control/host_test/main/test_estop_state_machine.cpp @@ -17,6 +17,11 @@ namespace { const int64_t kSampleMs = CONFIG_OPENSHOCK_ESTOP_POLL_INTERVAL_MS; // EStopManager's poll interval struct Rig { + explicit Rig(bool latching = false) + : machine(latching) + { + } + EStopStateMachine machine; int64_t now = 1'000'000; @@ -336,3 +341,87 @@ TEST_CASE("EStop is e-stopped in every state except Idle", "[device_control][est TEST_ASSERT_TRUE(sawState[std::to_underlying(EStopState::Active)]); TEST_ASSERT_TRUE(sawState[std::to_underlying(EStopState::ActiveClearing)]); } + +// --------------------------------------------------------------------------- +// Latching mode: the switch stays engaged mechanically, so disengaging it clears the E-Stop. +// --------------------------------------------------------------------------- + +TEST_CASE("EStop latching: engaging activates, staying engaged never clears", "[device_control][estop][latching]") +{ + Rig rig(true); + TEST_ASSERT_TRUE(rig.machine.latching()); + + rig.sample(kPressed); + assertState(EStopState::Active, rig); + + rig.hold(kPressed, EStopStateMachine::kHoldToClearTime * 2); // no hold-to-clear in latching mode + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop latching: disengaging clears after the debounce", "[device_control][estop][latching]") +{ + Rig rig(true); + rig.sample(kPressed); + + rig.feed(kReleased, EStopStateMachine::kCheckCount - 1); + assertState(EStopState::Active, rig); + + rig.sample(kReleased); + assertState(EStopState::Idle, rig); +} + +TEST_CASE("EStop latching: rearm grace applies after clearing", "[device_control][estop][latching]") +{ + Rig rig(true); + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Idle, rig); + + rig.sample(kPressed); + assertState(EStopState::Idle, rig); // inside the grace window + + rig.hold(kPressed, EStopStateMachine::kRearmGraceTime); + assertState(EStopState::Active, rig); +} + +TEST_CASE("EStop latching: a software trigger needs the switch cycled to clear", "[device_control][estop][latching]") +{ + Rig rig(true); + rig.hold(kReleased, 100); + + rig.machine.Trigger(rig.now); + assertState(EStopState::Active, rig); + + // Switch already disengaged: staying disengaged must not clear it. + rig.hold(kReleased, 10'000); + assertState(EStopState::Active, rig); + + // Engage, then disengage. + rig.sample(kPressed); + assertState(EStopState::Active, rig); + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Idle, rig); +} + +TEST_CASE("EStop latching: a software trigger while engaged clears on disengage", "[device_control][estop][latching]") +{ + Rig rig(true); + rig.sample(kPressed); // engaged + + rig.machine.Trigger(rig.now); + rig.hold(kPressed, 100); // still engaged: seen engaged again, not yet disengaged + assertState(EStopState::Active, rig); + + rig.feed(kReleased, EStopStateMachine::kCheckCount); + assertState(EStopState::Idle, rig); +} + +TEST_CASE("EStop momentary is the default mode", "[device_control][estop]") +{ + Rig rig; + TEST_ASSERT_FALSE(rig.machine.latching()); + + rig.sample(kPressed); + rig.feed(kReleased, EStopStateMachine::kCheckCount * 4); + assertState(EStopState::Active, rig); // releasing alone never clears a momentary E-Stop +} diff --git a/components/device_control/include/estop/EStopConfig.h b/components/device_control/include/estop/EStopConfig.h index 96101ef2..ac14a491 100644 --- a/components/device_control/include/estop/EStopConfig.h +++ b/components/device_control/include/estop/EStopConfig.h @@ -1,7 +1,8 @@ #pragma once // The `estop` config section: whether the E-Stop input is used and on which pin. Owned by device_control and kept in -// NVS through the config framework (config/Section.h); NVS namespace "estop", keys enabled (u8) and gpio (i8). +// NVS through the config framework (config/Section.h); NVS namespace "estop", keys enabled (u8), gpio (i8) and +// latching (u8). #include "config/Section.h" @@ -15,13 +16,16 @@ namespace OpenShock::JSON { } namespace OpenShock::Config { - // The schema's `active` and `latching` fields are not modelled: nothing reads them. struct EStopConfig { - EStopConfig(); // The defaults: the one place they are defined - EStopConfig(bool enabled, gpio_num_t gpioPin); + EStopConfig(); // The defaults: the one place they are defined + EStopConfig(bool enabled, gpio_num_t gpioPin); // The switch type is the board's default + EStopConfig(bool enabled, gpio_num_t gpioPin, bool latching); bool enabled; gpio_num_t gpioPin; + // A latching switch (stays engaged, e.g. twist-to-release) clears the E-Stop when disengaged; a momentary button + // (false) clears it when held. Defaults to the board's OPENSHOCK_ESTOP_LATCHING. + bool latching; /// @brief Disables the E-Stop when its pin can't be used as an input. The section runs it on every value it takes in. void Normalize(); @@ -39,4 +43,6 @@ namespace OpenShock::Config { bool SetEStopEnabled(bool enabled); [[nodiscard]] gpio_num_t GetEStopGpioPin(); bool SetEStopGpioPin(gpio_num_t gpioPin); + [[nodiscard]] bool GetEStopLatching(); + bool SetEStopLatching(bool latching); } // namespace OpenShock::Config diff --git a/components/device_control/include/estop/EStopManager.h b/components/device_control/include/estop/EStopManager.h index 16fc7688..5522337b 100644 --- a/components/device_control/include/estop/EStopManager.h +++ b/components/device_control/include/estop/EStopManager.h @@ -8,10 +8,11 @@ namespace OpenShock::EStopManager { [[nodiscard]] bool Init(); - // Both apply the change to the running E-Stop and then persist it to the config (like CommandHandler::SetRfTxPin), + // These apply the change to the running E-Stop and then persist it to the config (like CommandHandler::SetRfTxPin), // so callers don't have to keep the two in sync. They refuse while the E-Stop is active. bool SetEStopEnabled(bool enabled); bool SetEStopPin(gpio_num_t pin); + bool SetEStopLatching(bool latching); bool IsEStopped(); int64_t LastEStopped(); diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 89a8c28c..4b432558 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -27,6 +27,7 @@ static OpenShock::SimpleMutex s_estopMutex = {}; // Guarded via Mutex static ManagedTask s_estopTask(TAG, "EStop task"); static gpio_num_t s_estopPin = GPIO_NUM_NC; // Captured by value when the task starts +static bool s_estopLatching = false; // Captured by value when the task starts // Wrapped in atomics as they're read (or set via public methods) by Tasks potentially running on other cores. static std::atomic s_estopActivatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active. @@ -59,9 +60,9 @@ static void estopmgr_publishState(EStopState state, EStopState& lastState, TickT static void estopmgr_latchWithoutTask(); // Samples the estop at a fixed rate and updates internal state + events -static void estopmgr_managerTask(gpio_num_t estopPin) +static void estopmgr_managerTask(gpio_num_t estopPin, bool latching) { - EStopStateMachine machine; + EStopStateMachine machine(latching); EStopState lastPublishedState = EStopState::Idle; // Carry over an E-Stop latched while no task was running (software trigger); it must be cleared with the button. @@ -175,9 +176,11 @@ static bool estopmgr_taskStart() s_estopPin = pin; } - // The pin is captured by value, so the task keeps sampling the pin it was started with even if s_estopPin changes. + // The pin and switch type are captured by value, so the task keeps running with what it was started with even if the + // globals change. gpio_num_t pin = s_estopPin; - if (!s_estopTask.start(TAG, CONFIG_OPENSHOCK_ESTOP_TASK_STACK_SIZE, CONFIG_OPENSHOCK_ESTOP_TASK_PRIORITY, 1, [pin] { estopmgr_managerTask(pin); })) { + bool latching = s_estopLatching; + if (!s_estopTask.start(TAG, CONFIG_OPENSHOCK_ESTOP_TASK_STACK_SIZE, CONFIG_OPENSHOCK_ESTOP_TASK_PRIORITY, 1, [pin, latching] { estopmgr_managerTask(pin, latching); })) { OS_LOGE(TAG, "Failed to create EStop event handler task"); return false; } @@ -211,12 +214,16 @@ bool EStopManager::Init() s_estopInitialized = true; const Config::EStopConfig cfg = OpenShock::Config::GetEStopConfig(); + + OpenShock::ScopedLock lock__(&s_estopMutex); + + // Set regardless of enabled, so a later SetEStopEnabled(true) starts the task with the right switch type. + s_estopLatching = cfg.latching; + if (!cfg.enabled) { return true; } - OpenShock::ScopedLock lock__(&s_estopMutex); - if (!estopmgr_configurePin(cfg.gpioPin)) { return false; } @@ -306,6 +313,68 @@ bool EStopManager::SetEStopPin(gpio_num_t pin) return true; } +// Requires s_estopMutex. Restarts the task with the new switch type; refuses while the E-Stop is active. +static bool estopmgr_applyLatching(bool latching) +{ + // An active E-Stop carried into the other switch type could be cleared without the way its own type clears it. + if (EStopManager::IsEStopped()) { + OS_LOGW(TAG, "Refusing to change EStop switch type while EStop is active"); + return false; + } + + if (!s_estopTask.running()) { + s_estopLatching = latching; + return true; + } + + // The task captures the switch type when it starts, so restart it to apply the new one. + if (!estopmgr_taskStop()) { + return false; + } + + // A press or software trigger can activate the E-Stop while the task stops; keep the old switch type then. + if (EStopManager::IsEStopped()) { + OS_LOGW(TAG, "EStop activated while changing the switch type, keeping the old one"); + if (!estopmgr_taskStart()) { + // Task is gone and we can't bring it back, so the pin isn't sampled. The E-Stop stays latched until reboot. + OS_LOGE(TAG, "Failed to restart EStop task, latching EStop until reboot"); + estopmgr_latchWithoutTask(); + } + return false; + } + + bool oldLatching = s_estopLatching; + s_estopLatching = latching; + + if (!estopmgr_taskStart()) { + // Task is gone and we can't bring it back, so the pin isn't sampled. Fail safe: latch until reboot, which restarts + // the task with the switch type still in config. + OS_LOGE(TAG, "Failed to restart EStop task, latching EStop until reboot"); + s_estopLatching = oldLatching; + estopmgr_latchWithoutTask(); + return false; + } + + return true; +} + +bool EStopManager::SetEStopLatching(bool latching) +{ + OpenShock::ScopedLock lock__(&s_estopMutex); + + // Saved even when the running switch type already matches, so a retry after a failed save still writes it. + if (s_estopLatching != latching && !estopmgr_applyLatching(latching)) { + return false; + } + + if (!Config::SetEStopLatching(latching)) { + OS_LOGE(TAG, "Failed to save EStop switch type to config"); + return false; + } + + return true; +} + bool EStopManager::IsEStopped() { return s_estopActive.load() || s_externallyTriggered.load(); @@ -323,10 +392,11 @@ static void estopmgr_latchWithoutTask() { int64_t expected = 0; bool latched = s_estopActivatedAt.compare_exchange_strong(expected, OpenShock::millis()); - s_estopActive.store(true); // Active either way: just latched, or already was - s_externallyTriggered.store(false); // Taken over by the latch + // Active either way: just latched, or already was. The external trigger is taken over by the latch. + s_estopActive.store(true); + s_externallyTriggered.store(false); if (!latched) { - return; // Already active + return; // Already active } // Bounded: the latch itself is already in effect, the event only informs listeners diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index fa6bb13e..237dbac9 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -14,22 +14,36 @@ const char* const TAG = "Config::EStopConfig"; using namespace OpenShock::Config; -static constexpr const char* kNamespace = "estop"; -static constexpr const char* kKeyEnabled = "enabled"; -static constexpr const char* kKeyGpio = "gpio"; +static constexpr const char* kNamespace = "estop"; +static constexpr const char* kKeyEnabled = "enabled"; +static constexpr const char* kKeyGpio = "gpio"; +static constexpr const char* kKeyLatching = "latching"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "estop", .order = 6, .namespaces = kNamespaces}); +// The board's default switch type (OPENSHOCK_ESTOP_LATCHING in boards/.defaults, stamped into the descriptor). +static bool defaultLatching() +{ + return (OpenShock::Board::Get().flags & OPENSHOCK_BOARD_FLAG_ESTOP_LATCHING) != 0; +} + EStopConfig::EStopConfig() : enabled(OpenShock::IsValidInputPin(Board::Get().estopGpio)) , gpioPin(static_cast(Board::Get().estopGpio)) + , latching(defaultLatching()) { } EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) + : EStopConfig(enabled, gpioPin, defaultLatching()) +{ +} + +EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin, bool latching) : enabled(enabled) , gpioPin(gpioPin) + , latching(latching) { } @@ -49,6 +63,12 @@ bool EStopConfig::LoadNvs(const char* partition) return false; } + // The switch type first: it is kept when a corrupt pin resets the rest of the section below. + bool storedLatching = defaultLatching(); + if (!Nvs::Load(*handle, kKeyLatching, storedLatching, defaultLatching())) { + return false; + } + const int8_t boardPin = static_cast(Board::Get().estopGpio); int8_t storedPin = boardPin; if (!Nvs::Load(*handle, kKeyGpio, storedPin, boardPin)) { @@ -58,10 +78,12 @@ bool EStopConfig::LoadNvs(const char* partition) auto pin = Parse::GpioNum(storedPin); if (!pin) { OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using default", storedPin); - *this = EStopConfig(); + *this = EStopConfig(); + latching = storedLatching; return true; } - gpioPin = *pin; + gpioPin = *pin; + latching = storedLatching; return Nvs::Load(*handle, kKeyEnabled, enabled, OpenShock::IsValidInputPin(gpioPin)); } @@ -73,7 +95,7 @@ bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) co } // The pin first: NVS commits key by key, so a power cut between the two leaves the old enable state on the new pin // (disabled stays disabled; enabled moves to the pin that was asked for), never the E-Stop enabled on the old pin - return Nvs::Saver(*handle, *this, previous).Set(kKeyGpio, &EStopConfig::gpioPin).Set(kKeyEnabled, &EStopConfig::enabled).Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyGpio, &EStopConfig::gpioPin).Set(kKeyEnabled, &EStopConfig::enabled).Set(kKeyLatching, &EStopConfig::latching).Commit(); } bool EStopConfig::FromJSON(JSON::JsonView json) @@ -88,6 +110,10 @@ bool EStopConfig::FromJSON(JSON::JsonView json) return false; } + if (!Parse::JsonBool(latching, json, "latching", defaultLatching(), TAG)) { + return false; + } + return true; } @@ -97,6 +123,7 @@ void EStopConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensiti JSON::objBegin(gen, name); json_gen_obj_set_bool(gen, "enabled", enabled); json_gen_obj_set_int(gen, "gpioPin", gpioPin); + json_gen_obj_set_bool(gen, "latching", latching); JSON::objEnd(gen, name); } @@ -140,3 +167,16 @@ bool OpenShock::Config::SetEStopGpioPin(gpio_num_t gpioPin) return true; }); } + +bool OpenShock::Config::GetEStopLatching() +{ + return s_section.Read([](const EStopConfig& estop) { return estop.latching; }); +} + +bool OpenShock::Config::SetEStopLatching(bool latching) +{ + return s_section.Update([&](EStopConfig& estop) { + estop.latching = latching; + return true; + }); +} diff --git a/components/device_control/src/estop/EStopStateMachine.h b/components/device_control/src/estop/EStopStateMachine.h index a9007635..292ce240 100644 --- a/components/device_control/src/estop/EStopStateMachine.h +++ b/components/device_control/src/estop/EStopStateMachine.h @@ -10,6 +10,13 @@ namespace OpenShock { /// @brief The EStop debounce + hold-to-clear state machine, free of FreeRTOS/GPIO so it can be host tested. /// EStopManager's task feeds it one GPIO sample per tick (or a software trigger) and mirrors activatedAt() into /// the atomic that IsEStopped() reads. activatedAt() is non-zero in every state except Idle. + /// + /// Momentary mode (latching == false), for a momentary push button: + /// Idle -> press -> Active -> press and hold for kHoldToClearTime -> AwaitingRelease -> release -> Idle + /// + /// Latching mode (latching == true), for a switch that mechanically stays engaged (e.g. twist-to-release mushroom button): + /// Idle -> engage -> Active -> disengage -> Idle + /// A software triggered (or restored) E-Stop is cleared by cycling the switch: engage, then disengage. class EStopStateMachine { public: static constexpr int64_t kHoldToClearTime = 5000; @@ -22,6 +29,12 @@ namespace OpenShock { // Grace period after deactivation (prevents immediate re-trigger on release bounce/EMI) static constexpr int64_t kRearmGraceTime = 250; // tune as needed + explicit EStopStateMachine(bool latching = false) noexcept + : m_latching(latching) + { + } + + bool latching() const noexcept { return m_latching; } EStopState state() const noexcept { return m_state; } int64_t activatedAt() const noexcept { return m_activatedAt; } @@ -32,8 +45,9 @@ namespace OpenShock { m_activatedAt = now; } - m_state = EStopState::Active; - m_rearmBlocked = false; + m_state = EStopState::Active; + m_rearmBlocked = false; + m_switchEngaged = false; // Do not modify history/lastBtnState here; rely on physical button state // on subsequent samples. @@ -66,12 +80,22 @@ namespace OpenShock { } if (btnState) { - m_state = EStopState::Active; - m_activatedAt = now; + m_state = EStopState::Active; + m_activatedAt = now; + m_switchEngaged = true; } break; case EStopState::Active: + if (m_latching) { + if (btnState) { + m_switchEngaged = true; + } else if (m_switchEngaged) { // switch disengaged -> clear E-Stop + clear(now); + } + break; + } + // Once active, if the input gets pressed, start hold-to-clear timing. if (pressedEdge) { m_state = EStopState::ActiveClearing; @@ -90,18 +114,25 @@ namespace OpenShock { case EStopState::AwaitingRelease: if (!btnState) { // fully released -> clear E-Stop - m_state = EStopState::Idle; - m_activatedAt = 0; - - // Start grace period to prevent immediate re-trigger. - m_rearmBlocked = true; - m_rearmAt = now + kRearmGraceTime; + clear(now); } break; } } private: + void clear(int64_t now) noexcept + { + m_state = EStopState::Idle; + m_activatedAt = 0; + + // Start grace period to prevent immediate re-trigger. + m_rearmBlocked = true; + m_rearmAt = now + kRearmGraceTime; + } + + bool m_latching; + EStopState m_state = EStopState::Idle; int64_t m_activatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active. @@ -115,5 +146,8 @@ namespace OpenShock { // Debounced button state: true == pressed, false == released bool m_lastBtnState = false; + + // Latching mode: whether the switch has been seen engaged since the E-Stop became active + bool m_switchEngaged = false; }; } // namespace OpenShock diff --git a/components/serial_console/src/command_handlers/estop.cpp b/components/serial_console/src/command_handlers/estop.cpp index c52037cc..a8e60b3a 100644 --- a/components/serial_console/src/command_handlers/estop.cpp +++ b/components/serial_console/src/command_handlers/estop.cpp @@ -48,6 +48,27 @@ static void handleEStopPinCommand(std::string_view arg, bool isAutomated) SERPR_SUCCESS("Saved config"); } +static void handleEStopLatchingCommand(std::string_view arg, bool isAutomated) +{ + if (arg.empty()) { + SERPR_RESPONSE("EStopLatching|%s", OpenShock::Config::GetEStopLatching() ? "true" : "false"); + return; + } + + auto latching = OpenShock::Convert::ToBool(arg); + if (!latching) { + SERPR_ERROR("Invalid argument (must be a boolean)"); + return; + } + + if (!OpenShock::EStopManager::SetEStopLatching(*latching)) { + SERPR_ERROR("Failed to set EStop latching (refused while the EStop is active)"); + return; + } + + SERPR_SUCCESS("Saved config"); +} + OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::EStopHandler() { auto group = OpenShock::SerialCmds::CommandGroup("estop"); @@ -60,5 +81,9 @@ OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::ESto auto& setPinCommand = group.addCommand("pin", "Set the GPIO pin used for the E-Stop.", handleEStopPinCommand); setPinCommand.addArgument("pin", "must be a number", "4"); + group.addCommand("latching", "Get whether the E-Stop is a latching switch.", handleEStopLatchingCommand); + auto& setLatchingCommand = group.addCommand("latching", "Set whether the E-Stop is a latching switch (clears when disengaged) or a momentary button (hold to clear).", handleEStopLatchingCommand); + setLatchingCommand.addArgument("latching", "must be a boolean", "true"); + return group; } diff --git a/scripts/tests/test_stamp_board.py b/scripts/tests/test_stamp_board.py index e8212648..67263644 100644 --- a/scripts/tests/test_stamp_board.py +++ b/scripts/tests/test_stamp_board.py @@ -37,9 +37,18 @@ def test_fragment_defaults_to_no_pins(tmp_path): assert spec.chip == 'ESP32-C3' assert (spec.rf_tx_gpio, spec.estop_gpio, spec.led_gpio, spec.ws2812b_gpio) == (-1, -1, -1, -1) assert spec.gpio_bypass == () + assert spec.estop_latching is False assert spec.flags == 0 +def test_fragment_sets_the_estop_latching_flag(tmp_path): + spec = boards.read_board_fragment( + write_fragment(tmp_path, 'Twist', 'CONFIG_IDF_TARGET="esp32"\nOPENSHOCK_ESTOP_LATCHING=y\n') + ) + assert spec.estop_latching is True + assert spec.flags == boards.FLAG_ESTOP_LATCHING + + @pytest.mark.parametrize( 'line', [ diff --git a/scripts/utils/boards.py b/scripts/utils/boards.py index dc33b73a..6d11f411 100644 --- a/scripts/utils/boards.py +++ b/scripts/utils/boards.py @@ -1,7 +1,7 @@ """Board fragments (boards/.defaults) and the build variants they group into. A fragment's ``CONFIG_*`` lines are Kconfig. Its bare ``OPENSHOCK_*`` lines are the board's pins and -LED flags, which are stamped into the image after linking rather than compiled in, so boards that +flags (LED, E-Stop), which are stamped into the image after linking rather than compiled in, so boards that differ only in them can share one compile. """ @@ -18,7 +18,7 @@ # The pin keys, in the order they sit in the descriptor. PIN_KEYS = ('OPENSHOCK_RF_TX_GPIO', 'OPENSHOCK_ESTOP_PIN', 'OPENSHOCK_LED_GPIO', 'OPENSHOCK_LED_WS2812B') -BOOL_KEYS = ('OPENSHOCK_LED_SWAP_RG_CHANNELS',) +BOOL_KEYS = ('OPENSHOCK_LED_SWAP_RG_CHANNELS', 'OPENSHOCK_ESTOP_LATCHING') LIST_KEYS = ('OPENSHOCK_GPIO_BYPASS',) KNOWN_KEYS = PIN_KEYS + BOOL_KEYS + LIST_KEYS @@ -31,6 +31,7 @@ # Descriptor flag bits (BoardDescriptor.h: OPENSHOCK_BOARD_FLAG_*). FLAG_LED_SWAP_RG = 1 << 0 +FLAG_ESTOP_LATCHING = 1 << 1 @dataclass(frozen=True) @@ -43,11 +44,12 @@ class BoardSpec: led_gpio: int = NO_PIN ws2812b_gpio: int = NO_PIN led_swap_rg: bool = False + estop_latching: bool = False gpio_bypass: tuple[int, ...] = () @property def flags(self) -> int: - return FLAG_LED_SWAP_RG if self.led_swap_rg else 0 + return (FLAG_LED_SWAP_RG if self.led_swap_rg else 0) | (FLAG_ESTOP_LATCHING if self.estop_latching else 0) @property def gpio_bypass_mask(self) -> int: @@ -173,6 +175,7 @@ def read_board_fragment(fragment: Path) -> BoardSpec: led_gpio=values['OPENSHOCK_LED_GPIO'], ws2812b_gpio=values['OPENSHOCK_LED_WS2812B'], led_swap_rg=values['OPENSHOCK_LED_SWAP_RG_CHANNELS'], + estop_latching=values['OPENSHOCK_ESTOP_LATCHING'], gpio_bypass=values['OPENSHOCK_GPIO_BYPASS'], ) diff --git a/test/host_support/include/openshock_board.h b/test/host_support/include/openshock_board.h index 30124085..d9702c90 100644 --- a/test/host_support/include/openshock_board.h +++ b/test/host_support/include/openshock_board.h @@ -7,6 +7,7 @@ // component's CMakeLists.txt. #pragma once +#define OPENSHOCK_ESTOP_LATCHING 0 #define OPENSHOCK_ESTOP_PIN -1 #define OPENSHOCK_FW_BOARD "HostTest" #define OPENSHOCK_FW_BOARD_HOSTTEST 1 diff --git a/test/host_support/src/TestBoardDescriptor.cpp b/test/host_support/src/TestBoardDescriptor.cpp index 2ebdf53c..241aee0d 100644 --- a/test/host_support/src/TestBoardDescriptor.cpp +++ b/test/host_support/src/TestBoardDescriptor.cpp @@ -15,7 +15,7 @@ namespace { desc.estopGpio = OPENSHOCK_ESTOP_PIN; desc.ledGpio = OPENSHOCK_LED_GPIO; desc.ws2812bGpio = OPENSHOCK_LED_WS2812B; - desc.flags = OPENSHOCK_LED_SWAP_RG_CHANNELS ? OPENSHOCK_BOARD_FLAG_LED_SWAP_RG : 0u; + desc.flags = (OPENSHOCK_LED_SWAP_RG_CHANNELS ? OPENSHOCK_BOARD_FLAG_LED_SWAP_RG : 0u) | (OPENSHOCK_ESTOP_LATCHING ? OPENSHOCK_BOARD_FLAG_ESTOP_LATCHING : 0u); desc.gpioBypassMask = OPENSHOCK_GPIO_BYPASS_MASK; return OpenShock::Board::Seal(desc); }