Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changes/estop-latching-switch.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion components/common/include/BoardDescriptor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion components/config/host_test/main/config_test_helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions components/config/host_test/main/config_test_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions components/config/host_test/main/test_defaults.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
14 changes: 10 additions & 4 deletions components/device_control/include/estop/EStopConfig.h
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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();
Expand All @@ -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
3 changes: 2 additions & 1 deletion components/device_control/include/estop/EStopManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
88 changes: 79 additions & 9 deletions components/device_control/src/EStopManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int64_t> s_estopActivatedAt = 0; // When == 0, EStop not active. When != 0, EStop is active.
Expand Down Expand Up @@ -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);
Comment thread
hhvrc marked this conversation as resolved.
Resolved
EStopState lastPublishedState = EStopState::Idle;

// Carry over an E-Stop latched while no task was running (software trigger); it must be cleared with the button.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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()) {
Comment thread
hhvrc marked this conversation as resolved.
Resolved
// 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;
Comment thread
hhvrc marked this conversation as resolved.
Resolved
}

return true;
}

bool EStopManager::IsEStopped()
{
return s_estopActive.load() || s_externallyTriggered.load();
Expand All @@ -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
Expand Down
Loading
Loading