diff --git a/drivers/easee_cloud.lua b/drivers/easee_cloud.lua index c9e44a5..317397e 100644 --- a/drivers/easee_cloud.lua +++ b/drivers/easee_cloud.lua @@ -2,14 +2,14 @@ -- Emits: EV -- Protocol: HTTPS (Easee Cloud REST API) -- +-- Sign convention (SITE = positive W flows INTO the site): +-- ev.w: always positive when charging — an EVSE is a one-way load. +-- -- Authenticates with the user's Easee account (email + password from -- config), polls charger state every 5 seconds, and emits DerEV -- readings so the dispatch clamp keeps home batteries from feeding -- the car. -- --- Site sign convention: EV charging power is positive W because it is --- load flowing into the site; this driver emits no generation/export. --- -- Config example: -- drivers: -- - name: easee @@ -62,6 +62,22 @@ local pending_amp_resend = false local pending_amp_resend_at_ms = 0 local last_amps_set = nil +-- paused_state tracks whether the LAST command we successfully sent was +-- ev_pause. Easee's REST API has no way to query "are you currently +-- in user-pause" — op_mode 2 ("awaiting start") covers both "paused" +-- and "plugged in but car hasn't started a session yet". Without this +-- flag, after a controller pause + re-offer, we'd write +-- dynamicChargerCurrent=6 successfully but the contactor stays open +-- because we never sent resume_charging. We saw this in the field as +-- "easee_a=6, easee_chg=false, ev_w=0, reason=100/52" stuck states. +local paused_state = false + +-- command_stalled_since_ms tracks when we last wrote a non-zero amps +-- offer that did NOT translate into actual charging. Used to surface +-- a derived diagnostic for the controller / UI so a stuck Easee +-- (firmware error, EV-side reject) is observable rather than silent. +local command_stalled_since_ms = 0 + -- Easee charger physical limits. These are the manufacturer's hard -- bounds, not operator preferences — `dynamicChargerCurrent` written -- below the minimum is silently rejected by the unit (returns success @@ -306,7 +322,13 @@ local REASON_LABELS = { [79] = "car not drawing current", [80] = "current ramping", [81] = "limited by car", - [100] = "undefined error", + -- Easee's public enumeration doesn't define 100. Field observation: + -- we see it when the EV is the side rejecting the offer (Tesla + -- charge-on-solar window, charge limit reached, scheduled charging + -- still active, transient handshake hiccup). Labelling it + -- "undefined error" misleads operators into chasing a charger + -- problem when the wallbox is fine. + [100] = "EV not accepting current", } local email, password, configured_max_a @@ -476,6 +498,26 @@ function driver_poll() actual_amps_per_phase = power_w / vv / phases end + -- command_stalled: true when we've been offering >0 A for >30 s but + -- the charger isn't drawing AND Easee is reporting an EV-side or + -- "max dynamic too low" reason. Lets the controller / UI tell the + -- difference between "EV declined" (legitimate, e.g. Tesla SoC + -- limit reached) and "wallbox accepted but contactor stuck" + -- (needs operator attention). Note: connected==true is required so + -- a disconnected cable doesn't latch the flag. + local command_stalled = false + local now = host.millis() + local stalled_reason = (reason_code == 52) or (reason_code == 53) or (reason_code == 100) + if connected and (last_amps_set or 0) > 0 and not charging and stalled_reason then + if command_stalled_since_ms == 0 then + command_stalled_since_ms = now + elseif (now - command_stalled_since_ms) >= 30000 then + command_stalled = true + end + else + command_stalled_since_ms = 0 + end + host.emit("ev", { w = power_w, connected = connected, @@ -490,6 +532,7 @@ function driver_poll() max_a = dyn_current, -- last-set dynamic limit (echoes our write, may lag) actual_amps_per_phase = actual_amps_per_phase, -- live per-phase A derived from totalPower phases = phases, -- our committed phase count (1 or 3) + command_stalled = command_stalled, -- offer>0 but contactor open >30s on stall reason }) -- Defense against Easee's post-phaseMode reset of dynamicChargerCurrent @@ -543,11 +586,17 @@ function driver_command(action, power_w, cmd) if not charger_serial or not ensure_auth(email, password) then return false end if action == "ev_start" then - return post_command("/commands/start_charging") + local ok = post_command("/commands/start_charging") + if ok then paused_state = false end + return ok elseif action == "ev_pause" then - return post_command("/commands/pause_charging") + local ok = post_command("/commands/pause_charging") + if ok then paused_state = true end + return ok elseif action == "ev_resume" then - return post_command("/commands/resume_charging") + local ok = post_command("/commands/resume_charging") + if ok then paused_state = false end + return ok elseif action == "ev_set_current" then -- Driver-level phase decision: read the operator's preferences -- + site fuse from the cmd, decide 1Φ vs 3Φ here based on @@ -600,6 +649,25 @@ function driver_command(action, power_w, cmd) pending_amp_resend = true pending_amp_resend_at_ms = now_ms + 5000 end + -- Auto-resume after pause. The controller's contract used + -- to be: ev_pause → (later) ev_resume → ev_set_current. + -- In practice the controller drops ev_resume in some + -- transitions and just re-issues ev_set_current with a + -- non-zero offer — Easee accepts the new + -- dynamicChargerCurrent but the contactor stays open + -- because pause is still active (op_mode=2, + -- reason_no_current=52 or 100, ev_w=0). Defensively + -- send resume_charging when we know we're paused and + -- the new offer is > 0. Idempotent on the Easee side; + -- a failure here doesn't fail the command (the offer + -- itself succeeded, controller will retry next tick). + if amps > 0 and paused_state then + if post_command("/commands/resume_charging") then + paused_state = false + host.log("info", "Easee: auto-resumed after pause (offer=" .. + tostring(amps) .. " A)") + end + end end return err == nil end diff --git a/drivers/ferroamp.lua b/drivers/ferroamp.lua index 5900a64..31cfd09 100644 --- a/drivers/ferroamp.lua +++ b/drivers/ferroamp.lua @@ -124,6 +124,11 @@ local function mj_to_wh(mj_val) return mj / 3600000 end +local function publish_auto(trans_id) + return host.mqtt_publish("extapi/control/request", + string.format('{"transId":"%s","cmd":{"name":"auto"}}', trans_id)) +end + ---------------------------------------------------------------------------- -- Driver interface ---------------------------------------------------------------------------- @@ -154,7 +159,7 @@ function driver_init(config) host.log("info", "Ferroamp: sent extapiversion query") -- Ensure we start in auto mode (clean state) - host.mqtt_publish("extapi/control/request", '{"transId":"init","cmd":{"name":"auto"}}') + publish_auto("init") host.log("info", "Ferroamp: set auto mode on init") end @@ -350,8 +355,7 @@ function driver_command(action, power_w, cmd) return host.mqtt_publish("extapi/control/request", payload) else -- Zero: return to auto mode - return host.mqtt_publish("extapi/control/request", - string.format('{"transId":"%s","cmd":{"name":"auto"}}', tid)) + return publish_auto(tid) end elseif action == "curtail" then local payload = string.format( @@ -363,18 +367,20 @@ function driver_command(action, power_w, cmd) return host.mqtt_publish("extapi/control/request", '{"transId":"ems","cmd":{"name":"pplim","arg":0}}') elseif action == "deinit" then - return host.mqtt_publish("extapi/control/request", - '{"transId":"ems","cmd":{"name":"auto"}}') + return publish_auto("ems") end return false end function driver_default_mode() - host.mqtt_publish("extapi/control/request", - '{"transId":"watchdog","cmd":{"name":"auto"}}') + publish_auto("watchdog") end function driver_cleanup() + -- Leave the EnergyHub in autonomous self-consumption when the EMS + -- stops or the driver hot-reloads. Otherwise the last forced + -- charge/discharge reference can remain visible in the Ferroamp app. + pcall(publish_auto, "cleanup") ehub_data = nil eso_data = nil sso_data = nil diff --git a/drivers/pixii.lua b/drivers/pixii.lua index 37a7281..05b4e38 100644 --- a/drivers/pixii.lua +++ b/drivers/pixii.lua @@ -142,6 +142,8 @@ function driver_poll() local meter_v_sf = read_sf(40249) local meter_hz_sf = read_sf(40251) local meter_w_sf = read_sf(40256) + local meter_va_sf = read_sf(40261) -- SunSpec model 213 offset 27 + local meter_var_sf = read_sf(40266) -- SunSpec model 213 offset 32 local meter_energy_sf = read_sf(40288) -- ---- Battery Values ---- @@ -274,6 +276,32 @@ function driver_poll() l3_w = scale(host.decode_i16(lpw_regs[3]), meter_w_sf) end + -- Reactive-power diagnostics: total VA and total VAR (model 213 + -- offsets 23 + 28 → 40257 / 40262, both I16). Per-phase variants + -- (offsets 24-26 / 29-31) are the SunSpec "not implemented" sentinel + -- 0x8000 on Pixii — confirmed live 2026-05-06 — so we don't bother + -- reading them. Total registers usually ARE populated. + -- + -- Sentinel-aware: SunSpec uses 0x8000 (= -32768 i16) for "register + -- not implemented". Filter before emit so the TS DB doesn't get + -- polluted with constant `-32768 × 10^sf` rows that look like real + -- measurements. + local function i16_present(reg) + return reg ~= 0x8000 + end + local ok_va, va_regs = pcall(host.modbus_read, 40257, 1, "holding") + local meter_va, meter_va_ok = 0, false + if ok_va and va_regs and i16_present(va_regs[1]) then + meter_va = scale(host.decode_i16(va_regs[1]), meter_va_sf) + meter_va_ok = true + end + local ok_var, var_regs = pcall(host.modbus_read, 40262, 1, "holding") + local meter_var, meter_var_ok = 0, false + if ok_var and var_regs and i16_present(var_regs[1]) then + meter_var = scale(host.decode_i16(var_regs[1]), meter_var_sf) + meter_var_ok = true + end + -- Compose signed per-phase current = sign(power) × |amperage|. -- A small dead-band around 0 W avoids flipping the sign when a -- near-zero phase reads as +0.4 W vs -0.4 W between polls. With @@ -329,6 +357,8 @@ function driver_poll() host.emit_metric("meter_l1_a", l1_a) host.emit_metric("meter_l2_a", l2_a) host.emit_metric("meter_l3_a", l3_a) + if meter_va_ok then host.emit_metric("meter_va", meter_va) end + if meter_var_ok then host.emit_metric("meter_var", meter_var) end host.emit_metric("grid_hz", meter_hz) return 5000 @@ -357,6 +387,9 @@ end local function write_setpoint_w(pixii_w) local hi, lo = encode_i32_be(pixii_w) + host.log("info", "Pixii: modbus_write_multi addr=" .. REG_SETPOINT_HI + .. " hi=" .. tostring(hi) .. " lo=" .. tostring(lo) + .. " (pixii_w=" .. tostring(pixii_w) .. ")") local err = host.modbus_write_multi(REG_SETPOINT_HI, { hi, lo }) if err ~= nil and err ~= "" then host.log("warn", "Pixii: setpoint write failed: " .. tostring(err)) @@ -373,7 +406,7 @@ end local function set_battery_power(power_w) -- Flip EMS → generator frame. local pixii_w = -power_w - host.log("debug", "Pixii: setpoint ems_w=" .. tostring(power_w) + host.log("info", "Pixii: setpoint ems_w=" .. tostring(power_w) .. " pixii_w=" .. tostring(pixii_w)) return write_setpoint_w(pixii_w) end diff --git a/drivers/tesla_vehicle.lua b/drivers/tesla_vehicle.lua index ed0c896..60ee6c8 100644 --- a/drivers/tesla_vehicle.lua +++ b/drivers/tesla_vehicle.lua @@ -2,6 +2,10 @@ -- Emits: Vehicle (DerVehicle) -- Protocol: HTTP (Tesla Owner API shape — /api/1/vehicles/{VIN}/vehicle_data) -- +-- Sign convention: read-only — emits Vehicle metadata (SoC, charge +-- limit) only, never a power channel. Site sign convention applies at +-- the wallbox driver that owns the kWh meter. +-- -- Fetches the vehicle's own SoC and charge_limit so forty-two-watts can -- show the real "24 / 50 %" in the EV bubble and let the loadpoint -- manager prefer the truth over its delivered-Wh inference. Designed @@ -11,10 +15,6 @@ -- OAuth token, no internet round-trip — the proxy IS the key to the -- vehicle. -- --- Site sign convention: DerVehicle is read-only state of charge and --- charge-limit metadata, so it emits no power channel and performs no --- sign conversion. --- -- Config: two fields, that's it. -- -- drivers: @@ -53,13 +53,32 @@ local STALE_AFTER_MS = 900000 -- Every WAKEUP_INTERVAL_MS we attach `wakeup=true` to ONE poll so the -- proxy forces a BLE wake. Without this the proxy serves cached data -- indefinitely while the car sleeps and our SoC slowly drifts from --- reality. 30 min is a balance between freshness and not draining the --- 12 V battery from constant BLE wakes. -local WAKEUP_INTERVAL_MS = 1800000 +-- reality. +-- +-- Two cadences: the conservative IDLE one (30 min) protects the 12 V +-- battery from constant BLE wakes when the car is parked + asleep; +-- the tighter CHARGING one (15 min) keeps SoC and charge_limit_pct +-- fresh while the car is on the wall so the MPC's planning targets +-- track the operator's in-app settings without 30-minute lag. The +-- car is already drawing AC power during a session, so the BLE-wake +-- battery-drain concern doesn't apply. +local WAKEUP_INTERVAL_MS = 1800000 -- 30 min, idle +local WAKEUP_INTERVAL_CHARGING_MS = 900000 -- 15 min, while charging + +-- When a non-wake poll fails (timeout, connection refused, anything +-- that isn't the proxy explicitly saying "BLE busy"), the most likely +-- cause is "car asleep and proxy gave up". Don't wait for the 30-min +-- periodic wake — arm a wake-retry on the very next poll. The retry +-- only fires if we haven't already attempted a wake within the recent +-- gap, so a flapping proxy can't trigger a wake storm. +local FAILURE_RETRY_INTERVAL_MS = 5000 -- schedule the retry this soon +local WAKE_RETRY_MIN_GAP_MS = 10000 -- don't wake more than once per ~10 s local base_url = nil local vin = nil local last_wakeup_ms = 0 +local last_wake_attempt_ms = 0 -- includes both periodic + retry wakes +local pending_wake_retry = false -- set by failed poll, consumed by next poll -- Cached last-known reading so we can keep publishing a value while -- the vehicle is asleep. Tesla returns 408 "vehicle unavailable" when @@ -187,10 +206,25 @@ function driver_poll() -- operator can press "Verify connection" in settings to force a -- one-shot wake, or wait for the next scheduled 30-min wake. local now = host.millis() - local do_wakeup = (last_wakeup_ms > 0) and ((now - last_wakeup_ms) >= WAKEUP_INTERVAL_MS) + -- Cadence depends on whether the last-known state was Charging — see + -- WAKEUP_INTERVAL_CHARGING_MS comment above. "Charging" comes from + -- the Tesla payload's charge_state.charging_state field; anything + -- else (Stopped / Complete / Disconnected / unknown) uses the + -- conservative 30-min cadence. + local wake_cadence_ms = (last.charging_state == "Charging") + and WAKEUP_INTERVAL_CHARGING_MS or WAKEUP_INTERVAL_MS + local do_wakeup = false + if pending_wake_retry and ((now - last_wake_attempt_ms) >= WAKE_RETRY_MIN_GAP_MS) then + -- Previous poll failed; we armed a retry. Consume it. + do_wakeup = true + pending_wake_retry = false + host.log("info", "tesla: wake-retry firing after previous poll failure") + elseif (last_wakeup_ms > 0) and ((now - last_wakeup_ms) >= wake_cadence_ms) then + do_wakeup = true + end if last_wakeup_ms == 0 then - -- Anchor the cadence at "now" so the first FORCED wake fires - -- exactly WAKEUP_INTERVAL_MS after startup, not on first poll. + -- Anchor the periodic cadence at "now" so the first FORCED wake + -- fires exactly wake_cadence_ms after startup, not on first poll. last_wakeup_ms = now end local url = base_url .. "/api/1/vehicles/" .. vin .. @@ -198,7 +232,10 @@ function driver_poll() if do_wakeup then url = url .. "&wakeup=true" last_wakeup_ms = now - host.log("info", "tesla: forcing BLE wakeup on this poll (30-min cadence)") + last_wake_attempt_ms = now + host.log("info", "tesla: forcing BLE wakeup on this poll" .. + " (cadence=" .. tostring(wake_cadence_ms / 60000) .. "min" .. + " charging=" .. tostring(last.charging_state == "Charging") .. ")") end -- host.http_get returns (body_string, nil) or (nil, error_string) — -- first return is the body directly, NOT a table with .body. The @@ -214,15 +251,30 @@ function driver_poll() -- in this state piles onto the rate-limit and prolongs it. -- Recovery is automatic when the next emit succeeds: -- driver_poll resets to POLL_INTERVAL_MS at the top of the - -- next call. + -- next call. Don't arm a wake-retry for these — the radio is + -- already busy; adding another wake won't help. local es = tostring(err) if es:match("HTTP 503") or es:match("HTTP 408") or es:match("Command Disallowed") then host.log("debug", "tesla: proxy busy (BLE busy) — backing off 3 min") emit_last() return 180000 -- 3 min end + -- Any other error (most commonly the host's 15 s HTTP timeout + -- when the car is asleep and the proxy's read of charge_state + -- never returns) — the car is probably asleep and the next + -- ordinary poll would just time out again. Arm a wake-retry + -- so the next poll attaches `&wakeup=true` and forces the + -- proxy to BLE-wake before reading. We don't arm if THIS poll + -- already woke (no point retrying with another wake — that's + -- the case the proxy is genuinely failing) — fall back to the + -- normal interval and let the periodic cadence catch up. host.log("warn", "tesla: poll HTTP error: " .. es) emit_last() + if not do_wakeup then + pending_wake_retry = true + host.log("info", "tesla: wake-retry armed (next poll will force BLE wake)") + return FAILURE_RETRY_INTERVAL_MS + end return POLL_INTERVAL_MS end if not body or body == "" then @@ -318,6 +370,44 @@ function driver_poll() end function driver_command(action, _, _) + -- Generic vehicle wake. The Go side fires `wake_up` whenever it + -- wants fresh telemetry without side effects — schedule edits, + -- the rising edge of wallbox-delivering-power, operator clicks + -- "Refresh". Any vehicle driver can implement this against its + -- own back-end; nothing here is Tesla-specific at the protocol + -- level. We hit the proxy's dedicated wake endpoint (rather than + -- the GET-with-wake the poll uses) so the response confirms + -- "wake initiated" instead of returning vehicle data — cleaner + -- separation between "wake" and "read". + -- + -- Reset the periodic-wake anchor so we don't immediately re-fire + -- a second wake on the next 30/15-min cadence right after the + -- caller already woke the car. Also clear any pending failure + -- retry — if a previous poll failed and armed a retry, the + -- caller's wake just supersedes it. + if action == "wake_up" or action == "ev_wake" then + if not base_url or not vin then + host.log("warn", "tesla: wake_up before init") + return false + end + local url = base_url .. "/api/1/vehicles/" .. vin .. "/command/wake_up" + local body, err = host.http_post(url, "{}", auth_headers()) + if err then + local es = tostring(err) + if es:match("HTTP 503") or es:match("HTTP 408") then + host.log("debug", "tesla: wake_up busy, will retry on next caller: " .. es) + return false + end + host.log("warn", "tesla: wake_up failed: " .. es) + return false + end + last_wakeup_ms = host.millis() + last_wake_attempt_ms = last_wakeup_ms + pending_wake_retry = false + local snippet = (body and #body > 0) and body:sub(1, 200) or "(empty body)" + host.log("info", "tesla: wake_up sent: " .. snippet) + return true + end -- Wake-and-start support. The loadpoint controller fires the -- generic `charge_start` action (defined as a cross-driver -- protocol — any vehicle driver can implement it) when the @@ -368,8 +458,8 @@ end function driver_cleanup() -- Reset every persistent piece of state so a hot-reload looks -- identical to a fresh process start (the `last_wakeup_ms` reset - -- in particular re-anchors the 30-min cadence so we don't fire a - -- wakeup the moment the reloaded driver runs its first poll). + -- in particular re-anchors the periodic cadence so we don't fire + -- a wakeup the moment the reloaded driver runs its first poll). last.soc = nil last.charge_limit = nil last.charging_state = nil @@ -378,4 +468,6 @@ function driver_cleanup() last.charger_actual_current = nil last.ts_ms = 0 last_wakeup_ms = 0 + last_wake_attempt_ms = 0 + pending_wake_retry = false end diff --git a/manifest.json b/manifest.json index d32dbb2..07e2dbb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "schema_version": 1, "repository": "https://github.com/srcfl/hugin-drivers", - "commit": "f2daaea66bb0abf5ec16b0fdc630322a0e6fe228", - "generated_at": "2026-05-12T09:57:57Z", + "commit": "3125960a80b5237e3a5ac609963ddb1302367938", + "generated_at": "2026-05-19T13:29:19Z", "drivers": [ { "id": "ctek-chargestorm", @@ -101,7 +101,7 @@ "path": "drivers/easee_cloud.lua", "filename": "easee_cloud.lua", "version": "1.0.0", - "sha256": "e1d9c6feb7c431bf8bfdb338776f134481c8c8e51569456bca257880c99ac7f4", + "sha256": "3de72ec2ddb58d676f455c4525bd2fe16f205834f368d4af0f848ea1ba4af63c", "url": "https://raw.githubusercontent.com/srcfl/hugin-drivers/main/drivers/easee_cloud.lua", "metadata": { "name": "Easee Cloud", @@ -132,7 +132,7 @@ "path": "drivers/ferroamp.lua", "filename": "ferroamp.lua", "version": "1.0.0", - "sha256": "1901290745589f9c10d7a4eaa96bdaabe39f9640fe77e5fdbdb1a70fd5bd8ece", + "sha256": "2b2a764d198b054e2e8fa4009617797639b8011afedad7e981a426e7a465ecdd", "url": "https://raw.githubusercontent.com/srcfl/hugin-drivers/main/drivers/ferroamp.lua", "metadata": { "name": "Ferroamp EnergyHub", @@ -449,7 +449,7 @@ "path": "drivers/pixii.lua", "filename": "pixii.lua", "version": "1.0.0", - "sha256": "5ee87c4a49e6196bb929623ee524a311021b4ab047baef95916ae52b41baf70c", + "sha256": "f566a9443b8e2ee1152ed5bd5c9997ff29a923c702e7b849adeeb07bc145e2ee", "url": "https://raw.githubusercontent.com/srcfl/hugin-drivers/main/drivers/pixii.lua", "metadata": { "name": "Pixii PowerShaper", @@ -820,7 +820,7 @@ "path": "drivers/tesla_vehicle.lua", "filename": "tesla_vehicle.lua", "version": "0.1.0", - "sha256": "a450961b9407b63de034f752d77639208167f6cf428416143a61b3981da09627", + "sha256": "c71e6a5c1a1785136f5bf09b50ba358132b8fb0b1734ea763c3d2dc6fba044f7", "url": "https://raw.githubusercontent.com/srcfl/hugin-drivers/main/drivers/tesla_vehicle.lua", "metadata": { "name": "Tesla Vehicle (BLE Proxy)",