From 3764b60bcd53abaf7d6f36c8b6356578024f728a Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 12:29:52 +0200 Subject: [PATCH 01/13] first prototype --- platformio.ini | 51 ++++---- usermods/redalert/library.json | 8 ++ usermods/redalert/redalert.cpp | 213 +++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 usermods/redalert/library.json create mode 100644 usermods/redalert/redalert.cpp diff --git a/platformio.ini b/platformio.ini index 29949d33c0..1ecc583207 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,29 +9,32 @@ # (use `platformio_override.ini` when building for your own board; see `platformio_override.ini.sample` for an example) # ------------------------------------------------------------------------------ -# CI/release binaries -default_envs = nodemcuv2 - esp8266_2m - esp01_1m_full - nodemcuv2_160 - esp8266_2m_160 - esp01_1m_full_160 - nodemcuv2_compat - esp8266_2m_compat - esp01_1m_full_compat - esp32dev - esp32dev_debug - esp32_eth - esp32_wrover - lolin_s2_mini - esp32c3dev - esp32c3dev_qio - esp32S3_wroom2 - esp32s3dev_16MB_opi - esp32s3dev_8MB_opi - esp32s3dev_8MB_qspi - esp32s3_4M_qspi - usermods +# CI/release binaries (comment out when building for a single board) +# default_envs = nodemcuv2 +# esp8266_2m +# esp01_1m_full +# nodemcuv2_160 +# esp8266_2m_160 +# esp01_1m_full_160 +# nodemcuv2_compat +# esp8266_2m_compat +# esp01_1m_full_compat +# esp32dev +# esp32dev_debug +# esp32_eth +# esp32_wrover +# lolin_s2_mini +# esp32c3dev +# esp32c3dev_qio +# esp32S3_wroom2 +# esp32s3dev_16MB_opi +# esp32s3dev_8MB_opi +# esp32s3dev_8MB_qspi +# esp32s3_4M_qspi +# usermods + +# Your board: uncomment exactly one line below (esp32dev = generic ESP32 Dev Module) +default_envs = esp32dev src_dir = ./wled00 data_dir = ./wled00/data @@ -457,7 +460,7 @@ board = esp32dev platform = ${esp32_idf_V4.platform} platform_packages = ${esp32_idf_V4.platform_packages} build_unflags = ${common.build_unflags} -custom_usermods = audioreactive +custom_usermods = redalert build_flags = ${common.build_flags} ${esp32_idf_V4.build_flags} -D WLED_RELEASE_NAME=\"ESP32\" #-D WLED_DISABLE_BROWNOUT_DET -DARDUINO_USB_CDC_ON_BOOT=0 ;; this flag is mandatory for "classic ESP32" when building with arduino-esp32 >=2.0.3 lib_deps = ${esp32_idf_V4.lib_deps} diff --git a/usermods/redalert/library.json b/usermods/redalert/library.json new file mode 100644 index 0000000000..cd6a1252be --- /dev/null +++ b/usermods/redalert/library.json @@ -0,0 +1,8 @@ +{ + "name": "usermod-redalert", + "version": "1.0.0", + "description": "Red alert / Pikud Haoref usermod for WLED", + "build": { + "libArchive": false + } +} diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp new file mode 100644 index 0000000000..9b2b79e5fe --- /dev/null +++ b/usermods/redalert/redalert.cpp @@ -0,0 +1,213 @@ +#pragma once + +#include "wled.h" +#include + +#ifndef USERMOD_ID_PIKUD_HAOREF +// Pick some unused ID or add to usermod_id.h +#define USERMOD_ID_PIKUD_HAOREF 0xdead +#endif + +class UsermodPikudHaoref : public Usermod { +private: + // Strings reused in config/UI to save flash + static const char _name[]; + static const char _enabled[]; + static const char _apiUrl[]; + static const char _areaName[]; + + // Configurable parameters + // Official Pikud Haoref alerts endpoint (JSON array) + String apiUrl = "https://www.oref.org.il/WarningMessages/alert/alerts.json"; + // Area / city name to watch for (must match an entry in the 'cities' array) + String areaName = "תל אביב - מזרח"; + bool enabled = true; + uint32_t pollIntervalMs = 15000; // 15 seconds + uint8_t alertBrightness = 255; + uint32_t alertColor = RGBW32(255, 0, 0, 0); // red + bool flashAlert = true; + uint32_t flashPeriodMs = 500; // on/off every 500ms + + // Internal state + unsigned long lastPoll = 0; + bool alertActive = false; + unsigned long alertStartMs = 0; + int lastHttpCode = 0; + + // Helper: poll the alert endpoint + void pollAlert() { + if (!enabled) return; + if (!Network.isConnected()) return; + if (apiUrl.length() == 0) return; + if (areaName.length() == 0) return; + + HTTPClient http; + http.setTimeout(3000); // 3s + + http.begin(apiUrl); + + // Pikud Haoref endpoint is somewhat picky about headers; set a UA. + http.addHeader("User-Agent", "WLED-PikudHaoref-ESP32"); + + int httpCode = http.GET(); + lastHttpCode = httpCode; + if (httpCode == HTTP_CODE_OK) { + String payload = http.getString(); + + // Response is a JSON array of alert objects, or [] when no alerts. + DynamicJsonDocument doc(4096); + DeserializationError err = deserializeJson(doc, payload); + if (!err) { + bool foundAlert = false; + + if (doc.is()) { + JsonArray alerts = doc.as(); + for (JsonVariant v : alerts) { + if (!v.is()) continue; + JsonObject alert = v.as(); + JsonArray cities = alert["cities"].as(); + for (JsonVariant cv : cities) { + const char* cityName = cv.as(); + if (!cityName) continue; + // Simple substring match so a broader area string still works. + if (String(cityName).indexOf(areaName) >= 0) { + foundAlert = true; + break; + } + } + if (foundAlert) break; + } + } + + if (foundAlert && !alertActive) { + alertStartMs = millis(); + } + alertActive = foundAlert; + } + } + http.end(); + } + + // Helper: render alert pattern on strip + void renderAlertEffect() { + if (!alertActive) return; + + uint32_t now = millis(); + bool onPhase = true; + + if (flashAlert) { + // Simple square-wave flash + onPhase = ((now / flashPeriodMs) % 2) == 0; + } + + if (!onPhase) { + // Off phase: let WLED draw normal effects + return; + } + + // Overwrite current frame with alert color + // (done late in the loop to "win" over regular effects) + uint16_t totalLen = strip.getLengthTotal(); + strip.setBrightness(alertBrightness); + for (uint16_t i = 0; i < totalLen; i++) { + strip.setPixelColor(i, alertColor); + } + } + +public: + // Called once at boot + void setup() override { + // Nothing special + } + + // Called frequently; do non-blocking work here + void loop() override { + if (!enabled) return; + + unsigned long now = millis(); + + // Periodic polling + if (now - lastPoll >= pollIntervalMs) { + lastPoll = now; + pollAlert(); + } + + // Draw alert if active + if (alertActive) { + renderAlertEffect(); + } + } + + // Called after WLED’s main effects finished drawing + void handleOverlayDraw() override { + // Alternative: do the drawing here instead of in loop() + if (alertActive) { + renderAlertEffect(); + } + } + + // Provide JSON config + void addToConfig(JsonObject &root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + + top[FPSTR(_enabled)] = enabled; + top[FPSTR(_apiUrl)] = apiUrl; + top[FPSTR(_areaName)] = areaName; + top["pollIntervalMs"] = pollIntervalMs; + top["alertBrightness"] = alertBrightness; + top["alertColor"] = alertColor; + top["flashAlert"] = flashAlert; + top["flashPeriodMs"] = flashPeriodMs; + } + + // Load JSON config + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) { + DEBUG_PRINTLN(F("PikudHaoref: No config found, using defaults.")); + return false; + } + + bool cfg = true; + cfg &= getJsonValue(top[FPSTR(_enabled)], enabled, true); + cfg &= getJsonValue(top[FPSTR(_apiUrl)], apiUrl, apiUrl); + cfg &= getJsonValue(top[FPSTR(_areaName)], areaName, areaName); + cfg &= getJsonValue(top["pollIntervalMs"], pollIntervalMs, pollIntervalMs); + cfg &= getJsonValue(top["alertBrightness"], alertBrightness, alertBrightness); + cfg &= getJsonValue(top["alertColor"], alertColor, alertColor); + cfg &= getJsonValue(top["flashAlert"], flashAlert, flashAlert); + cfg &= getJsonValue(top["flashPeriodMs"], flashPeriodMs, flashPeriodMs); + + return cfg; + } + + // Info in /json/info and UI + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + + JsonArray arr = user.createNestedArray(FPSTR(_name)); + arr.add(alertActive ? F("ALERT") : F("OK")); + arr.add(F(" Pikud Haoref")); + + JsonObject sensor = root["sensor"]; + if (sensor.isNull()) sensor = root.createNestedObject("sensor"); + JsonArray status = sensor.createNestedArray(FPSTR(_name)); + status.add(lastHttpCode); + status.add(F(" last HTTP status")); + } + + uint16_t getId() override { + return USERMOD_ID_PIKUD_HAOREF; + } +}; + +// flash-saving constant strings +const char UsermodPikudHaoref::_name[] PROGMEM = "RedAlert"; +const char UsermodPikudHaoref::_enabled[] PROGMEM = "enabled"; +const char UsermodPikudHaoref::_apiUrl[] PROGMEM = "apiUrl"; +const char UsermodPikudHaoref::_areaName[] PROGMEM = "areaName"; + +// register usermod instance +static UsermodPikudHaoref usermod_pikud_haoref; +REGISTER_USERMOD(usermod_pikud_haoref); \ No newline at end of file From decf6cac8c64896ab5b80024b0c2321135323999 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 16:52:37 +0200 Subject: [PATCH 02/13] Enhance alert handling in redalert usermod - Introduced new alert states: STATE_OK, STATE_PRE_ALERT, STATE_ALERT, STATE_END. - Added configurable parameters for alert presets and idle timeout handling. - Updated polling logic to determine alert state based on received JSON data. - Removed deprecated alert rendering logic and replaced it with state-based updates. - Improved JSON configuration handling for new alert parameters. --- usermods/redalert/redalert.cpp | 208 +++++++++++++++++++++++---------- 1 file changed, 145 insertions(+), 63 deletions(-) diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 9b2b79e5fe..e11a023b10 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -1,5 +1,3 @@ -#pragma once - #include "wled.h" #include @@ -15,6 +13,26 @@ class UsermodPikudHaoref : public Usermod { static const char _enabled[]; static const char _apiUrl[]; static const char _areaName[]; + static const char _alertEnabled[]; + static const char _preAlertEnabled[]; + static const char _endEnabled[]; + + static const char _alertPreset[]; + static const char _preAlertPreset[]; + static const char _endPreset[]; + + static const char _idleTimeoutSec[]; + static const char _idlePreset[]; + static const char _resetUrl[]; + + enum AlertState : uint8_t { + STATE_OK = 0, + STATE_PRE_ALERT = 1, + STATE_ALERT = 2, + STATE_END = 3 + }; + + bool initDone = false; // Configurable parameters // Official Pikud Haoref alerts endpoint (JSON array) @@ -23,17 +41,59 @@ class UsermodPikudHaoref : public Usermod { String areaName = "תל אביב - מזרח"; bool enabled = true; uint32_t pollIntervalMs = 15000; // 15 seconds - uint8_t alertBrightness = 255; - uint32_t alertColor = RGBW32(255, 0, 0, 0); // red - bool flashAlert = true; - uint32_t flashPeriodMs = 500; // on/off every 500ms + + // Per-state toggles and presets + bool enableAlert = true; + bool enablePreAlert = false; + bool enableEnd = false; + uint8_t alertPreset = 0; + uint8_t preAlertPreset = 0; + uint8_t endPreset = 0; + + // Idle timeout handling (seconds + preset) + uint32_t idleTimeoutSec = 0; // 0 = disabled + uint8_t idlePreset = 0; // Internal state unsigned long lastPoll = 0; - bool alertActive = false; - unsigned long alertStartMs = 0; int lastHttpCode = 0; + AlertState currentState = STATE_OK; + unsigned long lastStateChangeMs = 0; + bool idlePresetApplied = false; + + void updateState(AlertState newState) { + if (newState == currentState) return; + + currentState = newState; + lastStateChangeMs = millis(); + idlePresetApplied = false; + + if (!initDone) return; // prevent crashes at boot + + switch (currentState) { + case STATE_ALERT: + if (enableAlert && alertPreset > 0) { + applyPreset(alertPreset); + } + break; + case STATE_PRE_ALERT: + if (enablePreAlert && preAlertPreset > 0) { + applyPreset(preAlertPreset); + } + break; + case STATE_END: + if (enableEnd && endPreset > 0) { + applyPreset(endPreset); + } + break; + case STATE_OK: + default: + // No automatic preset on OK; user can rely on idlePreset instead. + break; + } + } + // Helper: poll the alert endpoint void pollAlert() { if (!enabled) return; @@ -52,72 +112,50 @@ class UsermodPikudHaoref : public Usermod { int httpCode = http.GET(); lastHttpCode = httpCode; if (httpCode == HTTP_CODE_OK) { + AlertState newState = STATE_OK; String payload = http.getString(); // Response is a JSON array of alert objects, or [] when no alerts. DynamicJsonDocument doc(4096); DeserializationError err = deserializeJson(doc, payload); if (!err) { - bool foundAlert = false; - if (doc.is()) { JsonArray alerts = doc.as(); for (JsonVariant v : alerts) { if (!v.is()) continue; JsonObject alert = v.as(); + // Expecting structure similar to oref_alert integration: + // "cities": [ "area1", ... ], "category": , etc. JsonArray cities = alert["cities"].as(); for (JsonVariant cv : cities) { const char* cityName = cv.as(); if (!cityName) continue; // Simple substring match so a broader area string still works. if (String(cityName).indexOf(areaName) >= 0) { - foundAlert = true; + int category = alert["category"] | 0; + if (category == 14) { + newState = STATE_PRE_ALERT; // "pre_alert" + } else if (category == 13) { + newState = STATE_END; // "end" + } else { + newState = STATE_ALERT; // main "alert" + } break; } } - if (foundAlert) break; + if (newState != STATE_OK) break; } } - - if (foundAlert && !alertActive) { - alertStartMs = millis(); - } - alertActive = foundAlert; + updateState(newState); } } http.end(); } - // Helper: render alert pattern on strip - void renderAlertEffect() { - if (!alertActive) return; - - uint32_t now = millis(); - bool onPhase = true; - - if (flashAlert) { - // Simple square-wave flash - onPhase = ((now / flashPeriodMs) % 2) == 0; - } - - if (!onPhase) { - // Off phase: let WLED draw normal effects - return; - } - - // Overwrite current frame with alert color - // (done late in the loop to "win" over regular effects) - uint16_t totalLen = strip.getLengthTotal(); - strip.setBrightness(alertBrightness); - for (uint16_t i = 0; i < totalLen; i++) { - strip.setPixelColor(i, alertColor); - } - } - public: // Called once at boot void setup() override { - // Nothing special + initDone = true; } // Called frequently; do non-blocking work here @@ -132,18 +170,15 @@ class UsermodPikudHaoref : public Usermod { pollAlert(); } - // Draw alert if active - if (alertActive) { - renderAlertEffect(); + // Idle timeout handling: if state hasn't changed for configured time, + // optionally apply a fallback preset once. + if (initDone && idleTimeoutSec > 0 && idlePreset > 0 && lastStateChangeMs > 0 && !idlePresetApplied) { + if (now - lastStateChangeMs >= idleTimeoutSec * 1000UL) { + applyPreset(idlePreset); + idlePresetApplied = true; + } } - } - // Called after WLED’s main effects finished drawing - void handleOverlayDraw() override { - // Alternative: do the drawing here instead of in loop() - if (alertActive) { - renderAlertEffect(); - } } // Provide JSON config @@ -154,10 +189,15 @@ class UsermodPikudHaoref : public Usermod { top[FPSTR(_apiUrl)] = apiUrl; top[FPSTR(_areaName)] = areaName; top["pollIntervalMs"] = pollIntervalMs; - top["alertBrightness"] = alertBrightness; - top["alertColor"] = alertColor; - top["flashAlert"] = flashAlert; - top["flashPeriodMs"] = flashPeriodMs; + + top[FPSTR(_alertEnabled)] = enableAlert; + top[FPSTR(_preAlertEnabled)] = enablePreAlert; + top[FPSTR(_endEnabled)] = enableEnd; + top[FPSTR(_alertPreset)] = alertPreset; + top[FPSTR(_preAlertPreset)] = preAlertPreset; + top[FPSTR(_endPreset)] = endPreset; + top[FPSTR(_idleTimeoutSec)] = idleTimeoutSec; + top[FPSTR(_idlePreset)] = idlePreset; } // Load JSON config @@ -173,10 +213,15 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(top[FPSTR(_apiUrl)], apiUrl, apiUrl); cfg &= getJsonValue(top[FPSTR(_areaName)], areaName, areaName); cfg &= getJsonValue(top["pollIntervalMs"], pollIntervalMs, pollIntervalMs); - cfg &= getJsonValue(top["alertBrightness"], alertBrightness, alertBrightness); - cfg &= getJsonValue(top["alertColor"], alertColor, alertColor); - cfg &= getJsonValue(top["flashAlert"], flashAlert, flashAlert); - cfg &= getJsonValue(top["flashPeriodMs"], flashPeriodMs, flashPeriodMs); + + cfg &= getJsonValue(top[FPSTR(_alertEnabled)], enableAlert, enableAlert); + cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); + cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); + cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); + cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); + cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); + cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); return cfg; } @@ -187,9 +232,25 @@ class UsermodPikudHaoref : public Usermod { if (user.isNull()) user = root.createNestedObject("u"); JsonArray arr = user.createNestedArray(FPSTR(_name)); - arr.add(alertActive ? F("ALERT") : F("OK")); + const __FlashStringHelper* stateLabel = F("OK"); + switch (currentState) { + case STATE_PRE_ALERT: stateLabel = F("PRE_ALERT"); break; + case STATE_ALERT: stateLabel = F("ALERT"); break; + case STATE_END: stateLabel = F("END"); break; + case STATE_OK: + default: stateLabel = F("OK"); break; + } + arr.add(stateLabel); arr.add(F(" Pikud Haoref")); + // Add a small button to reset the API URL back to default + String uiDomString = F(""); + arr.add(uiDomString); + JsonObject sensor = root["sensor"]; if (sensor.isNull()) sensor = root.createNestedObject("sensor"); JsonArray status = sensor.createNestedArray(FPSTR(_name)); @@ -197,6 +258,18 @@ class UsermodPikudHaoref : public Usermod { status.add(F(" last HTTP status")); } + // Handle JSON state updates (e.g. from the Reset URL button) + void readFromJsonState(JsonObject &root) override { + if (!initDone) return; + + JsonObject um = root[FPSTR(_name)]; + if (um.isNull()) return; + + if (um[FPSTR(_resetUrl)]) { + apiUrl = F("https://www.oref.org.il/WarningMessages/alert/alerts.json"); + } + } + uint16_t getId() override { return USERMOD_ID_PIKUD_HAOREF; } @@ -207,6 +280,15 @@ const char UsermodPikudHaoref::_name[] PROGMEM = "RedAlert"; const char UsermodPikudHaoref::_enabled[] PROGMEM = "enabled"; const char UsermodPikudHaoref::_apiUrl[] PROGMEM = "apiUrl"; const char UsermodPikudHaoref::_areaName[] PROGMEM = "areaName"; +const char UsermodPikudHaoref::_alertEnabled[] PROGMEM = "alertEnabled"; +const char UsermodPikudHaoref::_preAlertEnabled[] PROGMEM = "preAlertEnabled"; +const char UsermodPikudHaoref::_endEnabled[] PROGMEM = "endEnabled"; +const char UsermodPikudHaoref::_alertPreset[] PROGMEM = "alertPreset"; +const char UsermodPikudHaoref::_preAlertPreset[] PROGMEM = "preAlertPreset"; +const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; +const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; +const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; +const char UsermodPikudHaoref::_resetUrl[] PROGMEM = "resetUrl"; // register usermod instance static UsermodPikudHaoref usermod_pikud_haoref; From 9035e9eb56aa8da6fa42f8f48d68dfa066cd6eeb Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 18:47:36 +0200 Subject: [PATCH 03/13] Add WLED_DEBUG flag and enhance redalert usermod with new alert handling - Introduced new alert state handling for STATE_OK and improved JSON parsing for alert categories. - Added support for additional alert presets and refined polling logic. - Updated platformio.ini to include WLED_DEBUG for enhanced debugging capabilities. --- platformio.ini | 1 + usermods/redalert/redalert.cpp | 168 +++++++++++++++++++++++++++------ 2 files changed, 141 insertions(+), 28 deletions(-) diff --git a/platformio.ini b/platformio.ini index 1ecc583207..dc135b9ab3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -463,6 +463,7 @@ build_unflags = ${common.build_unflags} custom_usermods = redalert build_flags = ${common.build_flags} ${esp32_idf_V4.build_flags} -D WLED_RELEASE_NAME=\"ESP32\" #-D WLED_DISABLE_BROWNOUT_DET -DARDUINO_USB_CDC_ON_BOOT=0 ;; this flag is mandatory for "classic ESP32" when building with arduino-esp32 >=2.0.3 + -D WLED_DEBUG lib_deps = ${esp32_idf_V4.lib_deps} monitor_filters = esp32_exception_decoder board_build.partitions = ${esp32.default_partitions} diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index e11a023b10..6ab3b073d3 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -16,10 +16,12 @@ class UsermodPikudHaoref : public Usermod { static const char _alertEnabled[]; static const char _preAlertEnabled[]; static const char _endEnabled[]; + static const char _okEnabled[]; static const char _alertPreset[]; static const char _preAlertPreset[]; static const char _endPreset[]; + static const char _okPreset[]; static const char _idleTimeoutSec[]; static const char _idlePreset[]; @@ -46,9 +48,11 @@ class UsermodPikudHaoref : public Usermod { bool enableAlert = true; bool enablePreAlert = false; bool enableEnd = false; + bool enableOk = false; uint8_t alertPreset = 0; uint8_t preAlertPreset = 0; uint8_t endPreset = 0; + uint8_t okPreset = 0; // Idle timeout handling (seconds + preset) uint32_t idleTimeoutSec = 0; // 0 = disabled @@ -62,9 +66,52 @@ class UsermodPikudHaoref : public Usermod { unsigned long lastStateChangeMs = 0; bool idlePresetApplied = false; + const __FlashStringHelper* stateToLabel(AlertState s) { + switch (s) { + case STATE_PRE_ALERT: return F("PRE_ALERT"); + case STATE_ALERT: return F("ALERT"); + case STATE_END: return F("END"); + case STATE_OK: + default: return F("OK"); + } + } + + int extractCategory(JsonObject& alert) { + int category = 0; + + // Prefer "cat" (string or number) + JsonVariant catVar = alert["cat"]; + if (!catVar.isNull()) { + if (catVar.is()) { + category = catVar.as(); + } else if (catVar.is()) { + const char* catStr = catVar.as(); + if (catStr) category = atoi(catStr); + } + } else { + // Fallback to "category" + JsonVariant cat2 = alert["category"]; + if (!cat2.isNull()) { + if (cat2.is()) { + category = cat2.as(); + } else if (cat2.is()) { + const char* catStr2 = cat2.as(); + if (catStr2) category = atoi(catStr2); + } + } + } + + return category; + } + void updateState(AlertState newState) { if (newState == currentState) return; + DEBUG_PRINT(F("RedAlert: state change ")); + DEBUG_PRINT(stateToLabel(currentState)); + DEBUG_PRINT(F(" -> ")); + DEBUG_PRINTLN(stateToLabel(newState)); + currentState = newState; lastStateChangeMs = millis(); idlePresetApplied = false; @@ -89,7 +136,9 @@ class UsermodPikudHaoref : public Usermod { break; case STATE_OK: default: - // No automatic preset on OK; user can rely on idlePreset instead. + if (enableOk && okPreset > 0) { + applyPreset(okPreset); + } break; } } @@ -111,39 +160,85 @@ class UsermodPikudHaoref : public Usermod { int httpCode = http.GET(); lastHttpCode = httpCode; + + DEBUG_PRINT(F("RedAlert: HTTP GET ")); + DEBUG_PRINT(apiUrl); + DEBUG_PRINT(F(" -> code ")); + DEBUG_PRINTLN(httpCode); + if (httpCode == HTTP_CODE_OK) { AlertState newState = STATE_OK; String payload = http.getString(); - // Response is a JSON array of alert objects, or [] when no alerts. + DEBUG_PRINTLN(F("RedAlert: raw JSON payload:")); + DEBUG_PRINTLN(payload); + + // Response can be: + // - a single JSON object (current Pikud Haoref format) + // - or an array of such objects (for future/other wrappers) DynamicJsonDocument doc(4096); DeserializationError err = deserializeJson(doc, payload); - if (!err) { + if (err) { + DEBUG_PRINT(F("RedAlert: JSON parse error: ")); + DEBUG_PRINTLN(err.c_str()); + } else { if (doc.is()) { + // Array of alert objects JsonArray alerts = doc.as(); for (JsonVariant v : alerts) { if (!v.is()) continue; JsonObject alert = v.as(); - // Expecting structure similar to oref_alert integration: - // "cities": [ "area1", ... ], "category": , etc. - JsonArray cities = alert["cities"].as(); - for (JsonVariant cv : cities) { - const char* cityName = cv.as(); - if (!cityName) continue; - // Simple substring match so a broader area string still works. - if (String(cityName).indexOf(areaName) >= 0) { - int category = alert["category"] | 0; - if (category == 14) { - newState = STATE_PRE_ALERT; // "pre_alert" - } else if (category == 13) { - newState = STATE_END; // "end" - } else { - newState = STATE_ALERT; // main "alert" - } - break; - } + + // Newer format uses "data" array; older/wrapper formats may use "cities" + JsonArray cities = alert["data"].as(); + if (cities.isNull()) cities = alert["cities"].as(); + if (cities.isNull()) continue; + + // TEMP/PRAGMATIC: treat any alert with at least one city as a match + const char* cityName = cities[0].as(); + if (!cityName) cityName = ""; + + int category = extractCategory(alert); + + DEBUG_PRINT(F("RedAlert: FORCED match (array), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + + if (category == 14) { + newState = STATE_PRE_ALERT; // "pre_alert" + } else if (category == 13) { + newState = STATE_END; // "end" + } else { + newState = STATE_ALERT; // main "alert" + } + break; + } + } else if (doc.is()) { + // Single alert object (current Pikud Haoref "alerts.json" shape) + JsonObject alert = doc.as(); + + JsonArray cities = alert["data"].as(); + if (cities.isNull()) cities = alert["cities"].as(); + + if (!cities.isNull() && cities.size() > 0) { + const char* cityName = cities[0].as(); + if (!cityName) cityName = ""; + + int category = extractCategory(alert); + + DEBUG_PRINT(F("RedAlert: FORCED match (object), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + + if (category == 14) { + newState = STATE_PRE_ALERT; + } else if (category == 13) { + newState = STATE_END; + } else { + newState = STATE_ALERT; } - if (newState != STATE_OK) break; } } updateState(newState); @@ -156,26 +251,37 @@ class UsermodPikudHaoref : public Usermod { // Called once at boot void setup() override { initDone = true; + + DEBUG_PRINTLN(F("RedAlert: setup complete")); } // Called frequently; do non-blocking work here void loop() override { if (!enabled) return; + // First handle periodic polling unsigned long now = millis(); - - // Periodic polling if (now - lastPoll >= pollIntervalMs) { lastPoll = now; - pollAlert(); + pollAlert(); // may change state and update lastStateChangeMs } - // Idle timeout handling: if state hasn't changed for configured time, - // optionally apply a fallback preset once. - if (initDone && idleTimeoutSec > 0 && idlePreset > 0 && lastStateChangeMs > 0 && !idlePresetApplied) { + // Re-sample time *after* polling, so idle timing is relative to the + // most recent state change, not the time before pollAlert() ran. + now = millis(); + + // Idle timeout handling: + // If we have been in the *same* state for configured time without any + // state changes, optionally apply a fallback preset once. + if (initDone && + idleTimeoutSec > 0 && idlePreset > 0 && + lastStateChangeMs > 0 && !idlePresetApplied) { if (now - lastStateChangeMs >= idleTimeoutSec * 1000UL) { applyPreset(idlePreset); idlePresetApplied = true; + + DEBUG_PRINT(F("RedAlert: idle timeout reached, applying idlePreset=")); + DEBUG_PRINTLN(idlePreset); } } @@ -193,9 +299,11 @@ class UsermodPikudHaoref : public Usermod { top[FPSTR(_alertEnabled)] = enableAlert; top[FPSTR(_preAlertEnabled)] = enablePreAlert; top[FPSTR(_endEnabled)] = enableEnd; + top[FPSTR(_okEnabled)] = enableOk; top[FPSTR(_alertPreset)] = alertPreset; top[FPSTR(_preAlertPreset)] = preAlertPreset; top[FPSTR(_endPreset)] = endPreset; + top[FPSTR(_okPreset)] = okPreset; top[FPSTR(_idleTimeoutSec)] = idleTimeoutSec; top[FPSTR(_idlePreset)] = idlePreset; } @@ -217,9 +325,11 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(top[FPSTR(_alertEnabled)], enableAlert, enableAlert); cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); + cfg &= getJsonValue(top[FPSTR(_okEnabled)], enableOk, enableOk); cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); + cfg &= getJsonValue(top[FPSTR(_okPreset)], okPreset, okPreset); cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); @@ -283,9 +393,11 @@ const char UsermodPikudHaoref::_areaName[] PROGMEM = "areaName"; const char UsermodPikudHaoref::_alertEnabled[] PROGMEM = "alertEnabled"; const char UsermodPikudHaoref::_preAlertEnabled[] PROGMEM = "preAlertEnabled"; const char UsermodPikudHaoref::_endEnabled[] PROGMEM = "endEnabled"; +const char UsermodPikudHaoref::_okEnabled[] PROGMEM = "okEnabled"; const char UsermodPikudHaoref::_alertPreset[] PROGMEM = "alertPreset"; const char UsermodPikudHaoref::_preAlertPreset[] PROGMEM = "preAlertPreset"; const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; +const char UsermodPikudHaoref::_okPreset[] PROGMEM = "okPreset"; const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; const char UsermodPikudHaoref::_resetUrl[] PROGMEM = "resetUrl"; From 52a1d0cf544cbd91e1bef62fe4c03182cb828504 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 19:13:03 +0200 Subject: [PATCH 04/13] Enhance redalert usermod with area matching functionality - Added support for an "all areas" mode, allowing alerts to be triggered regardless of specific city names. - Implemented area matching logic to compare city names against configured areas, improving alert accuracy. - Updated JSON handling to accommodate new area matching requirements and ensure proper state transitions based on alerts. --- usermods/redalert/redalert.cpp | 106 ++++++++++++++++-------- usermods/redalert/redalert_text_utils.h | 54 ++++++++++++ 2 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 usermods/redalert/redalert_text_utils.h diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 6ab3b073d3..fc2901192b 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -1,5 +1,6 @@ #include "wled.h" #include +#include "redalert_text_utils.h" #ifndef USERMOD_ID_PIKUD_HAOREF // Pick some unused ID or add to usermod_id.h @@ -17,6 +18,7 @@ class UsermodPikudHaoref : public Usermod { static const char _preAlertEnabled[]; static const char _endEnabled[]; static const char _okEnabled[]; + static const char _allAreasEnabled[]; static const char _alertPreset[]; static const char _preAlertPreset[]; @@ -49,6 +51,7 @@ class UsermodPikudHaoref : public Usermod { bool enablePreAlert = false; bool enableEnd = false; bool enableOk = false; + bool enableAllAreas = false; uint8_t alertPreset = 0; uint8_t preAlertPreset = 0; uint8_t endPreset = 0; @@ -104,6 +107,26 @@ class UsermodPikudHaoref : public Usermod { return category; } + bool areaMatches(const char* cityNameRaw) { + if (enableAllAreas) return true; + if (!cityNameRaw) return false; + + String cityNorm = RedAlertText::normalizeAreaText(String(cityNameRaw)); + String areaNorm = RedAlertText::normalizeAreaText(areaName); + if (cityNorm.length() == 0 || areaNorm.length() == 0) return false; + + bool match = (cityNorm == areaNorm || cityNorm.indexOf(areaNorm) >= 0 || areaNorm.indexOf(cityNorm) >= 0); + + DEBUG_PRINT(F("RedAlert: area compare city=\"")); + DEBUG_PRINT(cityNorm); + DEBUG_PRINT(F("\" area=\"")); + DEBUG_PRINT(areaNorm); + DEBUG_PRINT(F("\" -> ")); + DEBUG_PRINTLN(match ? F("MATCH") : F("NO_MATCH")); + + return match; + } + void updateState(AlertState newState) { if (newState == currentState) return; @@ -148,7 +171,8 @@ class UsermodPikudHaoref : public Usermod { if (!enabled) return; if (!Network.isConnected()) return; if (apiUrl.length() == 0) return; - if (areaName.length() == 0) return; + // If we are NOT in "all areas" mode, we require a non-empty areaName. + if (!enableAllAreas && areaName.length() == 0) return; HTTPClient http; http.setTimeout(3000); // 3s @@ -194,25 +218,28 @@ class UsermodPikudHaoref : public Usermod { if (cities.isNull()) cities = alert["cities"].as(); if (cities.isNull()) continue; - // TEMP/PRAGMATIC: treat any alert with at least one city as a match - const char* cityName = cities[0].as(); - if (!cityName) cityName = ""; - - int category = extractCategory(alert); - - DEBUG_PRINT(F("RedAlert: FORCED match (array), city=\"")); - DEBUG_PRINT(cityName); - DEBUG_PRINT(F("\", category=")); - DEBUG_PRINTLN(category); - - if (category == 14) { - newState = STATE_PRE_ALERT; // "pre_alert" - } else if (category == 13) { - newState = STATE_END; // "end" - } else { - newState = STATE_ALERT; // main "alert" + for (JsonVariant cv : cities) { + const char* cityName = cv.as(); + if (!cityName) continue; + if (!areaMatches(cityName)) continue; + + int category = extractCategory(alert); + + DEBUG_PRINT(F("RedAlert: match (array), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + + if (category == 14) { + newState = STATE_PRE_ALERT; // "pre_alert" + } else if (category == 13) { + newState = STATE_END; // "end" + } else { + newState = STATE_ALERT; // main "alert" + } + break; } - break; + if (newState != STATE_OK) break; } } else if (doc.is()) { // Single alert object (current Pikud Haoref "alerts.json" shape) @@ -221,23 +248,27 @@ class UsermodPikudHaoref : public Usermod { JsonArray cities = alert["data"].as(); if (cities.isNull()) cities = alert["cities"].as(); - if (!cities.isNull() && cities.size() > 0) { - const char* cityName = cities[0].as(); - if (!cityName) cityName = ""; - - int category = extractCategory(alert); - - DEBUG_PRINT(F("RedAlert: FORCED match (object), city=\"")); - DEBUG_PRINT(cityName); - DEBUG_PRINT(F("\", category=")); - DEBUG_PRINTLN(category); - - if (category == 14) { - newState = STATE_PRE_ALERT; - } else if (category == 13) { - newState = STATE_END; - } else { - newState = STATE_ALERT; + if (!cities.isNull()) { + for (JsonVariant cv : cities) { + const char* cityName = cv.as(); + if (!cityName) continue; + if (!areaMatches(cityName)) continue; + + int category = extractCategory(alert); + + DEBUG_PRINT(F("RedAlert: match (object), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + + if (category == 14) { + newState = STATE_PRE_ALERT; + } else if (category == 13) { + newState = STATE_END; + } else { + newState = STATE_ALERT; + } + break; } } } @@ -300,6 +331,7 @@ class UsermodPikudHaoref : public Usermod { top[FPSTR(_preAlertEnabled)] = enablePreAlert; top[FPSTR(_endEnabled)] = enableEnd; top[FPSTR(_okEnabled)] = enableOk; + top[FPSTR(_allAreasEnabled)] = enableAllAreas; top[FPSTR(_alertPreset)] = alertPreset; top[FPSTR(_preAlertPreset)] = preAlertPreset; top[FPSTR(_endPreset)] = endPreset; @@ -326,6 +358,7 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); cfg &= getJsonValue(top[FPSTR(_okEnabled)], enableOk, enableOk); + cfg &= getJsonValue(top[FPSTR(_allAreasEnabled)], enableAllAreas, enableAllAreas); cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); @@ -394,6 +427,7 @@ const char UsermodPikudHaoref::_alertEnabled[] PROGMEM = "alertEnabled"; const char UsermodPikudHaoref::_preAlertEnabled[] PROGMEM = "preAlertEnabled"; const char UsermodPikudHaoref::_endEnabled[] PROGMEM = "endEnabled"; const char UsermodPikudHaoref::_okEnabled[] PROGMEM = "okEnabled"; +const char UsermodPikudHaoref::_allAreasEnabled[] PROGMEM = "allAreasEnabled"; const char UsermodPikudHaoref::_alertPreset[] PROGMEM = "alertPreset"; const char UsermodPikudHaoref::_preAlertPreset[] PROGMEM = "preAlertPreset"; const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; diff --git a/usermods/redalert/redalert_text_utils.h b/usermods/redalert/redalert_text_utils.h new file mode 100644 index 0000000000..de887aa76b --- /dev/null +++ b/usermods/redalert/redalert_text_utils.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +namespace RedAlertText { + +inline int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; +} + +inline String decodeUnicodeEscapes(const String& in) { + String out; + out.reserve(in.length()); + + for (uint16_t i = 0; i < in.length(); i++) { + char ch = in[i]; + if (ch == '\\' && (i + 5) < in.length() && in[i + 1] == 'u') { + int h1 = hexNibble(in[i + 2]); + int h2 = hexNibble(in[i + 3]); + int h3 = hexNibble(in[i + 4]); + int h4 = hexNibble(in[i + 5]); + if (h1 >= 0 && h2 >= 0 && h3 >= 0 && h4 >= 0) { + uint16_t cp = (h1 << 12) | (h2 << 8) | (h3 << 4) | h4; + if (cp <= 0x7F) { + out += (char)cp; + } else if (cp <= 0x7FF) { + out += (char)(0xC0 | ((cp >> 6) & 0x1F)); + out += (char)(0x80 | (cp & 0x3F)); + } else { + out += (char)(0xE0 | ((cp >> 12) & 0x0F)); + out += (char)(0x80 | ((cp >> 6) & 0x3F)); + out += (char)(0x80 | (cp & 0x3F)); + } + i += 5; + continue; + } + } + out += ch; + } + return out; +} + +inline String normalizeAreaText(const String& raw) { + String s = raw; + s.trim(); + if (s.indexOf("\\u") >= 0) s = decodeUnicodeEscapes(s); + while (s.indexOf(" ") >= 0) s.replace(" ", " "); + return s; +} + +} // namespace RedAlertText From 30de5c67fbe6cd4a12d795d76cfaa166e80cee4f Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 19:17:49 +0200 Subject: [PATCH 05/13] Refactor area name handling in redalert usermod - Introduced a normalized area name to streamline area matching logic. - Updated area comparison to use the normalized name for improved accuracy. - Added a method to refresh the normalized area name when necessary. - Adjusted conditions to ensure proper handling of alerts based on the normalized area text. --- usermods/redalert/redalert.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index fc2901192b..d6d570af96 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -45,6 +45,7 @@ class UsermodPikudHaoref : public Usermod { String areaName = "תל אביב - מזרח"; bool enabled = true; uint32_t pollIntervalMs = 15000; // 15 seconds + String normalizedAreaName = RedAlertText::normalizeAreaText(areaName); // Per-state toggles and presets bool enableAlert = true; @@ -69,6 +70,10 @@ class UsermodPikudHaoref : public Usermod { unsigned long lastStateChangeMs = 0; bool idlePresetApplied = false; + void refreshNormalizedAreaName() { + normalizedAreaName = RedAlertText::normalizeAreaText(areaName); + } + const __FlashStringHelper* stateToLabel(AlertState s) { switch (s) { case STATE_PRE_ALERT: return F("PRE_ALERT"); @@ -112,15 +117,15 @@ class UsermodPikudHaoref : public Usermod { if (!cityNameRaw) return false; String cityNorm = RedAlertText::normalizeAreaText(String(cityNameRaw)); - String areaNorm = RedAlertText::normalizeAreaText(areaName); - if (cityNorm.length() == 0 || areaNorm.length() == 0) return false; + if (cityNorm.length() == 0 || normalizedAreaName.length() == 0) return false; - bool match = (cityNorm == areaNorm || cityNorm.indexOf(areaNorm) >= 0 || areaNorm.indexOf(cityNorm) >= 0); + // Strict direction: city text may contain configured area text. + bool match = (cityNorm == normalizedAreaName || cityNorm.indexOf(normalizedAreaName) >= 0); DEBUG_PRINT(F("RedAlert: area compare city=\"")); DEBUG_PRINT(cityNorm); DEBUG_PRINT(F("\" area=\"")); - DEBUG_PRINT(areaNorm); + DEBUG_PRINT(normalizedAreaName); DEBUG_PRINT(F("\" -> ")); DEBUG_PRINTLN(match ? F("MATCH") : F("NO_MATCH")); @@ -171,8 +176,8 @@ class UsermodPikudHaoref : public Usermod { if (!enabled) return; if (!Network.isConnected()) return; if (apiUrl.length() == 0) return; - // If we are NOT in "all areas" mode, we require a non-empty areaName. - if (!enableAllAreas && areaName.length() == 0) return; + // If we are NOT in "all areas" mode, require non-empty normalized area text. + if (!enableAllAreas && normalizedAreaName.length() == 0) return; HTTPClient http; http.setTimeout(3000); // 3s @@ -281,6 +286,7 @@ class UsermodPikudHaoref : public Usermod { public: // Called once at boot void setup() override { + refreshNormalizedAreaName(); initDone = true; DEBUG_PRINTLN(F("RedAlert: setup complete")); @@ -366,6 +372,8 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); + refreshNormalizedAreaName(); + return cfg; } From 5a99d3c4ddd331ebec3cd06f2707fd12375265b3 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 19:29:35 +0200 Subject: [PATCH 06/13] Add verbose logging option to redalert usermod - Introduced a new boolean parameter for verbose logging to enhance debugging capabilities. - Updated logging statements to conditionally print detailed information based on the verboseLogs setting. - Refactored JSON configuration handling to include the new verboseLogs parameter, ensuring it is properly saved and loaded. --- usermods/redalert/redalert.cpp | 204 ++++++++++++++++++++++++--------- 1 file changed, 149 insertions(+), 55 deletions(-) diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index d6d570af96..7006f585aa 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -27,6 +27,7 @@ class UsermodPikudHaoref : public Usermod { static const char _idleTimeoutSec[]; static const char _idlePreset[]; + static const char _verboseLogs[]; static const char _resetUrl[]; enum AlertState : uint8_t { @@ -61,6 +62,7 @@ class UsermodPikudHaoref : public Usermod { // Idle timeout handling (seconds + preset) uint32_t idleTimeoutSec = 0; // 0 = disabled uint8_t idlePreset = 0; + bool verboseLogs = true; // current behavior: verbose logging on // Internal state unsigned long lastPoll = 0; @@ -122,12 +124,14 @@ class UsermodPikudHaoref : public Usermod { // Strict direction: city text may contain configured area text. bool match = (cityNorm == normalizedAreaName || cityNorm.indexOf(normalizedAreaName) >= 0); - DEBUG_PRINT(F("RedAlert: area compare city=\"")); - DEBUG_PRINT(cityNorm); - DEBUG_PRINT(F("\" area=\"")); - DEBUG_PRINT(normalizedAreaName); - DEBUG_PRINT(F("\" -> ")); - DEBUG_PRINTLN(match ? F("MATCH") : F("NO_MATCH")); + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: area compare city=\"")); + DEBUG_PRINT(cityNorm); + DEBUG_PRINT(F("\" area=\"")); + DEBUG_PRINT(normalizedAreaName); + DEBUG_PRINT(F("\" -> ")); + DEBUG_PRINTLN(match ? F("MATCH") : F("NO_MATCH")); + } return match; } @@ -190,17 +194,21 @@ class UsermodPikudHaoref : public Usermod { int httpCode = http.GET(); lastHttpCode = httpCode; - DEBUG_PRINT(F("RedAlert: HTTP GET ")); - DEBUG_PRINT(apiUrl); - DEBUG_PRINT(F(" -> code ")); - DEBUG_PRINTLN(httpCode); + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: HTTP GET ")); + DEBUG_PRINT(apiUrl); + DEBUG_PRINT(F(" -> code ")); + DEBUG_PRINTLN(httpCode); + } if (httpCode == HTTP_CODE_OK) { AlertState newState = STATE_OK; String payload = http.getString(); - DEBUG_PRINTLN(F("RedAlert: raw JSON payload:")); - DEBUG_PRINTLN(payload); + if (verboseLogs) { + DEBUG_PRINTLN(F("RedAlert: raw JSON payload:")); + DEBUG_PRINTLN(payload); + } // Response can be: // - a single JSON object (current Pikud Haoref format) @@ -230,10 +238,12 @@ class UsermodPikudHaoref : public Usermod { int category = extractCategory(alert); - DEBUG_PRINT(F("RedAlert: match (array), city=\"")); - DEBUG_PRINT(cityName); - DEBUG_PRINT(F("\", category=")); - DEBUG_PRINTLN(category); + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: match (array), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + } if (category == 14) { newState = STATE_PRE_ALERT; // "pre_alert" @@ -261,10 +271,12 @@ class UsermodPikudHaoref : public Usermod { int category = extractCategory(alert); - DEBUG_PRINT(F("RedAlert: match (object), city=\"")); - DEBUG_PRINT(cityName); - DEBUG_PRINT(F("\", category=")); - DEBUG_PRINTLN(category); + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: match (object), city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINT(F("\", category=")); + DEBUG_PRINTLN(category); + } if (category == 14) { newState = STATE_PRE_ALERT; @@ -289,7 +301,7 @@ class UsermodPikudHaoref : public Usermod { refreshNormalizedAreaName(); initDone = true; - DEBUG_PRINTLN(F("RedAlert: setup complete")); + if (verboseLogs) DEBUG_PRINTLN(F("RedAlert: setup complete")); } // Called frequently; do non-blocking work here @@ -317,8 +329,10 @@ class UsermodPikudHaoref : public Usermod { applyPreset(idlePreset); idlePresetApplied = true; - DEBUG_PRINT(F("RedAlert: idle timeout reached, applying idlePreset=")); - DEBUG_PRINTLN(idlePreset); + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: idle timeout reached, applying idlePreset=")); + DEBUG_PRINTLN(idlePreset); + } } } @@ -328,22 +342,33 @@ class UsermodPikudHaoref : public Usermod { void addToConfig(JsonObject &root) override { JsonObject top = root.createNestedObject(FPSTR(_name)); - top[FPSTR(_enabled)] = enabled; - top[FPSTR(_apiUrl)] = apiUrl; - top[FPSTR(_areaName)] = areaName; - top["pollIntervalMs"] = pollIntervalMs; - - top[FPSTR(_alertEnabled)] = enableAlert; - top[FPSTR(_preAlertEnabled)] = enablePreAlert; - top[FPSTR(_endEnabled)] = enableEnd; - top[FPSTR(_okEnabled)] = enableOk; - top[FPSTR(_allAreasEnabled)] = enableAllAreas; - top[FPSTR(_alertPreset)] = alertPreset; - top[FPSTR(_preAlertPreset)] = preAlertPreset; - top[FPSTR(_endPreset)] = endPreset; - top[FPSTR(_okPreset)] = okPreset; - top[FPSTR(_idleTimeoutSec)] = idleTimeoutSec; - top[FPSTR(_idlePreset)] = idlePreset; + // Core + JsonObject core = top.createNestedObject("core"); + core[FPSTR(_enabled)] = enabled; + core[FPSTR(_apiUrl)] = apiUrl; + core["pollIntervalMs"] = pollIntervalMs; + core[FPSTR(_verboseLogs)] = verboseLogs; + + // Area selection + JsonObject area = top.createNestedObject("area"); + area[FPSTR(_allAreasEnabled)] = enableAllAreas; + area[FPSTR(_areaName)] = areaName; + + // State actions (flat but grouped under "states" for a single divider) + JsonObject states = top.createNestedObject("states"); + states["alertEnabled"] = enableAlert; + states["alertPreset"] = alertPreset; + states["preAlertEnabled"] = enablePreAlert; + states["preAlertPreset"] = preAlertPreset; + states["endEnabled"] = enableEnd; + states["endPreset"] = endPreset; + states["okEnabled"] = enableOk; + states["okPreset"] = okPreset; + + // Idle fallback + JsonObject idle = top.createNestedObject("idle"); + idle["timeoutSec"] = idleTimeoutSec; + idle["preset"] = idlePreset; } // Load JSON config @@ -355,22 +380,90 @@ class UsermodPikudHaoref : public Usermod { } bool cfg = true; - cfg &= getJsonValue(top[FPSTR(_enabled)], enabled, true); - cfg &= getJsonValue(top[FPSTR(_apiUrl)], apiUrl, apiUrl); - cfg &= getJsonValue(top[FPSTR(_areaName)], areaName, areaName); - cfg &= getJsonValue(top["pollIntervalMs"], pollIntervalMs, pollIntervalMs); - - cfg &= getJsonValue(top[FPSTR(_alertEnabled)], enableAlert, enableAlert); - cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); - cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); - cfg &= getJsonValue(top[FPSTR(_okEnabled)], enableOk, enableOk); - cfg &= getJsonValue(top[FPSTR(_allAreasEnabled)], enableAllAreas, enableAllAreas); - cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); - cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); - cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); - cfg &= getJsonValue(top[FPSTR(_okPreset)], okPreset, okPreset); - cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); - cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); + + // Core (new grouped layout), with legacy flat-key fallback + JsonObject core = top["core"]; + if (!core.isNull()) { + cfg &= getJsonValue(core[FPSTR(_enabled)], enabled, enabled); + cfg &= getJsonValue(core[FPSTR(_apiUrl)], apiUrl, apiUrl); + cfg &= getJsonValue(core["pollIntervalMs"], pollIntervalMs, pollIntervalMs); + cfg &= getJsonValue(core[FPSTR(_verboseLogs)], verboseLogs, verboseLogs); + } else { + cfg &= getJsonValue(top[FPSTR(_enabled)], enabled, enabled); + cfg &= getJsonValue(top[FPSTR(_apiUrl)], apiUrl, apiUrl); + cfg &= getJsonValue(top["pollIntervalMs"], pollIntervalMs, pollIntervalMs); + cfg &= getJsonValue(top[FPSTR(_verboseLogs)], verboseLogs, verboseLogs); + } + + // Area selection (new grouped layout), with legacy flat-key fallback + JsonObject area = top["area"]; + if (!area.isNull()) { + cfg &= getJsonValue(area[FPSTR(_allAreasEnabled)], enableAllAreas, enableAllAreas); + cfg &= getJsonValue(area[FPSTR(_areaName)], areaName, areaName); + } else { + cfg &= getJsonValue(top[FPSTR(_allAreasEnabled)], enableAllAreas, enableAllAreas); + cfg &= getJsonValue(top[FPSTR(_areaName)], areaName, areaName); + } + + // State actions (new grouped layout), with legacy flat-key fallback + JsonObject states = top["states"]; + if (!states.isNull()) { + // New flat-in-group layout + if (!states["alertEnabled"].isNull() || !states["alertPreset"].isNull()) { + cfg &= getJsonValue(states["alertEnabled"], enableAlert, enableAlert); + cfg &= getJsonValue(states["alertPreset"], alertPreset, alertPreset); + cfg &= getJsonValue(states["preAlertEnabled"], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(states["preAlertPreset"], preAlertPreset, preAlertPreset); + cfg &= getJsonValue(states["endEnabled"], enableEnd, enableEnd); + cfg &= getJsonValue(states["endPreset"], endPreset, endPreset); + cfg &= getJsonValue(states["okEnabled"], enableOk, enableOk); + cfg &= getJsonValue(states["okPreset"], okPreset, okPreset); + } else { + // Backward compatibility: older nested-in-group layout + JsonObject sAlert = states["alert"]; + if (!sAlert.isNull()) { + cfg &= getJsonValue(sAlert["enabled"], enableAlert, enableAlert); + cfg &= getJsonValue(sAlert["preset"], alertPreset, alertPreset); + } + + JsonObject sPreAlert = states["preAlert"]; + if (!sPreAlert.isNull()) { + cfg &= getJsonValue(sPreAlert["enabled"], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(sPreAlert["preset"], preAlertPreset, preAlertPreset); + } + + JsonObject sEnd = states["end"]; + if (!sEnd.isNull()) { + cfg &= getJsonValue(sEnd["enabled"], enableEnd, enableEnd); + cfg &= getJsonValue(sEnd["preset"], endPreset, endPreset); + } + + JsonObject sOk = states["ok"]; + if (!sOk.isNull()) { + cfg &= getJsonValue(sOk["enabled"], enableOk, enableOk); + cfg &= getJsonValue(sOk["preset"], okPreset, okPreset); + } + } + } else { + cfg &= getJsonValue(top[FPSTR(_alertEnabled)], enableAlert, enableAlert); + cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); + cfg &= getJsonValue(top[FPSTR(_okEnabled)], enableOk, enableOk); + cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); + cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); + cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); + cfg &= getJsonValue(top[FPSTR(_okPreset)], okPreset, okPreset); + } + + // Idle fallback (new grouped layout), with legacy flat-key fallback + JsonObject idle = top["idle"]; + if (!idle.isNull()) { + cfg &= getJsonValue(idle["timeoutSec"], idleTimeoutSec, idleTimeoutSec); + cfg &= getJsonValue(idle["preset"], idlePreset, idlePreset); + } else { + cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); + cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); + } refreshNormalizedAreaName(); @@ -442,6 +535,7 @@ const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; const char UsermodPikudHaoref::_okPreset[] PROGMEM = "okPreset"; const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; +const char UsermodPikudHaoref::_verboseLogs[] PROGMEM = "verboseLogs"; const char UsermodPikudHaoref::_resetUrl[] PROGMEM = "resetUrl"; // register usermod instance From d2d75520855da9e690a1c98040625fd6f4fd3d8c Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 19:32:29 +0200 Subject: [PATCH 07/13] Enhance redalert usermod with idle timeout and fallback functionality - Introduced a new boolean parameter to enable or disable idle fallback handling. - Updated JSON configuration to include new pretty labels for core, area, states, and idle settings. - Refactored JSON loading and saving to maintain backward compatibility with legacy keys. - Improved clarity of code comments and structure for better maintainability. --- usermods/redalert/redalert.cpp | 87 ++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 7006f585aa..70379eb082 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -62,6 +62,7 @@ class UsermodPikudHaoref : public Usermod { // Idle timeout handling (seconds + preset) uint32_t idleTimeoutSec = 0; // 0 = disabled uint8_t idlePreset = 0; + bool idleEnabled = false; bool verboseLogs = true; // current behavior: verbose logging on // Internal state @@ -320,9 +321,10 @@ class UsermodPikudHaoref : public Usermod { now = millis(); // Idle timeout handling: - // If we have been in the *same* state for configured time without any - // state changes, optionally apply a fallback preset once. - if (initDone && + // If idle fallback is enabled and we have been in the *same* state + // for configured time without any state changes, optionally apply + // a fallback preset once. + if (initDone && idleEnabled && idleTimeoutSec > 0 && idlePreset > 0 && lastStateChangeMs > 0 && !idlePresetApplied) { if (now - lastStateChangeMs >= idleTimeoutSec * 1000UL) { @@ -344,31 +346,32 @@ class UsermodPikudHaoref : public Usermod { // Core JsonObject core = top.createNestedObject("core"); - core[FPSTR(_enabled)] = enabled; - core[FPSTR(_apiUrl)] = apiUrl; - core["pollIntervalMs"] = pollIntervalMs; - core[FPSTR(_verboseLogs)] = verboseLogs; + core["Enabled"] = enabled; + core["API URL"] = apiUrl; + core["Poll interval (ms)"] = pollIntervalMs; + core["Verbose logs"] = verboseLogs; // Area selection JsonObject area = top.createNestedObject("area"); - area[FPSTR(_allAreasEnabled)] = enableAllAreas; - area[FPSTR(_areaName)] = areaName; + area["Match all areas"] = enableAllAreas; + area["Target area name"] = areaName; // State actions (flat but grouped under "states" for a single divider) JsonObject states = top.createNestedObject("states"); - states["alertEnabled"] = enableAlert; - states["alertPreset"] = alertPreset; - states["preAlertEnabled"] = enablePreAlert; - states["preAlertPreset"] = preAlertPreset; - states["endEnabled"] = enableEnd; - states["endPreset"] = endPreset; - states["okEnabled"] = enableOk; - states["okPreset"] = okPreset; + states["Alert enabled"] = enableAlert; + states["Alert preset"] = alertPreset; + states["Pre-alert enabled"] = enablePreAlert; + states["Pre-alert preset"] = preAlertPreset; + states["End enabled"] = enableEnd; + states["End preset"] = endPreset; + states["OK enabled"] = enableOk; + states["OK preset"] = okPreset; // Idle fallback JsonObject idle = top.createNestedObject("idle"); - idle["timeoutSec"] = idleTimeoutSec; - idle["preset"] = idlePreset; + idle["Enable idle fallback"] = idleEnabled; + idle["Idle timeout (sec)"] = idleTimeoutSec; + idle["Idle preset"] = idlePreset; } // Load JSON config @@ -384,6 +387,12 @@ class UsermodPikudHaoref : public Usermod { // Core (new grouped layout), with legacy flat-key fallback JsonObject core = top["core"]; if (!core.isNull()) { + // New pretty labels + cfg &= getJsonValue(core["Enabled"], enabled, enabled); + cfg &= getJsonValue(core["API URL"], apiUrl, apiUrl); + cfg &= getJsonValue(core["Poll interval (ms)"], pollIntervalMs, pollIntervalMs); + cfg &= getJsonValue(core["Verbose logs"], verboseLogs, verboseLogs); + // Backward compatibility for old keys in core object cfg &= getJsonValue(core[FPSTR(_enabled)], enabled, enabled); cfg &= getJsonValue(core[FPSTR(_apiUrl)], apiUrl, apiUrl); cfg &= getJsonValue(core["pollIntervalMs"], pollIntervalMs, pollIntervalMs); @@ -398,6 +407,9 @@ class UsermodPikudHaoref : public Usermod { // Area selection (new grouped layout), with legacy flat-key fallback JsonObject area = top["area"]; if (!area.isNull()) { + cfg &= getJsonValue(area["Match all areas"], enableAllAreas, enableAllAreas); + cfg &= getJsonValue(area["Target area name"], areaName, areaName); + // Backward compatibility cfg &= getJsonValue(area[FPSTR(_allAreasEnabled)], enableAllAreas, enableAllAreas); cfg &= getJsonValue(area[FPSTR(_areaName)], areaName, areaName); } else { @@ -408,16 +420,25 @@ class UsermodPikudHaoref : public Usermod { // State actions (new grouped layout), with legacy flat-key fallback JsonObject states = top["states"]; if (!states.isNull()) { - // New flat-in-group layout - if (!states["alertEnabled"].isNull() || !states["alertPreset"].isNull()) { - cfg &= getJsonValue(states["alertEnabled"], enableAlert, enableAlert); - cfg &= getJsonValue(states["alertPreset"], alertPreset, alertPreset); - cfg &= getJsonValue(states["preAlertEnabled"], enablePreAlert, enablePreAlert); - cfg &= getJsonValue(states["preAlertPreset"], preAlertPreset, preAlertPreset); - cfg &= getJsonValue(states["endEnabled"], enableEnd, enableEnd); - cfg &= getJsonValue(states["endPreset"], endPreset, endPreset); - cfg &= getJsonValue(states["okEnabled"], enableOk, enableOk); - cfg &= getJsonValue(states["okPreset"], okPreset, okPreset); + // New flat-in-group layout with pretty labels + if (!states["Alert enabled"].isNull() || !states["Alert preset"].isNull()) { + cfg &= getJsonValue(states["Alert enabled"], enableAlert, enableAlert); + cfg &= getJsonValue(states["Alert preset"], alertPreset, alertPreset); + cfg &= getJsonValue(states["Pre-alert enabled"], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(states["Pre-alert preset"], preAlertPreset, preAlertPreset); + cfg &= getJsonValue(states["End enabled"], enableEnd, enableEnd); + cfg &= getJsonValue(states["End preset"], endPreset, endPreset); + cfg &= getJsonValue(states["OK enabled"], enableOk, enableOk); + cfg &= getJsonValue(states["OK preset"], okPreset, okPreset); + // Backward compatibility for original flat keys in states + cfg &= getJsonValue(states["alertEnabled"], enableAlert, enableAlert); + cfg &= getJsonValue(states["alertPreset"], alertPreset, alertPreset); + cfg &= getJsonValue(states["preAlertEnabled"], enablePreAlert, enablePreAlert); + cfg &= getJsonValue(states["preAlertPreset"], preAlertPreset, preAlertPreset); + cfg &= getJsonValue(states["endEnabled"], enableEnd, enableEnd); + cfg &= getJsonValue(states["endPreset"], endPreset, endPreset); + cfg &= getJsonValue(states["okEnabled"], enableOk, enableOk); + cfg &= getJsonValue(states["okPreset"], okPreset, okPreset); } else { // Backward compatibility: older nested-in-group layout JsonObject sAlert = states["alert"]; @@ -458,8 +479,12 @@ class UsermodPikudHaoref : public Usermod { // Idle fallback (new grouped layout), with legacy flat-key fallback JsonObject idle = top["idle"]; if (!idle.isNull()) { - cfg &= getJsonValue(idle["timeoutSec"], idleTimeoutSec, idleTimeoutSec); - cfg &= getJsonValue(idle["preset"], idlePreset, idlePreset); + cfg &= getJsonValue(idle["Enable idle fallback"], idleEnabled, idleEnabled); + cfg &= getJsonValue(idle["Idle timeout (sec)"], idleTimeoutSec, idleTimeoutSec); + cfg &= getJsonValue(idle["Idle preset"], idlePreset, idlePreset); + // Backward compatibility + cfg &= getJsonValue(idle["timeoutSec"], idleTimeoutSec, idleTimeoutSec); + cfg &= getJsonValue(idle["preset"], idlePreset, idlePreset); } else { cfg &= getJsonValue(top[FPSTR(_idleTimeoutSec)], idleTimeoutSec, idleTimeoutSec); cfg &= getJsonValue(top[FPSTR(_idlePreset)], idlePreset, idlePreset); From 65e9c9d4e99d6609609e81702e10ef6cfb4d2f31 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 21:10:20 +0200 Subject: [PATCH 08/13] Update redalert usermod for improved alert handling and HTTPS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed area name from "תל אביב - מזרח" to "תל אביב - מרכז" for better accuracy. - Reduced default polling interval to 100 ms for more frequent updates. - Enabled pre-alert and end alerts by default to enhance notification capabilities. - Updated alert presets for better configuration options. - Added support for HTTPS requests using WiFiClientSecure, allowing secure communication with the alert API. - Implemented logic to handle potential non-JSON characters in API responses, improving robustness. --- platformio.ini | 2 +- usermods/redalert/redalert.cpp | 64 +++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/platformio.ini b/platformio.ini index dc135b9ab3..9b32eb9bce 100644 --- a/platformio.ini +++ b/platformio.ini @@ -312,7 +312,7 @@ lib_deps = ;; generic definitions for all ESP32-S2 boards platform = ${esp32_idf_V4.platform} platform_packages = ${esp32_idf_V4.platform_packages} -build_unflags = ${common.build_unflags} +builwd_unflags = ${common.build_unflags} build_flags = -g -DARDUINO_ARCH_ESP32 -DARDUINO_ARCH_ESP32S2 diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 70379eb082..2a5984430b 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -1,5 +1,6 @@ #include "wled.h" #include +#include #include "redalert_text_utils.h" #ifndef USERMOD_ID_PIKUD_HAOREF @@ -43,20 +44,21 @@ class UsermodPikudHaoref : public Usermod { // Official Pikud Haoref alerts endpoint (JSON array) String apiUrl = "https://www.oref.org.il/WarningMessages/alert/alerts.json"; // Area / city name to watch for (must match an entry in the 'cities' array) - String areaName = "תל אביב - מזרח"; + String areaName = "תל אביב - מרכז"; bool enabled = true; - uint32_t pollIntervalMs = 15000; // 15 seconds + // Default poll interval in ms (UI label: "Poll Interval Ms") + uint32_t pollIntervalMs = 100; String normalizedAreaName = RedAlertText::normalizeAreaText(areaName); // Per-state toggles and presets bool enableAlert = true; - bool enablePreAlert = false; - bool enableEnd = false; + bool enablePreAlert = true; + bool enableEnd = true; bool enableOk = false; bool enableAllAreas = false; - uint8_t alertPreset = 0; - uint8_t preAlertPreset = 0; - uint8_t endPreset = 0; + uint8_t alertPreset = 3; + uint8_t preAlertPreset = 4; + uint8_t endPreset = 2; uint8_t okPreset = 0; // Idle timeout handling (seconds + preset) @@ -69,6 +71,11 @@ class UsermodPikudHaoref : public Usermod { unsigned long lastPoll = 0; int lastHttpCode = 0; + // HTTPS client for ESP32 (kept as a member so its lifetime + // covers the HTTPClient usage when doing HTTPS requests). + WiFiClientSecure httpsClient; + bool httpsClientConfigured = false; + AlertState currentState = STATE_OK; unsigned long lastStateChangeMs = 0; bool idlePresetApplied = false; @@ -187,7 +194,31 @@ class UsermodPikudHaoref : public Usermod { HTTPClient http; http.setTimeout(3000); // 3s - http.begin(apiUrl); + bool isHttps = apiUrl.startsWith("https://"); + bool beginOk = false; + + if (isHttps) { + // Configure insecure HTTPS once; this trades certificate validation + // for the ability to talk to the HTTPS-only endpoint without bundling + // CA certs. For this use-case (public alert feed) this is acceptable. + if (!httpsClientConfigured) { + httpsClient.setInsecure(); + httpsClientConfigured = true; + } + beginOk = http.begin(httpsClient, apiUrl); + } else { + beginOk = http.begin(apiUrl); + } + + if (!beginOk) { + lastHttpCode = -1; + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: http.begin() failed for URL ")); + DEBUG_PRINTLN(apiUrl); + } + http.end(); + return; + } // Pikud Haoref endpoint is somewhat picky about headers; set a UA. http.addHeader("User-Agent", "WLED-PikudHaoref-ESP32"); @@ -211,6 +242,23 @@ class UsermodPikudHaoref : public Usermod { DEBUG_PRINTLN(payload); } + // Some servers prepend a BOM or other non-JSON characters before + // the first '{'/'['. Strip everything before the first JSON token + // so ArduinoJson does not fail with InvalidInput. + int firstBrace = payload.indexOf('{'); + int firstBracket = payload.indexOf('['); + int start = -1; + if (firstBrace >= 0 && firstBracket >= 0) { + start = (firstBrace < firstBracket) ? firstBrace : firstBracket; + } else if (firstBrace >= 0) { + start = firstBrace; + } else if (firstBracket >= 0) { + start = firstBracket; + } + if (start > 0) { + payload.remove(0, start); + } + // Response can be: // - a single JSON object (current Pikud Haoref format) // - or an array of such objects (for future/other wrappers) From 607e2b88374aae8ae0c2d348774c23c89541dbf0 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 22:39:57 +0200 Subject: [PATCH 09/13] Improve alert category handling in redalert usermod - Added logic to ignore alerts with category 0 or missing category, preventing unnecessary state changes. - Updated handling for category 10 to treat it as an end/clear state, alongside category 13. - Enhanced verbose logging for better debugging of ignored alerts based on category. --- usermods/redalert/redalert.cpp | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 2a5984430b..f3bbf256d9 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -287,6 +287,17 @@ class UsermodPikudHaoref : public Usermod { int category = extractCategory(alert); + // Category 0 / missing category means "no specific alert category". + // Treat as no state change. + if (category <= 0) { + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: category 0/none, ignoring city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINTLN(F("\"")); + } + continue; + } + if (verboseLogs) { DEBUG_PRINT(F("RedAlert: match (array), city=\"")); DEBUG_PRINT(cityName); @@ -296,8 +307,9 @@ class UsermodPikudHaoref : public Usermod { if (category == 14) { newState = STATE_PRE_ALERT; // "pre_alert" - } else if (category == 13) { - newState = STATE_END; // "end" + } else if (category == 13 || category == 10) { + // 10: "may leave protected area but stay nearby" -> treat as END/clear + newState = STATE_END; } else { newState = STATE_ALERT; // main "alert" } @@ -320,6 +332,17 @@ class UsermodPikudHaoref : public Usermod { int category = extractCategory(alert); + // Category 0 / missing category means "no specific alert category". + // Treat as no state change. + if (category <= 0) { + if (verboseLogs) { + DEBUG_PRINT(F("RedAlert: category 0/none, ignoring city=\"")); + DEBUG_PRINT(cityName); + DEBUG_PRINTLN(F("\"")); + } + continue; + } + if (verboseLogs) { DEBUG_PRINT(F("RedAlert: match (object), city=\"")); DEBUG_PRINT(cityName); @@ -329,7 +352,8 @@ class UsermodPikudHaoref : public Usermod { if (category == 14) { newState = STATE_PRE_ALERT; - } else if (category == 13) { + } else if (category == 13 || category == 10) { + // 10: "may leave protected area but stay nearby" -> treat as END/clear newState = STATE_END; } else { newState = STATE_ALERT; From 75ab33d94f0a3ed1719f4d8ce74a054b6d717e81 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Wed, 11 Mar 2026 23:39:40 +0200 Subject: [PATCH 10/13] Refine alert category processing in redalert usermod - Implemented logic to ignore alerts with category 0 or missing category, reducing unnecessary state changes. - Updated handling for category 10 to function as an end/clear state, in addition to category 13. - Enhanced verbose logging to improve debugging for ignored alerts based on category. --- usermods/redalert/readme.md | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 usermods/redalert/readme.md diff --git a/usermods/redalert/readme.md b/usermods/redalert/readme.md new file mode 100644 index 0000000000..f17cca3816 --- /dev/null +++ b/usermods/redalert/readme.md @@ -0,0 +1,59 @@ +# Red Alert (Pikud HaOref) usermod + +WLED usermod that polls the Israeli Home Front Command (Pikud HaOref / Oref) alerts API and switches presets based on alert state for your area. + +## Features + +- Polls `https://www.oref.org.il/WarningMessages/alert/alerts.json` (or a custom HTTP/HTTPS URL). +- Area-based matching: configure a target area name (e.g. "תל אביב - מרכז") or "Match all areas". +- **Alert states** (each with optional preset): + - **Pre-alert** (category 14) → pre-alert preset + - **Alert** (all other positive categories) → alert preset + - **End / clear** (categories 10, 13 — "leave shelter, stay nearby") → end preset + - **OK** (no matching alert) → optional OK preset +- Idle fallback: after a configurable time in the same state, optionally apply an idle preset. +- HTTPS supported on ESP32 when built with a platform that provides `WiFiClientSecure` (e.g. stock `espressif32`). + +## Requirements + +- **ESP32** build with TLS support for the official Oref HTTPS endpoint. + For WLED’s default Tasmota-based ESP32 platform, HTTPS is not available to usermods; use a build that uses the stock **espressif32** platform (e.g. via `platformio_override.ini`) if you need HTTPS. +- WiFi connectivity. + +## Installation + +1. Add `redalert` to `custom_usermods` in your PlatformIO environment, for example in `platformio.ini` or `platformio_override.ini`: + + ```ini + [env:esp32dev] + custom_usermods = redalert + ``` + +2. If you need **HTTPS** (official Oref API), override the ESP32 platform to stock espressif32 and use a partition layout that fits the larger firmware (see main WLED docs or this usermod’s discussion for an example `platformio_override.ini`). + +3. Build and upload. + +## Configuration (WLED UI) + +- **Core**: Enabled, API URL, Poll interval (ms), Verbose logs. +- **Area**: Match all areas (checkbox), Target area name. +- **States**: Per-state toggles and preset IDs for Alert, Pre-alert, End, OK. +- **Idle**: Enable idle fallback, Idle timeout (sec), Idle preset. + +No category toggles are exposed; all categories are enabled. Semantics: + +- **Category 14** → pre-alert. +- **Categories 10, 13** → end/clear (“leave shelter, stay nearby”). +- **Category 0 or missing** → ignored (no state change). +- **Any other category** → alert. + +## API notes + +- The official endpoint is HTTPS-only and may require requests from Israeli IPs or with specific headers (`Referer`, `X-Requested-With`). The usermod sends a simple `User-Agent`. +- Response format: single JSON object with `id`, `cat`, `title`, `data` (array of city names), `desc`. The usermod strips leading non-JSON bytes (e.g. BOM) before parsing. + +## Files + +- `redalert.cpp` — usermod implementation. +- `redalert_text_utils.h` — area name normalization and Unicode escape decoding. +- `library.json` — usermod metadata. From 4e9bc205528733c0eede0a0b8d33a5bdf05bb834 Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Thu, 12 Mar 2026 00:13:27 +0200 Subject: [PATCH 11/13] Refactor redalert usermod documentation and remove unused reset URL functionality - Updated the README to clarify HTTPS requirements and installation steps. - Added a section on live logging over WebSocket, detailing usermod and client-side implementation. - Removed the reset URL button and associated logic from the usermod, streamlining the code and improving maintainability. --- usermods/redalert/readme.md | 31 +++++++++++++++++++++++++------ usermods/redalert/redalert.cpp | 20 +------------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/usermods/redalert/readme.md b/usermods/redalert/readme.md index f17cca3816..1066f9e9f9 100644 --- a/usermods/redalert/readme.md +++ b/usermods/redalert/readme.md @@ -17,20 +17,17 @@ WLED usermod that polls the Israeli Home Front Command (Pikud HaOref / Oref) ale ## Requirements - **ESP32** build with TLS support for the official Oref HTTPS endpoint. - For WLED’s default Tasmota-based ESP32 platform, HTTPS is not available to usermods; use a build that uses the stock **espressif32** platform (e.g. via `platformio_override.ini`) if you need HTTPS. +For WLED’s default Tasmota-based ESP32 platform, HTTPS is not available to usermods; use a build that uses the stock **espressif32** platform (e.g. via `platformio_override.ini`) if you need HTTPS. - WiFi connectivity. ## Installation 1. Add `redalert` to `custom_usermods` in your PlatformIO environment, for example in `platformio.ini` or `platformio_override.ini`: - - ```ini + ```ini [env:esp32dev] custom_usermods = redalert - ``` - + ``` 2. If you need **HTTPS** (official Oref API), override the ESP32 platform to stock espressif32 and use a partition layout that fits the larger firmware (see main WLED docs or this usermod’s discussion for an example `platformio_override.ini`). - 3. Build and upload. ## Configuration (WLED UI) @@ -52,8 +49,30 @@ No category toggles are exposed; all categories are enabled. Semantics: - The official endpoint is HTTPS-only and may require requests from Israeli IPs or with specific headers (`Referer`, `X-Requested-With`). The usermod sends a simple `User-Agent`. - Response format: single JSON object with `id`, `cat`, `title`, `data` (array of city names), `desc`. The usermod strips leading non-JSON bytes (e.g. BOM) before parsing. +## Live log over WebSocket //TODO!!! + +WLED exposes a single WebSocket at `**/ws`** for state and live LED updates. A usermod can reuse it to stream log lines wirelessly without core changes. + +**Usermod side** + +- Declare `extern AsyncWebSocket ws;` (from `wled.h` / the build). +- Whenever you want to send a log line, call e.g. `ws.textAll("{\"log\":\"RedAlert: ...\"}");` so every connected client receives the same text frame. +- Use a stable JSON shape (e.g. `{"log":"message"}` or `{"source":"redalert","msg":"..."}`) so clients can parse and display only log lines. + +**Client side** + +- Connect to `ws:///ws` (or `wss://` if you add TLS in front). +- Listen for `onmessage`; treat frames that parse as your log format as live log lines and append them to a console/view (browser, Node script, or custom dashboard). Ignore other frames (e.g. WLED state) or filter by a `source` field. + +**Caveats** + +- All WebSocket clients receive the same stream; WLED does not separate “log” from “state” traffic. Avoid sending huge or high-frequency log bursts to not interfere with the main UI. +- The official WLED UI has no “Logs” tab; you need your own page or tool to connect to `/ws` and show the log lines. +- No persistence: only clients connected at the time receive each message. + ## Files - `redalert.cpp` — usermod implementation. - `redalert_text_utils.h` — area name normalization and Unicode escape decoding. - `library.json` — usermod metadata. + diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index f3bbf256d9..91d3ed4814 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -29,7 +29,6 @@ class UsermodPikudHaoref : public Usermod { static const char _idleTimeoutSec[]; static const char _idlePreset[]; static const char _verboseLogs[]; - static const char _resetUrl[]; enum AlertState : uint8_t { STATE_OK = 0, @@ -584,14 +583,6 @@ class UsermodPikudHaoref : public Usermod { arr.add(stateLabel); arr.add(F(" Pikud Haoref")); - // Add a small button to reset the API URL back to default - String uiDomString = F(""); - arr.add(uiDomString); - JsonObject sensor = root["sensor"]; if (sensor.isNull()) sensor = root.createNestedObject("sensor"); JsonArray status = sensor.createNestedArray(FPSTR(_name)); @@ -599,16 +590,8 @@ class UsermodPikudHaoref : public Usermod { status.add(F(" last HTTP status")); } - // Handle JSON state updates (e.g. from the Reset URL button) void readFromJsonState(JsonObject &root) override { - if (!initDone) return; - - JsonObject um = root[FPSTR(_name)]; - if (um.isNull()) return; - - if (um[FPSTR(_resetUrl)]) { - apiUrl = F("https://www.oref.org.il/WarningMessages/alert/alerts.json"); - } + (void)root; } uint16_t getId() override { @@ -633,7 +616,6 @@ const char UsermodPikudHaoref::_okPreset[] PROGMEM = "okPreset"; const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; const char UsermodPikudHaoref::_verboseLogs[] PROGMEM = "verboseLogs"; -const char UsermodPikudHaoref::_resetUrl[] PROGMEM = "resetUrl"; // register usermod instance static UsermodPikudHaoref usermod_pikud_haoref; From c168561ea340f1bfc85b125c7312144ec094772e Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Thu, 12 Mar 2026 01:06:33 +0200 Subject: [PATCH 12/13] Refine alert state handling in redalert usermod - Updated alert state logic to differentiate between pre-alert and end states based on the API title for category 10. - Removed unused OK state handling and associated parameters to streamline the code. - Enhanced documentation in the README to reflect changes in alert state definitions and configurations. --- usermods/redalert/readme.md | 16 ++++---- usermods/redalert/redalert.cpp | 67 ++++++++++++---------------------- 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/usermods/redalert/readme.md b/usermods/redalert/readme.md index 1066f9e9f9..ea890dda74 100644 --- a/usermods/redalert/readme.md +++ b/usermods/redalert/readme.md @@ -7,10 +7,9 @@ WLED usermod that polls the Israeli Home Front Command (Pikud HaOref / Oref) ale - Polls `https://www.oref.org.il/WarningMessages/alert/alerts.json` (or a custom HTTP/HTTPS URL). - Area-based matching: configure a target area name (e.g. "תל אביב - מרכז") or "Match all areas". - **Alert states** (each with optional preset): - - **Pre-alert** (category 14) → pre-alert preset - - **Alert** (all other positive categories) → alert preset - - **End / clear** (categories 10, 13 — "leave shelter, stay nearby") → end preset - - **OK** (no matching alert) → optional OK preset + - **Pre-alert** (category 10 with title “בדקות הקרובות צפויות להתקבל התרעות באזורך”) → pre-alert preset + - **End / clear** (category 10 with title “האירוע הסתיים”) → end preset + - **Alert** (all other positive categories, e.g. 13, 14) → alert preset - Idle fallback: after a configurable time in the same state, optionally apply an idle preset. - HTTPS supported on ESP32 when built with a platform that provides `WiFiClientSecure` (e.g. stock `espressif32`). @@ -34,15 +33,16 @@ For WLED’s default Tasmota-based ESP32 platform, HTTPS is not available to use - **Core**: Enabled, API URL, Poll interval (ms), Verbose logs. - **Area**: Match all areas (checkbox), Target area name. -- **States**: Per-state toggles and preset IDs for Alert, Pre-alert, End, OK. +- **States**: Per-state toggles and preset IDs for Alert, Pre-alert, End. - **Idle**: Enable idle fallback, Idle timeout (sec), Idle preset. No category toggles are exposed; all categories are enabled. Semantics: -- **Category 14** → pre-alert. -- **Categories 10, 13** → end/clear (“leave shelter, stay nearby”). +- **Category 10** is used for both pre-alert and end; the API `title` field differentiates: + - `"האירוע הסתיים"` → end/clear + - `"בדקות הקרובות צפויות להתקבל התרעות באזורך"` → pre-alert - **Category 0 or missing** → ignored (no state change). -- **Any other category** → alert. +- **Any other category** (e.g. 13, 14) → alert. ## API notes diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 91d3ed4814..1c2604afa4 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -18,13 +18,13 @@ class UsermodPikudHaoref : public Usermod { static const char _alertEnabled[]; static const char _preAlertEnabled[]; static const char _endEnabled[]; - static const char _okEnabled[]; static const char _allAreasEnabled[]; static const char _alertPreset[]; static const char _preAlertPreset[]; static const char _endPreset[]; - static const char _okPreset[]; + static const char _titleEnd[]; + static const char _titlePreAlert[]; static const char _idleTimeoutSec[]; static const char _idlePreset[]; @@ -53,12 +53,10 @@ class UsermodPikudHaoref : public Usermod { bool enableAlert = true; bool enablePreAlert = true; bool enableEnd = true; - bool enableOk = false; bool enableAllAreas = false; uint8_t alertPreset = 3; uint8_t preAlertPreset = 4; uint8_t endPreset = 2; - uint8_t okPreset = 0; // Idle timeout handling (seconds + preset) uint32_t idleTimeoutSec = 0; // 0 = disabled @@ -89,7 +87,7 @@ class UsermodPikudHaoref : public Usermod { case STATE_ALERT: return F("ALERT"); case STATE_END: return F("END"); case STATE_OK: - default: return F("OK"); + default: return F("Idle"); } } @@ -121,6 +119,20 @@ class UsermodPikudHaoref : public Usermod { return category; } + // OREF API: category 10 is used for both pre-alert and end; differentiate by title. + // All other positive categories -> alert. + AlertState stateFromAlert(JsonObject& alert) { + int category = extractCategory(alert); + if (category != 10) { + return STATE_ALERT; // all non-10 categories (13, 14, etc.) -> alert + } + const char* title = alert["title"].as(); + if (!title) return STATE_END; + if (strcmp(title, (const char*)FPSTR(_titleEnd)) == 0) return STATE_END; + if (strcmp(title, (const char*)FPSTR(_titlePreAlert)) == 0) return STATE_PRE_ALERT; + return STATE_END; // category 10 with unknown title -> treat as end + } + bool areaMatches(const char* cityNameRaw) { if (enableAllAreas) return true; if (!cityNameRaw) return false; @@ -175,10 +187,7 @@ class UsermodPikudHaoref : public Usermod { break; case STATE_OK: default: - if (enableOk && okPreset > 0) { - applyPreset(okPreset); - } - break; + break; // no preset when idle / no alert } } @@ -304,14 +313,7 @@ class UsermodPikudHaoref : public Usermod { DEBUG_PRINTLN(category); } - if (category == 14) { - newState = STATE_PRE_ALERT; // "pre_alert" - } else if (category == 13 || category == 10) { - // 10: "may leave protected area but stay nearby" -> treat as END/clear - newState = STATE_END; - } else { - newState = STATE_ALERT; // main "alert" - } + newState = stateFromAlert(alert); break; } if (newState != STATE_OK) break; @@ -349,14 +351,7 @@ class UsermodPikudHaoref : public Usermod { DEBUG_PRINTLN(category); } - if (category == 14) { - newState = STATE_PRE_ALERT; - } else if (category == 13 || category == 10) { - // 10: "may leave protected area but stay nearby" -> treat as END/clear - newState = STATE_END; - } else { - newState = STATE_ALERT; - } + newState = stateFromAlert(alert); break; } } @@ -435,8 +430,6 @@ class UsermodPikudHaoref : public Usermod { states["Pre-alert preset"] = preAlertPreset; states["End enabled"] = enableEnd; states["End preset"] = endPreset; - states["OK enabled"] = enableOk; - states["OK preset"] = okPreset; // Idle fallback JsonObject idle = top.createNestedObject("idle"); @@ -499,8 +492,6 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(states["Pre-alert preset"], preAlertPreset, preAlertPreset); cfg &= getJsonValue(states["End enabled"], enableEnd, enableEnd); cfg &= getJsonValue(states["End preset"], endPreset, endPreset); - cfg &= getJsonValue(states["OK enabled"], enableOk, enableOk); - cfg &= getJsonValue(states["OK preset"], okPreset, okPreset); // Backward compatibility for original flat keys in states cfg &= getJsonValue(states["alertEnabled"], enableAlert, enableAlert); cfg &= getJsonValue(states["alertPreset"], alertPreset, alertPreset); @@ -508,8 +499,6 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(states["preAlertPreset"], preAlertPreset, preAlertPreset); cfg &= getJsonValue(states["endEnabled"], enableEnd, enableEnd); cfg &= getJsonValue(states["endPreset"], endPreset, endPreset); - cfg &= getJsonValue(states["okEnabled"], enableOk, enableOk); - cfg &= getJsonValue(states["okPreset"], okPreset, okPreset); } else { // Backward compatibility: older nested-in-group layout JsonObject sAlert = states["alert"]; @@ -529,22 +518,14 @@ class UsermodPikudHaoref : public Usermod { cfg &= getJsonValue(sEnd["enabled"], enableEnd, enableEnd); cfg &= getJsonValue(sEnd["preset"], endPreset, endPreset); } - - JsonObject sOk = states["ok"]; - if (!sOk.isNull()) { - cfg &= getJsonValue(sOk["enabled"], enableOk, enableOk); - cfg &= getJsonValue(sOk["preset"], okPreset, okPreset); - } } } else { cfg &= getJsonValue(top[FPSTR(_alertEnabled)], enableAlert, enableAlert); cfg &= getJsonValue(top[FPSTR(_preAlertEnabled)], enablePreAlert, enablePreAlert); cfg &= getJsonValue(top[FPSTR(_endEnabled)], enableEnd, enableEnd); - cfg &= getJsonValue(top[FPSTR(_okEnabled)], enableOk, enableOk); cfg &= getJsonValue(top[FPSTR(_alertPreset)], alertPreset, alertPreset); cfg &= getJsonValue(top[FPSTR(_preAlertPreset)], preAlertPreset, preAlertPreset); cfg &= getJsonValue(top[FPSTR(_endPreset)], endPreset, endPreset); - cfg &= getJsonValue(top[FPSTR(_okPreset)], okPreset, okPreset); } // Idle fallback (new grouped layout), with legacy flat-key fallback @@ -572,13 +553,13 @@ class UsermodPikudHaoref : public Usermod { if (user.isNull()) user = root.createNestedObject("u"); JsonArray arr = user.createNestedArray(FPSTR(_name)); - const __FlashStringHelper* stateLabel = F("OK"); + const __FlashStringHelper* stateLabel = F("Idle"); switch (currentState) { case STATE_PRE_ALERT: stateLabel = F("PRE_ALERT"); break; case STATE_ALERT: stateLabel = F("ALERT"); break; case STATE_END: stateLabel = F("END"); break; case STATE_OK: - default: stateLabel = F("OK"); break; + default: stateLabel = F("Idle"); break; } arr.add(stateLabel); arr.add(F(" Pikud Haoref")); @@ -607,12 +588,12 @@ const char UsermodPikudHaoref::_areaName[] PROGMEM = "areaName"; const char UsermodPikudHaoref::_alertEnabled[] PROGMEM = "alertEnabled"; const char UsermodPikudHaoref::_preAlertEnabled[] PROGMEM = "preAlertEnabled"; const char UsermodPikudHaoref::_endEnabled[] PROGMEM = "endEnabled"; -const char UsermodPikudHaoref::_okEnabled[] PROGMEM = "okEnabled"; const char UsermodPikudHaoref::_allAreasEnabled[] PROGMEM = "allAreasEnabled"; const char UsermodPikudHaoref::_alertPreset[] PROGMEM = "alertPreset"; const char UsermodPikudHaoref::_preAlertPreset[] PROGMEM = "preAlertPreset"; const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; -const char UsermodPikudHaoref::_okPreset[] PROGMEM = "okPreset"; +const char UsermodPikudHaoref::_titleEnd[] PROGMEM = "\xd7\x94\xd7\x90\xd7\x99\xd7\xa8\xd7\x95\xd7\xa2 \xd7\x94\xd7\xa1\xd7\xaa\xd7\x99\xd7\x99\xd7\x9d"; // "האירוע הסתיים" UTF-8 +const char UsermodPikudHaoref::_titlePreAlert[] PROGMEM = "\xd7\x91\xd7\x93\xd7\xa7\xd7\x95\xd7\xaa \xd7\x94\xd7\xa7\xd7\xa8\xd7\x95\xd7\x91\xd7\x95\xd7\xaa \xd7\xa6\xd7\xa4\xd7\x95\xd7\x99\xd7\x95\xd7\xaa \xd7\x9c\xd7\x94\xd7\xaa\xd7\xa7\xd7\x91\xd7\x9c \xd7\x94\xd7\xaa\xd7\xa8\xd7\xa2\xd7\x95\xd7\xaa \xd7\x91\xd7\x90\xd7\x96\xd7\x95\xd7\xa8\xd7\x9a"; // "בדקות הקרובות..." UTF-8 const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; const char UsermodPikudHaoref::_verboseLogs[] PROGMEM = "verboseLogs"; From bbc07fa7647d3735c88cf34347764a8ef0ee767d Mon Sep 17 00:00:00 2001 From: shaulbarlev Date: Thu, 12 Mar 2026 02:12:37 +0200 Subject: [PATCH 13/13] Enhance redalert usermod with improved alert state differentiation - Updated the alert state handling to utilize raw JSON payloads for distinguishing between pre-alert and end states in category 10. - Introduced UTF-8 support for alert titles to improve compatibility with various payload formats. - Enhanced documentation in the README to clarify the changes in alert state definitions and the rationale behind the new implementation. --- .../redalert/CATEGORY10_TITLE_MATCHING.md | 80 +++++++++++++++++++ usermods/redalert/readme.md | 24 +----- usermods/redalert/redalert.cpp | 51 +++++++++--- 3 files changed, 123 insertions(+), 32 deletions(-) create mode 100644 usermods/redalert/CATEGORY10_TITLE_MATCHING.md diff --git a/usermods/redalert/CATEGORY10_TITLE_MATCHING.md b/usermods/redalert/CATEGORY10_TITLE_MATCHING.md new file mode 100644 index 0000000000..afc0abf12c --- /dev/null +++ b/usermods/redalert/CATEGORY10_TITLE_MATCHING.md @@ -0,0 +1,80 @@ +# Category 10 title matching (pre-alert vs end) + +**Important for future development.** This document explains how we tell apart **pre-alert** and **end** when the OREF API sends category 10 for both, and why the implementation is done this way. + +## The problem + +The OREF API uses **category 10** for two different things: + +- **Pre-alert**: title `"בדקות הקרובות צפויות להתקבל התרעות באזורך"` (alerts expected in your area soon). +- **End / clear**: title `"האירוע הסתיים"` (the event has ended). + +We must distinguish them by the `title` field. That sounds trivial, but in WLED it is not. + +## Why parsed `title` is unreliable + +1. **WLED disables Unicode decoding in ArduinoJson** + In `wled00/wled.h` you have: + ```c + #define ARDUINOJSON_DECODE_UNICODE 0 + ``` + So when the API sends `"title":"\u05d1\u05d3\u05e7\u05d5\u05ea ..."`, the parser does **not** convert `\uXXXX` into UTF-8. The string value stored in the document is whatever the library does with that (e.g. literal backslash + `u` + hex, or partial/copy behavior). + +2. **The pointer you get from `alert["title"].as()`** may not match what you expect: storage, deduplication, or PROGMEM/ESP32 behavior can make comparisons fail even when you think you’re comparing “the same” text. + +3. **Using PROGMEM + `strcpy_P` for the expected titles** and then `strstr(payload, buf)` failed in practice on ESP32: the copied buffers did not match the payload, so pre-alert was never detected. + +So we **do not rely on the parsed `title`** for category 10 when we have the raw payload. + +## The solution: search the raw JSON payload + +The HTTP response body is the raw JSON string. It literally contains: + +```json +"title":"\u05d1\u05d3\u05e7\u05d5\u05ea \u05d4\u05e7\u05e8\u05d5\u05d1\u05d5\u05ea ..." +``` + +So we **search that raw string** for unique substrings that identify each title: + +- **Pre-alert**: the title starts with “בדקות ” (minutes). In the wire format that is the literal characters `\`, `u`, `0`, `5`, `d`, `1`, … i.e. the ASCII sequence `\u05d1\u05d3\u05e7\u05d5\u05ea ` (with a space). +- **End**: “האירוע ” (the event) → `\u05d4\u05d0\u05d9\u05e8\u05d5\u05e2 ` in the JSON. + +We pass the raw payload into `stateFromAlert(alert, payload.c_str())` and, when `rawPayload != nullptr` and category is 10, we **only** use `strstr(rawPayload, needle)` with these needles. No parsed title, no PROGMEM buffers for this path. + +## Use inline string literals for the needles (no PROGMEM) + +We tried storing the expected titles in PROGMEM and copying them with `strcpy_P` into stack buffers, then passing those buffers to `strstr(rawPayload, buf)`. On ESP32 that did **not** work: the match failed every time (likely PROGMEM/`strcpy_P` or alignment behavior). + +The fix that works is to pass **inline string literals** directly to `strstr`: + +```c +if (strstr(rawPayload, "\\u05d1\\u05d3\\u05e7\\u05d5\\u05ea ") != nullptr) return STATE_PRE_ALERT; +if (strstr(rawPayload, "\\u05d4\\u05d0\\u05d9\\u05e8\\u05d5\\u05e2 ") != nullptr) return STATE_END; +``` + +In C, `"\\u05d1"` is a single backslash plus `u05d1`, i.e. the same sequence as in the JSON. So we are searching for the exact bytes that appear on the wire. The compiler places these literals in flash, but using them as the second argument to `strstr` works correctly on ESP32; we avoid any PROGMEM copy for this critical path. + +**Do not** replace these with PROGMEM + `strcpy_P` for the raw-payload branch unless you re-verify on real hardware that the match succeeds. + +## Where this lives in code + +- **`stateFromAlert(JsonObject& alert, const char* rawPayload = nullptr)`** in `redalert.cpp`. +- When `category == 10` and `rawPayload != nullptr`, only the two `strstr(rawPayload, "\\u05...")` checks above are used; the parsed `title` and PROGMEM buffers are not used for that branch. +- Call sites (array and object parsing) pass `payload.c_str()` so the same buffer that was logged and parsed is searched. + +## If the API or titles change + +1. Get a sample JSON response that contains the new pre-alert or end title. +2. In that JSON, find the exact `"title":"\uXXXX\uXXXX ..."` sequence for the new text. +3. In C, that corresponds to a literal `"\\uXXXX\\uXXXX ..."` (double backslash for one backslash). +4. Pick a **unique** substring (e.g. first word + space) so you don’t false-positive on other fields. +5. Update the corresponding `strstr(rawPayload, "\\u05...")` line in `stateFromAlert` with the new needle. +6. Keep using **inline literals** for the raw-payload path; do not move these needles to PROGMEM without re-testing. + +## Fallback when raw payload is not available + +When `stateFromAlert` is called with `rawPayload == nullptr`, we fall back to the parsed `title`: we copy PROGMEM expected titles (escaped and UTF-8 forms) into stack buffers and use `strstr(title, buf)`. That path may still be flaky on some builds (e.g. due to `ARDUINOJSON_DECODE_UNICODE 0` or storage layout). In normal operation we **always** pass the payload, so the raw-payload branch is the one that must be correct. + +--- + +**Summary:** For category 10, we distinguish pre-alert vs end by searching the **raw JSON payload** for literal `\u05XX` substrings, using **inline string literals** in `strstr()` so we don’t depend on PROGMEM/strcpy_P on ESP32. Do not rely on the parsed `title` for this when WLED has Unicode decoding disabled. diff --git a/usermods/redalert/readme.md b/usermods/redalert/readme.md index ea890dda74..f6cacaf8d6 100644 --- a/usermods/redalert/readme.md +++ b/usermods/redalert/readme.md @@ -40,7 +40,8 @@ No category toggles are exposed; all categories are enabled. Semantics: - **Category 10** is used for both pre-alert and end; the API `title` field differentiates: - `"האירוע הסתיים"` → end/clear - - `"בדקות הקרובות צפויות להתקבל התרעות באזורך"` → pre-alert + - `"בדקות הקרובות צפויות להתקבל התרעות באזורך"` → pre-alert + **Implementation note:** See [CATEGORY10_TITLE_MATCHING.md](CATEGORY10_TITLE_MATCHING.md) for why we match on the raw JSON payload and use inline string literals (critical for maintainers). - **Category 0 or missing** → ignored (no state change). - **Any other category** (e.g. 13, 14) → alert. @@ -49,27 +50,6 @@ No category toggles are exposed; all categories are enabled. Semantics: - The official endpoint is HTTPS-only and may require requests from Israeli IPs or with specific headers (`Referer`, `X-Requested-With`). The usermod sends a simple `User-Agent`. - Response format: single JSON object with `id`, `cat`, `title`, `data` (array of city names), `desc`. The usermod strips leading non-JSON bytes (e.g. BOM) before parsing. -## Live log over WebSocket //TODO!!! - -WLED exposes a single WebSocket at `**/ws`** for state and live LED updates. A usermod can reuse it to stream log lines wirelessly without core changes. - -**Usermod side** - -- Declare `extern AsyncWebSocket ws;` (from `wled.h` / the build). -- Whenever you want to send a log line, call e.g. `ws.textAll("{\"log\":\"RedAlert: ...\"}");` so every connected client receives the same text frame. -- Use a stable JSON shape (e.g. `{"log":"message"}` or `{"source":"redalert","msg":"..."}`) so clients can parse and display only log lines. - -**Client side** - -- Connect to `ws:///ws` (or `wss://` if you add TLS in front). -- Listen for `onmessage`; treat frames that parse as your log format as live log lines and append them to a console/view (browser, Node script, or custom dashboard). Ignore other frames (e.g. WLED state) or filter by a `source` field. - -**Caveats** - -- All WebSocket clients receive the same stream; WLED does not separate “log” from “state” traffic. Avoid sending huge or high-frequency log bursts to not interfere with the main UI. -- The official WLED UI has no “Logs” tab; you need your own page or tool to connect to `/ws` and show the log lines. -- No persistence: only clients connected at the time receive each message. - ## Files - `redalert.cpp` — usermod implementation. diff --git a/usermods/redalert/redalert.cpp b/usermods/redalert/redalert.cpp index 1c2604afa4..29fe72eba1 100644 --- a/usermods/redalert/redalert.cpp +++ b/usermods/redalert/redalert.cpp @@ -1,6 +1,7 @@ #include "wled.h" #include #include +#include #include "redalert_text_utils.h" #ifndef USERMOD_ID_PIKUD_HAOREF @@ -23,8 +24,10 @@ class UsermodPikudHaoref : public Usermod { static const char _alertPreset[]; static const char _preAlertPreset[]; static const char _endPreset[]; - static const char _titleEnd[]; + static const char _titleEnd[]; // JSON-escaped form static const char _titlePreAlert[]; + static const char _titleEndUtf8[]; // UTF-8 form (for proxy/decoded payloads) + static const char _titlePreAlertUtf8[]; static const char _idleTimeoutSec[]; static const char _idlePreset[]; @@ -120,17 +123,41 @@ class UsermodPikudHaoref : public Usermod { } // OREF API: category 10 is used for both pre-alert and end; differentiate by title. - // All other positive categories -> alert. - AlertState stateFromAlert(JsonObject& alert) { + // rawPayload: when non-null, we search the raw JSON for title strings. + AlertState stateFromAlert(JsonObject& alert, const char* rawPayload = nullptr) { int category = extractCategory(alert); if (category != 10) { return STATE_ALERT; // all non-10 categories (13, 14, etc.) -> alert } + + // Search raw JSON for literal "\u05XX" sequences (as in the wire format). Use inline literals so no PROGMEM. + // Pre-alert: "בדקות הקרובות..." appears as \u05d1\u05d3\u05e7\u05d5\u05ea in JSON + // End: "האירוע הסתיים" appears as \u05d4\u05d0\u05d9\u05e8\u05d5\u05e2 in JSON + if (rawPayload != nullptr) { + if (strstr(rawPayload, "\\u05d1\\u05d3\\u05e7\\u05d5\\u05ea ") != nullptr) return STATE_PRE_ALERT; + if (strstr(rawPayload, "\\u05d4\\u05d0\\u05d9\\u05e8\\u05d5\\u05e2 ") != nullptr) return STATE_END; + return STATE_END; + } + + char bufEnd[80]; + char bufPreAlert[160]; + strcpy_P(bufEnd, (PGM_P)_titleEnd); + strcpy_P(bufPreAlert, (PGM_P)_titlePreAlert); + const char* title = alert["title"].as(); if (!title) return STATE_END; - if (strcmp(title, (const char*)FPSTR(_titleEnd)) == 0) return STATE_END; - if (strcmp(title, (const char*)FPSTR(_titlePreAlert)) == 0) return STATE_PRE_ALERT; - return STATE_END; // category 10 with unknown title -> treat as end + + if (strstr(title, bufPreAlert) != nullptr) return STATE_PRE_ALERT; + if (strstr(title, bufEnd) != nullptr) return STATE_END; + + char bufEndU8[32]; + char bufPreAlertU8[96]; + strcpy_P(bufEndU8, (PGM_P)_titleEndUtf8); + strcpy_P(bufPreAlertU8, (PGM_P)_titlePreAlertUtf8); + if (strstr(title, bufPreAlertU8) != nullptr) return STATE_PRE_ALERT; + if (strstr(title, bufEndU8) != nullptr) return STATE_END; + + return STATE_END; } bool areaMatches(const char* cityNameRaw) { @@ -313,7 +340,7 @@ class UsermodPikudHaoref : public Usermod { DEBUG_PRINTLN(category); } - newState = stateFromAlert(alert); + newState = stateFromAlert(alert, payload.c_str()); break; } if (newState != STATE_OK) break; @@ -351,7 +378,7 @@ class UsermodPikudHaoref : public Usermod { DEBUG_PRINTLN(category); } - newState = stateFromAlert(alert); + newState = stateFromAlert(alert, payload.c_str()); break; } } @@ -592,8 +619,12 @@ const char UsermodPikudHaoref::_allAreasEnabled[] PROGMEM = "allAreasEnabled"; const char UsermodPikudHaoref::_alertPreset[] PROGMEM = "alertPreset"; const char UsermodPikudHaoref::_preAlertPreset[] PROGMEM = "preAlertPreset"; const char UsermodPikudHaoref::_endPreset[] PROGMEM = "endPreset"; -const char UsermodPikudHaoref::_titleEnd[] PROGMEM = "\xd7\x94\xd7\x90\xd7\x99\xd7\xa8\xd7\x95\xd7\xa2 \xd7\x94\xd7\xa1\xd7\xaa\xd7\x99\xd7\x99\xd7\x9d"; // "האירוע הסתיים" UTF-8 -const char UsermodPikudHaoref::_titlePreAlert[] PROGMEM = "\xd7\x91\xd7\x93\xd7\xa7\xd7\x95\xd7\xaa \xd7\x94\xd7\xa7\xd7\xa8\xd7\x95\xd7\x91\xd7\x95\xd7\xaa \xd7\xa6\xd7\xa4\xd7\x95\xd7\x99\xd7\x95\xd7\xaa \xd7\x9c\xd7\x94\xd7\xaa\xd7\xa7\xd7\x91\xd7\x9c \xd7\x94\xd7\xaa\xd7\xa8\xd7\xa2\xd7\x95\xd7\xaa \xd7\x91\xd7\x90\xd7\x96\xd7\x95\xd7\xa8\xd7\x9a"; // "בדקות הקרובות..." UTF-8 +// JSON-escaped form (raw API / ARDUINOJSON_DECODE_UNICODE 0) +const char UsermodPikudHaoref::_titleEnd[] PROGMEM = "\\u05d4\\u05d0\\u05d9\\u05e8\\u05d5\\u05e2 \\u05d4\\u05e1\\u05ea\\u05d9\\u05d9\\u05de"; // "האירוע הסתיים" +const char UsermodPikudHaoref::_titlePreAlert[] PROGMEM = "\\u05d1\\u05d3\\u05e7\\u05d5\\u05ea \\u05d4\\u05e7\\u05e8\\u05d5\\u05d1\\u05d5\\u05ea \\u05e6\\u05e4\\u05d5\\u05d9\\u05d5\\u05ea \\u05dc\\u05d4\\u05ea\\u05e7\\u05d1\\u05dc \\u05d4\\u05ea\\u05e8\\u05e2\\u05d5\\u05ea \\u05d1\\u05d0\\u05d6\\u05d5\\u05e8\\u05da"; // "בדקות הקרובות צפויות..." +// UTF-8 form (decoded / proxy) +const char UsermodPikudHaoref::_titleEndUtf8[] PROGMEM = "\xd7\x94\xd7\x90\xd7\x99\xd7\xa8\xd7\x95\xd7\xa2 \xd7\x94\xd7\xa1\xd7\xaa\xd7\x99\xd7\x99\xd7\x9d"; +const char UsermodPikudHaoref::_titlePreAlertUtf8[] PROGMEM = "\xd7\x91\xd7\x93\xd7\xa7\xd7\x95\xd7\xaa \xd7\x94\xd7\xa7\xd7\xa8\xd7\x95\xd7\x91\xd7\x95\xd7\xaa \xd7\xa6\xd7\xa4\xd7\x95\xd7\x99\xd7\x95\xd7\xaa \xd7\x9c\xd7\x94\xd7\xaa\xd7\xa7\xd7\x91\xd7\x9c \xd7\x94\xd7\xaa\xd7\xa8\xd7\xa2\xd7\x95\xd7\xaa \xd7\x91\xd7\x90\xd7\x96\xd7\x95\xd7\xa8\xd7\x9a"; const char UsermodPikudHaoref::_idleTimeoutSec[] PROGMEM = "idleTimeoutSec"; const char UsermodPikudHaoref::_idlePreset[] PROGMEM = "idlePreset"; const char UsermodPikudHaoref::_verboseLogs[] PROGMEM = "verboseLogs";