From 4cace1eccb917f37e73e4f6e4eceff700d1b86b3 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 22:06:26 +0200 Subject: [PATCH 01/12] Add HomeWizard P1 usermod with Grid Flow effect Polls a HomeWizard P1 meter (local API v1) and visualizes live grid power as a custom effect: purple pulses for offtake, green for injection, white breathing when neutral. The meter is discovered automatically via mDNS (_hwenergy._tcp) or set manually. HTTP polling is fully asynchronous (AsyncClient); TCP callbacks only buffer data and parsing happens on the main loop task, avoiding the blocking-request watchdog resets of the previous attempt. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 410 +++++++++++++++++++++++ usermods/homewizard_p1/library.json | 6 + usermods/homewizard_p1/readme.md | 49 +++ 3 files changed, 465 insertions(+) create mode 100644 usermods/homewizard_p1/homewizard_p1.cpp create mode 100644 usermods/homewizard_p1/library.json create mode 100644 usermods/homewizard_p1/readme.md diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp new file mode 100644 index 0000000000..7261a22bc7 --- /dev/null +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -0,0 +1,410 @@ +#include "wled.h" + +/* + * HomeWizard P1 usermod + * + * Polls a HomeWizard P1 meter (Wi-Fi P1 dongle) on the local network and + * exposes the current grid power as a WLED effect ("Grid Flow"): + * - offtake / import (active_power_w > 0): purple pulses flowing in one direction + * - injection / export (active_power_w < 0): green pulses flowing the other way + * - near zero: calm white breathing + * + * The P1 meter is found automatically via mDNS (service _hwenergy._tcp), + * or a fixed IP/hostname can be set in Usermod Settings. + * + * Data source: HomeWizard local API v1, GET http:///api/v1/data + * ("Local API" must be enabled in the HomeWizard Energy app). + * API reference: https://api-documentation.homewizard.com/docs/v1/ + * + * Implementation notes: + * - The HTTP request is fully asynchronous (AsyncClient); TCP callbacks only + * append to a buffer and set flags, all parsing and WLED state changes + * happen in the usermod loop() on the main task. This keeps the LED + * pipeline running and avoids the watchdog resets a blocking HTTPClient + * in loop() would cause. + * - mDNS discovery (MDNS.queryService) blocks for a few seconds, so it only + * runs while no host is known, with a retry backoff. + * + * Async TCP handling pattern based on usermods/usermod_v2_HttpPullLightControl + * (Roel Broersma), effect structure based on usermods/user_fx. + */ + +// AI: below section was generated by an AI + +// --------------------------------------------------------------------------- +// State shared between the usermod instance and the effect function. +// Written from the main loop only (parsing happens in loop(), not in TCP +// callbacks), read by the effect on the same task -> no locking needed. +// --------------------------------------------------------------------------- +static float p1PowerW = 0.0f; // latest active power, >0 import, <0 export +static bool p1DataValid = false; // false until first fetch or when data is stale +static int p1DeadbandW = 50; // |power| below this is treated as "neutral" +static int p1FullScaleW = 2500; // |power| at which color/speed reach maximum + +// Fixed identity colors (matching common energy-app conventions) +static const uint32_t P1_COLOR_IMPORT = RGBW32(160, 0, 255, 0); // purple +static const uint32_t P1_COLOR_EXPORT = RGBW32( 0, 255, 60, 0); // green +static const uint32_t P1_COLOR_IDLE = RGBW32(255, 255, 255, 0); // white + +// --------------------------------------------------------------------------- +// Effect: Grid Flow +// +// Sliders: +// Speed - scales the flow speed (on top of the power-based speed) +// Pulses - number of pulses travelling along the segment +// Trail - length of the fading tail behind each pulse head +// The flow direction is fixed by import/export; use the segment "reverse" +// option to flip it for your physical layout. +// --------------------------------------------------------------------------- +static void mode_grid_flow(void) { + if (SEGLEN < 1) { SEGMENT.fill(SEGCOLOR(0)); return; } + + const float power = p1PowerW; + const bool neutral = !p1DataValid || fabsf(power) <= (float)p1DeadbandW; + + if (neutral) { + // No data yet or balanced grid: calm white breathing. + // While data is missing the breathing is dimmer, so the states are distinguishable. + uint8_t breath = sin8_t(strip.now >> 4); // ~4 s cycle + uint8_t level = p1DataValid ? (140 + (breath >> 2)) : (30 + (breath >> 3)); + SEGMENT.fill(color_fade(P1_COLOR_IDLE, level)); + return; + } + + const bool importing = power > 0.0f; + // Normalize |power| to 0..1 between deadband and full scale + float t = (fabsf(power) - (float)p1DeadbandW) / (float)max(1, p1FullScaleW - p1DeadbandW); + t = constrain(t, 0.0f, 1.0f); + + const uint32_t baseColor = importing ? P1_COLOR_IMPORT : P1_COLOR_EXPORT; + + // Flow speed in pixels/second: 4..60 px/s scaled by power and the speed slider + float pxPerSec = (4.0f + 56.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); + // Position offset in 8.8 fixed point pixels; import and export flow in + // opposite directions so you can see at a glance which way energy "moves" + uint32_t offset = (uint32_t)((strip.now * (uint32_t)(pxPerSec * 256.0f)) / 1000); + + // Pulse spacing from the density slider (more density = more pulses) + unsigned numPulses = 1 + ((SEGLEN * (unsigned)SEGMENT.intensity) >> 11); // 1 pulse per ~8px at max + unsigned spacing = max(6U, (unsigned)SEGLEN / numPulses); + const uint32_t spacingFP = (uint32_t)spacing << 8; + + // Trail length: fraction of the spacing that fades out behind the head + unsigned trail = 2 + (((unsigned)SEGMENT.custom1 * (spacing - 2)) >> 8); + const uint32_t trailFP = (uint32_t)trail << 8; + + // Dim background in the base color so the whole strip shows the state + const uint32_t bgColor = color_fade(baseColor, 24); + + for (unsigned i = 0; i < SEGLEN; i++) { + uint32_t posFP = ((uint32_t)i << 8); + // Import flows toward segment start, export flows toward segment end + posFP = importing ? (posFP + offset) : (posFP - offset); + uint32_t phase = posFP % spacingFP; // 0 = pulse head + + uint32_t color; + if (phase < trailFP) { + // Head is brightest, tail fades to background level + uint8_t level = (uint8_t)(255 - ((phase * (255 - 40)) / trailFP)); + // More power = brighter pulses overall + level = scale8(level, 128 + (uint8_t)(127.0f * t)); + color = color_fade(baseColor, max((uint8_t)24, level)); + } else { + color = bgColor; + } + SEGMENT.setPixelColor(i, color); + } +} +static const char _data_FX_MODE_GRID_FLOW[] PROGMEM = "Grid Flow@Speed,Pulses,Trail;;;1;sx=128,ix=96,c1=128"; + +// --------------------------------------------------------------------------- +// Usermod +// --------------------------------------------------------------------------- +class HomeWizardP1Usermod : public Usermod { +private: + static const char _name[]; + + bool enabled = true; + + // Configuration (Usermod Settings) + String host = ""; // empty = discover via mDNS + uint16_t updateIntervalMs = 3000; // poll interval; P1 v1 data updates about once per second + bool autoDiscover = true; + + // Discovery state + String discoveredHost = ""; + unsigned long lastDiscoveryAttempt = 0; + static const unsigned long DISCOVERY_RETRY_MS = 30000; + + // Fetch state + AsyncClient *client = nullptr; + unsigned long lastFetchStart = 0; + unsigned long lastGoodData = 0; + static const unsigned long FETCH_TIMEOUT_MS = 5000; // watchdog for a hanging client + static const unsigned long DATA_STALE_MS = 20000; // invalidate power data after this + + // Response buffer, filled by TCP callbacks, parsed in loop(). + // 6 KiB fits headers + the v1 JSON payload comfortably. + static const size_t RESP_BUF_SIZE = 6144; + char *respBuf = nullptr; + volatile size_t respLen = 0; + volatile bool respComplete = false; // set by onDisconnect: response fully received + volatile bool clientFailed = false; // set by onError/onTimeout + + // Status for the info page + bool lastFetchOk = false; + String lastError = ""; + + bool haveHost() const { return host.length() > 0 || discoveredHost.length() > 0; } + String activeHost() const { return host.length() > 0 ? host : discoveredHost; } + + // Discover the P1 meter via mDNS (_hwenergy._tcp). Blocking for a few + // seconds, so this is only called while no host is known, with backoff. + void discoverMeter() { +#ifdef ARDUINO_ARCH_ESP32 + lastDiscoveryAttempt = millis(); + DEBUG_PRINTLN(F("P1: mDNS discovery (_hwenergy._tcp)...")); + int n = MDNS.queryService("hwenergy", "tcp"); + for (int i = 0; i < n; i++) { + String productType = MDNS.txt(i, "product_type"); + DEBUG_PRINTF_P(PSTR("P1: mDNS result %d: %s (%s)\n"), i, MDNS.IP(i).toString().c_str(), productType.c_str()); + // HWE-P1 is the P1 meter; other HomeWizard products (kWh meter, socket) also announce hwenergy + if (productType.startsWith(F("HWE-P1")) || n == 1) { + discoveredHost = MDNS.IP(i).toString(); + DEBUG_PRINTF_P(PSTR("P1: using meter at %s\n"), discoveredHost.c_str()); + return; + } + } + if (n > 0 && discoveredHost.length() == 0) { + // No explicit P1 found, fall back to the first announced device + discoveredHost = MDNS.IP(0).toString(); + } + if (discoveredHost.length() == 0) DEBUG_PRINTLN(F("P1: no meter found via mDNS")); +#else + // ESP8266: no service discovery implemented, a manual host is required + lastDiscoveryAttempt = millis(); +#endif + } + + // Kick off an asynchronous GET /api/v1/data. Returns immediately; the + // response is collected by the TCP callbacks and parsed later in loop(). + void startFetch() { + if (client != nullptr) return; // previous request still in flight + + respLen = 0; + respComplete = false; + clientFailed = false; + + client = new AsyncClient(); + if (!client) { lastError = F("out of memory"); return; } + + client->onData([](void *arg, AsyncClient *c, void *data, size_t len) { + // Runs on the async_tcp task: only append to the buffer, no WLED calls here + HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; + size_t space = RESP_BUF_SIZE - 1 - um->respLen; + size_t toCopy = len < space ? len : space; + if (toCopy > 0) { + memcpy(um->respBuf + um->respLen, data, toCopy); + um->respLen = um->respLen + toCopy; + um->respBuf[um->respLen] = '\0'; + } + }, this); + + client->onDisconnect([](void *arg, AsyncClient *c) { + // Server closed the connection (Connection: close) -> response complete. + // Deleting the client here follows the AsyncTCP reference pattern. + HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; + if (um->client == c) { + um->client = nullptr; + um->respComplete = true; + } + delete c; + }, this); + + client->onTimeout([](void *arg, AsyncClient *c, uint32_t time) { + HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; + if (um->client == c) { + um->client = nullptr; + um->clientFailed = true; + } + delete c; + }, this); + + client->onError([](void *arg, AsyncClient *c, int8_t error) { + HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; + if (um->client == c) { + um->client = nullptr; + um->clientFailed = true; + } + // AsyncTCP deletes a client that failed to connect on its own; do not delete here + }, this); + + client->onConnect([](void *arg, AsyncClient *c) { + HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; + String req = String(F("GET /api/v1/data HTTP/1.1\r\nHost: ")) + um->activeHost() + + F("\r\nConnection: close\r\nAccept: application/json\r\n\r\n"); + c->write(req.c_str()); + }, this); + + client->setRxTimeout(4); // seconds + client->setAckTimeout(4000); + + lastFetchStart = millis(); + if (!client->connect(activeHost().c_str(), 80)) { + delete client; + client = nullptr; + clientFailed = true; + } + } + + // Parse the buffered HTTP response; runs on the main task. + void parseResponse() { + lastFetchOk = false; + + char *body = strstr(respBuf, "\r\n\r\n"); + if (!body) { lastError = F("no HTTP body"); return; } + body += 4; + + // Basic status line check ("HTTP/1.1 200 OK") + if (strncmp(respBuf, "HTTP/1.1 200", 12) != 0 && strncmp(respBuf, "HTTP/1.0 200", 12) != 0) { + lastError = F("HTTP error"); + char *sp = strchr(respBuf, ' '); + if (sp) lastError += String(sp).substring(0, 5); + // A 404 here usually means the Local API is disabled in the HomeWizard app + return; + } + + // Only extract the fields we need; the filter keeps memory usage small + StaticJsonDocument<64> filter; + filter["active_power_w"] = true; + StaticJsonDocument<256> doc; + DeserializationError err = deserializeJson(doc, body, DeserializationOption::Filter(filter)); + if (err) { lastError = String(F("JSON: ")) + err.c_str(); return; } + if (!doc.containsKey("active_power_w")) { lastError = F("no active_power_w"); return; } + + p1PowerW = doc["active_power_w"].as(); + p1DataValid = true; + lastGoodData = millis(); + lastFetchOk = true; + lastError = ""; + DEBUG_PRINTF_P(PSTR("P1: %.0f W\n"), p1PowerW); + } + +public: + void setup() override { + respBuf = (char*)d_malloc(RESP_BUF_SIZE); + strip.addEffect(255, &mode_grid_flow, _data_FX_MODE_GRID_FLOW); + } + + void loop() override { + if (!enabled || respBuf == nullptr) return; + + // Handle a completed or failed request first (flags set by TCP callbacks) + if (respComplete) { + respComplete = false; + parseResponse(); + } + if (clientFailed) { + clientFailed = false; + lastFetchOk = false; + if (lastError.length() == 0) lastError = F("connect failed"); + // If a manually configured host keeps failing, do not clear it; + // a discovered host might be stale though, so forget it and rediscover + if (host.length() == 0 && millis() - lastGoodData > DATA_STALE_MS) discoveredHost = ""; + } + + // Watchdog for a client that neither completes nor errors out + if (client != nullptr && millis() - lastFetchStart > FETCH_TIMEOUT_MS) { + AsyncClient *c = client; + client = nullptr; // detach first so callbacks from close() are ignored + c->onDisconnect(nullptr); + c->onError(nullptr); + c->onTimeout(nullptr); + c->onData(nullptr); + delete c; + lastFetchOk = false; + lastError = F("timeout"); + } + + // Mark data stale when fetches keep failing, so the effect falls back to "waiting" + if (p1DataValid && millis() - lastGoodData > DATA_STALE_MS) p1DataValid = false; + + if (!WLED_CONNECTED) return; + + // No host known: try mDNS discovery with backoff + if (!haveHost()) { + if (autoDiscover && (lastDiscoveryAttempt == 0 || millis() - lastDiscoveryAttempt > DISCOVERY_RETRY_MS)) { + discoverMeter(); + } + return; + } + + // Time for the next poll? + if (client == nullptr && millis() - lastFetchStart >= updateIntervalMs) { + startFetch(); + } + } + + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + JsonArray info = user.createNestedArray(FPSTR(_name)); + + if (!enabled) { info.add(F("disabled")); return; } + if (!haveHost()) { + info.add(autoDiscover ? F("searching for P1 meter...") : F("no host configured")); + return; + } + if (p1DataValid) { + String s = String(p1PowerW, 0) + F(" W "); + if (p1PowerW > p1DeadbandW) s += F("(offtake)"); + else if (p1PowerW < -p1DeadbandW) s += F("(injection)"); + else s += F("(neutral)"); + s += F(" @ "); + s += activeHost(); + info.add(s); + } else { + String s = String(F("no data from ")) + activeHost(); + if (lastError.length() > 0) s += String(F(" - ")) + lastError; + info.add(s); + } + } + + void addToConfig(JsonObject &root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top["enabled"] = enabled; + top["host"] = host; + top["autoDiscover"] = autoDiscover; + top["updateIntervalMs"] = updateIntervalMs; + top["deadbandW"] = p1DeadbandW; + top["fullScaleW"] = p1FullScaleW; + } + + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + bool configComplete = !top.isNull(); + + String prevHost = host; + configComplete &= getJsonValue(top["enabled"], enabled, enabled); + configComplete &= getJsonValue(top["host"], host, host); + configComplete &= getJsonValue(top["autoDiscover"], autoDiscover, autoDiscover); + configComplete &= getJsonValue(top["updateIntervalMs"], updateIntervalMs, updateIntervalMs); + configComplete &= getJsonValue(top["deadbandW"], p1DeadbandW, p1DeadbandW); + configComplete &= getJsonValue(top["fullScaleW"], p1FullScaleW, p1FullScaleW); + + updateIntervalMs = max((uint16_t)1000, updateIntervalMs); + p1DeadbandW = max(0, p1DeadbandW); + p1FullScaleW = max(p1DeadbandW + 1, p1FullScaleW); + host.trim(); + if (host != prevHost) discoveredHost = ""; // host changed: reset discovery state + + return configComplete; + } +}; + +// AI: end + +const char HomeWizardP1Usermod::_name[] PROGMEM = "HomeWizard P1"; + +static HomeWizardP1Usermod homewizard_p1; +REGISTER_USERMOD(homewizard_p1); diff --git a/usermods/homewizard_p1/library.json b/usermods/homewizard_p1/library.json new file mode 100644 index 0000000000..a141b43a02 --- /dev/null +++ b/usermods/homewizard_p1/library.json @@ -0,0 +1,6 @@ +{ + "name": "homewizard_p1", + "build": { + "libArchive": false + } +} diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md new file mode 100644 index 0000000000..f28ff597b0 --- /dev/null +++ b/usermods/homewizard_p1/readme.md @@ -0,0 +1,49 @@ +# HomeWizard P1 usermod + +Visualizes your home's live grid power on a LED strip using a +[HomeWizard P1 meter](https://www.homewizard.com/p1-meter/) in the same network. + +- **Offtake / import** (consuming from the grid): purple pulses flowing toward the segment start; more power = faster and brighter. +- **Injection / export** (feeding into the grid, e.g. solar): green pulses flowing toward the segment end. +- **Neutral** (within the deadband around 0 W): calm white breathing. +- **No data** (meter unreachable): dim white breathing. + +## Setup + +1. Enable the **Local API** for the P1 meter in the HomeWizard Energy app + (Settings → Meters → your P1 meter → Local API). +2. Build WLED with `custom_usermods = homewizard_p1` and flash it. +3. In WLED, go to Config → Usermods → HomeWizard P1. With *autoDiscover* + enabled and *host* left empty, the meter is found automatically via mDNS + (`_hwenergy._tcp`). Alternatively enter the meter's IP address as *host*. + (On ESP8266 discovery is not supported; set *host* manually.) +4. Select the **Grid Flow** effect on your segment. + +## Settings + +| Setting | Meaning | +|---|---| +| `enabled` | Master switch for polling | +| `host` | IP/hostname of the P1 meter; leave empty for mDNS auto-discovery | +| `autoDiscover` | Search for the meter via mDNS when no host is set | +| `updateIntervalMs` | Poll interval in ms (min 1000; the v1 API updates about once per second) | +| `deadbandW` | Power band around 0 W treated as neutral | +| `fullScaleW` | Power at which color and flow speed reach their maximum | + +## Effect sliders (Grid Flow) + +- **Speed**: scales the overall flow speed (the actual speed also grows with power). +- **Pulses**: how many pulses travel along the segment. +- **Trail**: length of the fading tail behind each pulse. + +Flow direction is tied to import/export; use the segment's *reverse* option if +it should run the other way on your physical strip. + +## Technical notes + +- Uses the HomeWizard [local API v1](https://api-documentation.homewizard.com/docs/v1/) + endpoint `GET /api/v1/data`, field `active_power_w` (positive = import, negative = export). +- The HTTP request is fully asynchronous (AsyncClient). TCP callbacks only + buffer data; parsing and all WLED state changes happen on the main loop task. +- mDNS discovery blocks for a few seconds, so it only runs while no meter is + known, with a 30 s retry backoff. The LEDs may pause briefly during discovery. From 64e0541a61e429f7adbb818d104f229dde8cfd0b Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 22:26:50 +0200 Subject: [PATCH 02/12] Fix double-delete of AsyncClient on ack timeout Deleting the client inside onTimeout runs the destructor, whose close fires onDisconnect, which deleted the client a second time. Only flag the failure in onTimeout; the rx timeout closes the connection and onDisconnect performs the single delete. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index 7261a22bc7..1b1bab47bc 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -222,12 +222,15 @@ class HomeWizardP1Usermod : public Usermod { }, this); client->onTimeout([](void *arg, AsyncClient *c, uint32_t time) { + // Only flag the failure; do NOT delete here. Deleting runs ~AsyncClient, + // whose close fires the onDisconnect callback, which would delete again. + // The rx timeout closes the connection shortly after, and onDisconnect + // performs the (single) delete. HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; if (um->client == c) { um->client = nullptr; um->clientFailed = true; } - delete c; }, this); client->onError([](void *arg, AsyncClient *c, int8_t error) { From 049de82ffb17ad11c4e19a122eafac6324a52dc9 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 22:42:36 +0200 Subject: [PATCH 03/12] Smooth all color and state transitions in Grid Flow Render from a low-pass filtered power value (~1.2 s time constant) so 3 s poll steps ramp instead of jump. Fade the color white -> purple/ green proportionally with power (no hard cut at the deadband edge), let pulses gradually emerge from the neutral breathing, and cross-fade the waiting/active brightness via a smoothed presence factor. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 83 ++++++++++++++++-------- usermods/homewizard_p1/readme.md | 5 ++ 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index 1b1bab47bc..e8eef662b4 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -36,10 +36,12 @@ // Written from the main loop only (parsing happens in loop(), not in TCP // callbacks), read by the effect on the same task -> no locking needed. // --------------------------------------------------------------------------- -static float p1PowerW = 0.0f; // latest active power, >0 import, <0 export -static bool p1DataValid = false; // false until first fetch or when data is stale -static int p1DeadbandW = 50; // |power| below this is treated as "neutral" -static int p1FullScaleW = 2500; // |power| at which color/speed reach maximum +static float p1PowerW = 0.0f; // latest active power, >0 import, <0 export +static float p1PowerSmoothW = 0.0f; // low-pass filtered power driving the visuals +static float p1Presence = 0.0f; // 0..1, fades in/out as data becomes (in)valid +static bool p1DataValid = false; // false until first fetch or when data is stale +static int p1DeadbandW = 50; // |power| below this is treated as "neutral" +static int p1FullScaleW = 2500; // |power| at which color/speed reach maximum // Fixed identity colors (matching common energy-app conventions) static const uint32_t P1_COLOR_IMPORT = RGBW32(160, 0, 255, 0); // purple @@ -59,24 +61,30 @@ static const uint32_t P1_COLOR_IDLE = RGBW32(255, 255, 255, 0); // white static void mode_grid_flow(void) { if (SEGLEN < 1) { SEGMENT.fill(SEGCOLOR(0)); return; } - const float power = p1PowerW; - const bool neutral = !p1DataValid || fabsf(power) <= (float)p1DeadbandW; - - if (neutral) { - // No data yet or balanced grid: calm white breathing. - // While data is missing the breathing is dimmer, so the states are distinguishable. - uint8_t breath = sin8_t(strip.now >> 4); // ~4 s cycle - uint8_t level = p1DataValid ? (140 + (breath >> 2)) : (30 + (breath >> 3)); - SEGMENT.fill(color_fade(P1_COLOR_IDLE, level)); - return; - } - + // All rendering is driven by the low-pass filtered power, so poll steps, + // deadband crossings and import/export flips all animate smoothly. + const float power = p1PowerSmoothW; const bool importing = power > 0.0f; + // Normalize |power| to 0..1 between deadband and full scale float t = (fabsf(power) - (float)p1DeadbandW) / (float)max(1, p1FullScaleW - p1DeadbandW); t = constrain(t, 0.0f, 1.0f); - const uint32_t baseColor = importing ? P1_COLOR_IMPORT : P1_COLOR_EXPORT; + // Pulses gradually emerge from the breathing over the first quarter of the + // power range instead of snapping in at the deadband edge + const float emerge = constrain(t * 4.0f, 0.0f, 1.0f); + + // Color fades white -> purple (import) / green (export) with power. + // A direction flip passes through the deadband, i.e. through white. + const uint32_t ident = importing ? P1_COLOR_IMPORT : P1_COLOR_EXPORT; + const uint32_t baseColor = color_blend(P1_COLOR_IDLE, ident, (uint8_t)(t * 255.0f)); + + // Breathing level: bright when data is valid, dim while waiting for data. + // p1Presence fades between the two so data loss/recovery is not a hard cut. + const uint8_t breath = sin8_t(strip.now >> 4); // ~4 s cycle + const int breatheValid = 140 + (breath >> 2); + const int breatheInvalid = 30 + (breath >> 3); + const int idleLevel = breatheInvalid + (int)((breatheValid - breatheInvalid) * p1Presence); // Flow speed in pixels/second: 4..60 px/s scaled by power and the speed slider float pxPerSec = (4.0f + 56.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); @@ -93,8 +101,8 @@ static void mode_grid_flow(void) { unsigned trail = 2 + (((unsigned)SEGMENT.custom1 * (spacing - 2)) >> 8); const uint32_t trailFP = (uint32_t)trail << 8; - // Dim background in the base color so the whole strip shows the state - const uint32_t bgColor = color_fade(baseColor, 24); + // Pulses get brighter with power + const uint8_t pulseBri = 170 + (uint8_t)(85.0f * t); for (unsigned i = 0; i < SEGLEN; i++) { uint32_t posFP = ((uint32_t)i << 8); @@ -102,17 +110,20 @@ static void mode_grid_flow(void) { posFP = importing ? (posFP + offset) : (posFP - offset); uint32_t phase = posFP % spacingFP; // 0 = pulse head - uint32_t color; + // Flow layer brightness: bright head fading along the trail, dim background + int flowLevel; if (phase < trailFP) { - // Head is brightest, tail fades to background level - uint8_t level = (uint8_t)(255 - ((phase * (255 - 40)) / trailFP)); - // More power = brighter pulses overall - level = scale8(level, 128 + (uint8_t)(127.0f * t)); - color = color_fade(baseColor, max((uint8_t)24, level)); + flowLevel = 255 - (int)((phase * (255 - 40)) / trailFP); + flowLevel = scale8((uint8_t)flowLevel, pulseBri); } else { - color = bgColor; + flowLevel = 24; } - SEGMENT.setPixelColor(i, color); + + // Cross-fade breathing -> flow by emerge for a seamless neutral boundary + int level = idleLevel + (int)((flowLevel - idleLevel) * emerge); + level = constrain(level, 10, 255); + + SEGMENT.setPixelColor(i, color_fade(baseColor, (uint8_t)level)); } } static const char _data_FX_MODE_GRID_FLOW[] PROGMEM = "Grid Flow@Speed,Pulses,Trail;;;1;sx=128,ix=96,c1=128"; @@ -155,6 +166,9 @@ class HomeWizardP1Usermod : public Usermod { bool lastFetchOk = false; String lastError = ""; + // Timestamp for the visual smoothing filter + unsigned long lastSmoothUpdate = 0; + bool haveHost() const { return host.length() > 0 || discoveredHost.length() > 0; } String activeHost() const { return host.length() > 0 ? host : discoveredHost; } @@ -302,6 +316,21 @@ class HomeWizardP1Usermod : public Usermod { void loop() override { if (!enabled || respBuf == nullptr) return; + // Low-pass filter the power value (and a data-presence factor) toward + // their targets with a ~1.2 s time constant. The effect renders from the + // filtered values, so 3 s poll steps become smooth ramps instead of jumps. + unsigned long now = millis(); + unsigned long dt = now - lastSmoothUpdate; + if (dt > 0) { + lastSmoothUpdate = now; + if (dt > 1000) dt = 1000; // avoid a huge first step after boot/reconnect + float a = (float)dt / (1200.0f + (float)dt); + float powerTarget = p1DataValid ? p1PowerW : 0.0f; // fade home to neutral when data is lost + p1PowerSmoothW += (powerTarget - p1PowerSmoothW) * a; + float presenceTarget = p1DataValid ? 1.0f : 0.0f; + p1Presence += (presenceTarget - p1Presence) * a; + } + // Handle a completed or failed request first (flags set by TCP callbacks) if (respComplete) { respComplete = false; diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index f28ff597b0..c1fe84a635 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -39,6 +39,11 @@ Visualizes your home's live grid power on a LED strip using a Flow direction is tied to import/export; use the segment's *reverse* option if it should run the other way on your physical strip. +All transitions are smooth: the power value is low-pass filtered (~1.2 s), the +color fades white → purple/green proportionally with power, and the pulses +gradually emerge from the neutral breathing rather than snapping in at the +deadband edge. + ## Technical notes - Uses the HomeWizard [local API v1](https://api-documentation.homewizard.com/docs/v1/) From 44f74cac0e7b872e9b7d5bb0184015568bb95d14 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 23:12:25 +0200 Subject: [PATCH 04/12] Rework P1 fetching to a FreeRTOS task; fix animation stutter and color ramp Testing revealed stuttering pulses, no visible green during injection, and a lockup after about a minute of running. - Move all networking (mDNS discovery + HTTP polling) to a dedicated low-priority task pinned to core 0, replacing the AsyncClient state machine. This removes the client deletion races that most plausibly caused the lockup, and discovery no longer pauses the LEDs. - Integrate the pulse position over time instead of deriving it from time * speed; speed changes no longer rescale the phase and teleport the pulses (the stutter). - Saturate the identity color (purple/green) at ~25% of the power range with an ease-out curve so a few hundred watts is clearly colored; magnitude keeps scaling flow speed and brightness to full scale. - Add a Blur slider and a quadratic comet-style trail falloff. - Add p1_simulator.py, a fake local API v1 that cycles injection/ neutral/offtake every 10 s for testing without a real meter; verified rendered colors against it via /json/live. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 493 +++++++++++------------ usermods/homewizard_p1/p1_simulator.py | 44 ++ usermods/homewizard_p1/readme.md | 19 +- 3 files changed, 294 insertions(+), 262 deletions(-) create mode 100644 usermods/homewizard_p1/p1_simulator.py diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index e8eef662b4..e9f0968280 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -1,7 +1,11 @@ #include "wled.h" +#ifdef ARDUINO_ARCH_ESP32 +#include +#endif + /* - * HomeWizard P1 usermod + * HomeWizard P1 usermod (ESP32 only) * * Polls a HomeWizard P1 meter (Wi-Fi P1 dongle) on the local network and * exposes the current grid power as a WLED effect ("Grid Flow"): @@ -10,38 +14,37 @@ * - near zero: calm white breathing * * The P1 meter is found automatically via mDNS (service _hwenergy._tcp), - * or a fixed IP/hostname can be set in Usermod Settings. + * or a fixed IP/hostname (optionally "host:port") can be set in Usermod Settings. * * Data source: HomeWizard local API v1, GET http:///api/v1/data * ("Local API" must be enabled in the HomeWizard Energy app). * API reference: https://api-documentation.homewizard.com/docs/v1/ * - * Implementation notes: - * - The HTTP request is fully asynchronous (AsyncClient); TCP callbacks only - * append to a buffer and set flags, all parsing and WLED state changes - * happen in the usermod loop() on the main task. This keeps the LED - * pipeline running and avoids the watchdog resets a blocking HTTPClient - * in loop() would cause. - * - mDNS discovery (MDNS.queryService) blocks for a few seconds, so it only - * runs while no host is known, with a retry backoff. - * - * Async TCP handling pattern based on usermods/usermod_v2_HttpPullLightControl - * (Roel Broersma), effect structure based on usermods/user_fx. + * Architecture: + * - All networking (mDNS discovery + HTTP polling) runs on a dedicated + * low-priority FreeRTOS task pinned to core 0, the same pattern the + * audioreactive usermod uses for its processing task. Blocking calls there + * cannot stall the LED pipeline on the main loop task, and there is no + * async client lifecycle to race on. + * - The task publishes plain 32-bit values (atomic on ESP32); host strings + * are exchanged under a FreeRTOS mutex, per docs/cpp.instructions.md. + * - The main loop low-pass filters the power value; the effect renders only + * from filtered values so all transitions are smooth. */ // AI: below section was generated by an AI // --------------------------------------------------------------------------- -// State shared between the usermod instance and the effect function. -// Written from the main loop only (parsing happens in loop(), not in TCP -// callbacks), read by the effect on the same task -> no locking needed. +// State shared between the fetch task, the usermod instance and the effect. +// 32-bit aligned loads/stores are atomic on ESP32; strings use p1Mutex. // --------------------------------------------------------------------------- -static float p1PowerW = 0.0f; // latest active power, >0 import, <0 export -static float p1PowerSmoothW = 0.0f; // low-pass filtered power driving the visuals -static float p1Presence = 0.0f; // 0..1, fades in/out as data becomes (in)valid -static bool p1DataValid = false; // false until first fetch or when data is stale -static int p1DeadbandW = 50; // |power| below this is treated as "neutral" -static int p1FullScaleW = 2500; // |power| at which color/speed reach maximum +static volatile float p1PowerW = 0.0f; // latest active power, >0 import, <0 export (fetch task) +static volatile uint32_t p1LastGoodMs = 0; // millis() of last successful fetch (fetch task) +static bool p1DataValid = false; // derived in main loop from p1LastGoodMs +static float p1PowerSmoothW = 0.0f; // low-pass filtered power driving the visuals (main loop) +static float p1Presence = 0.0f; // 0..1, fades in/out as data becomes (in)valid (main loop) +static int p1DeadbandW = 50; // |power| below this is treated as "neutral" +static int p1FullScaleW = 2500; // |power| at which flow speed/brightness reach maximum // Fixed identity colors (matching common energy-app conventions) static const uint32_t P1_COLOR_IMPORT = RGBW32(160, 0, 255, 0); // purple @@ -52,14 +55,19 @@ static const uint32_t P1_COLOR_IDLE = RGBW32(255, 255, 255, 0); // white // Effect: Grid Flow // // Sliders: -// Speed - scales the flow speed (on top of the power-based speed) -// Pulses - number of pulses travelling along the segment -// Trail - length of the fading tail behind each pulse head +// Speed - scales the flow speed (on top of the power-based speed) +// Pulses - number of pulses travelling along the segment +// Trail - length of the fading tail behind each pulse head +// Blur - softens the pulses for a smoother look // The flow direction is fixed by import/export; use the segment "reverse" // option to flip it for your physical layout. // --------------------------------------------------------------------------- static void mode_grid_flow(void) { if (SEGLEN < 1) { SEGMENT.fill(SEGCOLOR(0)); return; } + // Per-segment fractional pulse position, so speed changes shift the pattern + // continuously (position is integrated, never recomputed from time * speed) + if (!SEGENV.allocateData(sizeof(float))) { SEGMENT.fill(SEGCOLOR(0)); return; } + float *flowPos = reinterpret_cast(SEGENV.data); // All rendering is driven by the low-pass filtered power, so poll steps, // deadband crossings and import/export flips all animate smoothly. @@ -70,14 +78,16 @@ static void mode_grid_flow(void) { float t = (fabsf(power) - (float)p1DeadbandW) / (float)max(1, p1FullScaleW - p1DeadbandW); t = constrain(t, 0.0f, 1.0f); - // Pulses gradually emerge from the breathing over the first quarter of the - // power range instead of snapping in at the deadband edge - const float emerge = constrain(t * 4.0f, 0.0f, 1.0f); + // Identity (import/export) must be readable at low power: color saturation + // and pulse visibility reach full at ~25% of the range (with an ease-out + // curve), while flow speed and brightness keep scaling all the way to full + // scale. So a few hundred watts is already clearly green/purple, and the + // magnitude shows in how fast and bright the pulses run. + float activity = constrain(t * 4.0f, 0.0f, 1.0f); + activity = 1.0f - (1.0f - activity) * (1.0f - activity); - // Color fades white -> purple (import) / green (export) with power. - // A direction flip passes through the deadband, i.e. through white. const uint32_t ident = importing ? P1_COLOR_IMPORT : P1_COLOR_EXPORT; - const uint32_t baseColor = color_blend(P1_COLOR_IDLE, ident, (uint8_t)(t * 255.0f)); + const uint32_t baseColor = color_blend(P1_COLOR_IDLE, ident, (uint8_t)(activity * 255.0f)); // Breathing level: bright when data is valid, dim while waiting for data. // p1Presence fades between the two so data loss/recovery is not a hard cut. @@ -86,17 +96,22 @@ static void mode_grid_flow(void) { const int breatheInvalid = 30 + (breath >> 3); const int idleLevel = breatheInvalid + (int)((breatheValid - breatheInvalid) * p1Presence); - // Flow speed in pixels/second: 4..60 px/s scaled by power and the speed slider - float pxPerSec = (4.0f + 56.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); - // Position offset in 8.8 fixed point pixels; import and export flow in - // opposite directions so you can see at a glance which way energy "moves" - uint32_t offset = (uint32_t)((strip.now * (uint32_t)(pxPerSec * 256.0f)) / 1000); - // Pulse spacing from the density slider (more density = more pulses) unsigned numPulses = 1 + ((SEGLEN * (unsigned)SEGMENT.intensity) >> 11); // 1 pulse per ~8px at max unsigned spacing = max(6U, (unsigned)SEGLEN / numPulses); const uint32_t spacingFP = (uint32_t)spacing << 8; + // Integrate pulse position: flow speed 4..60 px/s scaled by power and the + // speed slider; import and export flow in opposite directions + float pxPerSec = (4.0f + 56.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); + uint32_t dtMs = (SEGENV.call == 0) ? 0 : (strip.now - SEGENV.step); + SEGENV.step = strip.now; + if (dtMs > 200) dtMs = 200; // clamp huge gaps (effect just (re)started) + *flowPos += (importing ? pxPerSec : -pxPerSec) * (float)dtMs / 1000.0f; + *flowPos = fmodf(*flowPos, (float)spacing); + if (*flowPos < 0.0f) *flowPos += (float)spacing; + const uint32_t posFP = (uint32_t)(*flowPos * 256.0f); + // Trail length: fraction of the spacing that fades out behind the head unsigned trail = 2 + (((unsigned)SEGMENT.custom1 * (spacing - 2)) >> 8); const uint32_t trailFP = (uint32_t)trail << 8; @@ -105,28 +120,28 @@ static void mode_grid_flow(void) { const uint8_t pulseBri = 170 + (uint8_t)(85.0f * t); for (unsigned i = 0; i < SEGLEN; i++) { - uint32_t posFP = ((uint32_t)i << 8); - // Import flows toward segment start, export flows toward segment end - posFP = importing ? (posFP + offset) : (posFP - offset); - uint32_t phase = posFP % spacingFP; // 0 = pulse head + uint32_t phase = (((uint32_t)i << 8) + posFP) % spacingFP; // 0 = pulse head - // Flow layer brightness: bright head fading along the trail, dim background + // Flow layer: bright head with a quadratic (comet-like) falloff, dim background int flowLevel; if (phase < trailFP) { - flowLevel = 255 - (int)((phase * (255 - 40)) / trailFP); + float x = 1.0f - (float)phase / (float)trailFP; + flowLevel = 40 + (int)(215.0f * x * x); flowLevel = scale8((uint8_t)flowLevel, pulseBri); } else { flowLevel = 24; } - // Cross-fade breathing -> flow by emerge for a seamless neutral boundary - int level = idleLevel + (int)((flowLevel - idleLevel) * emerge); + // Cross-fade breathing -> flow by activity for a seamless neutral boundary + int level = idleLevel + (int)((flowLevel - idleLevel) * activity); level = constrain(level, 10, 255); SEGMENT.setPixelColor(i, color_fade(baseColor, (uint8_t)level)); } + + if (SEGMENT.custom2 > 0) SEGMENT.blur(SEGMENT.custom2 >> 1); } -static const char _data_FX_MODE_GRID_FLOW[] PROGMEM = "Grid Flow@Speed,Pulses,Trail;;;1;sx=128,ix=96,c1=128"; +static const char _data_FX_MODE_GRID_FLOW[] PROGMEM = "Grid Flow@Speed,Pulses,Trail,Blur;;;1;sx=128,ix=96,c1=128,c2=64"; // --------------------------------------------------------------------------- // Usermod @@ -135,246 +150,183 @@ class HomeWizardP1Usermod : public Usermod { private: static const char _name[]; + // Configuration (Usermod Settings, main thread) bool enabled = true; + String host = ""; // empty = discover via mDNS; may be "host:port" + volatile uint16_t updateIntervalMs = 2000; // poll interval; P1 v1 data updates about once per second + volatile bool autoDiscover = true; + volatile bool cfgEnabled = true; // task-visible copy of 'enabled' - // Configuration (Usermod Settings) - String host = ""; // empty = discover via mDNS - uint16_t updateIntervalMs = 3000; // poll interval; P1 v1 data updates about once per second - bool autoDiscover = true; - - // Discovery state - String discoveredHost = ""; - unsigned long lastDiscoveryAttempt = 0; - static const unsigned long DISCOVERY_RETRY_MS = 30000; - - // Fetch state - AsyncClient *client = nullptr; - unsigned long lastFetchStart = 0; - unsigned long lastGoodData = 0; - static const unsigned long FETCH_TIMEOUT_MS = 5000; // watchdog for a hanging client - static const unsigned long DATA_STALE_MS = 20000; // invalidate power data after this - - // Response buffer, filled by TCP callbacks, parsed in loop(). - // 6 KiB fits headers + the v1 JSON payload comfortably. - static const size_t RESP_BUF_SIZE = 6144; - char *respBuf = nullptr; - volatile size_t respLen = 0; - volatile bool respComplete = false; // set by onDisconnect: response fully received - volatile bool clientFailed = false; // set by onError/onTimeout - - // Status for the info page - bool lastFetchOk = false; - String lastError = ""; - - // Timestamp for the visual smoothing filter - unsigned long lastSmoothUpdate = 0; + // Task status codes for the info page + enum : int8_t { ST_IDLE = 0, ST_DISCOVERING, ST_OK, ST_HTTP_ERR, ST_CONN_ERR, ST_JSON_ERR, ST_NO_METER }; + volatile int8_t taskStatus = ST_IDLE; + volatile int16_t taskHttpCode = 0; - bool haveHost() const { return host.length() > 0 || discoveredHost.length() > 0; } - String activeHost() const { return host.length() > 0 ? host : discoveredHost; } + // Host strings exchanged between threads, guarded by p1Mutex + char manualHost[64] = ""; // copy of 'host' for the task + char discoveredHost[48] = ""; // written by the task after mDNS discovery - // Discover the P1 meter via mDNS (_hwenergy._tcp). Blocking for a few - // seconds, so this is only called while no host is known, with backoff. - void discoverMeter() { #ifdef ARDUINO_ARCH_ESP32 - lastDiscoveryAttempt = millis(); + SemaphoreHandle_t p1Mutex = nullptr; + TaskHandle_t fetchTaskHandle = nullptr; + + void ensureMutex() { if (p1Mutex == nullptr) p1Mutex = xSemaphoreCreateMutex(); } + + // Copy the effective host ("manual wins over discovered") into buf. + // Returns false when no host is known yet. + bool getEffectiveHost(char *buf, size_t len) { + bool have = false; + if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + if (manualHost[0] != '\0') { strlcpy(buf, manualHost, len); have = true; } + else if (discoveredHost[0] != '\0') { strlcpy(buf, discoveredHost, len); have = true; } + xSemaphoreGive(p1Mutex); + } + return have; + } + + // mDNS discovery of the P1 meter (_hwenergy._tcp). Blocking, but harmless + // here: this runs on the fetch task, not the LED loop. + void discoverMeter() { + taskStatus = ST_DISCOVERING; DEBUG_PRINTLN(F("P1: mDNS discovery (_hwenergy._tcp)...")); int n = MDNS.queryService("hwenergy", "tcp"); + int found = -1; for (int i = 0; i < n; i++) { String productType = MDNS.txt(i, "product_type"); DEBUG_PRINTF_P(PSTR("P1: mDNS result %d: %s (%s)\n"), i, MDNS.IP(i).toString().c_str(), productType.c_str()); // HWE-P1 is the P1 meter; other HomeWizard products (kWh meter, socket) also announce hwenergy - if (productType.startsWith(F("HWE-P1")) || n == 1) { - discoveredHost = MDNS.IP(i).toString(); - DEBUG_PRINTF_P(PSTR("P1: using meter at %s\n"), discoveredHost.c_str()); - return; - } + if (productType.startsWith(F("HWE-P1"))) { found = i; break; } } - if (n > 0 && discoveredHost.length() == 0) { - // No explicit P1 found, fall back to the first announced device - discoveredHost = MDNS.IP(0).toString(); - } - if (discoveredHost.length() == 0) DEBUG_PRINTLN(F("P1: no meter found via mDNS")); -#else - // ESP8266: no service discovery implemented, a manual host is required - lastDiscoveryAttempt = millis(); -#endif - } - - // Kick off an asynchronous GET /api/v1/data. Returns immediately; the - // response is collected by the TCP callbacks and parsed later in loop(). - void startFetch() { - if (client != nullptr) return; // previous request still in flight - - respLen = 0; - respComplete = false; - clientFailed = false; - - client = new AsyncClient(); - if (!client) { lastError = F("out of memory"); return; } - - client->onData([](void *arg, AsyncClient *c, void *data, size_t len) { - // Runs on the async_tcp task: only append to the buffer, no WLED calls here - HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; - size_t space = RESP_BUF_SIZE - 1 - um->respLen; - size_t toCopy = len < space ? len : space; - if (toCopy > 0) { - memcpy(um->respBuf + um->respLen, data, toCopy); - um->respLen = um->respLen + toCopy; - um->respBuf[um->respLen] = '\0'; - } - }, this); - - client->onDisconnect([](void *arg, AsyncClient *c) { - // Server closed the connection (Connection: close) -> response complete. - // Deleting the client here follows the AsyncTCP reference pattern. - HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; - if (um->client == c) { - um->client = nullptr; - um->respComplete = true; - } - delete c; - }, this); - - client->onTimeout([](void *arg, AsyncClient *c, uint32_t time) { - // Only flag the failure; do NOT delete here. Deleting runs ~AsyncClient, - // whose close fires the onDisconnect callback, which would delete again. - // The rx timeout closes the connection shortly after, and onDisconnect - // performs the (single) delete. - HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; - if (um->client == c) { - um->client = nullptr; - um->clientFailed = true; + if (found < 0 && n > 0) found = 0; // no explicit P1: fall back to the first device + if (found >= 0) { + String ip = MDNS.IP(found).toString(); + if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + strlcpy(discoveredHost, ip.c_str(), sizeof(discoveredHost)); + xSemaphoreGive(p1Mutex); } - }, this); - - client->onError([](void *arg, AsyncClient *c, int8_t error) { - HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; - if (um->client == c) { - um->client = nullptr; - um->clientFailed = true; - } - // AsyncTCP deletes a client that failed to connect on its own; do not delete here - }, this); - - client->onConnect([](void *arg, AsyncClient *c) { - HomeWizardP1Usermod *um = (HomeWizardP1Usermod*)arg; - String req = String(F("GET /api/v1/data HTTP/1.1\r\nHost: ")) + um->activeHost() + - F("\r\nConnection: close\r\nAccept: application/json\r\n\r\n"); - c->write(req.c_str()); - }, this); - - client->setRxTimeout(4); // seconds - client->setAckTimeout(4000); - - lastFetchStart = millis(); - if (!client->connect(activeHost().c_str(), 80)) { - delete client; - client = nullptr; - clientFailed = true; + DEBUG_PRINTF_P(PSTR("P1: using meter at %s\n"), ip.c_str()); + } else { + taskStatus = ST_NO_METER; + DEBUG_PRINTLN(F("P1: no meter found via mDNS")); } } - // Parse the buffered HTTP response; runs on the main task. - void parseResponse() { - lastFetchOk = false; - - char *body = strstr(respBuf, "\r\n\r\n"); - if (!body) { lastError = F("no HTTP body"); return; } - body += 4; - - // Basic status line check ("HTTP/1.1 200 OK") - if (strncmp(respBuf, "HTTP/1.1 200", 12) != 0 && strncmp(respBuf, "HTTP/1.0 200", 12) != 0) { - lastError = F("HTTP error"); - char *sp = strchr(respBuf, ' '); - if (sp) lastError += String(sp).substring(0, 5); - // A 404 here usually means the Local API is disabled in the HomeWizard app + // One blocking fetch of /api/v1/data; runs on the fetch task + void fetchOnce(const char *hostSpec) { + // Allow "host:port" (useful for testing against a simulator) + char hostName[64]; + strlcpy(hostName, hostSpec, sizeof(hostName)); + uint16_t port = 80; + char *colon = strchr(hostName, ':'); + if (colon != nullptr) { *colon = '\0'; port = atoi(colon + 1); } + + WiFiClient wifiClient; + HTTPClient http; + http.setConnectTimeout(1500); + http.setTimeout(1500); + if (!http.begin(wifiClient, hostName, port, F("/api/v1/data"))) { + taskStatus = ST_CONN_ERR; + return; + } + int code = http.GET(); + taskHttpCode = code; + if (code != HTTP_CODE_OK) { + http.end(); + // A 404 usually means the Local API is disabled in the HomeWizard app + taskStatus = (code > 0) ? ST_HTTP_ERR : ST_CONN_ERR; return; } + String payload = http.getString(); + http.end(); - // Only extract the fields we need; the filter keeps memory usage small + // Only extract the field we need; the filter keeps memory usage small StaticJsonDocument<64> filter; filter["active_power_w"] = true; StaticJsonDocument<256> doc; - DeserializationError err = deserializeJson(doc, body, DeserializationOption::Filter(filter)); - if (err) { lastError = String(F("JSON: ")) + err.c_str(); return; } - if (!doc.containsKey("active_power_w")) { lastError = F("no active_power_w"); return; } - + DeserializationError err = deserializeJson(doc, payload, DeserializationOption::Filter(filter)); + if (err || !doc.containsKey("active_power_w")) { + taskStatus = ST_JSON_ERR; + return; + } p1PowerW = doc["active_power_w"].as(); - p1DataValid = true; - lastGoodData = millis(); - lastFetchOk = true; - lastError = ""; - DEBUG_PRINTF_P(PSTR("P1: %.0f W\n"), p1PowerW); + p1LastGoodMs = millis(); + taskStatus = ST_OK; + DEBUG_PRINTF_P(PSTR("P1: %.0f W\n"), (double)p1PowerW); } + // Fetch task main: discovery + polling, forever. All blocking work lives here. + static void fetchTask(void *param) { + HomeWizardP1Usermod *um = static_cast(param); + uint32_t lastDiscoveryMs = 0; + for (;;) { + if (!um->cfgEnabled || !WLED_CONNECTED) { + um->taskStatus = ST_IDLE; + vTaskDelay(pdMS_TO_TICKS(500)); + continue; + } + char hostBuf[64]; + if (!um->getEffectiveHost(hostBuf, sizeof(hostBuf))) { + if (um->autoDiscover && (lastDiscoveryMs == 0 || millis() - lastDiscoveryMs > 30000)) { + lastDiscoveryMs = millis(); + um->discoverMeter(); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + um->fetchOnce(hostBuf); + // A discovered meter that stopped answering may have changed IP: forget + // it after 20 s of failures so discovery can find it again + if (um->taskStatus != ST_OK && millis() - p1LastGoodMs > 20000) { + if (xSemaphoreTake(um->p1Mutex, portMAX_DELAY) == pdTRUE) { + if (um->manualHost[0] == '\0') um->discoveredHost[0] = '\0'; + xSemaphoreGive(um->p1Mutex); + } + } + uint16_t interval = um->updateIntervalMs; + vTaskDelay(pdMS_TO_TICKS(interval)); + } + } +#endif // ARDUINO_ARCH_ESP32 + + // Timestamp for the visual smoothing filter (main loop) + unsigned long lastSmoothUpdate = 0; + public: void setup() override { - respBuf = (char*)d_malloc(RESP_BUF_SIZE); strip.addEffect(255, &mode_grid_flow, _data_FX_MODE_GRID_FLOW); +#ifdef ARDUINO_ARCH_ESP32 + ensureMutex(); + if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + strlcpy(manualHost, host.c_str(), sizeof(manualHost)); + xSemaphoreGive(p1Mutex); + } + cfgEnabled = enabled; + // Low priority, pinned to core 0 (WLED's LED loop runs on core 1) + xTaskCreatePinnedToCore(fetchTask, "P1Fetch", 10240, this, 1, &fetchTaskHandle, 0); +#endif } void loop() override { - if (!enabled || respBuf == nullptr) return; + if (!enabled) return; + + // Data is valid when the last successful fetch is recent + uint32_t lastGood = p1LastGoodMs; + p1DataValid = (lastGood != 0) && (millis() - lastGood < 20000); // Low-pass filter the power value (and a data-presence factor) toward // their targets with a ~1.2 s time constant. The effect renders from the - // filtered values, so 3 s poll steps become smooth ramps instead of jumps. + // filtered values, so poll steps become smooth ramps instead of jumps. unsigned long now = millis(); unsigned long dt = now - lastSmoothUpdate; if (dt > 0) { lastSmoothUpdate = now; if (dt > 1000) dt = 1000; // avoid a huge first step after boot/reconnect float a = (float)dt / (1200.0f + (float)dt); - float powerTarget = p1DataValid ? p1PowerW : 0.0f; // fade home to neutral when data is lost + float powerTarget = p1DataValid ? (float)p1PowerW : 0.0f; // fade home to neutral when data is lost p1PowerSmoothW += (powerTarget - p1PowerSmoothW) * a; float presenceTarget = p1DataValid ? 1.0f : 0.0f; p1Presence += (presenceTarget - p1Presence) * a; } - - // Handle a completed or failed request first (flags set by TCP callbacks) - if (respComplete) { - respComplete = false; - parseResponse(); - } - if (clientFailed) { - clientFailed = false; - lastFetchOk = false; - if (lastError.length() == 0) lastError = F("connect failed"); - // If a manually configured host keeps failing, do not clear it; - // a discovered host might be stale though, so forget it and rediscover - if (host.length() == 0 && millis() - lastGoodData > DATA_STALE_MS) discoveredHost = ""; - } - - // Watchdog for a client that neither completes nor errors out - if (client != nullptr && millis() - lastFetchStart > FETCH_TIMEOUT_MS) { - AsyncClient *c = client; - client = nullptr; // detach first so callbacks from close() are ignored - c->onDisconnect(nullptr); - c->onError(nullptr); - c->onTimeout(nullptr); - c->onData(nullptr); - delete c; - lastFetchOk = false; - lastError = F("timeout"); - } - - // Mark data stale when fetches keep failing, so the effect falls back to "waiting" - if (p1DataValid && millis() - lastGoodData > DATA_STALE_MS) p1DataValid = false; - - if (!WLED_CONNECTED) return; - - // No host known: try mDNS discovery with backoff - if (!haveHost()) { - if (autoDiscover && (lastDiscoveryAttempt == 0 || millis() - lastDiscoveryAttempt > DISCOVERY_RETRY_MS)) { - discoverMeter(); - } - return; - } - - // Time for the next poll? - if (client == nullptr && millis() - lastFetchStart >= updateIntervalMs) { - startFetch(); - } } void addToJsonInfo(JsonObject &root) override { @@ -382,31 +334,44 @@ class HomeWizardP1Usermod : public Usermod { if (user.isNull()) user = root.createNestedObject("u"); JsonArray info = user.createNestedArray(FPSTR(_name)); +#ifndef ARDUINO_ARCH_ESP32 + info.add(F("requires ESP32")); + return; +#else if (!enabled) { info.add(F("disabled")); return; } - if (!haveHost()) { - info.add(autoDiscover ? F("searching for P1 meter...") : F("no host configured")); - return; - } + + char hostBuf[64] = ""; + getEffectiveHost(hostBuf, sizeof(hostBuf)); + if (p1DataValid) { - String s = String(p1PowerW, 0) + F(" W "); - if (p1PowerW > p1DeadbandW) s += F("(offtake)"); - else if (p1PowerW < -p1DeadbandW) s += F("(injection)"); - else s += F("(neutral)"); + float p = p1PowerW; + String s = String(p, 0) + F(" W "); + if (p > p1DeadbandW) s += F("(offtake)"); + else if (p < -p1DeadbandW) s += F("(injection)"); + else s += F("(neutral)"); s += F(" @ "); - s += activeHost(); + s += hostBuf; info.add(s); } else { - String s = String(F("no data from ")) + activeHost(); - if (lastError.length() > 0) s += String(F(" - ")) + lastError; + String s; + switch (taskStatus) { + case ST_DISCOVERING: + case ST_NO_METER: s = F("searching for P1 meter..."); break; + case ST_HTTP_ERR: s = String(F("HTTP ")) + taskHttpCode; s += F(" from "); s += hostBuf; break; + case ST_CONN_ERR: s = String(F("no connection to ")) + hostBuf; break; + case ST_JSON_ERR: s = String(F("bad data from ")) + hostBuf; break; + default: s = (hostBuf[0] || autoDiscover) ? F("waiting for data...") : F("no host configured"); + } info.add(s); } +#endif } void addToConfig(JsonObject &root) override { JsonObject top = root.createNestedObject(FPSTR(_name)); top["enabled"] = enabled; top["host"] = host; - top["autoDiscover"] = autoDiscover; + top["autoDiscover"] = (bool)autoDiscover; top["updateIntervalMs"] = updateIntervalMs; top["deadbandW"] = p1DeadbandW; top["fullScaleW"] = p1FullScaleW; @@ -417,18 +382,30 @@ class HomeWizardP1Usermod : public Usermod { bool configComplete = !top.isNull(); String prevHost = host; + bool ad = autoDiscover; + uint16_t iv = updateIntervalMs; configComplete &= getJsonValue(top["enabled"], enabled, enabled); configComplete &= getJsonValue(top["host"], host, host); - configComplete &= getJsonValue(top["autoDiscover"], autoDiscover, autoDiscover); - configComplete &= getJsonValue(top["updateIntervalMs"], updateIntervalMs, updateIntervalMs); + configComplete &= getJsonValue(top["autoDiscover"], ad, ad); + configComplete &= getJsonValue(top["updateIntervalMs"], iv, iv); configComplete &= getJsonValue(top["deadbandW"], p1DeadbandW, p1DeadbandW); configComplete &= getJsonValue(top["fullScaleW"], p1FullScaleW, p1FullScaleW); - updateIntervalMs = max((uint16_t)1000, updateIntervalMs); + autoDiscover = ad; + updateIntervalMs = max((uint16_t)1000, iv); + cfgEnabled = enabled; p1DeadbandW = max(0, p1DeadbandW); p1FullScaleW = max(p1DeadbandW + 1, p1FullScaleW); host.trim(); - if (host != prevHost) discoveredHost = ""; // host changed: reset discovery state + +#ifdef ARDUINO_ARCH_ESP32 + ensureMutex(); + if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + strlcpy(manualHost, host.c_str(), sizeof(manualHost)); + if (host != prevHost) discoveredHost[0] = '\0'; // host changed: rediscover + xSemaphoreGive(p1Mutex); + } +#endif return configComplete; } diff --git a/usermods/homewizard_p1/p1_simulator.py b/usermods/homewizard_p1/p1_simulator.py new file mode 100644 index 0000000000..f3309919c4 --- /dev/null +++ b/usermods/homewizard_p1/p1_simulator.py @@ -0,0 +1,44 @@ +"""Fake HomeWizard P1 local API v1 for testing the WLED usermod. + +Serves /api/v1/data and cycles active_power_w every 10 seconds: + injection (-800 W) -> neutral (10 W) -> offtake (+1200 W) +""" +import json +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +CYCLE = [(-800.0, "injection"), (10.0, "neutral"), (1200.0, "offtake")] +PERIOD_S = 10 + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/api/v1/data": + self.send_response(404) + self.end_headers() + return + phase_idx = int(time.time() // PERIOD_S) % len(CYCLE) + power, label = CYCLE[phase_idx] + body = json.dumps({ + "wifi_ssid": "simulated", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "Simulator", + "active_power_w": power, + "active_power_l1_w": power, + "total_power_import_kwh": 1234.5, + "total_power_export_kwh": 678.9, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + print(f"{time.strftime('%H:%M:%S')} served {power:+.0f} W ({label}) to {self.client_address[0]}", flush=True) + + def log_message(self, *args): + pass # quiet default access log; we print our own line above + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", 8123), Handler).serve_forever() diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index c1fe84a635..5ba21a6939 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -44,11 +44,22 @@ color fades white → purple/green proportionally with power, and the pulses gradually emerge from the neutral breathing rather than snapping in at the deadband edge. +## Development: P1 simulator + +[`p1_simulator.py`](p1_simulator.py) serves a fake local API v1 on port 8123 +that cycles injection (-800 W) → neutral (10 W) → offtake (+1200 W) every 10 +seconds. Run it on a PC in the same network (`python p1_simulator.py`) and set +the usermod *host* to `:8123` to test all states without a real meter. +Building with `-D WLED_ENABLE_JSONLIVE` additionally lets you verify the +rendered colors over HTTP via `/json/live`. + ## Technical notes - Uses the HomeWizard [local API v1](https://api-documentation.homewizard.com/docs/v1/) endpoint `GET /api/v1/data`, field `active_power_w` (positive = import, negative = export). -- The HTTP request is fully asynchronous (AsyncClient). TCP callbacks only - buffer data; parsing and all WLED state changes happen on the main loop task. -- mDNS discovery blocks for a few seconds, so it only runs while no meter is - known, with a 30 s retry backoff. The LEDs may pause briefly during discovery. +- All networking (mDNS discovery and HTTP polling) runs on a dedicated + low-priority FreeRTOS task pinned to core 0, so blocking calls can never + stall the LED pipeline. The task publishes plain 32-bit values (atomic on + ESP32); host strings are exchanged under a mutex. ESP32 only. +- The pulse position is integrated over time (not derived from `time * speed`), + so speed changes shift the animation continuously without jumps. From b8700605b9799199cb2acf591ddd6705c9165ef9 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 23:47:31 +0200 Subject: [PATCH 05/12] Harden data-loss recovery; calmer flow speed; visible no-data state Investigation of a reported 'LEDs off until power cycle' incident ruled out brownout (full-white stress test), meter rate limiting (90 fresh connections in 3 min, zero failures) and crashes under state changes (12 min of simulated injection/neutral/offtake cycling, stable heap). The remaining explanation: fetch failures put the effect into its no-data state, which was so dim it read as 'off', and discovery could fail to recover because the last known meter address was discarded. - Never forget the discovered meter IP; background re-discovery only replaces it on success, so a broken mDNS state no longer strands the usermod and polling resumes as soon as the meter answers. - Raise the no-data breathing level so a data outage is visibly 'breathing dimly' instead of looking switched off. - Report seconds since the last successful fetch on the info page. - Guard the smoothing filters against NaN poisoning. - Halve the flow speed range (2-30 px/s): ambient, not alarming. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 38 +++++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index e9f0968280..118bde638d 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -92,8 +92,10 @@ static void mode_grid_flow(void) { // Breathing level: bright when data is valid, dim while waiting for data. // p1Presence fades between the two so data loss/recovery is not a hard cut. const uint8_t breath = sin8_t(strip.now >> 4); // ~4 s cycle + // Note: the no-data level must stay clearly visible - if it is too dim the + // strip looks simply "off" and a data outage is indistinguishable from a fault const int breatheValid = 140 + (breath >> 2); - const int breatheInvalid = 30 + (breath >> 3); + const int breatheInvalid = 60 + (breath >> 2); const int idleLevel = breatheInvalid + (int)((breatheValid - breatheInvalid) * p1Presence); // Pulse spacing from the density slider (more density = more pulses) @@ -101,9 +103,10 @@ static void mode_grid_flow(void) { unsigned spacing = max(6U, (unsigned)SEGLEN / numPulses); const uint32_t spacingFP = (uint32_t)spacing << 8; - // Integrate pulse position: flow speed 4..60 px/s scaled by power and the - // speed slider; import and export flow in opposite directions - float pxPerSec = (4.0f + 56.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); + // Integrate pulse position: flow speed 2..30 px/s scaled by power and the + // speed slider; import and export flow in opposite directions. Deliberately + // calm — this is ambient information, not an alarm. + float pxPerSec = (2.0f + 28.0f * t) * ((float)(1 + SEGMENT.speed) / 128.0f); uint32_t dtMs = (SEGENV.call == 0) ? 0 : (strip.now - SEGENV.step); SEGENV.step = strip.now; if (dtMs > 200) dtMs = 200; // clamp huge gaps (effect just (re)started) @@ -274,13 +277,22 @@ class HomeWizardP1Usermod : public Usermod { continue; } um->fetchOnce(hostBuf); - // A discovered meter that stopped answering may have changed IP: forget - // it after 20 s of failures so discovery can find it again - if (um->taskStatus != ST_OK && millis() - p1LastGoodMs > 20000) { + // A discovered meter that stopped answering may have changed IP: retry + // discovery in the background, but NEVER forget the last known address. + // discoverMeter() only overwrites it on success, so even when mDNS is + // broken (e.g. after a WiFi hiccup) the old IP keeps being polled and + // the usermod recovers as soon as the meter answers again. + if (um->taskStatus != ST_OK && um->autoDiscover && + millis() - p1LastGoodMs > 20000 && millis() - lastDiscoveryMs > 30000) { + bool manual = false; if (xSemaphoreTake(um->p1Mutex, portMAX_DELAY) == pdTRUE) { - if (um->manualHost[0] == '\0') um->discoveredHost[0] = '\0'; + manual = (um->manualHost[0] != '\0'); xSemaphoreGive(um->p1Mutex); } + if (!manual) { + lastDiscoveryMs = millis(); + um->discoverMeter(); + } } uint16_t interval = um->updateIntervalMs; vTaskDelay(pdMS_TO_TICKS(interval)); @@ -326,6 +338,10 @@ class HomeWizardP1Usermod : public Usermod { p1PowerSmoothW += (powerTarget - p1PowerSmoothW) * a; float presenceTarget = p1DataValid ? 1.0f : 0.0f; p1Presence += (presenceTarget - p1Presence) * a; + // A NaN would poison the filters permanently (NaN + x*a stays NaN); + // guard so a bad sample can never freeze the visuals until power cycle + if (!isfinite(p1PowerSmoothW)) p1PowerSmoothW = 0.0f; + if (!isfinite(p1Presence)) p1Presence = 0.0f; } } @@ -362,6 +378,12 @@ class HomeWizardP1Usermod : public Usermod { case ST_JSON_ERR: s = String(F("bad data from ")) + hostBuf; break; default: s = (hostBuf[0] || autoDiscover) ? F("waiting for data...") : F("no host configured"); } + uint32_t lastGood = p1LastGoodMs; + if (lastGood != 0) { + s += F(" (last data "); + s += (millis() - lastGood) / 1000; + s += F(" s ago)"); + } info.add(s); } #endif From 4cea20d419daec952e5ae1ce460f04eba3c4b2ed Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 23:56:27 +0200 Subject: [PATCH 06/12] Polish usermod documentation for upstream submission State the ESP32 requirement explicitly, document the Blur slider, and add a power-supply note (white is the maximum-current state; configure ABL with headroom for the ESP32). Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/readme.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index 5ba21a6939..ee0aa3b7b4 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -3,6 +3,9 @@ Visualizes your home's live grid power on a LED strip using a [HomeWizard P1 meter](https://www.homewizard.com/p1-meter/) in the same network. +Requires an ESP32 (any variant with WiFi); the networking runs on a dedicated +FreeRTOS task, which the ESP8266 does not support. + - **Offtake / import** (consuming from the grid): purple pulses flowing toward the segment start; more power = faster and brighter. - **Injection / export** (feeding into the grid, e.g. solar): green pulses flowing toward the segment end. - **Neutral** (within the deadband around 0 W): calm white breathing. @@ -16,7 +19,6 @@ Visualizes your home's live grid power on a LED strip using a 3. In WLED, go to Config → Usermods → HomeWizard P1. With *autoDiscover* enabled and *host* left empty, the meter is found automatically via mDNS (`_hwenergy._tcp`). Alternatively enter the meter's IP address as *host*. - (On ESP8266 discovery is not supported; set *host* manually.) 4. Select the **Grid Flow** effect on your segment. ## Settings @@ -35,6 +37,12 @@ Visualizes your home's live grid power on a LED strip using a - **Speed**: scales the overall flow speed (the actual speed also grows with power). - **Pulses**: how many pulses travel along the segment. - **Trail**: length of the fading tail behind each pulse. +- **Blur**: softens the pulses for a smoother look. + +Tip: white (the neutral state) lights all three LED channels and is the +maximum-current state. If your device browns out right when the strip turns +white, set a realistic PSU limit in Config → LED Preferences (ABL), leaving +~250 mA headroom for the ESP32 itself. Flow direction is tied to import/export; use the segment's *reverse* option if it should run the other way on your physical strip. From 2ac2c333617f918c8e4312c5558805329494cec9 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sat, 4 Jul 2026 23:57:45 +0200 Subject: [PATCH 07/12] Restrict usermod to espressif32 platform The networking runs on a FreeRTOS task, so the usermod is ESP32-only; declaring the platform lets ESP8266 CI builds skip it cleanly. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/library.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/usermods/homewizard_p1/library.json b/usermods/homewizard_p1/library.json index a141b43a02..583c1cc5b4 100644 --- a/usermods/homewizard_p1/library.json +++ b/usermods/homewizard_p1/library.json @@ -2,5 +2,6 @@ "name": "homewizard_p1", "build": { "libArchive": false - } + }, + "platforms": ["espressif32"] } From 0d6a2487e374c0199c71027664e0c70e44f1e650 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sun, 5 Jul 2026 15:19:41 +0200 Subject: [PATCH 08/12] Document phantom GPIO0 button presses in troubleshooting Root cause of the recurring switch-off-at-white incidents, captured live with the firmware healthy and rendering: master power was toggled off (and the segment color randomized to green) with no HTTP or UDP trace - phantom presses of the default GPIO0 button from electrical noise during the white-transition current step. Self-latching because an off strip draws no current, so no phantom press turns it back on. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/readme.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index ee0aa3b7b4..e967f66db5 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -39,10 +39,22 @@ FreeRTOS task, which the ESP8266 does not support. - **Trail**: length of the fading tail behind each pulse. - **Blur**: softens the pulses for a smoother look. -Tip: white (the neutral state) lights all three LED channels and is the -maximum-current state. If your device browns out right when the strip turns -white, set a realistic PSU limit in Config → LED Preferences (ABL), leaving -~250 mA headroom for the ESP32 itself. +## Troubleshooting + +White (the neutral state) lights all three LED channels and is the +maximum-current state, so power problems tend to show up exactly when the +strip turns white: + +- **Device browns out / reboots at the white transition**: set a realistic + PSU limit in Config → LED Preferences (ABL), leaving ~250 mA headroom for + the ESP32 itself. +- **Light switches itself off (and maybe shows a random color) and stays off + until a power cycle**: the current step can couple electrical noise into + GPIO0, where WLED's default button lives — phantom "presses" toggle the + power off (and a phantom long-press sets a random color). Once off, no + current flows, so no phantom press ever turns it back on. If you don't use + a physical button, remove the GPIO0 button in Config → LED Preferences → + buttons (set it to disabled), or move it to another pin. Flow direction is tied to import/export; use the segment's *reverse* option if it should run the other way on your physical strip. From 06291b90faf02ceea0881f5dba573df150d39a0c Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sun, 5 Jul 2026 15:42:42 +0200 Subject: [PATCH 09/12] Document EMI freeze mitigation in troubleshooting Second failure mode captured live: hard chip freeze (serial silent, no panic) exactly as a ~5 kW load switched off, i.e. a mains transient. Distinct from the phantom-button mode and inherent to the use case, since a grid-power light lives near big switching loads. Document the hardware mitigations and the WLED_WATCHDOG_TIMEOUT build flag. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index e967f66db5..7ef7b9431a 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -55,6 +55,14 @@ strip turns white: current flows, so no phantom press ever turns it back on. If you don't use a physical button, remove the GPIO0 button in Config → LED Preferences → buttons (set it to disabled), or move it to another pin. +- **Device freezes hard (unreachable, serial silent, no crash log) when large + appliances switch**: mains transients from multi-kW loads can latch up the + ESP32 — inherently more likely with this usermod, since your light is by + definition near big switching loads. Mitigate in hardware (330 Ω series + resistor in the data line, ≥1000 µF capacitor across the strip's 5 V input, + short data wires, a 74AHCT125 level shifter) and build with + `-D WLED_WATCHDOG_TIMEOUT=10` so any remaining freeze self-recovers by + reboot instead of waiting for a power cycle. Flow direction is tied to import/export; use the segment's *reverse* option if it should run the other way on your physical strip. From 6711a590817837d4e48e2a2174c01322c31c1960 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sun, 5 Jul 2026 15:48:11 +0200 Subject: [PATCH 10/12] Correct troubleshooting: external controllers, not phantom button The switch-off-at-transition incidents were caused by a leftover Home Assistant automation reacting to the same P1 meter, not by GPIO0 noise: the signature (on=false, brightness and color set) recurred with the button removed, and the HA WLED integration polling was visible in the debug serial all along as Not-Found /presets.json calls. Replace the phantom-button advice with guidance on identifying external controllers, which is the far more likely cause for this usermod audience. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/readme.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index 7ef7b9431a..2b99243caa 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -48,13 +48,17 @@ strip turns white: - **Device browns out / reboots at the white transition**: set a realistic PSU limit in Config → LED Preferences (ABL), leaving ~250 mA headroom for the ESP32 itself. -- **Light switches itself off (and maybe shows a random color) and stays off - until a power cycle**: the current step can couple electrical noise into - GPIO0, where WLED's default button lives — phantom "presses" toggle the - power off (and a phantom long-press sets a random color). Once off, no - current flows, so no phantom press ever turns it back on. If you don't use - a physical button, remove the GPIO0 button in Config → LED Preferences → - buttons (set it to disabled), or move it to another pin. +- **Light switches itself off or changes color, seemingly at power + transitions**: check for external controllers before suspecting hardware. + A Home Assistant automation (or another WLED syncing via UDP, Alexa, an IR + remote) that reacts to the same meter will fire at the exact moments this + usermod changes state, making it look like the usermod is at fault. This + is especially likely if you experimented with meter-triggered automations + before installing this usermod. Diagnosis tips: `state.on == false` in + `/json/state` means something commanded "off" (a crash cannot do that); + a `WLED_DEBUG` build logs incoming UDP sync senders, and repeated + `Not-Found HTTP call: /presets.json` lines in the debug serial reveal a + connected Home Assistant WLED integration. - **Device freezes hard (unreachable, serial silent, no crash log) when large appliances switch**: mains transients from multi-kW loads can latch up the ESP32 — inherently more likely with this usermod, since your light is by From 2c6efd05c94f8b93f10845076162f0d65e4355c0 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Sun, 5 Jul 2026 16:33:21 +0200 Subject: [PATCH 11/12] Address coderabbit review feedback - Use std::atomic for all scalars crossing the fetch-task boundary instead of volatile. - Handle mutex and task creation failures: ensureMutex() reports failure, all xSemaphoreTake() paths are guarded against a null mutex, and a failed xTaskCreatePinnedToCore() is surfaced on the info page instead of leaving the usermod silently idle. - Validate host:port with strtol and reject malformed or out-of-range ports instead of wrapping them. - Parse the HTTP response from the stream (no full-body buffering) and require active_power_w to be numeric, rejecting null. - Reset the smoothed visuals when the usermod is disabled so the still-registered effect does not show stale import/export state. - Clamp deadbandW before deriving the full-scale floor to avoid signed overflow at extreme config values. Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 90 ++++++++++++++++-------- 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index 118bde638d..4068d55041 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -1,5 +1,6 @@ #include "wled.h" +#include #ifdef ARDUINO_ARCH_ESP32 #include #endif @@ -36,10 +37,11 @@ // --------------------------------------------------------------------------- // State shared between the fetch task, the usermod instance and the effect. -// 32-bit aligned loads/stores are atomic on ESP32; strings use p1Mutex. +// Scalars crossing the task boundary are std::atomic (lock-free for 32-bit +// types on ESP32); strings use p1Mutex. // --------------------------------------------------------------------------- -static volatile float p1PowerW = 0.0f; // latest active power, >0 import, <0 export (fetch task) -static volatile uint32_t p1LastGoodMs = 0; // millis() of last successful fetch (fetch task) +static std::atomic p1PowerW{0.0f}; // latest active power, >0 import, <0 export (fetch task) +static std::atomic p1LastGoodMs{0}; // millis() of last successful fetch (fetch task) static bool p1DataValid = false; // derived in main loop from p1LastGoodMs static float p1PowerSmoothW = 0.0f; // low-pass filtered power driving the visuals (main loop) static float p1Presence = 0.0f; // 0..1, fades in/out as data becomes (in)valid (main loop) @@ -155,15 +157,15 @@ class HomeWizardP1Usermod : public Usermod { // Configuration (Usermod Settings, main thread) bool enabled = true; - String host = ""; // empty = discover via mDNS; may be "host:port" - volatile uint16_t updateIntervalMs = 2000; // poll interval; P1 v1 data updates about once per second - volatile bool autoDiscover = true; - volatile bool cfgEnabled = true; // task-visible copy of 'enabled' + String host = ""; // empty = discover via mDNS; may be "host:port" + std::atomic updateIntervalMs{2000}; // poll interval; P1 v1 data updates about once per second + std::atomic autoDiscover{true}; + std::atomic cfgEnabled{true}; // task-visible copy of 'enabled' // Task status codes for the info page - enum : int8_t { ST_IDLE = 0, ST_DISCOVERING, ST_OK, ST_HTTP_ERR, ST_CONN_ERR, ST_JSON_ERR, ST_NO_METER }; - volatile int8_t taskStatus = ST_IDLE; - volatile int16_t taskHttpCode = 0; + enum : int8_t { ST_IDLE = 0, ST_DISCOVERING, ST_OK, ST_HTTP_ERR, ST_CONN_ERR, ST_JSON_ERR, ST_NO_METER, ST_TASK_FAIL }; + std::atomic taskStatus{ST_IDLE}; + std::atomic taskHttpCode{0}; // Host strings exchanged between threads, guarded by p1Mutex char manualHost[64] = ""; // copy of 'host' for the task @@ -173,13 +175,17 @@ class HomeWizardP1Usermod : public Usermod { SemaphoreHandle_t p1Mutex = nullptr; TaskHandle_t fetchTaskHandle = nullptr; - void ensureMutex() { if (p1Mutex == nullptr) p1Mutex = xSemaphoreCreateMutex(); } + // Creation can fail under memory pressure; callers must handle false + bool ensureMutex() { + if (p1Mutex == nullptr) p1Mutex = xSemaphoreCreateMutex(); + return p1Mutex != nullptr; + } // Copy the effective host ("manual wins over discovered") into buf. // Returns false when no host is known yet. bool getEffectiveHost(char *buf, size_t len) { bool have = false; - if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + if (p1Mutex != nullptr && xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { if (manualHost[0] != '\0') { strlcpy(buf, manualHost, len); have = true; } else if (discoveredHost[0] != '\0') { strlcpy(buf, discoveredHost, len); have = true; } xSemaphoreGive(p1Mutex); @@ -203,7 +209,7 @@ class HomeWizardP1Usermod : public Usermod { if (found < 0 && n > 0) found = 0; // no explicit P1: fall back to the first device if (found >= 0) { String ip = MDNS.IP(found).toString(); - if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + if (p1Mutex != nullptr && xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { strlcpy(discoveredHost, ip.c_str(), sizeof(discoveredHost)); xSemaphoreGive(p1Mutex); } @@ -221,7 +227,17 @@ class HomeWizardP1Usermod : public Usermod { strlcpy(hostName, hostSpec, sizeof(hostName)); uint16_t port = 80; char *colon = strchr(hostName, ':'); - if (colon != nullptr) { *colon = '\0'; port = atoi(colon + 1); } + if (colon != nullptr) { + *colon = '\0'; + char *end = nullptr; + long parsedPort = strtol(colon + 1, &end, 10); + // Reject malformed or out-of-range ports instead of silently wrapping + if (hostName[0] == '\0' || end == colon + 1 || *end != '\0' || parsedPort <= 0 || parsedPort > 65535) { + taskStatus = ST_CONN_ERR; + return; + } + port = (uint16_t)parsedPort; + } WiFiClient wifiClient; HTTPClient http; @@ -239,15 +255,15 @@ class HomeWizardP1Usermod : public Usermod { taskStatus = (code > 0) ? ST_HTTP_ERR : ST_CONN_ERR; return; } - String payload = http.getString(); - http.end(); - - // Only extract the field we need; the filter keeps memory usage small + // Parse straight from the stream (no full-body buffering); the filter + // only extracts the field we need and keeps memory usage small StaticJsonDocument<64> filter; filter["active_power_w"] = true; StaticJsonDocument<256> doc; - DeserializationError err = deserializeJson(doc, payload, DeserializationOption::Filter(filter)); - if (err || !doc.containsKey("active_power_w")) { + DeserializationError err = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter)); + http.end(); + // is() also rejects "active_power_w": null (present but not numeric) + if (err || !doc["active_power_w"].is()) { taskStatus = ST_JSON_ERR; return; } @@ -285,7 +301,7 @@ class HomeWizardP1Usermod : public Usermod { if (um->taskStatus != ST_OK && um->autoDiscover && millis() - p1LastGoodMs > 20000 && millis() - lastDiscoveryMs > 30000) { bool manual = false; - if (xSemaphoreTake(um->p1Mutex, portMAX_DELAY) == pdTRUE) { + if (um->p1Mutex != nullptr && xSemaphoreTake(um->p1Mutex, portMAX_DELAY) == pdTRUE) { manual = (um->manualHost[0] != '\0'); xSemaphoreGive(um->p1Mutex); } @@ -307,19 +323,33 @@ class HomeWizardP1Usermod : public Usermod { void setup() override { strip.addEffect(255, &mode_grid_flow, _data_FX_MODE_GRID_FLOW); #ifdef ARDUINO_ARCH_ESP32 - ensureMutex(); + if (!ensureMutex()) { + taskStatus = ST_TASK_FAIL; // out of memory; surfaced on the info page + return; + } if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { strlcpy(manualHost, host.c_str(), sizeof(manualHost)); xSemaphoreGive(p1Mutex); } cfgEnabled = enabled; // Low priority, pinned to core 0 (WLED's LED loop runs on core 1) - xTaskCreatePinnedToCore(fetchTask, "P1Fetch", 10240, this, 1, &fetchTaskHandle, 0); + if (xTaskCreatePinnedToCore(fetchTask, "P1Fetch", 10240, this, 1, &fetchTaskHandle, 0) != pdPASS) { + fetchTaskHandle = nullptr; + taskStatus = ST_TASK_FAIL; + DEBUG_PRINTLN(F("P1: fetch task creation failed")); + } #endif } void loop() override { - if (!enabled) return; + if (!enabled) { + // Reset the visuals so a disabled usermod doesn't keep showing stale + // import/export state through the still-registered effect + p1DataValid = false; + p1PowerSmoothW = 0.0f; + p1Presence = 0.0f; + return; + } // Data is valid when the last successful fetch is recent uint32_t lastGood = p1LastGoodMs; @@ -373,7 +403,7 @@ class HomeWizardP1Usermod : public Usermod { switch (taskStatus) { case ST_DISCOVERING: case ST_NO_METER: s = F("searching for P1 meter..."); break; - case ST_HTTP_ERR: s = String(F("HTTP ")) + taskHttpCode; s += F(" from "); s += hostBuf; break; + case ST_HTTP_ERR: s = String(F("HTTP ")) + taskHttpCode.load(); s += F(" from "); s += hostBuf; break; case ST_CONN_ERR: s = String(F("no connection to ")) + hostBuf; break; case ST_JSON_ERR: s = String(F("bad data from ")) + hostBuf; break; default: s = (hostBuf[0] || autoDiscover) ? F("waiting for data...") : F("no host configured"); @@ -393,8 +423,8 @@ class HomeWizardP1Usermod : public Usermod { JsonObject top = root.createNestedObject(FPSTR(_name)); top["enabled"] = enabled; top["host"] = host; - top["autoDiscover"] = (bool)autoDiscover; - top["updateIntervalMs"] = updateIntervalMs; + top["autoDiscover"] = autoDiscover.load(); + top["updateIntervalMs"] = updateIntervalMs.load(); top["deadbandW"] = p1DeadbandW; top["fullScaleW"] = p1FullScaleW; } @@ -416,8 +446,10 @@ class HomeWizardP1Usermod : public Usermod { autoDiscover = ad; updateIntervalMs = max((uint16_t)1000, iv); cfgEnabled = enabled; - p1DeadbandW = max(0, p1DeadbandW); - p1FullScaleW = max(p1DeadbandW + 1, p1FullScaleW); + // Clamp deadband before deriving the full-scale floor so deadband+1 + // cannot overflow; 1 MW is a generous ceiling for a home connection + p1DeadbandW = constrain(p1DeadbandW, 0, 100000); + p1FullScaleW = constrain(p1FullScaleW, p1DeadbandW + 1, 1000000); host.trim(); #ifdef ARDUINO_ARCH_ESP32 From 0c4efab1623d93bdb50c1db38735a53421a6cd38 Mon Sep 17 00:00:00 2001 From: stephan-vlegel Date: Mon, 6 Jul 2026 09:02:18 +0200 Subject: [PATCH 12/12] Address maintainer review feedback - Handle init failures once in setup(): if mutex or task creation fails the usermod disables itself (reason shown on the info page) and the effect falls back to the segment color; this removes the scattered null-handle checks (softhack007's suggestion). - Replace all portMAX_DELAY mutex waits with a 50 ms bounded wait; on timeout the caller skips the cycle and retries on the next one. - Use xTaskCreate instead of xTaskCreatePinnedToCore (no need to pin). - Right-size the task stack: measured high-water mark on real hardware is ~2.3 KB used (mDNS + HTTP + JSON), so 10240 -> 4096, leaving ~1.8 KB margin (verified on-device: 1852 B headroom). Debug builds report the headroom on the info page. - Remove all ARDUINO_ARCH_ESP32 guards: library.json already restricts the usermod to espressif32 (willmmiles). - Trim the readme troubleshooting to the usermod-specific pitfall (external meter-triggered automations); drop the generic PSU and EMI hardware advice (softhack007's readme questions). - Guard the config-save host sync against the pre-setup case where the mutex does not exist yet (coderabbit). Co-Authored-By: Claude Fable 5 --- usermods/homewizard_p1/homewizard_p1.cpp | 86 +++++++++++++----------- usermods/homewizard_p1/readme.md | 40 ++++------- 2 files changed, 59 insertions(+), 67 deletions(-) diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp index 4068d55041..0f76e240dc 100644 --- a/usermods/homewizard_p1/homewizard_p1.cpp +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -1,9 +1,7 @@ #include "wled.h" #include -#ifdef ARDUINO_ARCH_ESP32 #include -#endif /* * HomeWizard P1 usermod (ESP32 only) @@ -23,10 +21,10 @@ * * Architecture: * - All networking (mDNS discovery + HTTP polling) runs on a dedicated - * low-priority FreeRTOS task pinned to core 0, the same pattern the - * audioreactive usermod uses for its processing task. Blocking calls there - * cannot stall the LED pipeline on the main loop task, and there is no - * async client lifecycle to race on. + * low-priority FreeRTOS task, the same pattern the audioreactive usermod + * uses for its processing task. Blocking calls there cannot stall the LED + * pipeline on the main loop task, and there is no async client lifecycle + * to race on. ESP32 only (platform restriction in library.json). * - The task publishes plain 32-bit values (atomic on ESP32); host strings * are exchanged under a FreeRTOS mutex, per docs/cpp.instructions.md. * - The main loop low-pass filters the power value; the effect renders only @@ -42,6 +40,7 @@ // --------------------------------------------------------------------------- static std::atomic p1PowerW{0.0f}; // latest active power, >0 import, <0 export (fetch task) static std::atomic p1LastGoodMs{0}; // millis() of last successful fetch (fetch task) +static bool p1Enabled = false; // usermod running; main thread only (loop + effect) static bool p1DataValid = false; // derived in main loop from p1LastGoodMs static float p1PowerSmoothW = 0.0f; // low-pass filtered power driving the visuals (main loop) static float p1Presence = 0.0f; // 0..1, fades in/out as data becomes (in)valid (main loop) @@ -65,7 +64,8 @@ static const uint32_t P1_COLOR_IDLE = RGBW32(255, 255, 255, 0); // white // option to flip it for your physical layout. // --------------------------------------------------------------------------- static void mode_grid_flow(void) { - if (SEGLEN < 1) { SEGMENT.fill(SEGCOLOR(0)); return; } + // Usermod disabled or failed to initialize: fall back to the segment color + if (!p1Enabled || SEGLEN < 1) { SEGMENT.fill(SEGCOLOR(0)); return; } // Per-segment fractional pulse position, so speed changes shift the pattern // continuously (position is integrated, never recomputed from time * speed) if (!SEGENV.allocateData(sizeof(float))) { SEGMENT.fill(SEGCOLOR(0)); return; } @@ -171,21 +171,19 @@ class HomeWizardP1Usermod : public Usermod { char manualHost[64] = ""; // copy of 'host' for the task char discoveredHost[48] = ""; // written by the task after mDNS discovery -#ifdef ARDUINO_ARCH_ESP32 SemaphoreHandle_t p1Mutex = nullptr; TaskHandle_t fetchTaskHandle = nullptr; + std::atomic taskStackFree{0}; // task stack headroom in bytes, for diagnostics - // Creation can fail under memory pressure; callers must handle false - bool ensureMutex() { - if (p1Mutex == nullptr) p1Mutex = xSemaphoreCreateMutex(); - return p1Mutex != nullptr; - } + // Mutex sections only copy small strings, so a short bounded wait suffices; + // on timeout the caller skips this cycle and retries on the next one + static constexpr TickType_t MUTEX_WAIT = pdMS_TO_TICKS(50); // Copy the effective host ("manual wins over discovered") into buf. - // Returns false when no host is known yet. + // Returns false when no host is known (or the mutex is briefly contended). bool getEffectiveHost(char *buf, size_t len) { bool have = false; - if (p1Mutex != nullptr && xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + if (xSemaphoreTake(p1Mutex, MUTEX_WAIT) == pdTRUE) { if (manualHost[0] != '\0') { strlcpy(buf, manualHost, len); have = true; } else if (discoveredHost[0] != '\0') { strlcpy(buf, discoveredHost, len); have = true; } xSemaphoreGive(p1Mutex); @@ -209,7 +207,7 @@ class HomeWizardP1Usermod : public Usermod { if (found < 0 && n > 0) found = 0; // no explicit P1: fall back to the first device if (found >= 0) { String ip = MDNS.IP(found).toString(); - if (p1Mutex != nullptr && xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + if (xSemaphoreTake(p1Mutex, MUTEX_WAIT) == pdTRUE) { strlcpy(discoveredHost, ip.c_str(), sizeof(discoveredHost)); xSemaphoreGive(p1Mutex); } @@ -301,7 +299,7 @@ class HomeWizardP1Usermod : public Usermod { if (um->taskStatus != ST_OK && um->autoDiscover && millis() - p1LastGoodMs > 20000 && millis() - lastDiscoveryMs > 30000) { bool manual = false; - if (um->p1Mutex != nullptr && xSemaphoreTake(um->p1Mutex, portMAX_DELAY) == pdTRUE) { + if (xSemaphoreTake(um->p1Mutex, MUTEX_WAIT) == pdTRUE) { manual = (um->manualHost[0] != '\0'); xSemaphoreGive(um->p1Mutex); } @@ -310,11 +308,11 @@ class HomeWizardP1Usermod : public Usermod { um->discoverMeter(); } } + um->taskStackFree = uxTaskGetStackHighWaterMark(nullptr); uint16_t interval = um->updateIntervalMs; vTaskDelay(pdMS_TO_TICKS(interval)); } } -#endif // ARDUINO_ARCH_ESP32 // Timestamp for the visual smoothing filter (main loop) unsigned long lastSmoothUpdate = 0; @@ -322,26 +320,31 @@ class HomeWizardP1Usermod : public Usermod { public: void setup() override { strip.addEffect(255, &mode_grid_flow, _data_FX_MODE_GRID_FLOW); -#ifdef ARDUINO_ARCH_ESP32 - if (!ensureMutex()) { - taskStatus = ST_TASK_FAIL; // out of memory; surfaced on the info page - return; - } - if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { - strlcpy(manualHost, host.c_str(), sizeof(manualHost)); - xSemaphoreGive(p1Mutex); + // Handle init failures once, here: on any failure the usermod disables + // itself (info page shows why, effect falls back to the segment color), + // so the runtime code needs no null-handle checks. + p1Mutex = xSemaphoreCreateMutex(); + if (p1Mutex != nullptr) { + if (xSemaphoreTake(p1Mutex, MUTEX_WAIT) == pdTRUE) { // uncontended: task not started yet + strlcpy(manualHost, host.c_str(), sizeof(manualHost)); + xSemaphoreGive(p1Mutex); + } + // Low priority; blocking calls in the task cannot stall the LED loop. + // Stack: measured high-water mark is ~2.3 KB used (mDNS + HTTP + JSON), + // 4096 leaves ~1.8 KB margin; debug builds report the headroom on the + // info page. + if (xTaskCreate(fetchTask, "P1Fetch", 4096, this, 1, &fetchTaskHandle) != pdPASS) fetchTaskHandle = nullptr; } - cfgEnabled = enabled; - // Low priority, pinned to core 0 (WLED's LED loop runs on core 1) - if (xTaskCreatePinnedToCore(fetchTask, "P1Fetch", 10240, this, 1, &fetchTaskHandle, 0) != pdPASS) { - fetchTaskHandle = nullptr; + if (p1Mutex == nullptr || fetchTaskHandle == nullptr) { + enabled = false; taskStatus = ST_TASK_FAIL; - DEBUG_PRINTLN(F("P1: fetch task creation failed")); + DEBUG_PRINTLN(F("P1: init failed (out of memory), usermod disabled")); } -#endif + cfgEnabled = enabled; } void loop() override { + p1Enabled = enabled; if (!enabled) { // Reset the visuals so a disabled usermod doesn't keep showing stale // import/export state through the still-registered effect @@ -380,11 +383,10 @@ class HomeWizardP1Usermod : public Usermod { if (user.isNull()) user = root.createNestedObject("u"); JsonArray info = user.createNestedArray(FPSTR(_name)); -#ifndef ARDUINO_ARCH_ESP32 - info.add(F("requires ESP32")); - return; -#else - if (!enabled) { info.add(F("disabled")); return; } + if (!enabled) { + info.add(taskStatus == ST_TASK_FAIL ? F("init failed (out of memory)") : F("disabled")); + return; + } char hostBuf[64] = ""; getEffectiveHost(hostBuf, sizeof(hostBuf)); @@ -416,6 +418,9 @@ class HomeWizardP1Usermod : public Usermod { } info.add(s); } +#ifdef WLED_DEBUG + uint32_t stackFree = taskStackFree; + if (stackFree > 0) info.add(String(F("task stack headroom: ")) + stackFree + F(" B")); #endif } @@ -452,14 +457,13 @@ class HomeWizardP1Usermod : public Usermod { p1FullScaleW = constrain(p1FullScaleW, p1DeadbandW + 1, 1000000); host.trim(); -#ifdef ARDUINO_ARCH_ESP32 - ensureMutex(); - if (xSemaphoreTake(p1Mutex, portMAX_DELAY) == pdTRUE) { + // The initial config load runs before setup() (no mutex yet); setup() + // makes the first copy of 'host', so only later saves need to sync here + if (p1Mutex != nullptr && xSemaphoreTake(p1Mutex, MUTEX_WAIT) == pdTRUE) { strlcpy(manualHost, host.c_str(), sizeof(manualHost)); if (host != prevHost) discoveredHost[0] = '\0'; // host changed: rediscover xSemaphoreGive(p1Mutex); } -#endif return configComplete; } diff --git a/usermods/homewizard_p1/readme.md b/usermods/homewizard_p1/readme.md index 2b99243caa..63ae3f8d56 100644 --- a/usermods/homewizard_p1/readme.md +++ b/usermods/homewizard_p1/readme.md @@ -41,32 +41,20 @@ FreeRTOS task, which the ESP8266 does not support. ## Troubleshooting -White (the neutral state) lights all three LED channels and is the -maximum-current state, so power problems tend to show up exactly when the -strip turns white: - -- **Device browns out / reboots at the white transition**: set a realistic - PSU limit in Config → LED Preferences (ABL), leaving ~250 mA headroom for - the ESP32 itself. -- **Light switches itself off or changes color, seemingly at power - transitions**: check for external controllers before suspecting hardware. - A Home Assistant automation (or another WLED syncing via UDP, Alexa, an IR - remote) that reacts to the same meter will fire at the exact moments this - usermod changes state, making it look like the usermod is at fault. This - is especially likely if you experimented with meter-triggered automations - before installing this usermod. Diagnosis tips: `state.on == false` in - `/json/state` means something commanded "off" (a crash cannot do that); - a `WLED_DEBUG` build logs incoming UDP sync senders, and repeated - `Not-Found HTTP call: /presets.json` lines in the debug serial reveal a - connected Home Assistant WLED integration. -- **Device freezes hard (unreachable, serial silent, no crash log) when large - appliances switch**: mains transients from multi-kW loads can latch up the - ESP32 — inherently more likely with this usermod, since your light is by - definition near big switching loads. Mitigate in hardware (330 Ω series - resistor in the data line, ≥1000 µF capacitor across the strip's 5 V input, - short data wires, a 74AHCT125 level shifter) and build with - `-D WLED_WATCHDOG_TIMEOUT=10` so any remaining freeze self-recovers by - reboot instead of waiting for a power cycle. +One pitfall specific to this usermod: it changes the light at the exact +moments your household power changes, so *unrelated* problems that are also +triggered by power changes look like usermod bugs. In particular, if you +experimented with meter-triggered automations (e.g. Home Assistant) before +installing this usermod, a leftover automation will fight it at every +import/export transition. If the light switches off or shows unexpected +colors, check `/json/state` first: `"on": false` means something *commanded* +the light off — the usermod never changes power, brightness or segment +colors, so look for an external controller. + +Also note that white (the neutral state) lights all three LED channels and is +therefore the highest-current state this usermod produces; an undersized +power supply shows up exactly at the neutral transition. See the general +WLED documentation on ABL/current limiting if that matches your symptoms. Flow direction is tied to import/export; use the segment's *reverse* option if it should run the other way on your physical strip.