Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 75 additions & 7 deletions drivers/easee_cloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions drivers/ferroamp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
35 changes: 34 additions & 1 deletion drivers/pixii.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
Loading
Loading