diff --git a/usermods/homewizard_p1/homewizard_p1.cpp b/usermods/homewizard_p1/homewizard_p1.cpp new file mode 100644 index 0000000000..0f76e240dc --- /dev/null +++ b/usermods/homewizard_p1/homewizard_p1.cpp @@ -0,0 +1,477 @@ +#include "wled.h" + +#include +#include + +/* + * 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"): + * - 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 (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/ + * + * Architecture: + * - All networking (mDNS discovery + HTTP polling) runs on a dedicated + * 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 + * from filtered values so all transitions are smooth. + */ + +// AI: below section was generated by an AI + +// --------------------------------------------------------------------------- +// State shared between the fetch task, the usermod instance and the effect. +// Scalars crossing the task boundary are std::atomic (lock-free for 32-bit +// types on ESP32); strings use p1Mutex. +// --------------------------------------------------------------------------- +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) +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 +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 +// 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) { + // 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; } + 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. + 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); + + // 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); + + const uint32_t ident = importing ? P1_COLOR_IMPORT : P1_COLOR_EXPORT; + 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. + 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 = 60 + (breath >> 2); + const int idleLevel = breatheInvalid + (int)((breatheValid - breatheInvalid) * p1Presence); + + // 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 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) + *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; + + // Pulses get brighter with power + const uint8_t pulseBri = 170 + (uint8_t)(85.0f * t); + + for (unsigned i = 0; i < SEGLEN; i++) { + uint32_t phase = (((uint32_t)i << 8) + posFP) % spacingFP; // 0 = pulse head + + // Flow layer: bright head with a quadratic (comet-like) falloff, dim background + int flowLevel; + if (phase < 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 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,Blur;;;1;sx=128,ix=96,c1=128,c2=64"; + +// --------------------------------------------------------------------------- +// Usermod +// --------------------------------------------------------------------------- +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" + 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, 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 + char discoveredHost[48] = ""; // written by the task after mDNS discovery + + SemaphoreHandle_t p1Mutex = nullptr; + TaskHandle_t fetchTaskHandle = nullptr; + std::atomic taskStackFree{0}; // task stack headroom in bytes, for diagnostics + + // 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 (or the mutex is briefly contended). + bool getEffectiveHost(char *buf, size_t len) { + bool have = false; + 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); + } + 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"))) { found = i; break; } + } + 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, MUTEX_WAIT) == pdTRUE) { + strlcpy(discoveredHost, ip.c_str(), sizeof(discoveredHost)); + xSemaphoreGive(p1Mutex); + } + 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")); + } + } + + // 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'; + 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; + 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; + } + // 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, 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; + } + p1PowerW = doc["active_power_w"].as(); + 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: 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, MUTEX_WAIT) == pdTRUE) { + manual = (um->manualHost[0] != '\0'); + xSemaphoreGive(um->p1Mutex); + } + if (!manual) { + lastDiscoveryMs = millis(); + um->discoverMeter(); + } + } + um->taskStackFree = uxTaskGetStackHighWaterMark(nullptr); + uint16_t interval = um->updateIntervalMs; + vTaskDelay(pdMS_TO_TICKS(interval)); + } + } + + // Timestamp for the visual smoothing filter (main loop) + unsigned long lastSmoothUpdate = 0; + +public: + void setup() override { + strip.addEffect(255, &mode_grid_flow, _data_FX_MODE_GRID_FLOW); + // 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; + } + if (p1Mutex == nullptr || fetchTaskHandle == nullptr) { + enabled = false; + taskStatus = ST_TASK_FAIL; + DEBUG_PRINTLN(F("P1: init failed (out of memory), usermod disabled")); + } + 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 + p1DataValid = false; + p1PowerSmoothW = 0.0f; + p1Presence = 0.0f; + 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 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 ? (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; + // 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; + } + } + + 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(taskStatus == ST_TASK_FAIL ? F("init failed (out of memory)") : F("disabled")); + return; + } + + char hostBuf[64] = ""; + getEffectiveHost(hostBuf, sizeof(hostBuf)); + + if (p1DataValid) { + 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 += hostBuf; + info.add(s); + } else { + 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.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"); + } + uint32_t lastGood = p1LastGoodMs; + if (lastGood != 0) { + s += F(" (last data "); + s += (millis() - lastGood) / 1000; + s += F(" s ago)"); + } + info.add(s); + } +#ifdef WLED_DEBUG + uint32_t stackFree = taskStackFree; + if (stackFree > 0) info.add(String(F("task stack headroom: ")) + stackFree + F(" B")); +#endif + } + + void addToConfig(JsonObject &root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top["enabled"] = enabled; + top["host"] = host; + top["autoDiscover"] = autoDiscover.load(); + top["updateIntervalMs"] = updateIntervalMs.load(); + top["deadbandW"] = p1DeadbandW; + top["fullScaleW"] = p1FullScaleW; + } + + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + 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"], ad, ad); + configComplete &= getJsonValue(top["updateIntervalMs"], iv, iv); + configComplete &= getJsonValue(top["deadbandW"], p1DeadbandW, p1DeadbandW); + configComplete &= getJsonValue(top["fullScaleW"], p1FullScaleW, p1FullScaleW); + + autoDiscover = ad; + updateIntervalMs = max((uint16_t)1000, iv); + cfgEnabled = enabled; + // 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(); + + // 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); + } + + 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..583c1cc5b4 --- /dev/null +++ b/usermods/homewizard_p1/library.json @@ -0,0 +1,7 @@ +{ + "name": "homewizard_p1", + "build": { + "libArchive": false + }, + "platforms": ["espressif32"] +} 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 new file mode 100644 index 0000000000..63ae3f8d56 --- /dev/null +++ b/usermods/homewizard_p1/readme.md @@ -0,0 +1,85 @@ +# 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. + +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. +- **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*. +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. +- **Blur**: softens the pulses for a smoother look. + +## Troubleshooting + +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. + +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. + +## 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). +- 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.