From c4f43109ff8feacd955bb41f4dc0650cc9025cc6 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 16:15:17 +0100 Subject: [PATCH 01/27] feat(visual): turn the status LEDs off with the statusled serial command Adds the led config section (namespace led, key status). With the status LEDs off the hub stays dark except for an active E-Stop or a critical error; the LED test still shows every pattern. Closes #266 --- .changes/status-led-toggle.md | 12 +++ .../config/host_test/main/CMakeLists.txt | 3 +- .../host_test/main/config_test_helpers.cpp | 6 +- .../host_test/main/config_test_helpers.h | 5 +- .../config/host_test/main/test_defaults.cpp | 1 + .../config/host_test/main/test_roundtrip.cpp | 2 + .../config/host_test/main/test_storage.cpp | 2 +- .../device_control/include/visual/LedConfig.h | 38 ++++++++ .../include/visual/VisualStateManager.h | 6 ++ .../device_control/src/visual/LedConfig.cpp | 91 +++++++++++++++++++ .../src/visual/VisualStateManager.cpp | 47 +++++++++- .../serial_console/command_handlers/index.h | 4 + .../src/command_handlers/statusled.cpp | 35 +++++++ docs/STORAGE.md | 1 + 14 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 .changes/status-led-toggle.md create mode 100644 components/device_control/include/visual/LedConfig.h create mode 100644 components/device_control/src/visual/LedConfig.cpp create mode 100644 components/serial_console/src/command_handlers/statusled.cpp diff --git a/.changes/status-led-toggle.md b/.changes/status-led-toggle.md new file mode 100644 index 00000000..582d1dce --- /dev/null +++ b/.changes/status-led-toggle.md @@ -0,0 +1,12 @@ +--- +kind: added +--- +Turn the status LEDs off + +## Release Note +The status LEDs can now be turned off with the serial command `statusled false`, for hubs in a bedroom + +With the status LEDs off, the hub stays dark during normal operation and only lights up for an active E-Stop or a critical error. `statusled true` turns them back on. + +- Add the `led` config section (NVS namespace `led`, key `status` (u8); `led.statusEnabled` in jsonconfig), defaulting to on +- The LED test (`ledtest`) still shows every pattern while the status LEDs are off diff --git a/components/config/host_test/main/CMakeLists.txt b/components/config/host_test/main/CMakeLists.txt index 7c0595da..56ea0c84 100644 --- a/components/config/host_test/main/CMakeLists.txt +++ b/components/config/host_test/main/CMakeLists.txt @@ -20,6 +20,7 @@ idf_component_register( # Each module's section "${_components}/device_control/src/radio/RFConfig.cpp" "${_components}/device_control/src/estop/EStopConfig.cpp" + "${_components}/device_control/src/visual/LedConfig.cpp" "${_components}/core/src/wifi/WiFiConfig.cpp" "${_components}/core/src/wifi/WiFiCredentials.cpp" "${_components}/core/src/BackendConfig.cpp" @@ -33,7 +34,7 @@ idf_component_register( "${_components}/common/src/StringHelpers.cpp" INCLUDE_DIRS "." "${_components}/config/include" # config/*.h - "${_components}/device_control/include" # radio/RFConfig.h, estop/EStopConfig.h + "${_components}/device_control/include" # radio/RFConfig.h, estop/EStopConfig.h, visual/LedConfig.h "${_components}/core/include" # wifi/WiFiConfig.h, BackendConfig.h, OtaUpdateConfig.h "${_components}/captive_portal/include" # captiveportal/CaptivePortalConfig.h "${_components}/serial_console/include" # serial_console/SerialInputConfig.h diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index bdd1400a..8957208b 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -16,7 +16,7 @@ namespace ConfigTest { bool Full::SaveNvs(const char* partition) const { return rf.SaveNvs(partition, nullptr) && wifi.SaveNvs(partition, nullptr) && captivePortal.SaveNvs(partition, nullptr) && backend.SaveNvs(partition, nullptr) && serialInput.SaveNvs(partition, nullptr) && otaUpdate.SaveNvs(partition, nullptr) - && estop.SaveNvs(partition, nullptr); + && estop.SaveNvs(partition, nullptr) && led.SaveNvs(partition, nullptr); } Full MakeSampleConfig() @@ -36,6 +36,7 @@ namespace ConfigTest { 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.led = Config::LedConfig(false); return config; } @@ -89,6 +90,7 @@ namespace ConfigTest { config.serialInput = Config::GetSerialInputConfig(); config.otaUpdate = Config::GetOtaUpdateConfig(); config.estop = Config::GetEStopConfig(); + config.led = Config::GetLedConfig(); return config; } @@ -135,5 +137,7 @@ 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.led.statusEnabled, actual.led.statusEnabled, "led.statusEnabled"); } } // 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..a59c35f1 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -10,6 +10,7 @@ #include "BackendConfig.h" #include "captiveportal/CaptivePortalConfig.h" #include "estop/EStopConfig.h" +#include "visual/LedConfig.h" #include "OtaUpdateConfig.h" #include "radio/RFConfig.h" #include "serial_console/SerialInputConfig.h" @@ -34,7 +35,8 @@ namespace ConfigTest { inline constexpr const char* kPortal = "portal"; inline constexpr const char* kSerial = "serial"; inline constexpr const char* kOta = "ota"; - inline constexpr const char* kAll[] = {kBackend, kWifi, kWifiCfg, kRf, kEstop, kPortal, kSerial, kOta}; + inline constexpr const char* kLed = "led"; + inline constexpr const char* kAll[] = {kBackend, kWifi, kWifiCfg, kRf, kEstop, kPortal, kSerial, kOta, kLed}; } // namespace Ns namespace Backend { inline constexpr const char* kDomain = "domain"; @@ -66,6 +68,7 @@ namespace ConfigTest { OpenShock::Config::SerialInputConfig serialInput; OpenShock::Config::OtaUpdateConfig otaUpdate; OpenShock::Config::EStopConfig estop; + OpenShock::Config::LedConfig led; // Every section's keys bool SaveNvs(const char* partition) const; diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index e8fc8114..3d297964 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_TRUE(config.led.statusEnabled); } TEST_CASE("An empty store reads as the defaults and Init writes nothing", "[config][defaults]") diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 729352e5..48ad28ff 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -39,6 +39,7 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Updated)); TEST_ASSERT_TRUE(Config::SetEStopGpioPin(GPIO_NUM_13)); TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); + TEST_ASSERT_TRUE(Config::SetLedStatusEnabled(false)); const Full before = Snapshot(); TEST_ASSERT_EQUAL_UINT8_ARRAY(bssid, before.wifi.credentialsList[0].bssid.data(), 6); @@ -72,6 +73,7 @@ TEST_CASE("Whole-section setters round trip through the store", "[config][roundt TEST_ASSERT_TRUE(Config::SetSerialInputConfig(sample.serialInput)); TEST_ASSERT_TRUE(Config::SetOtaUpdateConfig(sample.otaUpdate)); TEST_ASSERT_TRUE(Config::SetEStopConfig(sample.estop)); + TEST_ASSERT_TRUE(Config::SetLedConfig(sample.led)); AssertConfigEqual(sample, Snapshot()); Reload(); diff --git a/components/config/host_test/main/test_storage.cpp b/components/config/host_test/main/test_storage.cpp index d5385790..fdfbc59b 100644 --- a/components/config/host_test/main/test_storage.cpp +++ b/components/config/host_test/main/test_storage.cpp @@ -20,7 +20,7 @@ TEST_CASE("The store is the cfg partition", "[config][storage]") TEST_CASE("Sections link themselves into the registry in document order", "[config][storage]") { - const char* expected[] = {"rf", "wifi", "captivePortal", "backend", "serialInput", "otaUpdate", "estop"}; + const char* expected[] = {"rf", "wifi", "captivePortal", "backend", "serialInput", "otaUpdate", "estop", "led"}; std::size_t i = 0; for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next(), ++i) { TEST_ASSERT_LESS_THAN_size_t(std::size(expected), i); diff --git a/components/device_control/include/visual/LedConfig.h b/components/device_control/include/visual/LedConfig.h new file mode 100644 index 00000000..fb49c7af --- /dev/null +++ b/components/device_control/include/visual/LedConfig.h @@ -0,0 +1,38 @@ +#pragma once + +// The `led` config section: whether the status LEDs show the hub's state. Owned by device_control and kept in NVS +// through the config framework (config/Section.h); namespace "led", key status (u8). + +#include "config/Section.h" + +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct LedConfig { + LedConfig(); // The defaults: the one place they are defined + LedConfig(bool statusEnabled); + + // Off: the LEDs stay dark during normal operation and only light up for an active E-Stop or a critical error. + bool statusEnabled; + + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } + + /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. + bool LoadNvs(const char* partition); + /// @brief Writes the keys that differ from `previous` (every key when null) to `partition`. + bool SaveNvs(const char* partition, const LedConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + }; + + [[nodiscard]] LedConfig GetLedConfig(); + bool SetLedConfig(const LedConfig& config); + [[nodiscard]] bool GetLedStatusEnabled(); + bool SetLedStatusEnabled(bool enabled); +} // namespace OpenShock::Config diff --git a/components/device_control/include/visual/VisualStateManager.h b/components/device_control/include/visual/VisualStateManager.h index 445fc85b..e5e1d0c9 100644 --- a/components/device_control/include/visual/VisualStateManager.h +++ b/components/device_control/include/visual/VisualStateManager.h @@ -5,6 +5,12 @@ namespace OpenShock::VisualStateManager { [[nodiscard]] bool Init(); + /// @brief Whether the status LEDs show the hub's state. When off, they only light up for an active E-Stop or a + /// critical error. + [[nodiscard]] bool GetStatusLedsEnabled(); + /// @brief Saves the setting to the `led` config section and applies it at once. + bool SetStatusLedsEnabled(bool enabled); + void SetCriticalError(); void SetScanningStarted(); diff --git a/components/device_control/src/visual/LedConfig.cpp b/components/device_control/src/visual/LedConfig.cpp new file mode 100644 index 00000000..07ce4cc0 --- /dev/null +++ b/components/device_control/src/visual/LedConfig.cpp @@ -0,0 +1,91 @@ +#include + +#include "visual/LedConfig.h" + +const char* const TAG = "Config::LedConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; + +static constexpr const char* kNamespace = "led"; +static constexpr const char* kKeyStatus = "status"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "led", .order = 7, .namespaces = kNamespaces}); + +LedConfig::LedConfig() + : statusEnabled(true) +{ +} + +LedConfig::LedConfig(bool statusEnabled) + : statusEnabled(statusEnabled) +{ +} + +bool LedConfig::FromJSON(JSON::JsonView json) +{ + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + if (!Parse::JsonBool(statusEnabled, json, "statusEnabled", true, TAG)) { + return false; + } + + return true; +} + +void LedConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + (void)withSensitiveData; + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "statusEnabled", statusEnabled); + JSON::objEnd(gen, name); +} + +bool LedConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + return Nvs::Load(*handle, kKeyStatus, statusEnabled, true); +} + +bool LedConfig::SaveNvs(const char* partition, const LedConfig* previous) const +{ + auto handle = Nvs::Handle::OpenForWrite(partition, kNamespace); + if (!handle) { + return false; + } + return Nvs::Saver(*handle, *this, previous).Set(kKeyStatus, &LedConfig::statusEnabled).Commit(); +} + +LedConfig OpenShock::Config::GetLedConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetLedConfig(const LedConfig& config) +{ + return s_section.Set(config); +} + +bool OpenShock::Config::GetLedStatusEnabled() +{ + return s_section.Read([](const LedConfig& led) { return led.statusEnabled; }); +} + +bool OpenShock::Config::SetLedStatusEnabled(bool enabled) +{ + return s_section.Update([&](LedConfig& led) { + led.statusEnabled = enabled; + return true; + }); +} diff --git a/components/device_control/src/visual/VisualStateManager.cpp b/components/device_control/src/visual/VisualStateManager.cpp index eb051df4..01ac4660 100644 --- a/components/device_control/src/visual/VisualStateManager.cpp +++ b/components/device_control/src/visual/VisualStateManager.cpp @@ -13,6 +13,7 @@ const char* const TAG = "VisualStateManager"; #include "led_drivers/RgbLedDriver.h" #include "Logging.h" #include "SimpleMutex.h" +#include "visual/LedConfig.h" #include #include @@ -35,12 +36,16 @@ const uint32_t kWiFiScanningFlag = 1 << 7; // Bitmask of when the system is running normally const uint32_t kStatusOKMask = kWebSocketConnectedFlag | kHasIpAddressFlag | kWiFiConnectedFlag; +// Still shown with the status LEDs turned off: a stopped or failing hub must stay visible +const uint32_t kAlwaysShownMask = kCriticalErrorFlag | kEmergencyStoppedFlag | kEmergencyStopActiveClearingFlag | kEmergencyStopAwaitingReleaseFlag; + // 32-bit so the atomic is lock-free. Both need seq_cst so a state change during the LED test isn't lost. static std::atomic s_stateFlags = 0; static std::atomic s_ledTestActive = false; // While set, the LED test owns the LEDs; flags keep tracking live state static OpenShock::SimpleMutex s_applyMutex; // Serializes reading the flags with applying them, so an older snapshot can't land last static std::unique_ptr s_monoLedDriver; static std::unique_ptr s_rgbLedDriver; +static std::atomic s_statusEnabled = true; // The `led` section's statusEnabled, loaded in Init // Sets or clears `flag` and returns whether that changed it. Atomic per flag, so concurrent setters (the event loop, // SetCriticalError, SetScanningStarted) each see their own change and none is missed. @@ -188,6 +193,9 @@ const MonoLedDriver::State kSolidOnPattern[] = { const MonoLedDriver::State kSolidOffPattern[] = { {false, 100'000} }; +const RgbLedDriver::RGBState kSolidOffRGBPattern[] = { + {0, 0, 0, 100'000} +}; template static inline void updateVisualStateGPIO(const MonoLedDriver::State (&override)[N]) @@ -228,11 +236,25 @@ static void updateVisualStateRGB(uint32_t flags) s_rgbLedDriver->SetPattern(kWiFiDisconnectedRGBPattern); } -static void applyVisualState(uint32_t flags) +// `showAll` ignores the status LED setting, for the LED test. +static void applyVisualState(uint32_t flags, bool showAll = false) { bool gpioActive = s_monoLedDriver != nullptr; bool rgbActive = s_rgbLedDriver != nullptr; + if (!showAll && !s_statusEnabled.load()) { + flags &= kAlwaysShownMask; + if (flags == 0) { + if (gpioActive) { + s_monoLedDriver->SetPattern(kSolidOffPattern); + } + if (rgbActive) { + s_rgbLedDriver->SetPattern(kSolidOffRGBPattern); + } + return; + } + } + if (gpioActive && rgbActive) { if (flags == kStatusOKMask) { updateVisualStateGPIO(kSolidOnPattern); @@ -409,6 +431,8 @@ bool VisualStateManager::Init() return true; } + s_statusEnabled.store(Config::GetLedStatusEnabled()); + esp_err_t err; err = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, handleEspWiFiEvent, nullptr); @@ -435,6 +459,25 @@ bool VisualStateManager::Init() return true; } +bool VisualStateManager::GetStatusLedsEnabled() +{ + return s_statusEnabled.load(); +} + +bool VisualStateManager::SetStatusLedsEnabled(bool enabled) +{ + if (!Config::SetLedStatusEnabled(enabled)) { + OS_LOGE(TAG, "Failed to save the status LED setting to config"); + return false; + } + + if (s_statusEnabled.exchange(enabled) != enabled) { + updateVisualState(); + } + + return true; +} + void VisualStateManager::SetCriticalError() { if (setStateFlag(kCriticalErrorFlag, true)) { @@ -578,7 +621,7 @@ void VisualStateManager::RunLedTest() OS_LOGI(TAG, " State patterns:"); for (const auto& step : steps) { OS_LOGI(TAG, " %s", step.name); - applyVisualState(step.flags); + applyVisualState(step.flags, true); vTaskDelay(pdMS_TO_TICKS(step.durationMs)); } diff --git a/components/serial_console/include/serial_console/command_handlers/index.h b/components/serial_console/include/serial_console/command_handlers/index.h index 9096b092..5ab18321 100644 --- a/components/serial_console/include/serial_console/command_handlers/index.h +++ b/components/serial_console/include/serial_console/command_handlers/index.h @@ -21,9 +21,11 @@ namespace OpenShock::SerialCmds::CommandHandlers { OpenShock::SerialCmds::CommandGroup RfTransmitHandler(); OpenShock::SerialCmds::CommandGroup FactoryResetHandler(); OpenShock::SerialCmds::CommandGroup LedTestHandler(); + OpenShock::SerialCmds::CommandGroup StatusLedHandler(); inline std::vector AllCommandHandlers() { + // clang-format off: one command group per line return { VersionHandler(), RestartHandler(), @@ -41,6 +43,8 @@ namespace OpenShock::SerialCmds::CommandHandlers { RfTransmitHandler(), FactoryResetHandler(), LedTestHandler(), + StatusLedHandler(), }; + // clang-format on } } // namespace OpenShock::SerialCmds::CommandHandlers diff --git a/components/serial_console/src/command_handlers/statusled.cpp b/components/serial_console/src/command_handlers/statusled.cpp new file mode 100644 index 00000000..d8c1e514 --- /dev/null +++ b/components/serial_console/src/command_handlers/statusled.cpp @@ -0,0 +1,35 @@ +#include + +#include "serial_console/command_handlers/common.h" + +#include "Convert.h" +#include "StringHelpers.h" +#include "visual/VisualStateManager.h" + +static void handleStatusLedCommand(std::string_view arg, bool isAutomated) +{ + if (arg.empty()) { + SERPR_RESPONSE("StatusLed|%s", OpenShock::VisualStateManager::GetStatusLedsEnabled() ? "true" : "false"); + return; + } + + auto enabled = OpenShock::Convert::ToBool(OpenShock::StringTrim(arg)); + if (!enabled) { + SERPR_ERROR("Invalid argument (not a boolean)"); + return; + } + + OpenShock::SerialCmds::ReportSaved(OpenShock::VisualStateManager::SetStatusLedsEnabled(*enabled)); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::StatusLedHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("statusled"); + + group.addCommand("Get whether the status LEDs show the hub's state", handleStatusLedCommand); + + auto& setCommand = group.addCommand("Turn the status LEDs on or off. When off they only light up for an active E-Stop or a critical error", handleStatusLedCommand); + setCommand.addArgument("enabled", "must be a boolean", "false"); + + return group; +} diff --git a/docs/STORAGE.md b/docs/STORAGE.md index c64739b5..6359b4e3 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -37,6 +37,7 @@ always kept free for compaction) it holds the whole config with some 32 long WiF | `serialInput` | serial_console (`serial_console/SerialInputConfig.h`) | `serial` | | `otaUpdate` | core (`OtaUpdateConfig.h`) | `ota` | | `estop` | device_control (`estop/EStopConfig.h`) | `estop` | +| `led` | device_control (`visual/LedConfig.h`) | `led` | Namespace and key names are at most 15 characters and are permanent once hubs hold them: renaming one is a migration, not an edit. A missing key From cc53f0e7461ba6ec00a9fec9a0dcbab9d7617ca9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 11:42:43 +0100 Subject: [PATCH 02/27] feat(visual): add LED modes and per-LED brightness Replaces the statusled on/off toggle with a mode and four brightness settings. The mode is all, critical (only an E-Stop or a critical error lights the LEDs) or off. Brightness is a percent of each LED's full output, set separately for the mono and RGB LED and for critical and normal patterns; the defaults keep today's levels (mono 100%, RGB 8% = 20/255). An RGB brightness above 30% logs a warning. The serial command is now led, with mode and brightness subcommands. The led config section's keys are mode, monoCrit, monoNorm, rgbCrit and rgbNorm. --- .changes/status-led-toggle.md | 12 +- components/common/include/enums/StatusLed.h | 77 ++++++++++++ .../host_test/main/config_test_helpers.cpp | 8 +- .../config/host_test/main/test_defaults.cpp | 7 +- .../config/host_test/main/test_roundtrip.cpp | 4 +- .../device_control/include/visual/LedConfig.h | 31 +++-- .../include/visual/VisualStateManager.h | 16 ++- .../device_control/src/visual/LedConfig.cpp | 95 ++++++++++++--- .../src/visual/VisualStateManager.cpp | 114 +++++++++++++----- .../serial_console/command_handlers/index.h | 4 +- .../src/command_handlers/led.cpp | 86 +++++++++++++ .../src/command_handlers/statusled.cpp | 35 ------ 12 files changed, 387 insertions(+), 102 deletions(-) create mode 100644 components/common/include/enums/StatusLed.h create mode 100644 components/serial_console/src/command_handlers/led.cpp delete mode 100644 components/serial_console/src/command_handlers/statusled.cpp diff --git a/.changes/status-led-toggle.md b/.changes/status-led-toggle.md index 582d1dce..ce9ed13c 100644 --- a/.changes/status-led-toggle.md +++ b/.changes/status-led-toggle.md @@ -1,12 +1,14 @@ --- kind: added --- -Turn the status LEDs off +Turn the status LEDs off or dim them ## Release Note -The status LEDs can now be turned off with the serial command `statusled false`, for hubs in a bedroom +The status LEDs can now be turned off, limited to critical states, or dimmed, for hubs in a bedroom. -With the status LEDs off, the hub stays dark during normal operation and only lights up for an active E-Stop or a critical error. `statusled true` turns them back on. +`led mode off` keeps the LEDs dark, `led mode critical` lights them only for an active E-Stop or a critical error, and `led mode all` shows every state again. `led brightness ` sets the brightness of the built-in LED or the RGB LED, separately for critical and normal patterns, as a percent of the LED's full output. -- Add the `led` config section (NVS namespace `led`, key `status` (u8); `led.statusEnabled` in jsonconfig), defaulting to on -- The LED test (`ledtest`) still shows every pattern while the status LEDs are off +- Add the `led` config section (NVS namespace `led`, keys `mode`, `monoCrit`, `monoNorm`, `rgbCrit`, `rgbNorm` (u8); `led.mode`, `led.monoCriticalBrightness`, `led.monoNormalBrightness`, `led.rgbCriticalBrightness` and `led.rgbNormalBrightness` in jsonconfig) +- The defaults keep today's look: every state shown, the built-in LED at 100%, the RGB LED at 8% (20/255) +- An RGB brightness above 30% logs a warning +- The LED test (`ledtest`) shows every state pattern whatever the mode, at the configured brightness diff --git a/components/common/include/enums/StatusLed.h b/components/common/include/enums/StatusLed.h new file mode 100644 index 00000000..539ec3c8 --- /dev/null +++ b/components/common/include/enums/StatusLed.h @@ -0,0 +1,77 @@ +#pragma once + +#include "StringHelpers.h" + +#include +#include +#include + +namespace OpenShock { + // Which states the status LEDs show + enum class LedMode : uint8_t { + All, // Every state + CriticalOnly, // Dark during normal operation; only an E-Stop or a critical error lights them + Off // Always dark + }; + + enum class LedType : uint8_t { + Mono, // The single-color built-in LED + Rgb // The WS2812B RGB LED + }; + + // What a pattern signals: critical is an E-Stop or a critical error, normal is everything else + enum class LedSignal : uint8_t { + Critical, + Normal + }; + + // The name jsonconfig and the serial console use; TryParseLedMode reads it back. + [[nodiscard]] constexpr const char* LedModeName(LedMode mode) + { + switch (mode) { + case LedMode::All: + return "all"; + case LedMode::CriticalOnly: + return "critical"; + case LedMode::Off: + return "off"; + } + return ""; + } + + [[nodiscard]] inline std::optional TryParseLedMode(std::string_view str) + { + if (StringIEquals(str, "all")) { + return LedMode::All; + } + if (StringIEquals(str, "critical")) { + return LedMode::CriticalOnly; + } + if (StringIEquals(str, "off")) { + return LedMode::Off; + } + return std::nullopt; + } + + [[nodiscard]] inline std::optional TryParseLedType(std::string_view str) + { + if (StringIEquals(str, "mono")) { + return LedType::Mono; + } + if (StringIEquals(str, "rgb")) { + return LedType::Rgb; + } + return std::nullopt; + } + + [[nodiscard]] inline std::optional TryParseLedSignal(std::string_view str) + { + if (StringIEquals(str, "critical")) { + return LedSignal::Critical; + } + if (StringIEquals(str, "normal")) { + return LedSignal::Normal; + } + return std::nullopt; + } +} // namespace OpenShock diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index 8957208b..ced924a4 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -36,7 +36,7 @@ namespace ConfigTest { 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.led = Config::LedConfig(false); + config.led = Config::LedConfig(LedMode::CriticalOnly, 60, 0, 25, 3); return config; } @@ -138,6 +138,10 @@ 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.led.statusEnabled, actual.led.statusEnabled, "led.statusEnabled"); + TEST_ASSERT_EQUAL_INT_MESSAGE(static_cast(expected.led.mode), static_cast(actual.led.mode), "led.mode"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected.led.monoCriticalBrightness, actual.led.monoCriticalBrightness, "led.monoCriticalBrightness"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected.led.monoNormalBrightness, actual.led.monoNormalBrightness, "led.monoNormalBrightness"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected.led.rgbCriticalBrightness, actual.led.rgbCriticalBrightness, "led.rgbCriticalBrightness"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected.led.rgbNormalBrightness, actual.led.rgbNormalBrightness, "led.rgbNormalBrightness"); } } // namespace ConfigTest diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 3d297964..57324d5d 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -56,7 +56,12 @@ 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_TRUE(config.led.statusEnabled); + // Today's LED levels: mono at full output, RGB at 20/255 + TEST_ASSERT_EQUAL_INT(static_cast(LedMode::All), static_cast(config.led.mode)); + TEST_ASSERT_EQUAL_UINT8(100, config.led.monoCriticalBrightness); + TEST_ASSERT_EQUAL_UINT8(100, config.led.monoNormalBrightness); + TEST_ASSERT_EQUAL_UINT8(8, config.led.rgbCriticalBrightness); + TEST_ASSERT_EQUAL_UINT8(8, config.led.rgbNormalBrightness); } TEST_CASE("An empty store reads as the defaults and Init writes nothing", "[config][defaults]") diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 48ad28ff..ff3a3c9a 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -39,9 +39,11 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Updated)); TEST_ASSERT_TRUE(Config::SetEStopGpioPin(GPIO_NUM_13)); TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); - TEST_ASSERT_TRUE(Config::SetLedStatusEnabled(false)); + TEST_ASSERT_TRUE(Config::SetLedMode(LedMode::Off)); + TEST_ASSERT_TRUE(Config::SetLedBrightness(LedType::Rgb, LedSignal::Normal, 200)); // Capped at 100 const Full before = Snapshot(); + TEST_ASSERT_EQUAL_UINT8(100, before.led.rgbNormalBrightness); TEST_ASSERT_EQUAL_UINT8_ARRAY(bssid, before.wifi.credentialsList[0].bssid.data(), 6); Reload(); diff --git a/components/device_control/include/visual/LedConfig.h b/components/device_control/include/visual/LedConfig.h index fb49c7af..3b346b18 100644 --- a/components/device_control/include/visual/LedConfig.h +++ b/components/device_control/include/visual/LedConfig.h @@ -1,11 +1,13 @@ #pragma once -// The `led` config section: whether the status LEDs show the hub's state. Owned by device_control and kept in NVS -// through the config framework (config/Section.h); namespace "led", key status (u8). +// The `led` config section: which states the status LEDs show and how bright they are. Owned by device_control and +// kept in NVS through the config framework (config/Section.h); namespace "led", keys mode, monoCrit, monoNorm, rgbCrit +// and rgbNorm (u8). #include "config/Section.h" +#include "enums/StatusLed.h" -#include +#include namespace OpenShock::JSON { class JsonView; @@ -13,14 +15,22 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct LedConfig { + static constexpr uint8_t kMaxBrightness = 100; // Brightness is a percent of the LED's full output + LedConfig(); // The defaults: the one place they are defined - LedConfig(bool statusEnabled); + LedConfig(LedMode mode, uint8_t monoCriticalBrightness, uint8_t monoNormalBrightness, uint8_t rgbCriticalBrightness, uint8_t rgbNormalBrightness); + + LedMode mode; + uint8_t monoCriticalBrightness; + uint8_t monoNormalBrightness; + uint8_t rgbCriticalBrightness; + uint8_t rgbNormalBrightness; - // Off: the LEDs stay dark during normal operation and only light up for an active E-Stop or a critical error. - bool statusEnabled; + [[nodiscard]] uint8_t& Brightness(LedType type, LedSignal signal); + [[nodiscard]] uint8_t Brightness(LedType type, LedSignal signal) const { return const_cast(this)->Brightness(type, signal); } - /// @brief No rules to apply yet. The section runs it on every value it takes in. - void Normalize() { } + /// @brief Caps each brightness at kMaxBrightness. The section runs it on every value it takes in. + void Normalize(); /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. bool LoadNvs(const char* partition); @@ -33,6 +43,7 @@ namespace OpenShock::Config { [[nodiscard]] LedConfig GetLedConfig(); bool SetLedConfig(const LedConfig& config); - [[nodiscard]] bool GetLedStatusEnabled(); - bool SetLedStatusEnabled(bool enabled); + bool SetLedMode(LedMode mode); + /// @brief `percent` is capped at LedConfig::kMaxBrightness. + bool SetLedBrightness(LedType type, LedSignal signal, uint8_t percent); } // namespace OpenShock::Config diff --git a/components/device_control/include/visual/VisualStateManager.h b/components/device_control/include/visual/VisualStateManager.h index e5e1d0c9..5571346b 100644 --- a/components/device_control/include/visual/VisualStateManager.h +++ b/components/device_control/include/visual/VisualStateManager.h @@ -1,15 +1,21 @@ #pragma once +#include "enums/StatusLed.h" + #include namespace OpenShock::VisualStateManager { [[nodiscard]] bool Init(); - /// @brief Whether the status LEDs show the hub's state. When off, they only light up for an active E-Stop or a - /// critical error. - [[nodiscard]] bool GetStatusLedsEnabled(); - /// @brief Saves the setting to the `led` config section and applies it at once. - bool SetStatusLedsEnabled(bool enabled); + // The setters save to the `led` config section and apply the change at once. + + [[nodiscard]] LedMode GetLedMode(); + bool SetLedMode(LedMode mode); + + /// @brief Percent of the LED's full output (0-100) for patterns of `signal`. + [[nodiscard]] uint8_t GetLedBrightness(LedType type, LedSignal signal); + /// @brief `percent` is capped at 100. Logs a warning for an RGB brightness above 30%. + bool SetLedBrightness(LedType type, LedSignal signal, uint8_t percent); void SetCriticalError(); void SetScanningStarted(); diff --git a/components/device_control/src/visual/LedConfig.cpp b/components/device_control/src/visual/LedConfig.cpp index 07ce4cc0..003f9acf 100644 --- a/components/device_control/src/visual/LedConfig.cpp +++ b/components/device_control/src/visual/LedConfig.cpp @@ -10,22 +10,60 @@ const char* const TAG = "Config::LedConfig"; #include "json/Json.h" -using namespace OpenShock::Config; +#include +#include -static constexpr const char* kNamespace = "led"; -static constexpr const char* kKeyStatus = "status"; +using namespace OpenShock::Config; +using OpenShock::LedMode; +using OpenShock::LedSignal; +using OpenShock::LedType; + +static constexpr const char* kNamespace = "led"; +static constexpr const char* kKeyMode = "mode"; +static constexpr const char* kKeyMonoCrit = "monoCrit"; +static constexpr const char* kKeyMonoNorm = "monoNorm"; +static constexpr const char* kKeyRgbCrit = "rgbCrit"; +static constexpr const char* kKeyRgbNorm = "rgbNorm"; + +// Today's levels: the mono LED at full output, the RGB LED at 20/255 (8%) since a WS2812B at full output is blinding +static constexpr LedMode kDefaultMode = LedMode::All; +static constexpr uint8_t kDefaultMonoBright = 100; +static constexpr uint8_t kDefaultRgbBright = 8; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "led", .order = 7, .namespaces = kNamespaces}); LedConfig::LedConfig() - : statusEnabled(true) + : mode(kDefaultMode) + , monoCriticalBrightness(kDefaultMonoBright) + , monoNormalBrightness(kDefaultMonoBright) + , rgbCriticalBrightness(kDefaultRgbBright) + , rgbNormalBrightness(kDefaultRgbBright) +{ +} + +LedConfig::LedConfig(LedMode mode, uint8_t monoCriticalBrightness, uint8_t monoNormalBrightness, uint8_t rgbCriticalBrightness, uint8_t rgbNormalBrightness) + : mode(mode) + , monoCriticalBrightness(monoCriticalBrightness) + , monoNormalBrightness(monoNormalBrightness) + , rgbCriticalBrightness(rgbCriticalBrightness) + , rgbNormalBrightness(rgbNormalBrightness) { } -LedConfig::LedConfig(bool statusEnabled) - : statusEnabled(statusEnabled) +uint8_t& LedConfig::Brightness(LedType type, LedSignal signal) { + if (type == LedType::Mono) { + return signal == LedSignal::Critical ? monoCriticalBrightness : monoNormalBrightness; + } + return signal == LedSignal::Critical ? rgbCriticalBrightness : rgbNormalBrightness; +} + +void LedConfig::Normalize() +{ + for (uint8_t* brightness : {&monoCriticalBrightness, &monoNormalBrightness, &rgbCriticalBrightness, &rgbNormalBrightness}) { + *brightness = std::min(*brightness, kMaxBrightness); + } } bool LedConfig::FromJSON(JSON::JsonView json) @@ -34,10 +72,17 @@ bool LedConfig::FromJSON(JSON::JsonView json) return *done; } - if (!Parse::JsonBool(statusEnabled, json, "statusEnabled", true, TAG)) { + // The mode is strict like a boolean: taking the default for a bad value would turn LEDs back on that were turned off + if (!Parse::JsonStrParsed(mode, json, "mode", OpenShock::TryParseLedMode, kDefaultMode)) { + OS_LOGE(TAG, "mode is not one of all, critical or off"); return false; } + monoCriticalBrightness = json["monoCriticalBrightness"].tryGetU8().value_or(kDefaultMonoBright); + monoNormalBrightness = json["monoNormalBrightness"].tryGetU8().value_or(kDefaultMonoBright); + rgbCriticalBrightness = json["rgbCriticalBrightness"].tryGetU8().value_or(kDefaultRgbBright); + rgbNormalBrightness = json["rgbNormalBrightness"].tryGetU8().value_or(kDefaultRgbBright); + return true; } @@ -45,7 +90,11 @@ void LedConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitive { (void)withSensitiveData; JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "statusEnabled", statusEnabled); + JSON::objSetString(gen, "mode", OpenShock::LedModeName(mode)); + json_gen_obj_set_int(gen, "monoCriticalBrightness", monoCriticalBrightness); + json_gen_obj_set_int(gen, "monoNormalBrightness", monoNormalBrightness); + json_gen_obj_set_int(gen, "rgbCriticalBrightness", rgbCriticalBrightness); + json_gen_obj_set_int(gen, "rgbNormalBrightness", rgbNormalBrightness); JSON::objEnd(gen, name); } @@ -55,7 +104,16 @@ bool LedConfig::LoadNvs(const char* partition) if (!handle) { return false; } - return Nvs::Load(*handle, kKeyStatus, statusEnabled, true); + + uint8_t storedMode = std::to_underlying(kDefaultMode); + bool ok = Nvs::Load(*handle, kKeyMode, storedMode, storedMode) && Nvs::Load(*handle, kKeyMonoCrit, monoCriticalBrightness, kDefaultMonoBright) && Nvs::Load(*handle, kKeyMonoNorm, monoNormalBrightness, kDefaultMonoBright) + && Nvs::Load(*handle, kKeyRgbCrit, rgbCriticalBrightness, kDefaultRgbBright) && Nvs::Load(*handle, kKeyRgbNorm, rgbNormalBrightness, kDefaultRgbBright); + if (!ok) { + return false; + } + + mode = storedMode <= std::to_underlying(LedMode::Off) ? static_cast(storedMode) : kDefaultMode; + return true; } bool LedConfig::SaveNvs(const char* partition, const LedConfig* previous) const @@ -64,7 +122,13 @@ bool LedConfig::SaveNvs(const char* partition, const LedConfig* previous) const if (!handle) { return false; } - return Nvs::Saver(*handle, *this, previous).Set(kKeyStatus, &LedConfig::statusEnabled).Commit(); + return Nvs::Saver(*handle, *this, previous) + .Set(kKeyMode, &LedConfig::mode) + .Set(kKeyMonoCrit, &LedConfig::monoCriticalBrightness) + .Set(kKeyMonoNorm, &LedConfig::monoNormalBrightness) + .Set(kKeyRgbCrit, &LedConfig::rgbCriticalBrightness) + .Set(kKeyRgbNorm, &LedConfig::rgbNormalBrightness) + .Commit(); } LedConfig OpenShock::Config::GetLedConfig() @@ -77,15 +141,18 @@ bool OpenShock::Config::SetLedConfig(const LedConfig& config) return s_section.Set(config); } -bool OpenShock::Config::GetLedStatusEnabled() +bool OpenShock::Config::SetLedMode(LedMode mode) { - return s_section.Read([](const LedConfig& led) { return led.statusEnabled; }); + return s_section.Update([&](LedConfig& led) { + led.mode = mode; + return true; + }); } -bool OpenShock::Config::SetLedStatusEnabled(bool enabled) +bool OpenShock::Config::SetLedBrightness(LedType type, LedSignal signal, uint8_t percent) { return s_section.Update([&](LedConfig& led) { - led.statusEnabled = enabled; + led.Brightness(type, signal) = std::min(percent, LedConfig::kMaxBrightness); return true; }); } diff --git a/components/device_control/src/visual/VisualStateManager.cpp b/components/device_control/src/visual/VisualStateManager.cpp index 01ac4660..f936c79b 100644 --- a/components/device_control/src/visual/VisualStateManager.cpp +++ b/components/device_control/src/visual/VisualStateManager.cpp @@ -18,6 +18,7 @@ const char* const TAG = "VisualStateManager"; #include #include +#include #include #include @@ -36,8 +37,8 @@ const uint32_t kWiFiScanningFlag = 1 << 7; // Bitmask of when the system is running normally const uint32_t kStatusOKMask = kWebSocketConnectedFlag | kHasIpAddressFlag | kWiFiConnectedFlag; -// Still shown with the status LEDs turned off: a stopped or failing hub must stay visible -const uint32_t kAlwaysShownMask = kCriticalErrorFlag | kEmergencyStoppedFlag | kEmergencyStopActiveClearingFlag | kEmergencyStopAwaitingReleaseFlag; +// Critical states: shown in LedMode::CriticalOnly and at the critical brightness. Their patterns win over every other +const uint32_t kCriticalMask = kCriticalErrorFlag | kEmergencyStoppedFlag | kEmergencyStopActiveClearingFlag | kEmergencyStopAwaitingReleaseFlag; // 32-bit so the atomic is lock-free. Both need seq_cst so a state change during the LED test isn't lost. static std::atomic s_stateFlags = 0; @@ -45,7 +46,32 @@ static std::atomic s_ledTestActive = false; // While set, the LED test o static OpenShock::SimpleMutex s_applyMutex; // Serializes reading the flags with applying them, so an older snapshot can't land last static std::unique_ptr s_monoLedDriver; static std::unique_ptr s_rgbLedDriver; -static std::atomic s_statusEnabled = true; // The `led` section's statusEnabled, loaded in Init + +// The `led` section's settings, loaded in Init. Each is atomic on its own; a setter re-applies after storing, so a mix +// of old and new values read mid-update is replaced at once. +static std::atomic s_ledMode = OpenShock::LedMode::All; +static std::atomic s_monoCriticalBrightness = 100; // Percent of full output +static std::atomic s_monoNormalBrightness = 100; +static std::atomic s_rgbCriticalBrightness = 8; +static std::atomic s_rgbNormalBrightness = 8; + +// RGB brightness above this gets a warning: a WS2812B is glaring well before full output +static constexpr uint8_t kRgbBrightnessWarnPercent = 30; + +static std::atomic& brightnessSetting(OpenShock::LedType type, OpenShock::LedSignal signal) +{ + bool critical = signal == OpenShock::LedSignal::Critical; + if (type == OpenShock::LedType::Mono) { + return critical ? s_monoCriticalBrightness : s_monoNormalBrightness; + } + return critical ? s_rgbCriticalBrightness : s_rgbNormalBrightness; +} + +// Percent (0-100) to the drivers' 0-255 scale, rounded +static inline uint8_t toDriverBrightness(uint8_t percent) +{ + return static_cast((static_cast(percent) * 255 + 50) / 100); +} // Sets or clears `flag` and returns whether that changed it. Atomic per flag, so concurrent setters (the event loop, // SetCriticalError, SetScanningStarted) each see their own change and none is missed. @@ -236,23 +262,32 @@ static void updateVisualStateRGB(uint32_t flags) s_rgbLedDriver->SetPattern(kWiFiDisconnectedRGBPattern); } -// `showAll` ignores the status LED setting, for the LED test. +// `showAll` ignores the LED mode, for the LED test; the brightness settings still apply. static void applyVisualState(uint32_t flags, bool showAll = false) { bool gpioActive = s_monoLedDriver != nullptr; bool rgbActive = s_rgbLedDriver != nullptr; - if (!showAll && !s_statusEnabled.load()) { - flags &= kAlwaysShownMask; - if (flags == 0) { - if (gpioActive) { - s_monoLedDriver->SetPattern(kSolidOffPattern); - } - if (rgbActive) { - s_rgbLedDriver->SetPattern(kSolidOffRGBPattern); - } - return; + bool critical = (flags & kCriticalMask) != 0; + LedMode mode = showAll ? LedMode::All : s_ledMode.load(); + if (mode == LedMode::Off || (mode == LedMode::CriticalOnly && !critical)) { + if (gpioActive) { + s_monoLedDriver->SetPattern(kSolidOffPattern); + } + if (rgbActive) { + s_rgbLedDriver->SetPattern(kSolidOffRGBPattern); } + return; + } + + // Before SetPattern: the pattern task applies the brightness with each step, and SetPattern restarts the pattern, + // so even a long solid step picks up a new brightness at once + LedSignal signal = critical ? LedSignal::Critical : LedSignal::Normal; + if (gpioActive) { + s_monoLedDriver->SetBrightness(toDriverBrightness(brightnessSetting(LedType::Mono, signal).load())); + } + if (rgbActive) { + s_rgbLedDriver->SetBrightness(toDriverBrightness(brightnessSetting(LedType::Rgb, signal).load())); } if (gpioActive && rgbActive) { @@ -421,7 +456,6 @@ bool VisualStateManager::Init() OS_LOGE(TAG, "Failed to initialize RGB LED manager"); return false; } - s_rgbLedDriver->SetBrightness(20); ledActive = true; } } @@ -431,7 +465,13 @@ bool VisualStateManager::Init() return true; } - s_statusEnabled.store(Config::GetLedStatusEnabled()); + const Config::LedConfig ledConfig = Config::GetLedConfig(); + s_ledMode.store(ledConfig.mode); + for (LedType type : {LedType::Mono, LedType::Rgb}) { + for (LedSignal signal : {LedSignal::Critical, LedSignal::Normal}) { + brightnessSetting(type, signal).store(ledConfig.Brightness(type, signal)); + } + } esp_err_t err; @@ -459,19 +499,44 @@ bool VisualStateManager::Init() return true; } -bool VisualStateManager::GetStatusLedsEnabled() +LedMode VisualStateManager::GetLedMode() { - return s_statusEnabled.load(); + return s_ledMode.load(); } -bool VisualStateManager::SetStatusLedsEnabled(bool enabled) +bool VisualStateManager::SetLedMode(LedMode mode) { - if (!Config::SetLedStatusEnabled(enabled)) { - OS_LOGE(TAG, "Failed to save the status LED setting to config"); + if (!Config::SetLedMode(mode)) { + OS_LOGE(TAG, "Failed to save the LED mode to config"); return false; } - if (s_statusEnabled.exchange(enabled) != enabled) { + if (s_ledMode.exchange(mode) != mode) { + updateVisualState(); + } + + return true; +} + +uint8_t VisualStateManager::GetLedBrightness(LedType type, LedSignal signal) +{ + return brightnessSetting(type, signal).load(); +} + +bool VisualStateManager::SetLedBrightness(LedType type, LedSignal signal, uint8_t percent) +{ + percent = std::min(percent, Config::LedConfig::kMaxBrightness); + + if (!Config::SetLedBrightness(type, signal, percent)) { + OS_LOGE(TAG, "Failed to save the LED brightness to config"); + return false; + } + + if (type == LedType::Rgb && percent > kRgbBrightnessWarnPercent) { + OS_LOGW(TAG, "RGB LED brightness set to %u%%: above %u%% a WS2812B is glaring and runs warm", percent, kRgbBrightnessWarnPercent); + } + + if (brightnessSetting(type, signal).exchange(percent) != percent) { updateVisualState(); } @@ -527,16 +592,12 @@ void VisualStateManager::RunLedTest() vTaskDelay(pdMS_TO_TICKS(20)); } - // Restore default brightness - s_monoLedDriver->SetBrightness(255); s_monoLedDriver->ClearPattern(); vTaskDelay(pdMS_TO_TICKS(500)); } // --- Phase 2: RGB LED color test --- if (s_rgbLedDriver != nullptr) { - uint8_t savedBrightness = 20; // default from Init() - s_rgbLedDriver->SetBrightness(255); OS_LOGI(TAG, " [RGB] Red"); @@ -596,7 +657,6 @@ void VisualStateManager::RunLedTest() vTaskDelay(pdMS_TO_TICKS(20)); } - s_rgbLedDriver->SetBrightness(savedBrightness); s_rgbLedDriver->ClearPattern(); vTaskDelay(pdMS_TO_TICKS(500)); } diff --git a/components/serial_console/include/serial_console/command_handlers/index.h b/components/serial_console/include/serial_console/command_handlers/index.h index 5ab18321..13577c36 100644 --- a/components/serial_console/include/serial_console/command_handlers/index.h +++ b/components/serial_console/include/serial_console/command_handlers/index.h @@ -21,7 +21,7 @@ namespace OpenShock::SerialCmds::CommandHandlers { OpenShock::SerialCmds::CommandGroup RfTransmitHandler(); OpenShock::SerialCmds::CommandGroup FactoryResetHandler(); OpenShock::SerialCmds::CommandGroup LedTestHandler(); - OpenShock::SerialCmds::CommandGroup StatusLedHandler(); + OpenShock::SerialCmds::CommandGroup LedHandler(); inline std::vector AllCommandHandlers() { @@ -43,7 +43,7 @@ namespace OpenShock::SerialCmds::CommandHandlers { RfTransmitHandler(), FactoryResetHandler(), LedTestHandler(), - StatusLedHandler(), + LedHandler(), }; // clang-format on } diff --git a/components/serial_console/src/command_handlers/led.cpp b/components/serial_console/src/command_handlers/led.cpp new file mode 100644 index 00000000..8807225b --- /dev/null +++ b/components/serial_console/src/command_handlers/led.cpp @@ -0,0 +1,86 @@ +#include + +#include "serial_console/command_handlers/common.h" + +#include "Convert.h" +#include "enums/StatusLed.h" +#include "StringHelpers.h" +#include "visual/VisualStateManager.h" + +using OpenShock::LedSignal; +using OpenShock::LedType; + +static void handleLedModeCommand(std::string_view arg, bool isAutomated) +{ + (void)isAutomated; + + arg = OpenShock::StringTrim(arg); + if (arg.empty()) { + SERPR_RESPONSE("LedMode|%s", OpenShock::LedModeName(OpenShock::VisualStateManager::GetLedMode())); + return; + } + + auto mode = OpenShock::TryParseLedMode(arg); + if (!mode) { + SERPR_ERROR("Invalid argument (must be all, critical or off)"); + return; + } + + OpenShock::SerialCmds::ReportSaved(OpenShock::VisualStateManager::SetLedMode(*mode)); +} + +static void handleLedBrightnessCommand(std::string_view arg, bool isAutomated) +{ + (void)isAutomated; + + arg = OpenShock::StringTrim(arg); + if (arg.empty()) { + using OpenShock::VisualStateManager::GetLedBrightness; + SERPR_RESPONSE("LedBrightness|mono|critical|%u", GetLedBrightness(LedType::Mono, LedSignal::Critical)); + SERPR_RESPONSE("LedBrightness|mono|normal|%u", GetLedBrightness(LedType::Mono, LedSignal::Normal)); + SERPR_RESPONSE("LedBrightness|rgb|critical|%u", GetLedBrightness(LedType::Rgb, LedSignal::Critical)); + SERPR_RESPONSE("LedBrightness|rgb|normal|%u", GetLedBrightness(LedType::Rgb, LedSignal::Normal)); + return; + } + + auto parts = OpenShock::TryStringSplit<3>(arg, ' '); + if (!parts) { + SERPR_ERROR("Invalid arguments (expected )"); + return; + } + + auto type = OpenShock::TryParseLedType((*parts)[0]); + auto signal = OpenShock::TryParseLedSignal((*parts)[1]); + auto percent = OpenShock::Convert::ToUint8(OpenShock::StringTrim((*parts)[2])); + if (!type) { + SERPR_ERROR("Invalid LED type (must be mono or rgb)"); + return; + } + if (!signal) { + SERPR_ERROR("Invalid signal (must be critical or normal)"); + return; + } + if (!percent || *percent > 100) { + SERPR_ERROR("Invalid brightness (must be a percent, 0-100)"); + return; + } + + OpenShock::SerialCmds::ReportSaved(OpenShock::VisualStateManager::SetLedBrightness(*type, *signal, *percent)); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::LedHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("led"); + + group.addCommand("mode", "Get which states the status LEDs show", handleLedModeCommand); + auto& setModeCommand = group.addCommand("mode", "Set which states the status LEDs show: all, critical (only an E-Stop or a critical error) or off", handleLedModeCommand); + setModeCommand.addArgument("mode", "all, critical or off", "critical"); + + group.addCommand("brightness", "Get the status LED brightness, per LED type and signal", handleLedBrightnessCommand); + auto& setBrightnessCommand = group.addCommand("brightness", "Set the brightness of one LED type for critical (E-Stop, critical error) or normal patterns", handleLedBrightnessCommand); + setBrightnessCommand.addArgument("type", "mono or rgb", "rgb"); + setBrightnessCommand.addArgument("signal", "critical or normal", "normal"); + setBrightnessCommand.addArgument("percent", "0-100, of the LED's full output; RGB above 30% logs a warning", "8"); + + return group; +} diff --git a/components/serial_console/src/command_handlers/statusled.cpp b/components/serial_console/src/command_handlers/statusled.cpp deleted file mode 100644 index d8c1e514..00000000 --- a/components/serial_console/src/command_handlers/statusled.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include - -#include "serial_console/command_handlers/common.h" - -#include "Convert.h" -#include "StringHelpers.h" -#include "visual/VisualStateManager.h" - -static void handleStatusLedCommand(std::string_view arg, bool isAutomated) -{ - if (arg.empty()) { - SERPR_RESPONSE("StatusLed|%s", OpenShock::VisualStateManager::GetStatusLedsEnabled() ? "true" : "false"); - return; - } - - auto enabled = OpenShock::Convert::ToBool(OpenShock::StringTrim(arg)); - if (!enabled) { - SERPR_ERROR("Invalid argument (not a boolean)"); - return; - } - - OpenShock::SerialCmds::ReportSaved(OpenShock::VisualStateManager::SetStatusLedsEnabled(*enabled)); -} - -OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::StatusLedHandler() -{ - auto group = OpenShock::SerialCmds::CommandGroup("statusled"); - - group.addCommand("Get whether the status LEDs show the hub's state", handleStatusLedCommand); - - auto& setCommand = group.addCommand("Turn the status LEDs on or off. When off they only light up for an active E-Stop or a critical error", handleStatusLedCommand); - setCommand.addArgument("enabled", "must be a boolean", "false"); - - return group; -} From 136e5c1604ba7d5fe1e77e9f570187f5be0f9840 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 11:50:06 +0100 Subject: [PATCH 03/27] feat(portal): configure and preview the status LEDs from the captive portal Adds an LEDs section to the portal's advanced setup: the LED mode, a brightness slider per LED and signal that lights that LED live while dragging and saves on release, buttons that preview each state's pattern, and a color wheel that lights the RGB LED in the picked color. The RGB sliders warn above 30%. The hub side: GET and PUT /api/led read and save the settings (any subset in one config write), POST /api/led/preview shows a pattern or a solid color for 10 seconds after the last request without saving anything, and DELETE /api/led/preview ends it. A preview ignores the LED mode; the LED test still wins over it. The default brightnesses move to Kconfig (OPENSHOCK_LED_{MONO,RGB}_{CRITICAL,NORMAL}_BRIGHTNESS), and the config setters fold into Config::UpdateLedConfig. --- .changes/status-led-toggle.md | 5 +- .../src/captiveportal/ApiHandlers.cpp | 204 +++++++++++++++ .../config/host_test/main/test_defaults.cpp | 9 +- .../config/host_test/main/test_roundtrip.cpp | 7 +- components/device_control/CMakeLists.txt | 2 +- components/device_control/Kconfig | 32 +++ .../device_control/include/visual/LedConfig.h | 6 +- .../include/visual/VisualStateManager.h | 44 +++- .../device_control/src/visual/LedConfig.cpp | 45 ++-- .../src/visual/VisualStateManager.cpp | 235 ++++++++++++++--- frontend/src/lib/api.ts | 101 ++++++++ frontend/src/lib/components/ColorWheel.svelte | 98 ++++++++ .../lib/components/sections/LedSection.svelte | 237 ++++++++++++++++++ frontend/src/lib/views/Advanced.svelte | 9 +- 14 files changed, 956 insertions(+), 78 deletions(-) create mode 100644 frontend/src/lib/components/ColorWheel.svelte create mode 100644 frontend/src/lib/components/sections/LedSection.svelte diff --git a/.changes/status-led-toggle.md b/.changes/status-led-toggle.md index ce9ed13c..ca41892e 100644 --- a/.changes/status-led-toggle.md +++ b/.changes/status-led-toggle.md @@ -8,7 +8,10 @@ The status LEDs can now be turned off, limited to critical states, or dimmed, fo `led mode off` keeps the LEDs dark, `led mode critical` lights them only for an active E-Stop or a critical error, and `led mode all` shows every state again. `led brightness ` sets the brightness of the built-in LED or the RGB LED, separately for critical and normal patterns, as a percent of the LED's full output. +The captive portal has a new LEDs section with the same settings, brightness sliders that light the LED live while dragging, buttons that preview each state's pattern, and a color wheel to test the RGB LED. + - Add the `led` config section (NVS namespace `led`, keys `mode`, `monoCrit`, `monoNorm`, `rgbCrit`, `rgbNorm` (u8); `led.mode`, `led.monoCriticalBrightness`, `led.monoNormalBrightness`, `led.rgbCriticalBrightness` and `led.rgbNormalBrightness` in jsonconfig) -- The defaults keep today's look: every state shown, the built-in LED at 100%, the RGB LED at 8% (20/255) +- The defaults keep today's look: every state shown, the built-in LED at 100%, the RGB LED at 8% (20/255). The default brightnesses are Kconfig options (`OPENSHOCK_LED_*_BRIGHTNESS`) +- Captive portal API: `GET`/`PUT /api/led` reads and saves the settings, `POST /api/led/preview?pattern=` or `?color=RRGGBB[&led=][&brightness=]` previews for 10 seconds without saving, `DELETE /api/led/preview` ends a preview - An RGB brightness above 30% logs a warning - The LED test (`ledtest`) shows every state pattern whatever the mode, at the configured brightness diff --git a/components/captive_portal/src/captiveportal/ApiHandlers.cpp b/components/captive_portal/src/captiveportal/ApiHandlers.cpp index 54888084..527e7b7b 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -13,12 +13,15 @@ const char* const TAG = "CaptivePortalApi"; #include "CommandHandler.h" #include "Convert.h" #include "enums/OtaUpdateChannel.h" +#include "enums/StatusLed.h" #include "GatewayConnectionManager.h" #include "http/ContentTypes.h" #include "Logging.h" #include "OtaUpdateConfig.h" #include "RateLimiter.h" #include "util/TaskUtils.h" +#include "visual/LedConfig.h" +#include "visual/VisualStateManager.h" #include "wifi/WiFiManager.h" #include "wifi/WiFiScanManager.h" @@ -385,6 +388,203 @@ static esp_err_t apiOtaCheck(httpd_req_t* req) return sendResp(req, S200, nullptr, {}); } +// GET /api/led: the LED settings, which LEDs the board has, and the limits the UI shows +static esp_err_t apiLedGet(httpd_req_t* req) +{ + namespace Vsm = OpenShock::VisualStateManager; + + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + OpenShock::JSON::objSetString(gen, "mode", LedModeName(Vsm::GetLedMode())); + for (LedType type : {LedType::Mono, LedType::Rgb}) { + const char* name = type == LedType::Mono ? "mono" : "rgb"; + OpenShock::JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "present", Vsm::HasLed(type)); + json_gen_obj_set_int(gen, "critical", Vsm::GetLedBrightness(type, LedSignal::Critical)); + json_gen_obj_set_int(gen, "normal", Vsm::GetLedBrightness(type, LedSignal::Normal)); + OpenShock::JSON::objEnd(gen, name); + } + json_gen_obj_set_int(gen, "rgbWarnAbove", Vsm::kRgbBrightnessWarnPercent); + json_gen_obj_set_int(gen, "previewMs", static_cast(Vsm::kPreviewDurationMs)); + json_gen_end_object(gen); + std::string json = writer.finish(); + return sendResp(req, S200, HTTP::ContentType::JSON, json); +} + +// Reads an optional 0-100 percent query parameter; false (after responding) on an invalid value +static bool parsePercentParam(httpd_req_t* req, const char* key, std::optional& out, esp_err_t& response) +{ + auto str = getQueryParam(req, key); + if (!str) { + if (str.error() == ParamError::Missing) return true; + response = sendParamError(req, str.error()); + return false; + } + auto value = Convert::ToUint8(*str); + if (!value || *value > Config::LedConfig::kMaxBrightness) { + response = sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + return false; + } + out = *value; + return true; +} + +// PUT /api/led?mode=&monoCritical=&monoNormal=&rgbCritical=&rgbNormal= +// Any subset of the LED settings, saved in one config write. +static esp_err_t apiLedPut(httpd_req_t* req) +{ + std::optional mode; + auto modeStr = getQueryParam(req, "mode"); + if (!modeStr && modeStr.error() != ParamError::Missing) { + return sendParamError(req, modeStr.error()); + } + if (modeStr) { + mode = TryParseLedMode(*modeStr); + if (!mode) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + } + + std::optional monoCritical, monoNormal, rgbCritical, rgbNormal; + esp_err_t response = ESP_OK; + if (!parsePercentParam(req, "monoCritical", monoCritical, response) || !parsePercentParam(req, "monoNormal", monoNormal, response) || !parsePercentParam(req, "rgbCritical", rgbCritical, response) + || !parsePercentParam(req, "rgbNormal", rgbNormal, response)) + { + return response; + } + + if (!mode && !monoCritical && !monoNormal && !rgbCritical && !rgbNormal) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); + } + + bool saved = VisualStateManager::UpdateLedConfig([&](Config::LedConfig& cfg) { + bool changed = false; + auto apply = [&changed](auto& field, const auto& value) { + if (value && field != *value) { + field = *value; + changed = true; + } + }; + apply(cfg.mode, mode); + apply(cfg.monoCriticalBrightness, monoCritical); + apply(cfg.monoNormalBrightness, monoNormal); + apply(cfg.rgbCriticalBrightness, rgbCritical); + apply(cfg.rgbNormalBrightness, rgbNormal); + return changed; + }); + if (!saved) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + +static std::optional parseLedPattern(std::string_view str) +{ + using VisualStateManager::LedPattern; + struct Entry { + std::string_view name; + LedPattern pattern; + }; + static constexpr Entry kPatterns[] = { + {"disconnected", LedPattern::WiFiDisconnected}, + {"scanning", LedPattern::WiFiScanning}, + {"nobackend", LedPattern::NoBackend}, + {"connected", LedPattern::Connected}, + {"estop", LedPattern::EStopped}, + {"estopclearing", LedPattern::EStopClearing}, + {"estoprelease", LedPattern::EStopAwaitingRelease}, + {"error", LedPattern::CriticalError}, + }; + for (const Entry& entry : kPatterns) { + if (StringIEquals(str, entry.name)) { + return entry.pattern; + } + } + return std::nullopt; +} + +static std::optional parseHexByte(std::string_view str) +{ + uint8_t value = 0; + for (char c : str) { + uint8_t digit; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + digit = c - 'A' + 10; + } else { + return std::nullopt; + } + value = static_cast(value * 16 + digit); + } + return value; +} + +// POST /api/led/preview?pattern= +// POST /api/led/preview?color=RRGGBB[&led=mono|rgb][&brightness=0-100] +// Takes over the LEDs for VisualStateManager::kPreviewDurationMs after the last call; nothing is saved. +static esp_err_t apiLedPreview(httpd_req_t* req) +{ + auto patternStr = getQueryParam(req, "pattern"); + if (!patternStr && patternStr.error() != ParamError::Missing) { + return sendParamError(req, patternStr.error()); + } + if (patternStr) { + auto pattern = parseLedPattern(*patternStr); + if (!pattern) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + VisualStateManager::PreviewPattern(*pattern); + return sendResp(req, S200, nullptr, {}); + } + + auto colorStr = getQueryParam(req, "color"); + if (!colorStr) { + return sendParamError(req, colorStr.error()); + } + std::string_view color = *colorStr; + if (color.size() != 6) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + auto red = parseHexByte(color.substr(0, 2)); + auto green = parseHexByte(color.substr(2, 2)); + auto blue = parseHexByte(color.substr(4, 2)); + if (!red || !green || !blue) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + + std::optional only; + auto ledStr = getQueryParam(req, "led"); + if (!ledStr && ledStr.error() != ParamError::Missing) { + return sendParamError(req, ledStr.error()); + } + if (ledStr) { + only = TryParseLedType(*ledStr); + if (!only) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + } + + std::optional brightness; + esp_err_t response = ESP_OK; + if (!parsePercentParam(req, "brightness", brightness, response)) { + return response; + } + + VisualStateManager::PreviewColor(*red, *green, *blue, only, brightness); + return sendResp(req, S200, nullptr, {}); +} + +// DELETE /api/led/preview: ends a preview early +static esp_err_t apiLedPreviewStop(httpd_req_t* req) +{ + VisualStateManager::StopPreview(); + return sendResp(req, S200, nullptr, {}); +} + void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInstance* instance) { registerHandler(server, "/api/board", HTTP_GET, apiBoard, instance); @@ -402,4 +602,8 @@ void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInst // changing it would let anyone in range install firmware. It can only be changed over serial (jsonconfig). registerHandler(server, "/api/ota/settings", HTTP_PUT, apiOtaSettings, instance); registerHandler(server, "/api/ota/check", HTTP_POST, apiOtaCheck, instance); + registerHandler(server, "/api/led", HTTP_GET, apiLedGet, instance); + registerHandler(server, "/api/led", HTTP_PUT, apiLedPut, instance); + registerHandler(server, "/api/led/preview", HTTP_POST, apiLedPreview, instance); + registerHandler(server, "/api/led/preview", HTTP_DELETE, apiLedPreviewStop, instance); } diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 57324d5d..ab539a50 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -56,12 +56,11 @@ 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); - // Today's LED levels: mono at full output, RGB at 20/255 TEST_ASSERT_EQUAL_INT(static_cast(LedMode::All), static_cast(config.led.mode)); - TEST_ASSERT_EQUAL_UINT8(100, config.led.monoCriticalBrightness); - TEST_ASSERT_EQUAL_UINT8(100, config.led.monoNormalBrightness); - TEST_ASSERT_EQUAL_UINT8(8, config.led.rgbCriticalBrightness); - TEST_ASSERT_EQUAL_UINT8(8, config.led.rgbNormalBrightness); + TEST_ASSERT_EQUAL_UINT8(CONFIG_OPENSHOCK_LED_MONO_CRITICAL_BRIGHTNESS, config.led.monoCriticalBrightness); + TEST_ASSERT_EQUAL_UINT8(CONFIG_OPENSHOCK_LED_MONO_NORMAL_BRIGHTNESS, config.led.monoNormalBrightness); + TEST_ASSERT_EQUAL_UINT8(CONFIG_OPENSHOCK_LED_RGB_CRITICAL_BRIGHTNESS, config.led.rgbCriticalBrightness); + TEST_ASSERT_EQUAL_UINT8(CONFIG_OPENSHOCK_LED_RGB_NORMAL_BRIGHTNESS, config.led.rgbNormalBrightness); } TEST_CASE("An empty store reads as the defaults and Init writes nothing", "[config][defaults]") diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index ff3a3c9a..b8df9ba0 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -39,8 +39,11 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Updated)); TEST_ASSERT_TRUE(Config::SetEStopGpioPin(GPIO_NUM_13)); TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); - TEST_ASSERT_TRUE(Config::SetLedMode(LedMode::Off)); - TEST_ASSERT_TRUE(Config::SetLedBrightness(LedType::Rgb, LedSignal::Normal, 200)); // Capped at 100 + TEST_ASSERT_TRUE(Config::UpdateLedConfig([](Config::LedConfig& led) { + led.mode = LedMode::Off; + led.rgbNormalBrightness = 200; // Capped at 100 + return true; + })); const Full before = Snapshot(); TEST_ASSERT_EQUAL_UINT8(100, before.led.rgbNormalBrightness); diff --git a/components/device_control/CMakeLists.txt b/components/device_control/CMakeLists.txt index cc893328..6a339ab2 100644 --- a/components/device_control/CMakeLists.txt +++ b/components/device_control/CMakeLists.txt @@ -14,7 +14,7 @@ idf_component_register( REQUIRES common esp_hal_gpio esp_driver_rmt freertos # public headers (enums, gpio_types, rmt_tx/encoder, TaskHandle/QueueHandle) config # its config sections (radio/RFConfig.h, estop/EStopConfig.h): config/Section.h PRIV_REQUIRES chipset events led_drivers logging temporal protocols # concurrency/util folded into common (public REQUIRE) - esp_driver_gpio esp_wifi esp_event esp_netif soc + esp_driver_gpio esp_wifi esp_event esp_netif esp_timer soc ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/device_control/Kconfig b/components/device_control/Kconfig index 7bfe01fa..7e947fdf 100644 --- a/components/device_control/Kconfig +++ b/components/device_control/Kconfig @@ -59,3 +59,35 @@ menu "OpenShock emergency stop" At most 16 samples fit the debounce history, hence the 5 ms floor. endmenu + +menu "OpenShock status LEDs" + + # Defaults of the led config section; each is a percent of the LED's full output, changeable at runtime + # (serial `led brightness`, the captive portal). The RGB defaults match the 20/255 the hub always used: + # a WS2812B at full output is blinding. + + config OPENSHOCK_LED_MONO_CRITICAL_BRIGHTNESS + int "Built-in LED brightness for critical patterns (%)" + range 0 100 + default 100 + help + Brightness of the single-color LED for an E-Stop or a critical error. + + config OPENSHOCK_LED_MONO_NORMAL_BRIGHTNESS + int "Built-in LED brightness for normal patterns (%)" + range 0 100 + default 100 + + config OPENSHOCK_LED_RGB_CRITICAL_BRIGHTNESS + int "RGB LED brightness for critical patterns (%)" + range 0 100 + default 8 + help + Brightness of the WS2812B RGB LED for an E-Stop or a critical error. + + config OPENSHOCK_LED_RGB_NORMAL_BRIGHTNESS + int "RGB LED brightness for normal patterns (%)" + range 0 100 + default 8 + +endmenu diff --git a/components/device_control/include/visual/LedConfig.h b/components/device_control/include/visual/LedConfig.h index 3b346b18..ebf75d1b 100644 --- a/components/device_control/include/visual/LedConfig.h +++ b/components/device_control/include/visual/LedConfig.h @@ -6,6 +6,7 @@ #include "config/Section.h" #include "enums/StatusLed.h" +#include "util/FunctionRef.h" #include @@ -43,7 +44,6 @@ namespace OpenShock::Config { [[nodiscard]] LedConfig GetLedConfig(); bool SetLedConfig(const LedConfig& config); - bool SetLedMode(LedMode mode); - /// @brief `percent` is capped at LedConfig::kMaxBrightness. - bool SetLedBrightness(LedType type, LedSignal signal, uint8_t percent); + /// @brief Read-modify-write of the section (config/Section.h Update); brightness is capped at kMaxBrightness. + bool UpdateLedConfig(Util::FunctionRef update); } // namespace OpenShock::Config diff --git a/components/device_control/include/visual/VisualStateManager.h b/components/device_control/include/visual/VisualStateManager.h index 5571346b..460dda38 100644 --- a/components/device_control/include/visual/VisualStateManager.h +++ b/components/device_control/include/visual/VisualStateManager.h @@ -1,25 +1,65 @@ #pragma once #include "enums/StatusLed.h" +#include "util/FunctionRef.h" #include +#include + +namespace OpenShock::Config { + struct LedConfig; +} namespace OpenShock::VisualStateManager { [[nodiscard]] bool Init(); - // The setters save to the `led` config section and apply the change at once. + /// @brief Whether this board has an LED of `type`. + [[nodiscard]] bool HasLed(LedType type); + + // The setters save to the `led` config section and apply the change at once. A save that raises an RGB brightness + // above kRgbBrightnessWarnPercent logs a warning: a WS2812B is glaring and runs warm well before full output. + + inline constexpr uint8_t kRgbBrightnessWarnPercent = 30; [[nodiscard]] LedMode GetLedMode(); bool SetLedMode(LedMode mode); /// @brief Percent of the LED's full output (0-100) for patterns of `signal`. [[nodiscard]] uint8_t GetLedBrightness(LedType type, LedSignal signal); - /// @brief `percent` is capped at 100. Logs a warning for an RGB brightness above 30%. + /// @brief `percent` is capped at 100. bool SetLedBrightness(LedType type, LedSignal signal, uint8_t percent); + /// @brief Several LED settings in one config write (Config::UpdateLedConfig). + bool UpdateLedConfig(Util::FunctionRef update); + void SetCriticalError(); void SetScanningStarted(); + // Previews take over the LEDs for a few seconds after the last preview call, then the live state shows again. + // State events keep being tracked meanwhile. The LED test wins over a preview. + + enum class LedPattern : uint8_t { + WiFiDisconnected, + WiFiScanning, + NoBackend, // Network up, gateway not connected + Connected, + EStopped, + EStopClearing, + EStopAwaitingRelease, + CriticalError, + }; + + inline constexpr uint32_t kPreviewDurationMs = 10'000; + + /// @brief Shows `pattern` whatever the LED mode, at the configured brightness. + void PreviewPattern(LedPattern pattern); + /// @brief Shows a solid color: the RGB LED in `red`/`green`/`blue`, the mono LED on unless the color is black. + /// @param only Light only this LED; the other stays dark. All LEDs when unset. + /// @param brightness Percent (capped at 100) for this preview only; each LED's normal brightness when unset. + void PreviewColor(uint8_t red, uint8_t green, uint8_t blue, std::optional only, std::optional brightness); + /// @brief Ends a preview early. + void StopPreview(); + /// Cycles through all LED patterns for visual verification. Blocks for ~20s. void RunLedTest(); } // namespace OpenShock::VisualStateManager diff --git a/components/device_control/src/visual/LedConfig.cpp b/components/device_control/src/visual/LedConfig.cpp index 003f9acf..05a4ce6c 100644 --- a/components/device_control/src/visual/LedConfig.cpp +++ b/components/device_control/src/visual/LedConfig.cpp @@ -25,20 +25,21 @@ static constexpr const char* kKeyMonoNorm = "monoNorm"; static constexpr const char* kKeyRgbCrit = "rgbCrit"; static constexpr const char* kKeyRgbNorm = "rgbNorm"; -// Today's levels: the mono LED at full output, the RGB LED at 20/255 (8%) since a WS2812B at full output is blinding -static constexpr LedMode kDefaultMode = LedMode::All; -static constexpr uint8_t kDefaultMonoBright = 100; -static constexpr uint8_t kDefaultRgbBright = 8; +static constexpr LedMode kDefaultMode = LedMode::All; +static constexpr uint8_t kDefaultMonoCritBright = CONFIG_OPENSHOCK_LED_MONO_CRITICAL_BRIGHTNESS; +static constexpr uint8_t kDefaultMonoNormBright = CONFIG_OPENSHOCK_LED_MONO_NORMAL_BRIGHTNESS; +static constexpr uint8_t kDefaultRgbCritBright = CONFIG_OPENSHOCK_LED_RGB_CRITICAL_BRIGHTNESS; +static constexpr uint8_t kDefaultRgbNormBright = CONFIG_OPENSHOCK_LED_RGB_NORMAL_BRIGHTNESS; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "led", .order = 7, .namespaces = kNamespaces}); LedConfig::LedConfig() : mode(kDefaultMode) - , monoCriticalBrightness(kDefaultMonoBright) - , monoNormalBrightness(kDefaultMonoBright) - , rgbCriticalBrightness(kDefaultRgbBright) - , rgbNormalBrightness(kDefaultRgbBright) + , monoCriticalBrightness(kDefaultMonoCritBright) + , monoNormalBrightness(kDefaultMonoNormBright) + , rgbCriticalBrightness(kDefaultRgbCritBright) + , rgbNormalBrightness(kDefaultRgbNormBright) { } @@ -78,10 +79,10 @@ bool LedConfig::FromJSON(JSON::JsonView json) return false; } - monoCriticalBrightness = json["monoCriticalBrightness"].tryGetU8().value_or(kDefaultMonoBright); - monoNormalBrightness = json["monoNormalBrightness"].tryGetU8().value_or(kDefaultMonoBright); - rgbCriticalBrightness = json["rgbCriticalBrightness"].tryGetU8().value_or(kDefaultRgbBright); - rgbNormalBrightness = json["rgbNormalBrightness"].tryGetU8().value_or(kDefaultRgbBright); + monoCriticalBrightness = json["monoCriticalBrightness"].tryGetU8().value_or(kDefaultMonoCritBright); + monoNormalBrightness = json["monoNormalBrightness"].tryGetU8().value_or(kDefaultMonoNormBright); + rgbCriticalBrightness = json["rgbCriticalBrightness"].tryGetU8().value_or(kDefaultRgbCritBright); + rgbNormalBrightness = json["rgbNormalBrightness"].tryGetU8().value_or(kDefaultRgbNormBright); return true; } @@ -106,8 +107,9 @@ bool LedConfig::LoadNvs(const char* partition) } uint8_t storedMode = std::to_underlying(kDefaultMode); - bool ok = Nvs::Load(*handle, kKeyMode, storedMode, storedMode) && Nvs::Load(*handle, kKeyMonoCrit, monoCriticalBrightness, kDefaultMonoBright) && Nvs::Load(*handle, kKeyMonoNorm, monoNormalBrightness, kDefaultMonoBright) - && Nvs::Load(*handle, kKeyRgbCrit, rgbCriticalBrightness, kDefaultRgbBright) && Nvs::Load(*handle, kKeyRgbNorm, rgbNormalBrightness, kDefaultRgbBright); + bool ok = Nvs::Load(*handle, kKeyMode, storedMode, storedMode) && Nvs::Load(*handle, kKeyMonoCrit, monoCriticalBrightness, kDefaultMonoCritBright) + && Nvs::Load(*handle, kKeyMonoNorm, monoNormalBrightness, kDefaultMonoNormBright) && Nvs::Load(*handle, kKeyRgbCrit, rgbCriticalBrightness, kDefaultRgbCritBright) + && Nvs::Load(*handle, kKeyRgbNorm, rgbNormalBrightness, kDefaultRgbNormBright); if (!ok) { return false; } @@ -141,18 +143,7 @@ bool OpenShock::Config::SetLedConfig(const LedConfig& config) return s_section.Set(config); } -bool OpenShock::Config::SetLedMode(LedMode mode) +bool OpenShock::Config::UpdateLedConfig(Util::FunctionRef update) { - return s_section.Update([&](LedConfig& led) { - led.mode = mode; - return true; - }); -} - -bool OpenShock::Config::SetLedBrightness(LedType type, LedSignal signal, uint8_t percent) -{ - return s_section.Update([&](LedConfig& led) { - led.Brightness(type, signal) = std::min(percent, LedConfig::kMaxBrightness); - return true; - }); + return s_section.Update(update); } diff --git a/components/device_control/src/visual/VisualStateManager.cpp b/components/device_control/src/visual/VisualStateManager.cpp index f936c79b..868adb64 100644 --- a/components/device_control/src/visual/VisualStateManager.cpp +++ b/components/device_control/src/visual/VisualStateManager.cpp @@ -16,11 +16,13 @@ const char* const TAG = "VisualStateManager"; #include "visual/LedConfig.h" #include +#include #include #include #include #include +#include // Status LED pins come from the board descriptor stamped into the image (BoardDescriptor.h); // -1 (== GPIO_NUM_NC) means the board has no such LED. @@ -50,13 +52,11 @@ static std::unique_ptr s_rgbLedDriver; // The `led` section's settings, loaded in Init. Each is atomic on its own; a setter re-applies after storing, so a mix // of old and new values read mid-update is replaced at once. static std::atomic s_ledMode = OpenShock::LedMode::All; -static std::atomic s_monoCriticalBrightness = 100; // Percent of full output -static std::atomic s_monoNormalBrightness = 100; -static std::atomic s_rgbCriticalBrightness = 8; -static std::atomic s_rgbNormalBrightness = 8; +static std::atomic s_monoCriticalBrightness = CONFIG_OPENSHOCK_LED_MONO_CRITICAL_BRIGHTNESS; // Percent of full output +static std::atomic s_monoNormalBrightness = CONFIG_OPENSHOCK_LED_MONO_NORMAL_BRIGHTNESS; +static std::atomic s_rgbCriticalBrightness = CONFIG_OPENSHOCK_LED_RGB_CRITICAL_BRIGHTNESS; +static std::atomic s_rgbNormalBrightness = CONFIG_OPENSHOCK_LED_RGB_NORMAL_BRIGHTNESS; -// RGB brightness above this gets a warning: a WS2812B is glaring well before full output -static constexpr uint8_t kRgbBrightnessWarnPercent = 30; static std::atomic& brightnessSetting(OpenShock::LedType type, OpenShock::LedSignal signal) { @@ -67,6 +67,17 @@ static std::atomic& brightnessSetting(OpenShock::LedType type, OpenShoc return critical ? s_rgbCriticalBrightness : s_rgbNormalBrightness; } +// A preview's content: a state pattern, or a solid color. Guarded by s_applyMutex. +struct Preview { + bool isColor; + uint32_t flags; // Pattern preview: the state flags whose pattern shows + uint8_t red, green, blue; + std::optional only; + std::optional brightness; +}; +static std::optional s_preview; +static esp_timer_handle_t s_previewTimer = nullptr; // Ends the preview kPreviewDurationMs after the last call + // Percent (0-100) to the drivers' 0-255 scale, rounded static inline uint8_t toDriverBrightness(uint8_t percent) { @@ -313,17 +324,87 @@ static void applyVisualState(uint32_t flags, bool showAll = false) OS_LOGW(TAG, "Trying to update visual state, but no LED is active!"); } -static void updateVisualState() +static void applyColorPreview(const Preview& preview) { - OpenShock::ScopedLock lock__(&s_applyMutex); + bool monoLit = !preview.only.has_value() || *preview.only == LedType::Mono; + bool rgbLit = !preview.only.has_value() || *preview.only == LedType::Rgb; + + if (s_monoLedDriver != nullptr) { + s_monoLedDriver->SetBrightness(toDriverBrightness(preview.brightness.value_or(s_monoNormalBrightness.load()))); + bool on = monoLit && (preview.red | preview.green | preview.blue) != 0; + s_monoLedDriver->SetPattern(on ? kSolidOnPattern : kSolidOffPattern); + } + if (s_rgbLedDriver != nullptr) { + s_rgbLedDriver->SetBrightness(toDriverBrightness(preview.brightness.value_or(s_rgbNormalBrightness.load()))); + const RgbLedDriver::RGBState color[] = { + rgbLit ? RgbLedDriver::RGBState {preview.red, preview.green, preview.blue, 100'000} : RgbLedDriver::RGBState {0, 0, 0, 100'000} + }; + s_rgbLedDriver->SetPattern(color); + } +} + +// Caller holds s_applyMutex +static void applyCurrentLocked() +{ if (s_ledTestActive.load()) { - return; // Re-applied from the live flags when the test finishes + return; // Re-applied from the live flags (or the preview) when the test finishes + } + + if (s_preview.has_value()) { + if (s_preview->isColor) { + applyColorPreview(*s_preview); + } else { + applyVisualState(s_preview->flags, true); + } + return; } applyVisualState(s_stateFlags.load()); } +static void updateVisualState() +{ + OpenShock::ScopedLock lock__(&s_applyMutex); + applyCurrentLocked(); +} + +static void startPreview(const Preview& preview) +{ + if (s_previewTimer == nullptr) { + return; // No LED, or Init has not run + } + + OpenShock::ScopedLock lock__(&s_applyMutex); + s_preview = preview; + + // Restarted by every preview call, so a client that keeps previewing (a dragged slider) keeps the LEDs + esp_timer_stop(s_previewTimer); // ESP_ERR_INVALID_STATE when not running: fine + esp_err_t err = esp_timer_start_once(s_previewTimer, static_cast(VisualStateManager::kPreviewDurationMs) * 1000); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to start the preview timer: %s", esp_err_to_name(err)); + s_preview.reset(); // Never leave a preview up with nothing to end it + } + + applyCurrentLocked(); +} + +static void endPreview() +{ + OpenShock::ScopedLock lock__(&s_applyMutex); + if (!s_preview.has_value()) { + return; + } + s_preview.reset(); + applyCurrentLocked(); +} + +static void handlePreviewTimer(void* arg) +{ + (void)arg; + endPreview(); +} + static void handleEspWiFiEvent(void* event_handler_arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { (void)event_handler_arg; @@ -425,6 +506,18 @@ static void handleOpenShockEvent(void* event_handler_arg, esp_event_base_t event } } +// Copies the `led` section into the atomics applyVisualState reads +static void loadLedConfig() +{ + const Config::LedConfig ledConfig = Config::GetLedConfig(); + s_ledMode.store(ledConfig.mode); + for (LedType type : {LedType::Mono, LedType::Rgb}) { + for (LedSignal signal : {LedSignal::Critical, LedSignal::Normal}) { + brightnessSetting(type, signal).store(ledConfig.Brightness(type, signal)); + } + } +} + bool VisualStateManager::Init() { bool ledActive = false; @@ -465,15 +558,21 @@ bool VisualStateManager::Init() return true; } - const Config::LedConfig ledConfig = Config::GetLedConfig(); - s_ledMode.store(ledConfig.mode); - for (LedType type : {LedType::Mono, LedType::Rgb}) { - for (LedSignal signal : {LedSignal::Critical, LedSignal::Normal}) { - brightnessSetting(type, signal).store(ledConfig.Brightness(type, signal)); - } - } + loadLedConfig(); - esp_err_t err; + const esp_timer_create_args_t previewTimerArgs = { + .callback = handlePreviewTimer, + .arg = nullptr, + .dispatch_method = ESP_TIMER_TASK, + .name = "LedPreview", + .skip_unhandled_events = true, + }; + esp_err_t err = esp_timer_create(&previewTimerArgs, &s_previewTimer); + if (err != ESP_OK) { + // Previews only; the LEDs still show the hub's state + OS_LOGE(TAG, "Failed to create the LED preview timer: %s", esp_err_to_name(err)); + s_previewTimer = nullptr; + } err = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, handleEspWiFiEvent, nullptr); if (err != ESP_OK) { @@ -506,16 +605,13 @@ LedMode VisualStateManager::GetLedMode() bool VisualStateManager::SetLedMode(LedMode mode) { - if (!Config::SetLedMode(mode)) { - OS_LOGE(TAG, "Failed to save the LED mode to config"); - return false; - } - - if (s_ledMode.exchange(mode) != mode) { - updateVisualState(); - } - - return true; + return UpdateLedConfig([&](Config::LedConfig& led) { + if (led.mode == mode) { + return false; + } + led.mode = mode; + return true; + }); } uint8_t VisualStateManager::GetLedBrightness(LedType type, LedSignal signal) @@ -525,24 +621,93 @@ uint8_t VisualStateManager::GetLedBrightness(LedType type, LedSignal signal) bool VisualStateManager::SetLedBrightness(LedType type, LedSignal signal, uint8_t percent) { - percent = std::min(percent, Config::LedConfig::kMaxBrightness); + return UpdateLedConfig([&](Config::LedConfig& led) { + uint8_t& brightness = led.Brightness(type, signal); + if (brightness == percent) { + return false; + } + brightness = percent; // The section caps it (LedConfig::Normalize) + return true; + }); +} - if (!Config::SetLedBrightness(type, signal, percent)) { - OS_LOGE(TAG, "Failed to save the LED brightness to config"); +bool VisualStateManager::UpdateLedConfig(Util::FunctionRef update) +{ + if (!Config::UpdateLedConfig(update)) { + OS_LOGE(TAG, "Failed to save the LED settings to config"); return false; } - if (type == LedType::Rgb && percent > kRgbBrightnessWarnPercent) { - OS_LOGW(TAG, "RGB LED brightness set to %u%%: above %u%% a WS2812B is glaring and runs warm", percent, kRgbBrightnessWarnPercent); - } + // Under s_applyMutex so two updates can't store their snapshots out of order + OpenShock::ScopedLock lock__(&s_applyMutex); - if (brightnessSetting(type, signal).exchange(percent) != percent) { - updateVisualState(); + uint8_t rgbBefore[] = {s_rgbCriticalBrightness.load(), s_rgbNormalBrightness.load()}; + loadLedConfig(); + uint8_t rgbAfter[] = {s_rgbCriticalBrightness.load(), s_rgbNormalBrightness.load()}; + for (std::size_t i = 0; i < std::size(rgbAfter); ++i) { + if (rgbAfter[i] > VisualStateManager::kRgbBrightnessWarnPercent && rgbAfter[i] != rgbBefore[i]) { + OS_LOGW(TAG, "RGB LED %s brightness set to %u%%: above %u%% a WS2812B is glaring and runs warm", i == 0 ? "critical" : "normal", rgbAfter[i], VisualStateManager::kRgbBrightnessWarnPercent); + } } + applyCurrentLocked(); return true; } +bool VisualStateManager::HasLed(LedType type) +{ + return type == LedType::Mono ? s_monoLedDriver != nullptr : s_rgbLedDriver != nullptr; +} + +void VisualStateManager::PreviewPattern(LedPattern pattern) +{ + uint32_t flags = 0; + switch (pattern) { + case LedPattern::WiFiDisconnected: + flags = 0; + break; + case LedPattern::WiFiScanning: + flags = kWiFiScanningFlag; + break; + case LedPattern::NoBackend: + flags = kHasIpAddressFlag | kWiFiConnectedFlag; + break; + case LedPattern::Connected: + flags = kStatusOKMask; + break; + case LedPattern::EStopped: + flags = kEmergencyStoppedFlag; + break; + case LedPattern::EStopClearing: + flags = kEmergencyStoppedFlag | kEmergencyStopActiveClearingFlag; + break; + case LedPattern::EStopAwaitingRelease: + flags = kEmergencyStoppedFlag | kEmergencyStopAwaitingReleaseFlag; + break; + case LedPattern::CriticalError: + flags = kCriticalErrorFlag; + break; + } + + startPreview(Preview {.isColor = false, .flags = flags, .red = 0, .green = 0, .blue = 0, .only = std::nullopt, .brightness = std::nullopt}); +} + +void VisualStateManager::PreviewColor(uint8_t red, uint8_t green, uint8_t blue, std::optional only, std::optional brightness) +{ + if (brightness.has_value()) { + brightness = std::min(*brightness, Config::LedConfig::kMaxBrightness); + } + startPreview(Preview {.isColor = true, .flags = 0, .red = red, .green = green, .blue = blue, .only = only, .brightness = brightness}); +} + +void VisualStateManager::StopPreview() +{ + if (s_previewTimer != nullptr) { + esp_timer_stop(s_previewTimer); + } + endPreview(); +} + void VisualStateManager::SetCriticalError() { if (setStateFlag(kCriticalErrorFlag, true)) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 56a87b91..9796c477 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -298,3 +298,104 @@ export function checkOtaUpdates(channel: OtaChannel): Promise { 'Failed to check for OTA updates' ); } + +// Status LEDs + +export type LedMode = 'all' | 'critical' | 'off'; +export type LedType = 'mono' | 'rgb'; +export type LedSignal = 'critical' | 'normal'; +export type LedPattern = + | 'disconnected' + | 'scanning' + | 'nobackend' + | 'connected' + | 'estop' + | 'estopclearing' + | 'estoprelease' + | 'error'; + +export interface LedBrightness { + present: boolean; // Whether the board has this LED + critical: number; // Percent of the LED's full output + normal: number; +} + +export interface LedSettings { + mode: LedMode; + mono: LedBrightness; + rgb: LedBrightness; + rgbWarnAbove: number; // RGB brightness (percent) above which the hub warns + previewMs: number; // How long a preview holds the LEDs after the last preview request +} + +export async function fetchLedSettings(): Promise { + try { + const res = await apiFetch('/api/led'); + if (!res.ok) { + toast.error('Failed to load LED settings: ' + (await getErrorMessage(res))); + return null; + } + return (await res.json()) as LedSettings; + } catch { + toast.error('Failed to load LED settings'); + return null; + } +} + +export type LedSettingsPatch = Partial< + Record<'mode' | 'monoCritical' | 'monoNormal' | 'rgbCritical' | 'rgbNormal', string> +>; + +// Any subset in one config write (PUT /api/led) +export async function setLedSettings(params: LedSettingsPatch): Promise { + try { + const res = await apiFetch('/api/led?' + new URLSearchParams(params), { method: 'PUT' }); + if (!res.ok) { + toast.error('Failed to save LED settings: ' + (await getErrorMessage(res))); + return false; + } + return true; + } catch { + toast.error('Failed to save LED settings'); + return false; + } +} + +// Previews hold the LEDs for LedSettings.previewMs after the last request; nothing is saved. + +export async function previewLedPattern(pattern: LedPattern): Promise { + try { + const res = await apiFetch('/api/led/preview?' + new URLSearchParams({ pattern }), { + method: 'POST', + }); + if (!res.ok) toast.error('Failed to preview LED pattern: ' + (await getErrorMessage(res))); + } catch { + toast.error('Failed to preview LED pattern'); + } +} + +// `color` is RRGGBB. Silent on failure: called many times a second while dragging. +export async function previewLedColor( + color: string, + options: { led?: LedType; brightness?: number } = {} +): Promise { + const params: Record = { color }; + if (options.led) params.led = options.led; + if (options.brightness !== undefined) params.brightness = String(options.brightness); + try { + const res = await apiFetch('/api/led/preview?' + new URLSearchParams(params), { + method: 'POST', + }); + return res.ok; + } catch { + return false; + } +} + +export async function stopLedPreview(): Promise { + try { + await apiFetch('/api/led/preview', { method: 'DELETE' }); + } catch { + // The preview ends on its own + } +} diff --git a/frontend/src/lib/components/ColorWheel.svelte b/frontend/src/lib/components/ColorWheel.svelte new file mode 100644 index 00000000..daf5865a --- /dev/null +++ b/frontend/src/lib/components/ColorWheel.svelte @@ -0,0 +1,98 @@ + + +
+ +
diff --git a/frontend/src/lib/components/sections/LedSection.svelte b/frontend/src/lib/components/sections/LedSection.svelte new file mode 100644 index 00000000..50070ecf --- /dev/null +++ b/frontend/src/lib/components/sections/LedSection.svelte @@ -0,0 +1,237 @@ + + +
+ + + {#if !settings} +

Loading LED settings…

+ {:else if presentTypes.length === 0} +

This board has no status LED.

+ {:else} +
+ +
+ {#each modes as mode (mode.value)} + {@const active = settings.mode === mode.value} + + {/each} +
+

+ {modes.find((m) => m.value === settings!.mode)?.description} +

+
+ + {#each presentTypes as { type, label } (type)} +
+

{label} brightness

+ {#each signals as s (s.signal)} + {@const value = settings[type][s.signal]} +
+
+ + {value}% +
+ onBrightnessDrag(type, s.signal, v)} + onValueCommit={(v: number) => onBrightnessCommit(type, s.signal, v)} + /> + {#if type === 'rgb' && value > settings.rgbWarnAbove} +

+ + Above {settings.rgbWarnAbove}% the RGB LED is glaring and runs warm. +

+ {/if} +
+ {/each} +
+ {/each} + +
+ +

+ Shows a state's pattern whatever the mode, at the saved brightness, for {previewSeconds} seconds. +

+
+ {#each patterns as pattern (pattern.value)} + + {/each} +
+
+ + {#if settings.rgb.present} +
+ +

+ Lights the RGB LED in the picked color at its normal brightness. +

+
+ +
+ + #{color} +
+
+
+ {/if} + + + {/if} +
diff --git a/frontend/src/lib/views/Advanced.svelte b/frontend/src/lib/views/Advanced.svelte index d9edf2d3..7b08fc02 100644 --- a/frontend/src/lib/views/Advanced.svelte +++ b/frontend/src/lib/views/Advanced.svelte @@ -1,8 +1,9 @@ @@ -27,6 +29,7 @@ import TestStep from '#lib/components/steps/TestStep.svelte'; import WiFiStep from '#lib/components/steps/WiFiStep.svelte'; import OtaSection from '#lib/components/sections/OtaSection.svelte'; + import LedSection from '#lib/components/sections/LedSection.svelte'; import SectionHeader from '#lib/components/SectionHeader.svelte'; import { ViewModeStore } from '#lib/stores/index.js'; import { Button } from '@openshock/svelte-core/components/ui/button'; @@ -108,6 +111,8 @@ {:else if shownSection === 'ota'} + {:else if shownSection === 'leds'} + {/if} From 4c90ba87d6ea45d5dd056bc4780fbbc2a6d103bf Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 12:00:09 +0100 Subject: [PATCH 04/27] feat(portal): stream LED previews over the WebSocket and follow the portal's UI conventions Previews move from HTTP to the local WebSocket: the color wheel and brightness sliders send them many times a second, and a ~20 byte frame on the open socket costs far less than a request on the single httpd task. The portal rate-limits them to one per 40 ms, latest wins, and holds off while the socket still has bytes queued. The hub gets the LED settings into the Ready message, so GET /api/led and the preview endpoints go; PUT /api/led stays for saving. Bumps the schemas submodule for LedSettings in ReadyMessage and the LedPreviewPattern/Color/Stop messages. UI: theme warning token instead of raw amber, Title Case labels and buttons, gap-4 spacing, h2 card headings, the info box for the preview note, and Loading... like the other sections. The brightness sliders get accessible names through a LabeledSlider that labels the thumb (the shadcn Slider puts extra props on the root, which is not the control). Brightness saves wait 500 ms for commits to stop, so stepping with the arrow keys is one flash write rather than one per step. --- .changes/status-led-toggle.md | 2 +- .../src/captiveportal/ApiHandlers.cpp | 134 +------ .../core/src/message_handlers/impl/WSLocal.h | 3 + .../src/message_handlers/websocket/Local.cpp | 3 + .../websocket/local/LedPreview.cpp | 79 ++++ components/core/src/serialization/WSLocal.cpp | 22 +- .../_fbs/HubToLocalMessage_generated.h | 240 +++++++++++- .../_fbs/LocalToHubMessage_generated.h | 341 +++++++++++++++++- frontend/src/lib/WebSocketClient.ts | 5 + .../_fbs/open-shock/serialization/local.ts | 5 + .../serialization/local/led-brightness.ts | 67 ++++ .../serialization/local/led-mode.ts | 9 + .../local/led-preview-color-message.ts | 102 ++++++ .../local/led-preview-pattern-message.ts | 54 +++ .../local/led-preview-pattern.ts | 14 + .../local/led-preview-stop-message.ts | 41 +++ .../serialization/local/led-settings.ts | 110 ++++++ .../serialization/local/led-target.ts | 9 + .../local/local-to-hub-message-payload.ts | 22 +- .../serialization/local/ready-message.ts | 12 +- frontend/src/lib/api.ts | 93 +---- frontend/src/lib/components/ColorWheel.svelte | 9 +- .../src/lib/components/LabeledSlider.svelte | 72 ++++ .../lib/components/sections/LedSection.svelte | 216 +++++------ frontend/src/lib/ledPreview.ts | 102 ++++++ frontend/src/lib/mappers/ConfigMapper.ts | 40 ++ schemas | 2 +- 27 files changed, 1464 insertions(+), 344 deletions(-) create mode 100644 components/core/src/message_handlers/websocket/local/LedPreview.cpp create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-brightness.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-mode.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern-message.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-stop-message.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/led-target.ts create mode 100644 frontend/src/lib/components/LabeledSlider.svelte create mode 100644 frontend/src/lib/ledPreview.ts diff --git a/.changes/status-led-toggle.md b/.changes/status-led-toggle.md index ca41892e..5fdac5f4 100644 --- a/.changes/status-led-toggle.md +++ b/.changes/status-led-toggle.md @@ -12,6 +12,6 @@ The captive portal has a new LEDs section with the same settings, brightness sli - Add the `led` config section (NVS namespace `led`, keys `mode`, `monoCrit`, `monoNorm`, `rgbCrit`, `rgbNorm` (u8); `led.mode`, `led.monoCriticalBrightness`, `led.monoNormalBrightness`, `led.rgbCriticalBrightness` and `led.rgbNormalBrightness` in jsonconfig) - The defaults keep today's look: every state shown, the built-in LED at 100%, the RGB LED at 8% (20/255). The default brightnesses are Kconfig options (`OPENSHOCK_LED_*_BRIGHTNESS`) -- Captive portal API: `GET`/`PUT /api/led` reads and saves the settings, `POST /api/led/preview?pattern=` or `?color=RRGGBB[&led=][&brightness=]` previews for 10 seconds without saving, `DELETE /api/led/preview` ends a preview +- The portal gets the LED settings in its Ready message and saves them with `PUT /api/led` (any subset in one config write). Previews stream over the local WebSocket (`LedPreviewPatternMessage`, `LedPreviewColorMessage`, `LedPreviewStopMessage`), rate limited by the portal, and last 10 seconds after the last one without saving anything - An RGB brightness above 30% logs a warning - The LED test (`ledtest`) shows every state pattern whatever the mode, at the configured brightness diff --git a/components/captive_portal/src/captiveportal/ApiHandlers.cpp b/components/captive_portal/src/captiveportal/ApiHandlers.cpp index 527e7b7b..b3ade500 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -388,30 +388,6 @@ static esp_err_t apiOtaCheck(httpd_req_t* req) return sendResp(req, S200, nullptr, {}); } -// GET /api/led: the LED settings, which LEDs the board has, and the limits the UI shows -static esp_err_t apiLedGet(httpd_req_t* req) -{ - namespace Vsm = OpenShock::VisualStateManager; - - OpenShock::JSON::StringWriter writer; - json_gen_str_t* gen = writer.gen(); - json_gen_start_object(gen); - OpenShock::JSON::objSetString(gen, "mode", LedModeName(Vsm::GetLedMode())); - for (LedType type : {LedType::Mono, LedType::Rgb}) { - const char* name = type == LedType::Mono ? "mono" : "rgb"; - OpenShock::JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "present", Vsm::HasLed(type)); - json_gen_obj_set_int(gen, "critical", Vsm::GetLedBrightness(type, LedSignal::Critical)); - json_gen_obj_set_int(gen, "normal", Vsm::GetLedBrightness(type, LedSignal::Normal)); - OpenShock::JSON::objEnd(gen, name); - } - json_gen_obj_set_int(gen, "rgbWarnAbove", Vsm::kRgbBrightnessWarnPercent); - json_gen_obj_set_int(gen, "previewMs", static_cast(Vsm::kPreviewDurationMs)); - json_gen_end_object(gen); - std::string json = writer.finish(); - return sendResp(req, S200, HTTP::ContentType::JSON, json); -} - // Reads an optional 0-100 percent query parameter; false (after responding) on an invalid value static bool parsePercentParam(httpd_req_t* req, const char* key, std::optional& out, esp_err_t& response) { @@ -479,112 +455,6 @@ static esp_err_t apiLedPut(httpd_req_t* req) return sendResp(req, S200, nullptr, {}); } -static std::optional parseLedPattern(std::string_view str) -{ - using VisualStateManager::LedPattern; - struct Entry { - std::string_view name; - LedPattern pattern; - }; - static constexpr Entry kPatterns[] = { - {"disconnected", LedPattern::WiFiDisconnected}, - {"scanning", LedPattern::WiFiScanning}, - {"nobackend", LedPattern::NoBackend}, - {"connected", LedPattern::Connected}, - {"estop", LedPattern::EStopped}, - {"estopclearing", LedPattern::EStopClearing}, - {"estoprelease", LedPattern::EStopAwaitingRelease}, - {"error", LedPattern::CriticalError}, - }; - for (const Entry& entry : kPatterns) { - if (StringIEquals(str, entry.name)) { - return entry.pattern; - } - } - return std::nullopt; -} - -static std::optional parseHexByte(std::string_view str) -{ - uint8_t value = 0; - for (char c : str) { - uint8_t digit; - if (c >= '0' && c <= '9') { - digit = c - '0'; - } else if (c >= 'a' && c <= 'f') { - digit = c - 'a' + 10; - } else if (c >= 'A' && c <= 'F') { - digit = c - 'A' + 10; - } else { - return std::nullopt; - } - value = static_cast(value * 16 + digit); - } - return value; -} - -// POST /api/led/preview?pattern= -// POST /api/led/preview?color=RRGGBB[&led=mono|rgb][&brightness=0-100] -// Takes over the LEDs for VisualStateManager::kPreviewDurationMs after the last call; nothing is saved. -static esp_err_t apiLedPreview(httpd_req_t* req) -{ - auto patternStr = getQueryParam(req, "pattern"); - if (!patternStr && patternStr.error() != ParamError::Missing) { - return sendParamError(req, patternStr.error()); - } - if (patternStr) { - auto pattern = parseLedPattern(*patternStr); - if (!pattern) { - return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); - } - VisualStateManager::PreviewPattern(*pattern); - return sendResp(req, S200, nullptr, {}); - } - - auto colorStr = getQueryParam(req, "color"); - if (!colorStr) { - return sendParamError(req, colorStr.error()); - } - std::string_view color = *colorStr; - if (color.size() != 6) { - return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); - } - auto red = parseHexByte(color.substr(0, 2)); - auto green = parseHexByte(color.substr(2, 2)); - auto blue = parseHexByte(color.substr(4, 2)); - if (!red || !green || !blue) { - return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); - } - - std::optional only; - auto ledStr = getQueryParam(req, "led"); - if (!ledStr && ledStr.error() != ParamError::Missing) { - return sendParamError(req, ledStr.error()); - } - if (ledStr) { - only = TryParseLedType(*ledStr); - if (!only) { - return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); - } - } - - std::optional brightness; - esp_err_t response = ESP_OK; - if (!parsePercentParam(req, "brightness", brightness, response)) { - return response; - } - - VisualStateManager::PreviewColor(*red, *green, *blue, only, brightness); - return sendResp(req, S200, nullptr, {}); -} - -// DELETE /api/led/preview: ends a preview early -static esp_err_t apiLedPreviewStop(httpd_req_t* req) -{ - VisualStateManager::StopPreview(); - return sendResp(req, S200, nullptr, {}); -} - void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInstance* instance) { registerHandler(server, "/api/board", HTTP_GET, apiBoard, instance); @@ -602,8 +472,6 @@ void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInst // changing it would let anyone in range install firmware. It can only be changed over serial (jsonconfig). registerHandler(server, "/api/ota/settings", HTTP_PUT, apiOtaSettings, instance); registerHandler(server, "/api/ota/check", HTTP_POST, apiOtaCheck, instance); - registerHandler(server, "/api/led", HTTP_GET, apiLedGet, instance); + // The LED settings reach the portal in its Ready message, and previews stream over the WebSocket registerHandler(server, "/api/led", HTTP_PUT, apiLedPut, instance); - registerHandler(server, "/api/led/preview", HTTP_POST, apiLedPreview, instance); - registerHandler(server, "/api/led/preview", HTTP_DELETE, apiLedPreviewStop, instance); } diff --git a/components/core/src/message_handlers/impl/WSLocal.h b/components/core/src/message_handlers/impl/WSLocal.h index 725b4e62..a9ec2d58 100644 --- a/components/core/src/message_handlers/impl/WSLocal.h +++ b/components/core/src/message_handlers/impl/WSLocal.h @@ -11,6 +11,9 @@ namespace OpenShock::MessageHandlers::Local::_Private { using HandlerType = HANDLER_SIG((*)); HANDLER_FN(Common_ShockerCommandList); + HANDLER_FN(LedPreviewPatternMessage); + HANDLER_FN(LedPreviewColorMessage); + HANDLER_FN(LedPreviewStopMessage); HANDLER_FN(InvalidMessage); } // namespace OpenShock::MessageHandlers::Local::_Private diff --git a/components/core/src/message_handlers/websocket/Local.cpp b/components/core/src/message_handlers/websocket/Local.cpp index 4427cd91..1f948e07 100644 --- a/components/core/src/message_handlers/websocket/Local.cpp +++ b/components/core/src/message_handlers/websocket/Local.cpp @@ -27,6 +27,9 @@ static std::array s_localHandlers = []() { handlers.fill(Handlers::HandleInvalidMessage); SET_HANDLER(Common_ShockerCommandList); + SET_HANDLER(LedPreviewPatternMessage); + SET_HANDLER(LedPreviewColorMessage); + SET_HANDLER(LedPreviewStopMessage); return handlers; }(); diff --git a/components/core/src/message_handlers/websocket/local/LedPreview.cpp b/components/core/src/message_handlers/websocket/local/LedPreview.cpp new file mode 100644 index 00000000..aefddcb7 --- /dev/null +++ b/components/core/src/message_handlers/websocket/local/LedPreview.cpp @@ -0,0 +1,79 @@ +#include "message_handlers/impl/WSLocal.h" + +const char* const TAG = "LocalMessageHandlers"; + +#include "Logging.h" +#include "visual/VisualStateManager.h" + +#include +#include + +namespace Schemas = OpenShock::Serialization::Local; +namespace Vsm = OpenShock::VisualStateManager; + +// The handlers cast between these by value +static_assert(std::to_underlying(Vsm::LedPattern::WiFiDisconnected) == std::to_underlying(Schemas::LedPreviewPattern::WifiDisconnected)); +static_assert(std::to_underlying(Vsm::LedPattern::WiFiScanning) == std::to_underlying(Schemas::LedPreviewPattern::WifiScanning)); +static_assert(std::to_underlying(Vsm::LedPattern::NoBackend) == std::to_underlying(Schemas::LedPreviewPattern::NoBackend)); +static_assert(std::to_underlying(Vsm::LedPattern::Connected) == std::to_underlying(Schemas::LedPreviewPattern::Connected)); +static_assert(std::to_underlying(Vsm::LedPattern::EStopped) == std::to_underlying(Schemas::LedPreviewPattern::EStopped)); +static_assert(std::to_underlying(Vsm::LedPattern::EStopClearing) == std::to_underlying(Schemas::LedPreviewPattern::EStopClearing)); +static_assert(std::to_underlying(Vsm::LedPattern::EStopAwaitingRelease) == std::to_underlying(Schemas::LedPreviewPattern::EStopAwaitingRelease)); +static_assert(std::to_underlying(Vsm::LedPattern::CriticalError) == std::to_underlying(Schemas::LedPreviewPattern::CriticalError)); + +void OpenShock::MessageHandlers::Local::_Private::HandleLedPreviewPatternMessage(uint8_t socketId, const Schemas::LocalToHubMessage* msg) +{ + (void)socketId; + + auto preview = msg->payload_as_LedPreviewPatternMessage(); + if (preview == nullptr) { + OS_LOGE(TAG, "Payload cannot be parsed as LedPreviewPatternMessage"); + return; + } + + // The enum comes off the wire: the verifier does not check its range + auto pattern = preview->pattern(); + if (pattern < Schemas::LedPreviewPattern::MIN || pattern > Schemas::LedPreviewPattern::MAX) { + OS_LOGW(TAG, "Unknown LED preview pattern %u", static_cast(std::to_underlying(pattern))); + return; + } + + Vsm::PreviewPattern(static_cast(std::to_underlying(pattern))); +} + +void OpenShock::MessageHandlers::Local::_Private::HandleLedPreviewColorMessage(uint8_t socketId, const Schemas::LocalToHubMessage* msg) +{ + (void)socketId; + + auto preview = msg->payload_as_LedPreviewColorMessage(); + if (preview == nullptr) { + OS_LOGE(TAG, "Payload cannot be parsed as LedPreviewColorMessage"); + return; + } + + std::optional only; + switch (preview->target()) { + case Schemas::LedTarget::All: + break; + case Schemas::LedTarget::Mono: + only = LedType::Mono; + break; + case Schemas::LedTarget::Rgb: + only = LedType::Rgb; + break; + default: + OS_LOGW(TAG, "Unknown LED preview target %u", static_cast(std::to_underlying(preview->target()))); + return; + } + + auto brightness = preview->brightness(); + Vsm::PreviewColor(preview->red(), preview->green(), preview->blue(), only, brightness.has_value() ? std::optional(*brightness) : std::nullopt); +} + +void OpenShock::MessageHandlers::Local::_Private::HandleLedPreviewStopMessage(uint8_t socketId, const Schemas::LocalToHubMessage* msg) +{ + (void)socketId; + (void)msg; + + Vsm::StopPreview(); +} diff --git a/components/core/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index ede4a9c6..77eda993 100644 --- a/components/core/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -7,6 +7,7 @@ #include "OtaUpdateConfig.h" #include "radio/RFConfig.h" #include "util/HexUtils.h" +#include "visual/VisualStateManager.h" #include "wifi/WiFiConfig.h" #include "wifi/WiFiNetwork.h" @@ -75,6 +76,24 @@ static flatbuffers::Offset createOtaSettings(flatbuffers::Fl return Local::CreateOtaSettings(builder, ota.isEnabled, cdnDomain, static_cast(ota.updateChannel), ota.checkInterval, ota.allowBackendManagement, ota.requireManualApproval); } +// createLedSettings casts between these by value +static_assert(std::to_underlying(OpenShock::LedMode::All) == std::to_underlying(Local::LedMode::All)); +static_assert(std::to_underlying(OpenShock::LedMode::CriticalOnly) == std::to_underlying(Local::LedMode::CriticalOnly)); +static_assert(std::to_underlying(OpenShock::LedMode::Off) == std::to_underlying(Local::LedMode::Off)); + +static flatbuffers::Offset createLedSettings(flatbuffers::FlatBufferBuilder& builder) +{ + namespace Vsm = OpenShock::VisualStateManager; + using OpenShock::LedSignal; + using OpenShock::LedType; + + auto brightness = [&](LedType type) { return Local::CreateLedBrightness(builder, Vsm::GetLedBrightness(type, LedSignal::Critical), Vsm::GetLedBrightness(type, LedSignal::Normal)); }; + auto mono = brightness(LedType::Mono); + auto rgb = brightness(LedType::Rgb); + + return Local::CreateLedSettings(builder, static_cast(Vsm::GetLedMode()), Vsm::HasLed(LedType::Mono), Vsm::HasLed(LedType::Rgb), mono, rgb, Vsm::kRgbBrightnessWarnPercent, Vsm::kPreviewDurationMs); +} + bool Local::SerializeErrorMessage(std::string_view message, Common::SerializationCallbackFn callback) { flatbuffers::FlatBufferBuilder builder(64 + message.length()); @@ -106,11 +125,12 @@ bool Local::SerializeReadyMessage(const WiFiNetwork* connectedNetwork, bool acco auto savedSsidsOffset = builder.CreateVector(savedSsids); auto otaOffset = createOtaSettings(builder); + auto ledOffset = createLedSettings(builder); const auto rfTxPin = static_cast(OpenShock::Config::GetRFConfigTxPin()); const auto estop = OpenShock::Config::GetEStopConfig(); - auto readyMessageOffset = Serialization::Local::CreateReadyMessage(builder, true, fbsNetwork, accountLinked, inputPinsOffset, outputPinsOffset, false, rfTxPin, estop.enabled, static_cast(estop.gpioPin), savedSsidsOffset, otaOffset); + auto readyMessageOffset = Serialization::Local::CreateReadyMessage(builder, true, fbsNetwork, accountLinked, inputPinsOffset, outputPinsOffset, false, rfTxPin, estop.enabled, static_cast(estop.gpioPin), savedSsidsOffset, otaOffset, ledOffset); return finishMessage(builder, Local::HubToLocalMessagePayload::ReadyMessage, readyMessageOffset, callback); } diff --git a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h index c12eadce..45908545 100644 --- a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h +++ b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h @@ -24,6 +24,12 @@ namespace Local { struct OtaSettings; struct OtaSettingsBuilder; +struct LedBrightness; +struct LedBrightnessBuilder; + +struct LedSettings; +struct LedSettingsBuilder; + struct ReadyMessage; struct ReadyMessageBuilder; @@ -81,6 +87,39 @@ inline const char *EnumNameOtaChannel(OtaChannel e) { return EnumNamesOtaChannel()[index]; } +enum class LedMode : uint8_t { + All = 0, + CriticalOnly = 1, + Off = 2, + MIN = All, + MAX = Off +}; + +inline const LedMode (&EnumValuesLedMode())[3] { + static const LedMode values[] = { + LedMode::All, + LedMode::CriticalOnly, + LedMode::Off + }; + return values; +} + +inline const char * const *EnumNamesLedMode() { + static const char * const names[4] = { + "All", + "CriticalOnly", + "Off", + nullptr + }; + return names; +} + +inline const char *EnumNameLedMode(LedMode e) { + if (::flatbuffers::IsOutRange(e, LedMode::All, LedMode::Off)) return ""; + const size_t index = static_cast(e); + return EnumNamesLedMode()[index]; +} + enum class HubToLocalMessagePayload : uint8_t { NONE = 0, ReadyMessage = 1, @@ -288,6 +327,186 @@ inline ::flatbuffers::Offset CreateOtaSettingsDirect( require_manual_approval); } +/// Brightness of one status LED, in percent of its full output. +struct LedBrightness FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LedBrightnessBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.LedBrightness"; + } + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_CRITICAL = 4, + VT_NORMAL = 6 + }; + /// E-Stop and critical-error patterns + uint8_t critical() const { + return GetField(VT_CRITICAL, 0); + } + /// Every other pattern + uint8_t normal() const { + return GetField(VT_NORMAL, 0); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_CRITICAL, 1) && + VerifyField(verifier, VT_NORMAL, 1) && + verifier.EndTable(); + } +}; + +struct LedBrightnessBuilder { + typedef LedBrightness Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_critical(uint8_t critical) { + fbb_.AddElement(LedBrightness::VT_CRITICAL, critical, 0); + } + void add_normal(uint8_t normal) { + fbb_.AddElement(LedBrightness::VT_NORMAL, normal, 0); + } + explicit LedBrightnessBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLedBrightness( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint8_t critical = 0, + uint8_t normal = 0) { + LedBrightnessBuilder builder_(_fbb); + builder_.add_normal(normal); + builder_.add_critical(critical); + return builder_.Finish(); +} + +struct LedBrightness::Traits { + using type = LedBrightness; + static auto constexpr Create = CreateLedBrightness; +}; + +/// The status LED settings the portal shows and edits. +struct LedSettings FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LedSettingsBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.LedSettings"; + } + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_MODE = 4, + VT_HAS_MONO = 6, + VT_HAS_RGB = 8, + VT_MONO = 10, + VT_RGB = 12, + VT_RGB_WARN_ABOVE = 14, + VT_PREVIEW_MS = 16 + }; + OpenShock::Serialization::Local::LedMode mode() const { + return static_cast(GetField(VT_MODE, 0)); + } + bool has_mono() const { + return GetField(VT_HAS_MONO, 0) != 0; + } + bool has_rgb() const { + return GetField(VT_HAS_RGB, 0) != 0; + } + const OpenShock::Serialization::Local::LedBrightness *mono() const { + return GetPointer(VT_MONO); + } + const OpenShock::Serialization::Local::LedBrightness *rgb() const { + return GetPointer(VT_RGB); + } + /// The hub warns about an RGB brightness above this percent. + uint8_t rgb_warn_above() const { + return GetField(VT_RGB_WARN_ABOVE, 0); + } + /// How long a preview holds the LEDs after the last preview message. + uint32_t preview_ms() const { + return GetField(VT_PREVIEW_MS, 0); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_MODE, 1) && + VerifyField(verifier, VT_HAS_MONO, 1) && + VerifyField(verifier, VT_HAS_RGB, 1) && + VerifyOffset(verifier, VT_MONO) && + verifier.VerifyTable(mono()) && + VerifyOffset(verifier, VT_RGB) && + verifier.VerifyTable(rgb()) && + VerifyField(verifier, VT_RGB_WARN_ABOVE, 1) && + VerifyField(verifier, VT_PREVIEW_MS, 4) && + verifier.EndTable(); + } +}; + +struct LedSettingsBuilder { + typedef LedSettings Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_mode(OpenShock::Serialization::Local::LedMode mode) { + fbb_.AddElement(LedSettings::VT_MODE, static_cast(mode), 0); + } + void add_has_mono(bool has_mono) { + fbb_.AddElement(LedSettings::VT_HAS_MONO, static_cast(has_mono), 0); + } + void add_has_rgb(bool has_rgb) { + fbb_.AddElement(LedSettings::VT_HAS_RGB, static_cast(has_rgb), 0); + } + void add_mono(::flatbuffers::Offset mono) { + fbb_.AddOffset(LedSettings::VT_MONO, mono); + } + void add_rgb(::flatbuffers::Offset rgb) { + fbb_.AddOffset(LedSettings::VT_RGB, rgb); + } + void add_rgb_warn_above(uint8_t rgb_warn_above) { + fbb_.AddElement(LedSettings::VT_RGB_WARN_ABOVE, rgb_warn_above, 0); + } + void add_preview_ms(uint32_t preview_ms) { + fbb_.AddElement(LedSettings::VT_PREVIEW_MS, preview_ms, 0); + } + explicit LedSettingsBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLedSettings( + ::flatbuffers::FlatBufferBuilder &_fbb, + OpenShock::Serialization::Local::LedMode mode = OpenShock::Serialization::Local::LedMode::All, + bool has_mono = false, + bool has_rgb = false, + ::flatbuffers::Offset mono = 0, + ::flatbuffers::Offset rgb = 0, + uint8_t rgb_warn_above = 0, + uint32_t preview_ms = 0) { + LedSettingsBuilder builder_(_fbb); + builder_.add_preview_ms(preview_ms); + builder_.add_rgb(rgb); + builder_.add_mono(mono); + builder_.add_rgb_warn_above(rgb_warn_above); + builder_.add_has_rgb(has_rgb); + builder_.add_has_mono(has_mono); + builder_.add_mode(mode); + return builder_.Finish(); +} + +struct LedSettings::Traits { + using type = LedSettings; + static auto constexpr Create = CreateLedSettings; +}; + struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { typedef ReadyMessageBuilder Builder; struct Traits; @@ -305,7 +524,8 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { VT_ESTOP_ENABLED = 18, VT_ESTOP_GPIO_PIN = 20, VT_SAVED_SSIDS = 22, - VT_OTA = 24 + VT_OTA = 24, + VT_LED = 26 }; bool poggies() const { return GetField(VT_POGGIES, 0) != 0; @@ -341,6 +561,9 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { const OpenShock::Serialization::Local::OtaSettings *ota() const { return GetPointer(VT_OTA); } + const OpenShock::Serialization::Local::LedSettings *led() const { + return GetPointer(VT_LED); + } template bool Verify(::flatbuffers::VerifierTemplate &verifier) const { return VerifyTableStart(verifier) && @@ -361,6 +584,8 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { verifier.VerifyVectorOfStrings(saved_ssids()) && VerifyOffset(verifier, VT_OTA) && verifier.VerifyTable(ota()) && + VerifyOffset(verifier, VT_LED) && + verifier.VerifyTable(led()) && verifier.EndTable(); } }; @@ -402,6 +627,9 @@ struct ReadyMessageBuilder { void add_ota(::flatbuffers::Offset ota) { fbb_.AddOffset(ReadyMessage::VT_OTA, ota); } + void add_led(::flatbuffers::Offset led) { + fbb_.AddOffset(ReadyMessage::VT_LED, led); + } explicit ReadyMessageBuilder(::flatbuffers::FlatBufferBuilder &_fbb) : fbb_(_fbb) { start_ = fbb_.StartTable(); @@ -425,8 +653,10 @@ inline ::flatbuffers::Offset CreateReadyMessage( bool estop_enabled = false, int8_t estop_gpio_pin = -1, ::flatbuffers::Offset<::flatbuffers::Vector<::flatbuffers::Offset<::flatbuffers::String>>> saved_ssids = 0, - ::flatbuffers::Offset ota = 0) { + ::flatbuffers::Offset ota = 0, + ::flatbuffers::Offset led = 0) { ReadyMessageBuilder builder_(_fbb); + builder_.add_led(led); builder_.add_ota(ota); builder_.add_saved_ssids(saved_ssids); builder_.add_gpio_valid_outputs(gpio_valid_outputs); @@ -458,7 +688,8 @@ inline ::flatbuffers::Offset CreateReadyMessageDirect( bool estop_enabled = false, int8_t estop_gpio_pin = -1, const std::vector<::flatbuffers::Offset<::flatbuffers::String>> *saved_ssids = nullptr, - ::flatbuffers::Offset ota = 0) { + ::flatbuffers::Offset ota = 0, + ::flatbuffers::Offset led = 0) { auto gpio_valid_inputs__ = gpio_valid_inputs ? _fbb.CreateVector(*gpio_valid_inputs) : 0; auto gpio_valid_outputs__ = gpio_valid_outputs ? _fbb.CreateVector(*gpio_valid_outputs) : 0; auto saved_ssids__ = saved_ssids ? _fbb.CreateVector<::flatbuffers::Offset<::flatbuffers::String>>(*saved_ssids) : 0; @@ -474,7 +705,8 @@ inline ::flatbuffers::Offset CreateReadyMessageDirect( estop_enabled, estop_gpio_pin, saved_ssids__, - ota); + ota, + led); } struct ErrorMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { diff --git a/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h b/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h index 6fa3ad46..cdcda8c5 100644 --- a/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h +++ b/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h @@ -19,35 +19,134 @@ namespace OpenShock { namespace Serialization { namespace Local { +struct LedPreviewPatternMessage; +struct LedPreviewPatternMessageBuilder; + +struct LedPreviewColorMessage; +struct LedPreviewColorMessageBuilder; + +struct LedPreviewStopMessage; +struct LedPreviewStopMessageBuilder; + struct LocalToHubMessage; struct LocalToHubMessageBuilder; +enum class LedPreviewPattern : uint8_t { + WifiDisconnected = 0, + WifiScanning = 1, + NoBackend = 2, + Connected = 3, + EStopped = 4, + EStopClearing = 5, + EStopAwaitingRelease = 6, + CriticalError = 7, + MIN = WifiDisconnected, + MAX = CriticalError +}; + +inline const LedPreviewPattern (&EnumValuesLedPreviewPattern())[8] { + static const LedPreviewPattern values[] = { + LedPreviewPattern::WifiDisconnected, + LedPreviewPattern::WifiScanning, + LedPreviewPattern::NoBackend, + LedPreviewPattern::Connected, + LedPreviewPattern::EStopped, + LedPreviewPattern::EStopClearing, + LedPreviewPattern::EStopAwaitingRelease, + LedPreviewPattern::CriticalError + }; + return values; +} + +inline const char * const *EnumNamesLedPreviewPattern() { + static const char * const names[9] = { + "WifiDisconnected", + "WifiScanning", + "NoBackend", + "Connected", + "EStopped", + "EStopClearing", + "EStopAwaitingRelease", + "CriticalError", + nullptr + }; + return names; +} + +inline const char *EnumNameLedPreviewPattern(LedPreviewPattern e) { + if (::flatbuffers::IsOutRange(e, LedPreviewPattern::WifiDisconnected, LedPreviewPattern::CriticalError)) return ""; + const size_t index = static_cast(e); + return EnumNamesLedPreviewPattern()[index]; +} + +enum class LedTarget : uint8_t { + All = 0, + Mono = 1, + Rgb = 2, + MIN = All, + MAX = Rgb +}; + +inline const LedTarget (&EnumValuesLedTarget())[3] { + static const LedTarget values[] = { + LedTarget::All, + LedTarget::Mono, + LedTarget::Rgb + }; + return values; +} + +inline const char * const *EnumNamesLedTarget() { + static const char * const names[4] = { + "All", + "Mono", + "Rgb", + nullptr + }; + return names; +} + +inline const char *EnumNameLedTarget(LedTarget e) { + if (::flatbuffers::IsOutRange(e, LedTarget::All, LedTarget::Rgb)) return ""; + const size_t index = static_cast(e); + return EnumNamesLedTarget()[index]; +} + enum class LocalToHubMessagePayload : uint8_t { NONE = 0, Common_ShockerCommandList = 1, + LedPreviewPatternMessage = 2, + LedPreviewColorMessage = 3, + LedPreviewStopMessage = 4, MIN = NONE, - MAX = Common_ShockerCommandList + MAX = LedPreviewStopMessage }; -inline const LocalToHubMessagePayload (&EnumValuesLocalToHubMessagePayload())[2] { +inline const LocalToHubMessagePayload (&EnumValuesLocalToHubMessagePayload())[5] { static const LocalToHubMessagePayload values[] = { LocalToHubMessagePayload::NONE, - LocalToHubMessagePayload::Common_ShockerCommandList + LocalToHubMessagePayload::Common_ShockerCommandList, + LocalToHubMessagePayload::LedPreviewPatternMessage, + LocalToHubMessagePayload::LedPreviewColorMessage, + LocalToHubMessagePayload::LedPreviewStopMessage }; return values; } inline const char * const *EnumNamesLocalToHubMessagePayload() { - static const char * const names[3] = { + static const char * const names[6] = { "NONE", "Common_ShockerCommandList", + "LedPreviewPatternMessage", + "LedPreviewColorMessage", + "LedPreviewStopMessage", nullptr }; return names; } inline const char *EnumNameLocalToHubMessagePayload(LocalToHubMessagePayload e) { - if (::flatbuffers::IsOutRange(e, LocalToHubMessagePayload::NONE, LocalToHubMessagePayload::Common_ShockerCommandList)) return ""; + if (::flatbuffers::IsOutRange(e, LocalToHubMessagePayload::NONE, LocalToHubMessagePayload::LedPreviewStopMessage)) return ""; const size_t index = static_cast(e); return EnumNamesLocalToHubMessagePayload()[index]; } @@ -60,11 +159,210 @@ template<> struct LocalToHubMessagePayloadTraits struct LocalToHubMessagePayloadTraits { + static const LocalToHubMessagePayload enum_value = LocalToHubMessagePayload::LedPreviewPatternMessage; +}; + +template<> struct LocalToHubMessagePayloadTraits { + static const LocalToHubMessagePayload enum_value = LocalToHubMessagePayload::LedPreviewColorMessage; +}; + +template<> struct LocalToHubMessagePayloadTraits { + static const LocalToHubMessagePayload enum_value = LocalToHubMessagePayload::LedPreviewStopMessage; +}; + template bool VerifyLocalToHubMessagePayload(::flatbuffers::VerifierTemplate &verifier, const void *obj, LocalToHubMessagePayload type); template bool VerifyLocalToHubMessagePayloadVector(::flatbuffers::VerifierTemplate &verifier, const ::flatbuffers::Vector<::flatbuffers::Offset> *values, const ::flatbuffers::Vector *types); +/// Shows a state's pattern on the status LEDs whatever the LED mode, at the saved brightness. Nothing is saved. +struct LedPreviewPatternMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LedPreviewPatternMessageBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.LedPreviewPatternMessage"; + } + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_PATTERN = 4 + }; + OpenShock::Serialization::Local::LedPreviewPattern pattern() const { + return static_cast(GetField(VT_PATTERN, 0)); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_PATTERN, 1) && + verifier.EndTable(); + } +}; + +struct LedPreviewPatternMessageBuilder { + typedef LedPreviewPatternMessage Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_pattern(OpenShock::Serialization::Local::LedPreviewPattern pattern) { + fbb_.AddElement(LedPreviewPatternMessage::VT_PATTERN, static_cast(pattern), 0); + } + explicit LedPreviewPatternMessageBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLedPreviewPatternMessage( + ::flatbuffers::FlatBufferBuilder &_fbb, + OpenShock::Serialization::Local::LedPreviewPattern pattern = OpenShock::Serialization::Local::LedPreviewPattern::WifiDisconnected) { + LedPreviewPatternMessageBuilder builder_(_fbb); + builder_.add_pattern(pattern); + return builder_.Finish(); +} + +struct LedPreviewPatternMessage::Traits { + using type = LedPreviewPatternMessage; + static auto constexpr Create = CreateLedPreviewPatternMessage; +}; + +/// Shows a solid color on the status LEDs: the RGB LED in the color, the mono LED on unless it is black. +/// Sent at a high rate while the user drags a color or brightness control. Nothing is saved. +struct LedPreviewColorMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LedPreviewColorMessageBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.LedPreviewColorMessage"; + } + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_RED = 4, + VT_GREEN = 6, + VT_BLUE = 8, + VT_TARGET = 10, + VT_BRIGHTNESS = 12 + }; + uint8_t red() const { + return GetField(VT_RED, 0); + } + uint8_t green() const { + return GetField(VT_GREEN, 0); + } + uint8_t blue() const { + return GetField(VT_BLUE, 0); + } + /// The LED to light; the other stays dark. + OpenShock::Serialization::Local::LedTarget target() const { + return static_cast(GetField(VT_TARGET, 0)); + } + /// Percent for this preview only; null is each LED's saved normal brightness. + ::flatbuffers::Optional brightness() const { + return GetOptional(VT_BRIGHTNESS); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyField(verifier, VT_RED, 1) && + VerifyField(verifier, VT_GREEN, 1) && + VerifyField(verifier, VT_BLUE, 1) && + VerifyField(verifier, VT_TARGET, 1) && + VerifyField(verifier, VT_BRIGHTNESS, 1) && + verifier.EndTable(); + } +}; + +struct LedPreviewColorMessageBuilder { + typedef LedPreviewColorMessage Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_red(uint8_t red) { + fbb_.AddElement(LedPreviewColorMessage::VT_RED, red, 0); + } + void add_green(uint8_t green) { + fbb_.AddElement(LedPreviewColorMessage::VT_GREEN, green, 0); + } + void add_blue(uint8_t blue) { + fbb_.AddElement(LedPreviewColorMessage::VT_BLUE, blue, 0); + } + void add_target(OpenShock::Serialization::Local::LedTarget target) { + fbb_.AddElement(LedPreviewColorMessage::VT_TARGET, static_cast(target), 0); + } + void add_brightness(uint8_t brightness) { + fbb_.AddElement(LedPreviewColorMessage::VT_BRIGHTNESS, brightness); + } + explicit LedPreviewColorMessageBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLedPreviewColorMessage( + ::flatbuffers::FlatBufferBuilder &_fbb, + uint8_t red = 0, + uint8_t green = 0, + uint8_t blue = 0, + OpenShock::Serialization::Local::LedTarget target = OpenShock::Serialization::Local::LedTarget::All, + ::flatbuffers::Optional brightness = ::flatbuffers::nullopt) { + LedPreviewColorMessageBuilder builder_(_fbb); + if(brightness) { builder_.add_brightness(*brightness); } + builder_.add_target(target); + builder_.add_blue(blue); + builder_.add_green(green); + builder_.add_red(red); + return builder_.Finish(); +} + +struct LedPreviewColorMessage::Traits { + using type = LedPreviewColorMessage; + static auto constexpr Create = CreateLedPreviewColorMessage; +}; + +/// Ends a preview early. +struct LedPreviewStopMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef LedPreviewStopMessageBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.LedPreviewStopMessage"; + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + verifier.EndTable(); + } +}; + +struct LedPreviewStopMessageBuilder { + typedef LedPreviewStopMessage Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + explicit LedPreviewStopMessageBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateLedPreviewStopMessage( + ::flatbuffers::FlatBufferBuilder &_fbb) { + LedPreviewStopMessageBuilder builder_(_fbb); + return builder_.Finish(); +} + +struct LedPreviewStopMessage::Traits { + using type = LedPreviewStopMessage; + static auto constexpr Create = CreateLedPreviewStopMessage; +}; + struct LocalToHubMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { typedef LocalToHubMessageBuilder Builder; struct Traits; @@ -85,6 +383,15 @@ struct LocalToHubMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table const OpenShock::Serialization::Common::ShockerCommandList *payload_as_Common_ShockerCommandList() const { return payload_type() == OpenShock::Serialization::Local::LocalToHubMessagePayload::Common_ShockerCommandList ? static_cast(payload()) : nullptr; } + const OpenShock::Serialization::Local::LedPreviewPatternMessage *payload_as_LedPreviewPatternMessage() const { + return payload_type() == OpenShock::Serialization::Local::LocalToHubMessagePayload::LedPreviewPatternMessage ? static_cast(payload()) : nullptr; + } + const OpenShock::Serialization::Local::LedPreviewColorMessage *payload_as_LedPreviewColorMessage() const { + return payload_type() == OpenShock::Serialization::Local::LocalToHubMessagePayload::LedPreviewColorMessage ? static_cast(payload()) : nullptr; + } + const OpenShock::Serialization::Local::LedPreviewStopMessage *payload_as_LedPreviewStopMessage() const { + return payload_type() == OpenShock::Serialization::Local::LocalToHubMessagePayload::LedPreviewStopMessage ? static_cast(payload()) : nullptr; + } template bool Verify(::flatbuffers::VerifierTemplate &verifier) const { return VerifyTableStart(verifier) && @@ -99,6 +406,18 @@ template<> inline const OpenShock::Serialization::Common::ShockerCommandList *Lo return payload_as_Common_ShockerCommandList(); } +template<> inline const OpenShock::Serialization::Local::LedPreviewPatternMessage *LocalToHubMessage::payload_as() const { + return payload_as_LedPreviewPatternMessage(); +} + +template<> inline const OpenShock::Serialization::Local::LedPreviewColorMessage *LocalToHubMessage::payload_as() const { + return payload_as_LedPreviewColorMessage(); +} + +template<> inline const OpenShock::Serialization::Local::LedPreviewStopMessage *LocalToHubMessage::payload_as() const { + return payload_as_LedPreviewStopMessage(); +} + struct LocalToHubMessageBuilder { typedef LocalToHubMessage Table; ::flatbuffers::FlatBufferBuilder &fbb_; @@ -145,6 +464,18 @@ inline bool VerifyLocalToHubMessagePayload(::flatbuffers::VerifierTemplate &v auto ptr = reinterpret_cast(obj); return verifier.VerifyTable(ptr); } + case LocalToHubMessagePayload::LedPreviewPatternMessage: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case LocalToHubMessagePayload::LedPreviewColorMessage: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } + case LocalToHubMessagePayload::LedPreviewStopMessage: { + auto ptr = reinterpret_cast(obj); + return verifier.VerifyTable(ptr); + } default: return true; } } diff --git a/frontend/src/lib/WebSocketClient.ts b/frontend/src/lib/WebSocketClient.ts index 681a6471..2e8aa373 100644 --- a/frontend/src/lib/WebSocketClient.ts +++ b/frontend/src/lib/WebSocketClient.ts @@ -127,6 +127,11 @@ export class WebSocketClient { }, delay); } + /** Bytes queued but not yet sent; 0 when there is no socket. */ + public get BufferedAmount(): number { + return this.#socket?.bufferedAmount ?? 0; + } + public Send(data: string | Blob | BufferSource): boolean { if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) { return false; diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local.ts b/frontend/src/lib/_fbs/open-shock/serialization/local.ts index 783a2b49..34f00ab2 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local.ts @@ -2,5 +2,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +export { LedPreviewColorMessage } from './local/led-preview-color-message'; +export { LedPreviewPattern } from './local/led-preview-pattern'; +export { LedPreviewPatternMessage } from './local/led-preview-pattern-message'; +export { LedPreviewStopMessage } from './local/led-preview-stop-message'; +export { LedTarget } from './local/led-target'; export { LocalToHubMessage } from './local/local-to-hub-message'; export { LocalToHubMessagePayload } from './local/local-to-hub-message-payload'; diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-brightness.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-brightness.ts new file mode 100644 index 00000000..2413e650 --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-brightness.ts @@ -0,0 +1,67 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +/** + * Brightness of one status LED, in percent of its full output. + */ +export class LedBrightness { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LedBrightness { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLedBrightness(bb:flatbuffers.ByteBuffer, obj?:LedBrightness):LedBrightness { + return (obj || new LedBrightness()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLedBrightness(bb:flatbuffers.ByteBuffer, obj?:LedBrightness):LedBrightness { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LedBrightness()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +/** + * E-Stop and critical-error patterns + */ +critical():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +/** + * Every other pattern + */ +normal():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +static startLedBrightness(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addCritical(builder:flatbuffers.Builder, critical:number) { + builder.addFieldInt8(0, critical, 0); +} + +static addNormal(builder:flatbuffers.Builder, normal:number) { + builder.addFieldInt8(1, normal, 0); +} + +static endLedBrightness(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLedBrightness(builder:flatbuffers.Builder, critical:number, normal:number):flatbuffers.Offset { + LedBrightness.startLedBrightness(builder); + LedBrightness.addCritical(builder, critical); + LedBrightness.addNormal(builder, normal); + return LedBrightness.endLedBrightness(builder); +} +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-mode.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-mode.ts new file mode 100644 index 00000000..a8fc78ab --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-mode.ts @@ -0,0 +1,9 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum LedMode { + All = 0, + CriticalOnly = 1, + Off = 2 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts new file mode 100644 index 00000000..31c1f79a --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts @@ -0,0 +1,102 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +import { LedTarget } from '../../../open-shock/serialization/local/led-target'; + + +/** + * Shows a solid color on the status LEDs: the RGB LED in the color, the mono LED on unless it is black. + * Sent at a high rate while the user drags a color or brightness control. Nothing is saved. + */ +export class LedPreviewColorMessage { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LedPreviewColorMessage { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLedPreviewColorMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewColorMessage):LedPreviewColorMessage { + return (obj || new LedPreviewColorMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLedPreviewColorMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewColorMessage):LedPreviewColorMessage { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LedPreviewColorMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +red():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +green():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +blue():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +/** + * The LED to light; the other stays dark. + */ +target():LedTarget { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : LedTarget.All; +} + +/** + * Percent for this preview only; null is each LED's saved normal brightness. + */ +brightness():number|null { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : null; +} + +static startLedPreviewColorMessage(builder:flatbuffers.Builder) { + builder.startObject(5); +} + +static addRed(builder:flatbuffers.Builder, red:number) { + builder.addFieldInt8(0, red, 0); +} + +static addGreen(builder:flatbuffers.Builder, green:number) { + builder.addFieldInt8(1, green, 0); +} + +static addBlue(builder:flatbuffers.Builder, blue:number) { + builder.addFieldInt8(2, blue, 0); +} + +static addTarget(builder:flatbuffers.Builder, target:LedTarget) { + builder.addFieldInt8(3, target, LedTarget.All); +} + +static addBrightness(builder:flatbuffers.Builder, brightness:number) { + builder.addFieldInt8(4, brightness, null); +} + +static endLedPreviewColorMessage(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLedPreviewColorMessage(builder:flatbuffers.Builder, red:number, green:number, blue:number, target:LedTarget, brightness:number|null):flatbuffers.Offset { + LedPreviewColorMessage.startLedPreviewColorMessage(builder); + LedPreviewColorMessage.addRed(builder, red); + LedPreviewColorMessage.addGreen(builder, green); + LedPreviewColorMessage.addBlue(builder, blue); + LedPreviewColorMessage.addTarget(builder, target); + if (brightness !== null) + LedPreviewColorMessage.addBrightness(builder, brightness); + return LedPreviewColorMessage.endLedPreviewColorMessage(builder); +} +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern-message.ts new file mode 100644 index 00000000..a8297a5a --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern-message.ts @@ -0,0 +1,54 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +import { LedPreviewPattern } from '../../../open-shock/serialization/local/led-preview-pattern'; + + +/** + * Shows a state's pattern on the status LEDs whatever the LED mode, at the saved brightness. Nothing is saved. + */ +export class LedPreviewPatternMessage { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LedPreviewPatternMessage { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLedPreviewPatternMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewPatternMessage):LedPreviewPatternMessage { + return (obj || new LedPreviewPatternMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLedPreviewPatternMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewPatternMessage):LedPreviewPatternMessage { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LedPreviewPatternMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +pattern():LedPreviewPattern { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : LedPreviewPattern.WifiDisconnected; +} + +static startLedPreviewPatternMessage(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addPattern(builder:flatbuffers.Builder, pattern:LedPreviewPattern) { + builder.addFieldInt8(0, pattern, LedPreviewPattern.WifiDisconnected); +} + +static endLedPreviewPatternMessage(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLedPreviewPatternMessage(builder:flatbuffers.Builder, pattern:LedPreviewPattern):flatbuffers.Offset { + LedPreviewPatternMessage.startLedPreviewPatternMessage(builder); + LedPreviewPatternMessage.addPattern(builder, pattern); + return LedPreviewPatternMessage.endLedPreviewPatternMessage(builder); +} +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern.ts new file mode 100644 index 00000000..61f8e345 --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-pattern.ts @@ -0,0 +1,14 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum LedPreviewPattern { + WifiDisconnected = 0, + WifiScanning = 1, + NoBackend = 2, + Connected = 3, + EStopped = 4, + EStopClearing = 5, + EStopAwaitingRelease = 6, + CriticalError = 7 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-stop-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-stop-message.ts new file mode 100644 index 00000000..dc6a5ae6 --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-stop-message.ts @@ -0,0 +1,41 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +/** + * Ends a preview early. + */ +export class LedPreviewStopMessage { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LedPreviewStopMessage { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLedPreviewStopMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewStopMessage):LedPreviewStopMessage { + return (obj || new LedPreviewStopMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLedPreviewStopMessage(bb:flatbuffers.ByteBuffer, obj?:LedPreviewStopMessage):LedPreviewStopMessage { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LedPreviewStopMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static startLedPreviewStopMessage(builder:flatbuffers.Builder) { + builder.startObject(0); +} + +static endLedPreviewStopMessage(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLedPreviewStopMessage(builder:flatbuffers.Builder):flatbuffers.Offset { + LedPreviewStopMessage.startLedPreviewStopMessage(builder); + return LedPreviewStopMessage.endLedPreviewStopMessage(builder); +} +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts new file mode 100644 index 00000000..55b3c1bc --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts @@ -0,0 +1,110 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +import { LedBrightness } from '../../../open-shock/serialization/local/led-brightness'; +import { LedMode } from '../../../open-shock/serialization/local/led-mode'; + + +/** + * The status LED settings the portal shows and edits. + */ +export class LedSettings { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LedSettings { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLedSettings(bb:flatbuffers.ByteBuffer, obj?:LedSettings):LedSettings { + return (obj || new LedSettings()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLedSettings(bb:flatbuffers.ByteBuffer, obj?:LedSettings):LedSettings { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LedSettings()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +mode():LedMode { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : LedMode.All; +} + +hasMono():boolean { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +hasRgb():boolean { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +mono(obj?:LedBrightness):LedBrightness|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? (obj || new LedBrightness()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +rgb(obj?:LedBrightness):LedBrightness|null { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? (obj || new LedBrightness()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +/** + * The hub warns about an RGB brightness above this percent. + */ +rgbWarnAbove():number { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; +} + +/** + * How long a preview holds the LEDs after the last preview message. + */ +previewMs():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0; +} + +static startLedSettings(builder:flatbuffers.Builder) { + builder.startObject(7); +} + +static addMode(builder:flatbuffers.Builder, mode:LedMode) { + builder.addFieldInt8(0, mode, LedMode.All); +} + +static addHasMono(builder:flatbuffers.Builder, hasMono:boolean) { + builder.addFieldInt8(1, +hasMono, +false); +} + +static addHasRgb(builder:flatbuffers.Builder, hasRgb:boolean) { + builder.addFieldInt8(2, +hasRgb, +false); +} + +static addMono(builder:flatbuffers.Builder, monoOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, monoOffset, 0); +} + +static addRgb(builder:flatbuffers.Builder, rgbOffset:flatbuffers.Offset) { + builder.addFieldOffset(4, rgbOffset, 0); +} + +static addRgbWarnAbove(builder:flatbuffers.Builder, rgbWarnAbove:number) { + builder.addFieldInt8(5, rgbWarnAbove, 0); +} + +static addPreviewMs(builder:flatbuffers.Builder, previewMs:number) { + builder.addFieldInt32(6, previewMs, 0); +} + +static endLedSettings(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-target.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-target.ts new file mode 100644 index 00000000..68050a6f --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-target.ts @@ -0,0 +1,9 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum LedTarget { + All = 0, + Mono = 1, + Rgb = 2 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/local-to-hub-message-payload.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/local-to-hub-message-payload.ts index d3dff488..6042720b 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local/local-to-hub-message-payload.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/local-to-hub-message-payload.ts @@ -3,32 +3,44 @@ /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { ShockerCommandList } from '../../../open-shock/serialization/common/shocker-command-list'; +import { LedPreviewColorMessage } from '../../../open-shock/serialization/local/led-preview-color-message'; +import { LedPreviewPatternMessage } from '../../../open-shock/serialization/local/led-preview-pattern-message'; +import { LedPreviewStopMessage } from '../../../open-shock/serialization/local/led-preview-stop-message'; export enum LocalToHubMessagePayload { NONE = 0, - Common_ShockerCommandList = 1 + Common_ShockerCommandList = 1, + LedPreviewPatternMessage = 2, + LedPreviewColorMessage = 3, + LedPreviewStopMessage = 4 } export function unionToLocalToHubMessagePayload( type: LocalToHubMessagePayload, - accessor: (obj:ShockerCommandList) => ShockerCommandList|null -): ShockerCommandList|null { + accessor: (obj:LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList) => LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList|null +): LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList|null { switch(LocalToHubMessagePayload[type]) { case 'NONE': return null; case 'Common_ShockerCommandList': return accessor(new ShockerCommandList())! as ShockerCommandList; + case 'LedPreviewPatternMessage': return accessor(new LedPreviewPatternMessage())! as LedPreviewPatternMessage; + case 'LedPreviewColorMessage': return accessor(new LedPreviewColorMessage())! as LedPreviewColorMessage; + case 'LedPreviewStopMessage': return accessor(new LedPreviewStopMessage())! as LedPreviewStopMessage; default: return null; } } export function unionListToLocalToHubMessagePayload( type: LocalToHubMessagePayload, - accessor: (index: number, obj:ShockerCommandList) => ShockerCommandList|null, + accessor: (index: number, obj:LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList) => LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList|null, index: number -): ShockerCommandList|null { +): LedPreviewColorMessage|LedPreviewPatternMessage|LedPreviewStopMessage|ShockerCommandList|null { switch(LocalToHubMessagePayload[type]) { case 'NONE': return null; case 'Common_ShockerCommandList': return accessor(index, new ShockerCommandList())! as ShockerCommandList; + case 'LedPreviewPatternMessage': return accessor(index, new LedPreviewPatternMessage())! as LedPreviewPatternMessage; + case 'LedPreviewColorMessage': return accessor(index, new LedPreviewColorMessage())! as LedPreviewColorMessage; + case 'LedPreviewStopMessage': return accessor(index, new LedPreviewStopMessage())! as LedPreviewStopMessage; default: return null; } } diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts index 3fe0712b..07ecc18b 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts @@ -4,6 +4,7 @@ import * as flatbuffers from 'flatbuffers'; +import { LedSettings } from '../../../open-shock/serialization/local/led-settings'; import { OtaSettings } from '../../../open-shock/serialization/local/ota-settings'; import { WifiNetwork } from '../../../open-shock/serialization/types/wifi-network'; @@ -111,8 +112,13 @@ ota(obj?:OtaSettings):OtaSettings|null { return offset ? (obj || new OtaSettings()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } +led(obj?:LedSettings):LedSettings|null { + const offset = this.bb!.__offset(this.bb_pos, 26); + return offset ? (obj || new LedSettings()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startReadyMessage(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addPoggies(builder:flatbuffers.Builder, poggies:boolean) { @@ -205,6 +211,10 @@ static addOta(builder:flatbuffers.Builder, otaOffset:flatbuffers.Offset) { builder.addFieldOffset(10, otaOffset, 0); } +static addLed(builder:flatbuffers.Builder, ledOffset:flatbuffers.Offset) { + builder.addFieldOffset(11, ledOffset, 0); +} + static endReadyMessage(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9796c477..7111f98e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; -import type { OtaUpdateConfig } from '#lib/mappers/ConfigMapper.js'; +import type { LedConfig, OtaUpdateConfig } from '#lib/mappers/ConfigMapper.js'; import { hubState } from '#lib/stores/index.js'; import { getApiBaseUrl } from '#lib/utils/localRedirect.js'; import { toast } from 'svelte-sonner'; @@ -299,103 +299,28 @@ export function checkOtaUpdates(channel: OtaChannel): Promise { ); } -// Status LEDs - -export type LedMode = 'all' | 'critical' | 'off'; -export type LedType = 'mono' | 'rgb'; -export type LedSignal = 'critical' | 'normal'; -export type LedPattern = - | 'disconnected' - | 'scanning' - | 'nobackend' - | 'connected' - | 'estop' - | 'estopclearing' - | 'estoprelease' - | 'error'; - -export interface LedBrightness { - present: boolean; // Whether the board has this LED - critical: number; // Percent of the LED's full output - normal: number; -} - -export interface LedSettings { - mode: LedMode; - mono: LedBrightness; - rgb: LedBrightness; - rgbWarnAbove: number; // RGB brightness (percent) above which the hub warns - previewMs: number; // How long a preview holds the LEDs after the last preview request -} - -export async function fetchLedSettings(): Promise { - try { - const res = await apiFetch('/api/led'); - if (!res.ok) { - toast.error('Failed to load LED settings: ' + (await getErrorMessage(res))); - return null; - } - return (await res.json()) as LedSettings; - } catch { - toast.error('Failed to load LED settings'); - return null; - } -} +// Status LEDs (previews stream over the WebSocket, see ledPreview.ts) export type LedSettingsPatch = Partial< Record<'mode' | 'monoCritical' | 'monoNormal' | 'rgbCritical' | 'rgbNormal', string> >; -// Any subset in one config write (PUT /api/led) -export async function setLedSettings(params: LedSettingsPatch): Promise { +// Any subset in one config write (PUT /api/led). On success `apply` mirrors the change into hubState.config, which +// is otherwise only sent once on connect. +export async function setLedSettings( + params: LedSettingsPatch, + apply: (led: LedConfig) => void +): Promise { try { const res = await apiFetch('/api/led?' + new URLSearchParams(params), { method: 'PUT' }); if (!res.ok) { toast.error('Failed to save LED settings: ' + (await getErrorMessage(res))); return false; } + if (hubState.config) apply(hubState.config.led); return true; } catch { toast.error('Failed to save LED settings'); return false; } } - -// Previews hold the LEDs for LedSettings.previewMs after the last request; nothing is saved. - -export async function previewLedPattern(pattern: LedPattern): Promise { - try { - const res = await apiFetch('/api/led/preview?' + new URLSearchParams({ pattern }), { - method: 'POST', - }); - if (!res.ok) toast.error('Failed to preview LED pattern: ' + (await getErrorMessage(res))); - } catch { - toast.error('Failed to preview LED pattern'); - } -} - -// `color` is RRGGBB. Silent on failure: called many times a second while dragging. -export async function previewLedColor( - color: string, - options: { led?: LedType; brightness?: number } = {} -): Promise { - const params: Record = { color }; - if (options.led) params.led = options.led; - if (options.brightness !== undefined) params.brightness = String(options.brightness); - try { - const res = await apiFetch('/api/led/preview?' + new URLSearchParams(params), { - method: 'POST', - }); - return res.ok; - } catch { - return false; - } -} - -export async function stopLedPreview(): Promise { - try { - await apiFetch('/api/led/preview', { method: 'DELETE' }); - } catch { - // The preview ends on its own - } -} diff --git a/frontend/src/lib/components/ColorWheel.svelte b/frontend/src/lib/components/ColorWheel.svelte index daf5865a..9f54eae8 100644 --- a/frontend/src/lib/components/ColorWheel.svelte +++ b/frontend/src/lib/components/ColorWheel.svelte @@ -9,6 +9,10 @@ let { color = $bindable('ff0000'), size = 200, disabled = false, onchange }: Props = $props(); + // White in the middle fading out over the hue ring + const WHEEL_BACKGROUND = + 'radial-gradient(circle closest-side, #fff, transparent), conic-gradient(red, yellow, lime, cyan, blue, magenta, red)'; + let wheel = $state(null); let hue = $state(0); // degrees, 0 = red at the top, clockwise let saturation = $state(1); @@ -75,8 +79,7 @@ ]} style:width="{size}px" style:height="{size}px" - style:background="radial-gradient(circle closest-side, #fff, transparent), conic-gradient(red, - yellow, lime, cyan, blue, magenta, red)" + style:background={WHEEL_BACKGROUND} role="slider" tabindex={disabled ? -1 : 0} aria-label="Color" @@ -93,6 +96,6 @@ class="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow ring-1 ring-black/40" style:left="{markerX}%" style:top="{markerY}%" - style:background="#{color}" + style:background={'#' + color} > diff --git a/frontend/src/lib/components/LabeledSlider.svelte b/frontend/src/lib/components/LabeledSlider.svelte new file mode 100644 index 00000000..b5db0001 --- /dev/null +++ b/frontend/src/lib/components/LabeledSlider.svelte @@ -0,0 +1,72 @@ + + + + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + diff --git a/frontend/src/lib/components/sections/LedSection.svelte b/frontend/src/lib/components/sections/LedSection.svelte index 50070ecf..064fb546 100644 --- a/frontend/src/lib/components/sections/LedSection.svelte +++ b/frontend/src/lib/components/sections/LedSection.svelte @@ -1,48 +1,50 @@ -
+
- {#if !settings} -

Loading LED settings…

+ {#if !led} +

Loading...

{:else if presentTypes.length === 0}

This board has no status LED.

{:else} @@ -147,7 +156,7 @@
{#each modes as mode (mode.value)} - {@const active = settings.mode === mode.value} + {@const active = led.mode === mode.value}
{#each presentTypes as { type, label } (type)}
-

{label} brightness

+

{label} Brightness

{#each signals as s (s.signal)} - {@const value = settings[type][s.signal]} + {@const value = brightness(type, s.signal)} + {@const labelId = `led-${type}-${s.signal}-label`}
- - {value}% + +
- onBrightnessDrag(type, s.signal, v)} - onValueCommit={(v: number) => onBrightnessCommit(type, s.signal, v)} + valuetext="{value}%" + onValueChange={(v) => onBrightnessDrag(type, s.signal, v)} + onValueCommit={(v) => onBrightnessCommit(type, s.signal, v)} /> - {#if type === 'rgb' && value > settings.rgbWarnAbove} -

+ {#if type === 'rgb' && value > led.rgbWarnAbove} +

- Above {settings.rgbWarnAbove}% the RGB LED is glaring and runs warm. + Above {led.rgbWarnAbove}% the RGB LED is glaring and runs warm.

{/if}
@@ -199,10 +206,7 @@ {/each}
- -

- Shows a state's pattern whatever the mode, at the saved brightness, for {previewSeconds} seconds. -

+
{#each patterns as pattern (pattern.value)}
- {#if settings.rgb.present} + {#if led.rgb.present}
- -

- Lights the RGB LED in the picked color at its normal brightness. -

+
- + #{color}
{/if} - +
+

+ Previews show a state's pattern whatever the mode, at the saved brightness, or light the RGB + LED in the picked color. They last {previewSeconds} seconds and change nothing on the hub. +

+
+ + {/if}
diff --git a/frontend/src/lib/ledPreview.ts b/frontend/src/lib/ledPreview.ts new file mode 100644 index 00000000..bf199da4 --- /dev/null +++ b/frontend/src/lib/ledPreview.ts @@ -0,0 +1,102 @@ +import { LedPreviewColorMessage } from '#lib/_fbs/open-shock/serialization/local/led-preview-color-message.js'; +import { LedPreviewPatternMessage } from '#lib/_fbs/open-shock/serialization/local/led-preview-pattern-message.js'; +import { LedPreviewPattern } from '#lib/_fbs/open-shock/serialization/local/led-preview-pattern.js'; +import { LedPreviewStopMessage } from '#lib/_fbs/open-shock/serialization/local/led-preview-stop-message.js'; +import { LedTarget } from '#lib/_fbs/open-shock/serialization/local/led-target.js'; +import { LocalToHubMessagePayload } from '#lib/_fbs/open-shock/serialization/local/local-to-hub-message-payload.js'; +import { LocalToHubMessage } from '#lib/_fbs/open-shock/serialization/local/local-to-hub-message.js'; +import { ConnectionState, WebSocketClient } from '#lib/WebSocketClient.js'; +import { Builder as FlatbufferBuilder } from 'flatbuffers'; + +// LED previews take over the hub's status LEDs for a few seconds after the last message; nothing is saved. They go +// over the WebSocket rather than HTTP: a dragged color wheel or slider sends them many times a second. + +export { LedPreviewPattern, LedTarget }; + +// At most this often while dragging; the LED can't show changes faster than a person can see them anyway +const MIN_INTERVAL_MS = 40; + +function send( + payloadType: LocalToHubMessagePayload, + build: (fbb: FlatbufferBuilder) => number +): boolean { + const fbb = new FlatbufferBuilder(64); + const payload = build(fbb); + const msg = LocalToHubMessage.createLocalToHubMessage(fbb, payloadType, payload); + fbb.finish(msg); + return WebSocketClient.Instance.Send(new Uint8Array(fbb.asUint8Array())); +} + +export function previewLedPattern(pattern: LedPreviewPattern): boolean { + return send(LocalToHubMessagePayload.LedPreviewPatternMessage, (fbb) => + LedPreviewPatternMessage.createLedPreviewPatternMessage(fbb, pattern) + ); +} + +export function stopLedPreview(): boolean { + cancelPendingColor(); + return send(LocalToHubMessagePayload.LedPreviewStopMessage, (fbb) => { + LedPreviewStopMessage.startLedPreviewStopMessage(fbb); + return LedPreviewStopMessage.endLedPreviewStopMessage(fbb); + }); +} + +export interface LedColorPreview { + color: string; // RRGGBB + target?: LedTarget; // Default: every LED + brightness?: number; // Percent for this preview only; default: each LED's saved normal brightness +} + +function sendColor({ color, target = LedTarget.All, brightness }: LedColorPreview): boolean { + const value = parseInt(color, 16); + return send(LocalToHubMessagePayload.LedPreviewColorMessage, (fbb) => + LedPreviewColorMessage.createLedPreviewColorMessage( + fbb, + (value >> 16) & 0xff, + (value >> 8) & 0xff, + value & 0xff, + target, + brightness ?? null + ) + ); +} + +let pending: LedColorPreview | null = null; +let timer: ReturnType | null = null; +let lastSent = 0; + +function cancelPendingColor() { + pending = null; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } +} + +function flush() { + timer = null; + if (!pending) return; + // Still sending the previous frame: wait rather than queue up stale colors + if (WebSocketClient.Instance.BufferedAmount > 0) { + timer = setTimeout(flush, MIN_INTERVAL_MS); + return; + } + const next = pending; + pending = null; + lastSent = Date.now(); + sendColor(next); +} + +/** + * Previews a color, rate limited: calls faster than every 40 ms collapse into the latest one, which is sent once the + * interval has passed. Returns false when not connected to the hub. + */ +export function previewLedColor(preview: LedColorPreview): boolean { + if (WebSocketClient.Instance.ConnectionState !== ConnectionState.CONNECTED) return false; + pending = preview; + if (timer === null) { + const wait = Math.max(0, lastSent + MIN_INTERVAL_MS - Date.now()); + timer = setTimeout(flush, wait); + } + return true; +} diff --git a/frontend/src/lib/mappers/ConfigMapper.ts b/frontend/src/lib/mappers/ConfigMapper.ts index 99e9dfe4..1efcf353 100644 --- a/frontend/src/lib/mappers/ConfigMapper.ts +++ b/frontend/src/lib/mappers/ConfigMapper.ts @@ -1,3 +1,4 @@ +import { LedMode as FbsLedMode } from '#lib/_fbs/open-shock/serialization/local/led-mode.js'; import type { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; import type { ReadyMessage } from '#lib/_fbs/open-shock/serialization/local/ready-message.js'; @@ -10,14 +11,52 @@ export interface OtaUpdateConfig { requireManualApproval: boolean; } +export type LedMode = 'all' | 'critical' | 'off'; + +export interface LedBrightness { + present: boolean; // Whether the board has this LED + critical: number; // Percent of the LED's full output + normal: number; +} + +export interface LedConfig { + mode: LedMode; + mono: LedBrightness; + rgb: LedBrightness; + rgbWarnAbove: number; // RGB brightness (percent) above which the hub warns + previewMs: number; // How long a preview holds the LEDs after the last preview message +} + // The settings the portal shows, as the hub reports them in its Ready message. export interface Config { rf: { txPin: number }; estop: { enabled: boolean; gpioPin: number }; otaUpdate: OtaUpdateConfig; + led: LedConfig; savedSSIDs: string[]; } +const _ledModes: Record = { + [FbsLedMode.All]: 'all', + [FbsLedMode.CriticalOnly]: 'critical', + [FbsLedMode.Off]: 'off', +}; + +function mapLedConfig(ready: ReadyMessage): LedConfig { + const led = ready.led(); + if (!led) throw new Error('ready.led is null'); + + const mono = led.mono(); + const rgb = led.rgb(); + return { + mode: _ledModes[led.mode()] ?? 'all', + mono: { present: led.hasMono(), critical: mono?.critical() ?? 0, normal: mono?.normal() ?? 0 }, + rgb: { present: led.hasRgb(), critical: rgb?.critical() ?? 0, normal: rgb?.normal() ?? 0 }, + rgbWarnAbove: led.rgbWarnAbove(), + previewMs: led.previewMs(), + }; +} + function mapOtaUpdateConfig(ready: ReadyMessage): OtaUpdateConfig { const ota = ready.ota(); if (!ota) throw new Error('ready.ota is null'); @@ -45,6 +84,7 @@ export function mapConfig(ready: ReadyMessage): Config { rf: { txPin: ready.rfTxPin() }, estop: { enabled: ready.estopEnabled(), gpioPin: ready.estopGpioPin() }, otaUpdate: mapOtaUpdateConfig(ready), + led: mapLedConfig(ready), savedSSIDs, }; } diff --git a/schemas b/schemas index 47701354..25ca00fc 160000 --- a/schemas +++ b/schemas @@ -1 +1 @@ -Subproject commit 4770135465cd342c51dd5cee68004b6321fee40f +Subproject commit 25ca00fc8711caee90a6a503312054403d51fa7e From 3a1210eb9c25e33c3c10a38390c80450f8d2728b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 12:12:43 +0100 Subject: [PATCH 05/27] feat(visual): offer LED settings only for the LEDs the board has The Ready message carries a brightness table only for an LED the board has (schemas bump), and the portal shows nothing for a missing one: no RGB card, color wheel or RGB wording without an RGB LED, and no LEDs section at all on a board with no status LED. The hub enforces the same: PUT /api/led refuses settings for a missing LED (NoSuchLed), the led serial commands list only present LEDs and refuse the others, and the RGB brightness warning is only logged on a board with an RGB LED. The stored config keeps all four brightness values, so a board descriptor that adds an LED later finds them. --- .../src/captiveportal/ApiHandlers.cpp | 8 ++++ components/core/src/serialization/WSLocal.cpp | 16 +++++-- .../include/visual/VisualStateManager.h | 4 +- .../src/visual/VisualStateManager.cpp | 2 +- .../src/command_handlers/led.cpp | 30 ++++++++++-- .../_fbs/HubToLocalMessage_generated.h | 35 ++++---------- .../_fbs/LocalToHubMessage_generated.h | 2 +- .../local/led-preview-color-message.ts | 2 +- .../serialization/local/led-settings.ts | 47 +++++++------------ frontend/src/lib/api.ts | 2 + .../lib/components/sections/LedSection.svelte | 5 +- frontend/src/lib/mappers/ConfigMapper.ts | 13 +++-- frontend/src/lib/views/Advanced.svelte | 16 +++++-- schemas | 2 +- 14 files changed, 105 insertions(+), 79 deletions(-) diff --git a/components/captive_portal/src/captiveportal/ApiHandlers.cpp b/components/captive_portal/src/captiveportal/ApiHandlers.cpp index b3ade500..1a21d3d5 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -46,6 +46,7 @@ static const char* const JSON_ERR_PASSWORD_LONG = "{\"error\":\"PasswordTooLon static const char* const JSON_ERR_CODE_REQUIRED = "{\"error\":\"CodeRequired\"}"; static const char* const JSON_ERR_INVALID_CHANNEL = "{\"error\":\"InvalidChannel\"}"; static const char* const JSON_ERR_RATE_LIMITED = "{\"error\":\"RateLimited\"}"; +static const char* const JSON_ERR_NO_SUCH_LED = "{\"error\":\"NoSuchLed\"}"; using namespace OpenShock; using namespace OpenShock::CaptivePortal; @@ -434,6 +435,13 @@ static esp_err_t apiLedPut(httpd_req_t* req) return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); } + // Settings only for the LEDs the board has; the portal never shows the others + if ((mode && !VisualStateManager::HasAnyLed()) || ((monoCritical || monoNormal) && !VisualStateManager::HasLed(LedType::Mono)) + || ((rgbCritical || rgbNormal) && !VisualStateManager::HasLed(LedType::Rgb))) + { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_NO_SUCH_LED); + } + bool saved = VisualStateManager::UpdateLedConfig([&](Config::LedConfig& cfg) { bool changed = false; auto apply = [&changed](auto& field, const auto& value) { diff --git a/components/core/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index 77eda993..d5d63822 100644 --- a/components/core/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -87,11 +87,17 @@ static flatbuffers::Offset createLedSettings(flatbuffers::Fl using OpenShock::LedSignal; using OpenShock::LedType; - auto brightness = [&](LedType type) { return Local::CreateLedBrightness(builder, Vsm::GetLedBrightness(type, LedSignal::Critical), Vsm::GetLedBrightness(type, LedSignal::Normal)); }; - auto mono = brightness(LedType::Mono); - auto rgb = brightness(LedType::Rgb); - - return Local::CreateLedSettings(builder, static_cast(Vsm::GetLedMode()), Vsm::HasLed(LedType::Mono), Vsm::HasLed(LedType::Rgb), mono, rgb, Vsm::kRgbBrightnessWarnPercent, Vsm::kPreviewDurationMs); + // Only the LEDs the board has, so the portal shows no option for one that isn't there + auto brightness = [&](LedType type) -> flatbuffers::Offset { + if (!Vsm::HasLed(type)) { + return 0; + } + return Local::CreateLedBrightness(builder, Vsm::GetLedBrightness(type, LedSignal::Critical), Vsm::GetLedBrightness(type, LedSignal::Normal)); + }; + auto mono = brightness(LedType::Mono); + auto rgb = brightness(LedType::Rgb); + + return Local::CreateLedSettings(builder, static_cast(Vsm::GetLedMode()), mono, rgb, Vsm::kRgbBrightnessWarnPercent, Vsm::kPreviewDurationMs); } bool Local::SerializeErrorMessage(std::string_view message, Common::SerializationCallbackFn callback) diff --git a/components/device_control/include/visual/VisualStateManager.h b/components/device_control/include/visual/VisualStateManager.h index 460dda38..28d2785d 100644 --- a/components/device_control/include/visual/VisualStateManager.h +++ b/components/device_control/include/visual/VisualStateManager.h @@ -13,8 +13,10 @@ namespace OpenShock::Config { namespace OpenShock::VisualStateManager { [[nodiscard]] bool Init(); - /// @brief Whether this board has an LED of `type`. + /// @brief Whether this board has an LED of `type`. False for both before Init. [[nodiscard]] bool HasLed(LedType type); + /// @brief Whether this board has any status LED. + [[nodiscard]] inline bool HasAnyLed() { return HasLed(LedType::Mono) || HasLed(LedType::Rgb); } // The setters save to the `led` config section and apply the change at once. A save that raises an RGB brightness // above kRgbBrightnessWarnPercent logs a warning: a WS2812B is glaring and runs warm well before full output. diff --git a/components/device_control/src/visual/VisualStateManager.cpp b/components/device_control/src/visual/VisualStateManager.cpp index 868adb64..e18a34be 100644 --- a/components/device_control/src/visual/VisualStateManager.cpp +++ b/components/device_control/src/visual/VisualStateManager.cpp @@ -645,7 +645,7 @@ bool VisualStateManager::UpdateLedConfig(Util::FunctionRef VisualStateManager::kRgbBrightnessWarnPercent && rgbAfter[i] != rgbBefore[i]) { + if (s_rgbLedDriver != nullptr && rgbAfter[i] > VisualStateManager::kRgbBrightnessWarnPercent && rgbAfter[i] != rgbBefore[i]) { OS_LOGW(TAG, "RGB LED %s brightness set to %u%%: above %u%% a WS2812B is glaring and runs warm", i == 0 ? "critical" : "normal", rgbAfter[i], VisualStateManager::kRgbBrightnessWarnPercent); } } diff --git a/components/serial_console/src/command_handlers/led.cpp b/components/serial_console/src/command_handlers/led.cpp index 8807225b..f04e8987 100644 --- a/components/serial_console/src/command_handlers/led.cpp +++ b/components/serial_console/src/command_handlers/led.cpp @@ -14,6 +14,11 @@ static void handleLedModeCommand(std::string_view arg, bool isAutomated) { (void)isAutomated; + if (!OpenShock::VisualStateManager::HasAnyLed()) { + SERPR_ERROR("This board has no status LED"); + return; + } + arg = OpenShock::StringTrim(arg); if (arg.empty()) { SERPR_RESPONSE("LedMode|%s", OpenShock::LedModeName(OpenShock::VisualStateManager::GetLedMode())); @@ -33,13 +38,24 @@ static void handleLedBrightnessCommand(std::string_view arg, bool isAutomated) { (void)isAutomated; + using OpenShock::VisualStateManager::GetLedBrightness; + using OpenShock::VisualStateManager::HasLed; + arg = OpenShock::StringTrim(arg); if (arg.empty()) { - using OpenShock::VisualStateManager::GetLedBrightness; - SERPR_RESPONSE("LedBrightness|mono|critical|%u", GetLedBrightness(LedType::Mono, LedSignal::Critical)); - SERPR_RESPONSE("LedBrightness|mono|normal|%u", GetLedBrightness(LedType::Mono, LedSignal::Normal)); - SERPR_RESPONSE("LedBrightness|rgb|critical|%u", GetLedBrightness(LedType::Rgb, LedSignal::Critical)); - SERPR_RESPONSE("LedBrightness|rgb|normal|%u", GetLedBrightness(LedType::Rgb, LedSignal::Normal)); + // Only the LEDs the board has + if (!OpenShock::VisualStateManager::HasAnyLed()) { + SERPR_ERROR("This board has no status LED"); + return; + } + for (LedType type : {LedType::Mono, LedType::Rgb}) { + if (!HasLed(type)) { + continue; + } + const char* name = type == LedType::Mono ? "mono" : "rgb"; + SERPR_RESPONSE("LedBrightness|%s|critical|%u", name, GetLedBrightness(type, LedSignal::Critical)); + SERPR_RESPONSE("LedBrightness|%s|normal|%u", name, GetLedBrightness(type, LedSignal::Normal)); + } return; } @@ -56,6 +72,10 @@ static void handleLedBrightnessCommand(std::string_view arg, bool isAutomated) SERPR_ERROR("Invalid LED type (must be mono or rgb)"); return; } + if (!HasLed(*type)) { + SERPR_ERROR("This board has no %s LED", *type == LedType::Mono ? "mono" : "rgb"); + return; + } if (!signal) { SERPR_ERROR("Invalid signal (must be critical or normal)"); return; diff --git a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h index 45908545..5b45df4a 100644 --- a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h +++ b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h @@ -391,7 +391,8 @@ struct LedBrightness::Traits { static auto constexpr Create = CreateLedBrightness; }; -/// The status LED settings the portal shows and edits. +/// The status LED settings the portal shows and edits. Only the LEDs the board has are present, so the portal shows +/// no option for an LED that isn't there. struct LedSettings FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { typedef LedSettingsBuilder Builder; struct Traits; @@ -400,29 +401,23 @@ struct LedSettings FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { } enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { VT_MODE = 4, - VT_HAS_MONO = 6, - VT_HAS_RGB = 8, - VT_MONO = 10, - VT_RGB = 12, - VT_RGB_WARN_ABOVE = 14, - VT_PREVIEW_MS = 16 + VT_MONO = 6, + VT_RGB = 8, + VT_RGB_WARN_ABOVE = 10, + VT_PREVIEW_MS = 12 }; OpenShock::Serialization::Local::LedMode mode() const { return static_cast(GetField(VT_MODE, 0)); } - bool has_mono() const { - return GetField(VT_HAS_MONO, 0) != 0; - } - bool has_rgb() const { - return GetField(VT_HAS_RGB, 0) != 0; - } + /// The single-color built-in LED; absent when the board has none. const OpenShock::Serialization::Local::LedBrightness *mono() const { return GetPointer(VT_MONO); } + /// The WS2812B RGB LED; absent when the board has none. const OpenShock::Serialization::Local::LedBrightness *rgb() const { return GetPointer(VT_RGB); } - /// The hub warns about an RGB brightness above this percent. + /// The hub warns about an RGB brightness above this percent. Only meaningful with `rgb`. uint8_t rgb_warn_above() const { return GetField(VT_RGB_WARN_ABOVE, 0); } @@ -434,8 +429,6 @@ struct LedSettings FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { bool Verify(::flatbuffers::VerifierTemplate &verifier) const { return VerifyTableStart(verifier) && VerifyField(verifier, VT_MODE, 1) && - VerifyField(verifier, VT_HAS_MONO, 1) && - VerifyField(verifier, VT_HAS_RGB, 1) && VerifyOffset(verifier, VT_MONO) && verifier.VerifyTable(mono()) && VerifyOffset(verifier, VT_RGB) && @@ -453,12 +446,6 @@ struct LedSettingsBuilder { void add_mode(OpenShock::Serialization::Local::LedMode mode) { fbb_.AddElement(LedSettings::VT_MODE, static_cast(mode), 0); } - void add_has_mono(bool has_mono) { - fbb_.AddElement(LedSettings::VT_HAS_MONO, static_cast(has_mono), 0); - } - void add_has_rgb(bool has_rgb) { - fbb_.AddElement(LedSettings::VT_HAS_RGB, static_cast(has_rgb), 0); - } void add_mono(::flatbuffers::Offset mono) { fbb_.AddOffset(LedSettings::VT_MONO, mono); } @@ -485,8 +472,6 @@ struct LedSettingsBuilder { inline ::flatbuffers::Offset CreateLedSettings( ::flatbuffers::FlatBufferBuilder &_fbb, OpenShock::Serialization::Local::LedMode mode = OpenShock::Serialization::Local::LedMode::All, - bool has_mono = false, - bool has_rgb = false, ::flatbuffers::Offset mono = 0, ::flatbuffers::Offset rgb = 0, uint8_t rgb_warn_above = 0, @@ -496,8 +481,6 @@ inline ::flatbuffers::Offset CreateLedSettings( builder_.add_rgb(rgb); builder_.add_mono(mono); builder_.add_rgb_warn_above(rgb_warn_above); - builder_.add_has_rgb(has_rgb); - builder_.add_has_mono(has_mono); builder_.add_mode(mode); return builder_.Finish(); } diff --git a/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h b/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h index cdcda8c5..67e5d095 100644 --- a/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h +++ b/components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h @@ -252,7 +252,7 @@ struct LedPreviewColorMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::T uint8_t blue() const { return GetField(VT_BLUE, 0); } - /// The LED to light; the other stays dark. + /// The LED to light; the other stays dark. A target the board doesn't have lights nothing. OpenShock::Serialization::Local::LedTarget target() const { return static_cast(GetField(VT_TARGET, 0)); } diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts index 31c1f79a..bd3b3691 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-preview-color-message.ts @@ -45,7 +45,7 @@ blue():number { } /** - * The LED to light; the other stays dark. + * The LED to light; the other stays dark. A target the board doesn't have lights nothing. */ target():LedTarget { const offset = this.bb!.__offset(this.bb_pos, 10); diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts index 55b3c1bc..4103d2f0 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/led-settings.ts @@ -9,7 +9,8 @@ import { LedMode } from '../../../open-shock/serialization/local/led-mode'; /** - * The status LED settings the portal shows and edits. + * The status LED settings the portal shows and edits. Only the LEDs the board has are present, so the portal shows + * no option for an LED that isn't there. */ export class LedSettings { bb: flatbuffers.ByteBuffer|null = null; @@ -34,31 +35,27 @@ mode():LedMode { return offset ? this.bb!.readUint8(this.bb_pos + offset) : LedMode.All; } -hasMono():boolean { - const offset = this.bb!.__offset(this.bb_pos, 6); - return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; -} - -hasRgb():boolean { - const offset = this.bb!.__offset(this.bb_pos, 8); - return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; -} - +/** + * The single-color built-in LED; absent when the board has none. + */ mono(obj?:LedBrightness):LedBrightness|null { - const offset = this.bb!.__offset(this.bb_pos, 10); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? (obj || new LedBrightness()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } +/** + * The WS2812B RGB LED; absent when the board has none. + */ rgb(obj?:LedBrightness):LedBrightness|null { - const offset = this.bb!.__offset(this.bb_pos, 12); + const offset = this.bb!.__offset(this.bb_pos, 8); return offset ? (obj || new LedBrightness()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } /** - * The hub warns about an RGB brightness above this percent. + * The hub warns about an RGB brightness above this percent. Only meaningful with `rgb`. */ rgbWarnAbove():number { - const offset = this.bb!.__offset(this.bb_pos, 14); + const offset = this.bb!.__offset(this.bb_pos, 10); return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0; } @@ -66,40 +63,32 @@ rgbWarnAbove():number { * How long a preview holds the LEDs after the last preview message. */ previewMs():number { - const offset = this.bb!.__offset(this.bb_pos, 16); + const offset = this.bb!.__offset(this.bb_pos, 12); return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0; } static startLedSettings(builder:flatbuffers.Builder) { - builder.startObject(7); + builder.startObject(5); } static addMode(builder:flatbuffers.Builder, mode:LedMode) { builder.addFieldInt8(0, mode, LedMode.All); } -static addHasMono(builder:flatbuffers.Builder, hasMono:boolean) { - builder.addFieldInt8(1, +hasMono, +false); -} - -static addHasRgb(builder:flatbuffers.Builder, hasRgb:boolean) { - builder.addFieldInt8(2, +hasRgb, +false); -} - static addMono(builder:flatbuffers.Builder, monoOffset:flatbuffers.Offset) { - builder.addFieldOffset(3, monoOffset, 0); + builder.addFieldOffset(1, monoOffset, 0); } static addRgb(builder:flatbuffers.Builder, rgbOffset:flatbuffers.Offset) { - builder.addFieldOffset(4, rgbOffset, 0); + builder.addFieldOffset(2, rgbOffset, 0); } static addRgbWarnAbove(builder:flatbuffers.Builder, rgbWarnAbove:number) { - builder.addFieldInt8(5, rgbWarnAbove, 0); + builder.addFieldInt8(3, rgbWarnAbove, 0); } static addPreviewMs(builder:flatbuffers.Builder, previewMs:number) { - builder.addFieldInt32(6, previewMs, 0); + builder.addFieldInt32(4, previewMs, 0); } static endLedSettings(builder:flatbuffers.Builder):flatbuffers.Offset { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7111f98e..2788af68 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -30,6 +30,8 @@ const _errorMessages: Record = { PasswordTooLong: 'Password must be at most 63 characters', // OTA InvalidChannel: 'Invalid update channel', + // LEDs + NoSuchLed: 'This board does not have that LED', // Generic MissingParam: 'Missing parameter', InvalidParam: 'Invalid value', diff --git a/frontend/src/lib/components/sections/LedSection.svelte b/frontend/src/lib/components/sections/LedSection.svelte index 064fb546..c4712bf2 100644 --- a/frontend/src/lib/components/sections/LedSection.svelte +++ b/frontend/src/lib/components/sections/LedSection.svelte @@ -231,8 +231,9 @@

- Previews show a state's pattern whatever the mode, at the saved brightness, or light the RGB - LED in the picked color. They last {previewSeconds} seconds and change nothing on the hub. + Previews show a state's pattern whatever the mode, at the saved brightness{led.rgb.present + ? ', or light the RGB LED in the picked color' + : ''}. They last {previewSeconds} seconds and change nothing on the hub.

diff --git a/frontend/src/lib/mappers/ConfigMapper.ts b/frontend/src/lib/mappers/ConfigMapper.ts index 1efcf353..3d197c76 100644 --- a/frontend/src/lib/mappers/ConfigMapper.ts +++ b/frontend/src/lib/mappers/ConfigMapper.ts @@ -1,3 +1,4 @@ +import type { LedBrightness as LedBrightnessTable } from '#lib/_fbs/open-shock/serialization/local/led-brightness.js'; import { LedMode as FbsLedMode } from '#lib/_fbs/open-shock/serialization/local/led-mode.js'; import type { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; import type { ReadyMessage } from '#lib/_fbs/open-shock/serialization/local/ready-message.js'; @@ -46,12 +47,16 @@ function mapLedConfig(ready: ReadyMessage): LedConfig { const led = ready.led(); if (!led) throw new Error('ready.led is null'); - const mono = led.mono(); - const rgb = led.rgb(); + // The hub only sends the LEDs the board has + const mapBrightness = (b: LedBrightnessTable | null): LedBrightness => ({ + present: b !== null, + critical: b?.critical() ?? 0, + normal: b?.normal() ?? 0, + }); return { mode: _ledModes[led.mode()] ?? 'all', - mono: { present: led.hasMono(), critical: mono?.critical() ?? 0, normal: mono?.normal() ?? 0 }, - rgb: { present: led.hasRgb(), critical: rgb?.critical() ?? 0, normal: rgb?.normal() ?? 0 }, + mono: mapBrightness(led.mono()), + rgb: mapBrightness(led.rgb()), rgbWarnAbove: led.rgbWarnAbove(), previewMs: led.previewMs(), }; diff --git a/frontend/src/lib/views/Advanced.svelte b/frontend/src/lib/views/Advanced.svelte index 7b08fc02..b10e4c6c 100644 --- a/frontend/src/lib/views/Advanced.svelte +++ b/frontend/src/lib/views/Advanced.svelte @@ -31,7 +31,7 @@ import OtaSection from '#lib/components/sections/OtaSection.svelte'; import LedSection from '#lib/components/sections/LedSection.svelte'; import SectionHeader from '#lib/components/SectionHeader.svelte'; - import { ViewModeStore } from '#lib/stores/index.js'; + import { hubState, ViewModeStore } from '#lib/stores/index.js'; import { Button } from '@openshock/svelte-core/components/ui/button'; import { ChevronRight, ArrowLeft } from '@lucide/svelte'; @@ -41,8 +41,18 @@ let { activeSection = $bindable('menu') }: Props = $props(); + // No LEDs section on a board without a status LED. Shown until the hub's config arrives, which says. + let hasLeds = $derived( + !hubState.config || hubState.config.led.mono.present || hubState.config.led.rgb.present + ); + let sections = $derived(advancedSections.filter((s) => s.id !== 'leds' || hasLeds)); + // On a phone 'menu' hides the content panel, so this only shows from md up. - let shownSection = $derived(activeSection === 'menu' ? advancedSections[0].id : activeSection); + let shownSection = $derived( + activeSection === 'menu' || !sections.some((s) => s.id === activeSection) + ? sections[0].id + : activeSection + );