diff --git a/.changeset/ftw-holds-no-driver-source.md b/.changeset/ftw-holds-no-driver-source.md new file mode 100644 index 00000000..db5feb5a --- /dev/null +++ b/.changeset/ftw-holds-no-driver-source.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Driver source is no longer committed here. `drivers/*.lua` is gitignored and fetched from the pinned device-drivers commit with `make drivers`. diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 51bcece0..3e9c6085 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -88,6 +88,12 @@ jobs: with: ref: ${{ needs.tag.outputs.version }} + # drivers/ is gitignored and fetched from the commit pinned in + # drivers/BUNDLED_SOURCE.json. The image copies it, so it has to exist + # in the build context before buildx runs. + - name: Fetch the bundled drivers + run: make drivers + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 1c4a0b7f..6f8e6da4 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -118,6 +118,12 @@ jobs: go-version: '1.26' cache-dependency-path: go/go.sum + # drivers/ is gitignored and fetched from the commit pinned in + # drivers/BUNDLED_SOURCE.json. Both the tarballs and the image carry it, + # so it has to exist before either is built. + - name: Fetch the bundled drivers + run: make drivers + - name: Build env: GOOS: ${{ matrix.goos }} @@ -236,6 +242,10 @@ jobs: with: ref: ${{ needs.meta.outputs.tag }} + # drivers/ is gitignored and fetched from the pin; the image copies it. + - name: Fetch the bundled drivers + run: make drivers + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52c6715b..ca0f54a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,6 +117,11 @@ jobs: - name: Vet working-directory: go run: go vet ./... + # drivers/ is gitignored and fetched from the pin, so a checkout alone + # leaves it empty. Anything that loads a driver needs this first. + - name: Fetch the bundled drivers + run: make drivers + - name: Test working-directory: go run: go test -count=1 ./... @@ -227,11 +232,16 @@ jobs: with: go-version: '1.26' cache-dependency-path: go/go.sum + # drivers/ is gitignored and fetched from the pin, so a checkout alone + # leaves it empty. Anything that loads a driver needs this first. + - name: Fetch the bundled drivers + run: make drivers + # drivers/ is a snapshot of srcfl/device-drivers, not a source. Hand- # editing a file here made this a second source of truth and the two # drifted: a Sungrow fix landed upstream while the bundled copy kept the # bug that took a customer's inverter offline. - - name: Bundled drivers match the pinned device-drivers commit + - name: No driver source is committed here run: bash scripts/sync-bundled-drivers.sh --check - name: Validate publication working-directory: go @@ -389,6 +399,11 @@ jobs: with: go-version: '1.26' cache-dependency-path: go/go.sum + # drivers/ is gitignored and fetched from the pin, so a checkout alone + # leaves it empty. Anything that loads a driver needs this first. + - name: Fetch the bundled drivers + run: make drivers + - run: make e2e test: diff --git a/.gitignore b/.gitignore index 6e9aeac2..127e881d 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ deploy/secrets/* # Goalbuddy renders local review boards; keep the markdown/state, not the # generated static board assets. docs/**/.goalbuddy-board/ + +# Bundled drivers are fetched from srcfl/device-drivers at the commit pinned +# in drivers/BUNDLED_SOURCE.json, never authored here. Run `make drivers`. +drivers/*.lua diff --git a/Makefile b/Makefile index 6407fbda..8a0e1b5f 100644 --- a/Makefile +++ b/Makefile @@ -96,10 +96,10 @@ container-boundary-test: optimizer/.venv/.installed: optimizer/pyproject.toml $(MAKE) optimizer-install -e2e: +e2e: drivers-present cd go && FTW_E2E=1 go test ./test/e2e -v -timeout 180s -driver-repository-validate: +driver-repository-validate: drivers-present cd go && go run ./cmd/ftw-driver-repository publish -unsigned -drivers ../drivers -output ../dist/driver-repository -base-url https://example.invalid/releases/download/drivers-local -repository https://github.com/srcfl/ftw DRIVER_BASE ?= origin/master @@ -187,7 +187,7 @@ build-windows-amd64: # ---- Release archives ---- -release: build-arm64 build-amd64 build-windows-amd64 +release: drivers-present build-arm64 build-amd64 build-windows-amd64 @mkdir -p release @# Per-arch staging dirs ship ftw and its compatibility alias. @for arch in arm64 amd64; do \ diff --git a/README.md b/README.md index c7fcddf8..54ae431d 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,21 @@ The local catalog is generated from `DRIVER` metadata. The public [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the editable source and FTW's default signed driver channel. -[`drivers/*.lua`](drivers/) is FTW's offline recovery set, and it is a -**generated snapshot** of that repository at the commit pinned in -[`drivers/BUNDLED_SOURCE.json`](drivers/BUNDLED_SOURCE.json). It exists because -startup is deliberately local: a gateway boots and runs without the network, so -a remote refresh must never block it. Editing a `.lua` file here is not a way to -change a driver — fix it upstream, move the pin, and run `make drivers`. -CI fails if the two drift. - -A fresh clone gets the files from git today. `make drivers` fetches the same -bytes from the pin, and CI proves the two agree on every pull request by -throwing the committed copies away and rebuilding from the pin alone. That is -the groundwork for dropping them from git entirely, so this repository holds no -driver source at all. +**This repository holds no driver source.** `drivers/*.lua` is gitignored and +fetched from that repository at the commit pinned in +[`drivers/BUNDLED_SOURCE.json`](drivers/BUNDLED_SOURCE.json): + +```bash +make drivers +``` + +The files still ship. FTW's offline recovery set exists because startup is +deliberately local — a gateway boots and runs without the network, so a remote +refresh must never block it — so the image, the release tarballs and the tests +all read `drivers/`. They are simply fetched rather than committed, which is +why a driver cannot be edited here at all. There is no file to open a pull +request against; fix it upstream and move the pin. CI fails if one is +committed. Moving the pin is a driver release. `make driver-versions-across-pin` compares the drivers at the old pin against the drivers at the new one and fails when a diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 7112ae58..828fd4a0 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "edb32ffed2f1225da160cc51c0cce8fe8e0adfd9", + "commit": "a4d0205d5f4439f0994b2b708a4d69a7a0c20023", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/drivers/ambibox_v2x.lua b/drivers/ambibox_v2x.lua deleted file mode 100644 index 02a12d86..00000000 --- a/drivers/ambibox_v2x.lua +++ /dev/null @@ -1,212 +0,0 @@ --- ambibox_v2x.lua --- Ambibox V2X charger over MQTT. --- Emits: v2x_charger --- --- Site convention: --- v2x_charger.w > 0 = vehicle charging --- v2x_charger.w < 0 = vehicle discharging into the site/grid - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "ambibox-v2x", - name = "Ambibox V2X", - manufacturer = "Ambibox", - version = "1.0.0", - protocols = { "mqtt" }, - capabilities = { "v2x_charger" }, - description = "Ambibox bidirectional V2X charger via MQTT.", - verification_status = "experimental", - connection_defaults = { - host = "sid-os.local", - port = 1883, - }, -} - -PROTOCOL = "mqtt" - -local state = {} -local seen_ms = {} -local dirty = {} -local has_data = false -local rated_power_w = 22000 -local telemetry_max_age_ms = 15000 - -local function field_from_topic(topic) - local field = nil - for segment in string.gmatch(topic, "[^/]+") do - field = segment - end - return field -end - -local function num(v) - return tonumber(v) or 0 -end - -local function snum(key) - return num(state[key]) -end - -local function has_any(t) - for _ in pairs(t) do return true end - return false -end - -local function present(key) - return state[key] ~= nil -end - -local function fresh(key, now) - return present(key) and seen_ms[key] ~= nil and (now - seen_ms[key]) <= telemetry_max_age_ms -end - -local function boolish(v) - return v == true or v == "true" or v == 1 or v == "1" -end - -local function clamp(v, lo, hi) - if v < lo then return lo end - if v > hi then return hi end - return v -end - -function driver_init(config) - host.set_make("Ambibox") - if config then - if config.serial then host.set_sn(tostring(config.serial)) end - if config.rated_power_w then rated_power_w = num(config.rated_power_w) end - if config.telemetry_max_age_ms then telemetry_max_age_ms = num(config.telemetry_max_age_ms) end - end - host.mqtt_subscribe("device/evCharger/#") -end - -function driver_poll() - local now = host.millis() - local messages = host.mqtt_messages() - if messages then - for _, msg in ipairs(messages) do - local field = field_from_topic(msg.topic) - if field then - if msg.payload == "" then - state[field] = nil - seen_ms[field] = nil - else - local nval = tonumber(msg.payload) - if nval then - state[field] = nval - else - state[field] = msg.payload - end - seen_ms[field] = now - has_data = true - end - dirty[field] = true - end - end - end - - if not has_data then return 1000 end - if next(dirty) == nil then return 1000 end - if not fresh("powerAc", now) then - for k in pairs(dirty) do dirty[k] = nil end - return 1000 - end - - local charger = {} - - -- host.emit requires w. MQTT fields arrive independently, so emit a - -- fresh cached snapshot on every fresh field update once powerAc exists. - if fresh("powerAc", now) then charger.w = snum("powerAc") end - if fresh("currentAc", now) then charger.a = snum("currentAc") end - if fresh("voltageAc", now) then charger.v = snum("voltageAc") end - if fresh("frequency", now) then charger.freq_hz = snum("frequency") end - - if fresh("currentAc1", now) then charger.l1_a = snum("currentAc1") end - if fresh("currentAc2", now) then charger.l2_a = snum("currentAc2") end - if fresh("currentAc3", now) then charger.l3_a = snum("currentAc3") end - if fresh("voltageAc1", now) then charger.l1_v = snum("voltageAc1") end - if fresh("voltageAc2", now) then charger.l2_v = snum("voltageAc2") end - if fresh("voltageAc3", now) then charger.l3_v = snum("voltageAc3") end - - if fresh("voltageAc1", now) and fresh("currentAc1", now) then - charger.l1_w = snum("voltageAc1") * snum("currentAc1") - end - if fresh("voltageAc2", now) and fresh("currentAc2", now) then - charger.l2_w = snum("voltageAc2") * snum("currentAc2") - end - if fresh("voltageAc3", now) and fresh("currentAc3", now) then - charger.l3_w = snum("voltageAc3") * snum("currentAc3") - end - - if fresh("powerDc", now) then charger.dc_w = snum("powerDc") end - if fresh("currentDc", now) then charger.dc_a = snum("currentDc") end - if fresh("voltageDc", now) then charger.dc_v = snum("voltageDc") end - - if fresh("soc", now) then - local soc = num(state.soc) - if soc > 1 then soc = soc / 100 end - charger.vehicle_soc = soc - end - - if fresh("maxEnergyRequest", now) then charger.ev_max_energy_req_wh = snum("maxEnergyRequest") end - if fresh("minEnergyRequest", now) then charger.ev_min_energy_req_wh = snum("minEnergyRequest") end - - if fresh("energyAcImportSession", now) then charger.session_charge_wh = snum("energyAcImportSession") end - if fresh("energyAcExportSession", now) then charger.session_discharge_wh = snum("energyAcExportSession") end - if fresh("energyAcImport", now) then charger.total_charge_wh = snum("energyAcImport") end - if fresh("energyAcExport", now) then charger.total_discharge_wh = snum("energyAcExport") end - - if fresh("chargePowerMin", now) then charger.charge_power_min_w = snum("chargePowerMin") end - if fresh("chargePowerMax", now) then charger.charge_power_max_w = snum("chargePowerMax") end - if fresh("dischargePowerMin", now) then charger.discharge_power_min_w = snum("dischargePowerMin") end - if fresh("dischargePowerMax", now) then charger.discharge_power_max_w = snum("dischargePowerMax") end - - if fresh("evConnected", now) then charger.connected = boolish(state.evConnected) end - - charger.protocol = "mqtt" - - if has_any(charger) then host.emit("v2x_charger", charger) end - - for k in pairs(dirty) do dirty[k] = nil end - return 1000 -end - -local function clamp_setpoint(power_w) - local limit = rated_power_w - if power_w > 0 and present("chargePowerMax") then - limit = math.min(limit, math.abs(snum("chargePowerMax"))) - elseif power_w < 0 and present("dischargePowerMax") then - limit = math.min(limit, math.abs(snum("dischargePowerMax"))) - end - return clamp(power_w, -limit, limit) -end - -function driver_command(action, power_w, cmd) - if action == "init" then - return host.mqtt_publish("device/evCharger/0/wakeUp", "true") - elseif action == "v2x_set_power" or action == "battery" then - return host.mqtt_publish("device/ess/0/targetPower", tostring(clamp_setpoint(power_w))) - elseif action == "v2x_stop" or action == "deinit" then - return host.mqtt_publish("device/ess/0/targetPower", "0") - elseif action == "curtail" then - local max = num(state.chargePowerMax) > 0 and snum("chargePowerMax") or rated_power_w - local limited = clamp(math.abs(power_w), 0, max) - return host.mqtt_publish("device/ess/0/limitChargePower", tostring(limited)) - elseif action == "curtail_disable" then - local max = num(state.chargePowerMax) > 0 and snum("chargePowerMax") or rated_power_w - return host.mqtt_publish("device/ess/0/limitChargePower", tostring(max)) - end - return false -end - -function driver_default_mode() - host.mqtt_publish("device/ess/0/targetPower", "0") -end - -function driver_cleanup() - state = {} - seen_ms = {} - dirty = {} - has_data = false -end diff --git a/drivers/ctek.lua b/drivers/ctek.lua deleted file mode 100644 index a0ef0b16..00000000 --- a/drivers/ctek.lua +++ /dev/null @@ -1,371 +0,0 @@ --- CTEK Chargestorm EV Charger Driver (Automation API v1) --- Emits: EV --- Protocol: Modbus/TCP --- --- Hardware + firmware: --- Requires a CTEK Chargestorm Connected 2 or Connected 3 running --- CSOS (Chargestorm OS) firmware 4.9.3 or later. Older firmware --- either lacks the Modbus/TCP automation server entirely (CSOS --- < 3.11.16) or lacks the charging-limit control register (CSOS --- < 4.9.0). Connect the CCU to LAN and: --- --- Automation → ModbusTCPEnable = true --- Automation → modbus_tcp_automation_api_version = 1 --- --- Use drivers/ctek_v2.lua if you've set the API version to 2 on the --- CCU instead. The only difference between the two variants is the --- base register address (0x1xxx for v1, 0x2xxx for v2); all register --- semantics, units, and function codes are identical. CTEK recommends --- running either the charging-control API or the NANOGRID™ limit --- control on API v2 if you want both enabled simultaneously. --- --- Unit identifier selects the outlet on dual-outlet stations: --- unit_id = 1 → EVSE1 (left outlet, or single-outlet station) --- unit_id = 2 → EVSE2 (right outlet) --- --- Register map (source: CTEK "Automation interface" v1.0, rev 6b4af7): --- --- Identity / meter type: --- 0x1000 API version (u16) --- 0x1001 API status, 0=OK (u16) --- 0x1002 EnergyMeterType (u16 enum) --- 0x1003..0x1008 Serial (12 ASCII chars, 2 per register, big-endian bytes) --- --- Telemetry (one contiguous read 0x1100..0x1108 = 9 regs): --- 0x1100..0x1101 Lifetime energy (Wh, u32 big-endian, high word first) --- 0x1102..0x1104 Per-phase current, L1/L2/L3 (u16 × 10⁻³ A, i.e. mA) --- 0x1105..0x1107 Per-phase voltage L1-N/L2-N/L3-N (u16 × 10⁻¹ V) --- 0x1108 Total active power (u16 W) --- --- Control: --- 0x1200 Charging limit (u16 A, read/write) — 0 disables --- charging; values 1..5 are treated as 0 by the --- charger (IEC 61851 minimum is 6 A). Setpoint is --- lost on charger restart. --- 0x1201 Maximum assignment (u16 A, read-only) — upper --- bound the charger will accept given current --- de-rating, schedules, NANOGRID™ curtailment, etc. --- --- Sign convention (SITE = positive W flows INTO the site): --- ev.w: always positive when charging — an EVSE is a one-way load; --- there's no vehicle-to-grid path on Chargestorm. --- --- Config example (config.yaml): --- drivers: --- - name: ctek --- lua: drivers/ctek.lua --- capabilities: --- modbus: --- host: 192.168.1.190 --- port: 502 --- unit_id: 1 # 1 = EVSE1, 2 = EVSE2 --- config: --- phases: 3 # 1 or 3; default 3 --- min_a: 6 # minimum charge current (A); default 6 --- max_a: 16 # fuse-limited max (A); default 16 --- voltage_v: 230 # nominal per-phase voltage; default 230 - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "ctek-chargestorm", - name = "CTEK Chargestorm (API v1)", - manufacturer = "CTEK", - version = "0.3.1", - protocols = { "modbus" }, - capabilities = { "ev" }, - description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v1 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.", - homepage = "https://www.ctek.com", - authors = { "FTW contributors" }, - tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, - verification_status = "beta", - verification_notes = "Register map per CTEK Automation interface v1.0; charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - ----------------------------------------------------------------------------- --- Register addresses — API v1. --- The v2 variant (drivers/ctek_v2.lua) differs ONLY in the base offsets --- (0x2xxx instead of 0x1xxx); keep the two files in sync when register --- semantics change. ----------------------------------------------------------------------------- -local REG_API_VERSION = 0x1000 -local REG_API_STATUS = 0x1001 -local REG_METER_TYPE = 0x1002 -local REG_SERIAL_BASE = 0x1003 -- 6 regs → 12 ASCII chars -local REG_TELEMETRY = 0x1100 -- 9 regs: energy(2) + I(3) + V(3) + W(1) -local REG_CHARGE_LIMIT = 0x1200 -- r/w -local REG_MAX_ASSIGN = 0x1201 -- r/o - ----------------------------------------------------------------------------- --- Runtime config (overridden from config.yaml in driver_init) ----------------------------------------------------------------------------- -local phases = 3 -local min_a = 6 -local max_a = 16 -local voltage_v = 230 - --- Last setpoint we successfully wrote. Used as the resume target when --- ev_start / ev_resume come in without a specific current. -local last_set_a = 0 - -local sn_read = false - ----------------------------------------------------------------------------- --- Helpers ----------------------------------------------------------------------------- - --- Reading a register the charger does not have costs a failed read on every --- poll forever, and the host counts those against the poll whether or not Lua --- caught the error. Enough of them and the site is marked offline and reports --- nothing at all, which is worse than reporting one field less. So stop asking --- once a register has proved it is not there. Three tries, because one failure --- proves nothing -- the link may just have been slow. --- --- Only driver_poll routes through this. The two reads in driver_init run once --- each and cost nothing per poll, and the control path (driver_command) only --- writes, so nothing here can veto a later command. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end - local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then - read_failures[addr] = nil - return regs - end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "CTEK: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) - end - return nil -end - -local function clamp_amps(a) - if a == nil then return 0 end - a = math.floor(a + 0.5) - if a <= 0 then return 0 end - if a < min_a then return 0 end -- below minimum → pause - if a > max_a then return max_a end - return a -end - -local function watts_to_amps(power_w) - if not power_w or power_w <= 0 then return 0 end - return math.floor((power_w / voltage_v / phases) + 0.5) -end - --- Decode a SunSpec/CTEK-style ASCII block where each u16 register packs --- two bytes of the string in big-endian order. The charger pads the --- tail with NUL bytes; stop at the first one to get a clean serial. -local function decode_ascii(regs, n) - local s = "" - for i = 1, n do - local r = regs[i] or 0 - local hi = math.floor(r / 256) - local lo = r % 256 - if hi == 0 then break end - if hi >= 32 and hi < 127 then s = s .. string.char(hi) end - if lo == 0 then break end - if lo >= 32 and lo < 127 then s = s .. string.char(lo) end - end - return s -end - -local function write_setpoint(amps) - local err = host.modbus_write(REG_CHARGE_LIMIT, amps) - if err ~= nil and err ~= "" then - host.log("warn", "CTEK: write charging limit failed: " .. tostring(err)) - return false - end - last_set_a = amps - return true -end - --- Deliberately not bounded: this runs once, from driver_init, so it can never --- cost more than a single failed read. Sharing a failure budget with the --- poll-time read of the same register would only spend that budget early. -local function read_setpoint() - local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding") - if not ok or not regs or not regs[1] then return nil end - return regs[1] -end - ----------------------------------------------------------------------------- --- Driver interface ----------------------------------------------------------------------------- - -function driver_init(config) - host.set_make("CTEK") - - if config then - if tonumber(config.phases) then - local p = math.floor(tonumber(config.phases)) - if p == 1 or p == 3 then phases = p end - end - if tonumber(config.min_a) then min_a = math.floor(tonumber(config.min_a)) end - if tonumber(config.max_a) then max_a = math.floor(tonumber(config.max_a)) end - if tonumber(config.voltage_v) then voltage_v = tonumber(config.voltage_v) end - end - - if min_a < 6 then min_a = 6 end -- IEC 61851 floor - if max_a < min_a then max_a = min_a end - - -- Sanity-check the CCU is serving API v1 on this unit. A mismatch - -- here means the operator enabled v2 instead — suggest the v2 - -- driver rather than silently reading back garbage. - -- Deliberately not bounded: one read at startup, never repeated. - local ok, api_regs = pcall(host.modbus_read, REG_API_VERSION, 2, "holding") - if ok and api_regs and api_regs[1] then - local api_ver = api_regs[1] - local api_status = api_regs[2] or 0 - host.log("info", string.format( - "CTEK: API v%d, status %d (expected v1 from this driver; use drivers/ctek_v2.lua for v2)", - api_ver, api_status)) - end - - host.log("info", string.format( - "CTEK: driver initialized (phases=%d, min=%dA, max=%dA, V=%.0f)", - phases, min_a, max_a, voltage_v)) - - local cur = read_setpoint() - if cur then - last_set_a = cur - host.log("info", "CTEK: charge limit readback = " .. tostring(cur) .. "A") - end -end - -function driver_poll() - -- One-shot serial read. Keys device identity to the EVSE serial so - -- battery/other models keyed on device_id stay stable across driver - -- renames. - if not sn_read then - local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding") - if sn_regs then - local sn = decode_ascii(sn_regs, 6) - if #sn > 0 then - host.set_sn(sn) - sn_read = true - end - end - end - - -- Telemetry block: 9 registers in one transaction so the energy - -- counter, per-phase current + voltage, and total power all come - -- from a consistent snapshot. - local tel = probe_read(REG_TELEMETRY, 9, "holding") - local ev_w = 0 - local i_l1, i_l2, i_l3 = 0, 0, 0 - local v_l1, v_l2, v_l3 = 0, 0, 0 - local lifetime_wh = 0 - if tel then - lifetime_wh = host.decode_u32_be(tel[1], tel[2]) - i_l1 = (tel[3] or 0) / 1000 - i_l2 = (tel[4] or 0) / 1000 - i_l3 = (tel[5] or 0) / 1000 - v_l1 = (tel[6] or 0) / 10 - v_l2 = (tel[7] or 0) / 10 - v_l3 = (tel[8] or 0) / 10 - ev_w = tel[9] or 0 - else - host.log("warn", "CTEK: telemetry block read failed") - end - - -- Control setpoints: charging limit (current target) + max - -- assignment (upper bound the charger will honour). The Chargestorm - -- itself exposes no "connector state" flag on Modbus — the clean - -- state code ("CHRG", "PAUS", "EVRD", …) is MQTT-only. Derive - -- charging / connected conservatively from the current + power - -- readings so the dispatch clamp has something to key off. - local limit, max_assign = last_set_a, max_a - local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding") - if ctl then - limit = ctl[1] or last_set_a - max_assign = ctl[2] or max_a - last_set_a = limit - end - - local max_phase_a = math.max(i_l1, i_l2, i_l3) - local charging = (ev_w > 100) or (max_phase_a > 1.0) - -- "Connected" is noisy over Modbus alone: when the car is plugged - -- but not yet authenticated or ramping, current + power both read - -- zero. Report connected whenever the charger is actively delivering - -- OR whenever a non-zero limit is in effect — the latter covers - -- "plan has scheduled a charging window; cable plugged". - local connected = charging or (limit >= min_a and max_assign > 0) - - host.emit("ev", { - w = ev_w, - connected = connected, - charging = charging, - max_a = limit, - phases = phases, - l1_v = v_l1, l2_v = v_l2, l3_v = v_l3, - l1_a = i_l1, l2_a = i_l2, l3_a = i_l3, - lifetime_wh = lifetime_wh, - }) - - host.emit_metric("ev_set_current_a", limit) - host.emit_metric("ev_max_assign_a", max_assign) - host.emit_metric("ev_l1_a", i_l1) - host.emit_metric("ev_l2_a", i_l2) - host.emit_metric("ev_l3_a", i_l3) - host.emit_metric("ev_l1_v", v_l1) - host.emit_metric("ev_l2_v", v_l2) - host.emit_metric("ev_l3_v", v_l3) - host.emit_metric("ev_power_w", ev_w) - host.emit_metric("ev_lifetime_wh", lifetime_wh) - - return 5000 -end - -function driver_command(action, power_w, cmd) - if action == "init" or action == "deinit" then - return true - end - - if action == "ev_set_current" then - local amps = clamp_amps(watts_to_amps(power_w)) - host.log("debug", "CTEK: ev_set_current " - .. tostring(power_w) .. "W → " .. tostring(amps) .. "A") - return write_setpoint(amps) - end - - if action == "ev_pause" then - return write_setpoint(0) - end - - if action == "ev_start" or action == "ev_resume" then - -- Without a target current in the command, resume at whatever - -- setpoint was in effect before the pause. If we've never - -- written one (first start after boot), fall back to max_a so - -- the car actually draws current instead of sitting idle. - local amps = (last_set_a and last_set_a >= min_a) and last_set_a or max_a - return write_setpoint(amps) - end - - host.log("warn", "CTEK: unknown action " .. tostring(action)) - return false -end - -function driver_default_mode() - -- EV chargers have no "autonomous self-consumption" equivalent. - -- The Chargestorm will continue at the last-written current limit - -- until the operator changes it or the car unplugs, which is the - -- right behaviour when the EMS loses contact — the user still - -- gets their car charged. Matches the Easee driver's stance. -end - -function driver_cleanup() - -- No-op: leave the setpoint where it is so a service restart - -- doesn't interrupt an active charging session. -end diff --git a/drivers/ctek_hybrid.lua b/drivers/ctek_hybrid.lua deleted file mode 100644 index f4a6d1ed..00000000 --- a/drivers/ctek_hybrid.lua +++ /dev/null @@ -1,657 +0,0 @@ --- CTEK Chargestorm EV Charger Driver — Hybrid (Modbus control + MQTT telemetry/state) --- Emits: EV --- Protocols: Modbus/TCP (control + fallback telemetry), MQTT (state + live telemetry) --- --- Why hybrid: --- The CSOS Modbus Automation API (v1, base 0x1xxx) gives us full telemetry --- (per-phase V/I, total W, lifetime Wh) and current-limit control, but the --- clean connector state code — "CHRG" (charging), "PAUS" (paused), "EVRD" --- (EV ready / connected, awaiting authorisation or schedule), "IDLE", etc. --- — is published ONLY over the CCU's Local MQTT publisher. With Modbus --- alone we have to *guess* "connected" from "limit > 0 and max_assign > 0", --- which mis-reports connected when the cable is unplugged but a charging --- window is queued. --- --- The CCU's Local MQTT publisher also produces a higher-quality telemetry --- stream (decimal voltages, grid frequency, real timestamps) on the `em` --- topic. This driver prefers MQTT-sourced telemetry when fresh and falls --- back to Modbus heuristics on every cycle — so a misconfigured or --- unreachable broker never takes the loadpoint offline. --- --- CSOS setup (web UI, https:///): --- 1) Configuration → MQTT → enable (the CCU itself runs the broker on --- port 1883 by default — no auth on stock CSOS 4.9.x). --- 2) Automation → ModbusTCPEnable = true, --- modbus_tcp_automation_api_version = 1. --- --- Observed CTEK CSOS 4.9.x topic shape: --- CTEK//evse/status {"assigned":0,"state":"PAUS","timestamp":...} --- CTEK//evse/em {"current":[a,a,a],"energy":Wh,"frequency":Hz, --- "power":W,"voltage":[v,v,v],"timestamp":...} --- CTEK//evse/meterinfo {"serialno":"272274U"} --- --- is a CSOS-internal identifier (e.g. "91728M03W4010406"), --- *not* the EVSE serial — so we use a `+` wildcard for it by default. --- Modbus is point-to-point per driver instance, so the wildcard only ever --- matches our own CCU's traffic. --- --- Config example (config.yaml): --- drivers: --- - name: ctek-chargestorm --- lua: drivers/ctek_hybrid.lua --- capabilities: --- modbus: --- host: 192.168.1.190 --- port: 502 --- unit_id: 1 # 1 = EVSE1, 2 = EVSE2 --- mqtt: --- host: 192.168.1.190 # CCU's own broker, OR a relay broker --- port: 1883 --- username: "" # optional --- password: "" # optional --- config: --- phases: 3 --- min_a: 6 --- max_a: 16 --- voltage_v: 230 --- # MQTT topic config — defaults match stock CSOS 4.9.x. Substitute --- # for the connector id; for an explicit station ID --- # if you want to pin it instead of the default `+` wildcard. --- mqtt_connector: 1 --- mqtt_discover_topic: "CTEK/#" # one-shot per-topic logging --- mqtt_state_topic: "CTEK/+/evse/status" # JSON; state field --- mqtt_state_json_field: "state" # JSON key for the code --- mqtt_em_topic: "CTEK/+/evse/em" # JSON; live telemetry --- mqtt_max_stale_ms: 30000 # after this, fall back to Modbus - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "ctek-chargestorm-hybrid", - name = "CTEK Chargestorm (Modbus + MQTT)", - manufacturer = "CTEK", - version = "0.3.1", - protocols = { "modbus", "mqtt" }, - capabilities = { "ev" }, - description = "CTEK Chargestorm Connected 2/3 — MQTT for state + live telemetry (preferred), Modbus/TCP for control + telemetry fallback.", - homepage = "https://www.ctek.com", - authors = { "FTW contributors" }, - tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, - verification_status = "alpha", - verification_notes = "Observed against CSOS 4.9.x on station 91728M03W4010406 (EVSE serial 272274U). MQTT broker is the CCU itself; defaults assume the stock topic layout `CTEK//evse/...`. Falls back to Modbus telemetry whenever MQTT is stale (>mqtt_max_stale_ms).", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - ----------------------------------------------------------------------------- --- Modbus register map (CSOS Automation interface v1.0) ----------------------------------------------------------------------------- -local REG_API_VERSION = 0x1000 -local REG_API_STATUS = 0x1001 -local REG_METER_TYPE = 0x1002 -local REG_SERIAL_BASE = 0x1003 -- 6 regs → 12 ASCII chars -local REG_TELEMETRY = 0x1100 -- 9 regs: energy(2) + I(3) + V(3) + W(1) -local REG_CHARGE_LIMIT = 0x1200 -- r/w -local REG_MAX_ASSIGN = 0x1201 -- r/o - ----------------------------------------------------------------------------- --- Runtime config (overridden from config.yaml in driver_init) ----------------------------------------------------------------------------- -local phases = 3 -local min_a = 6 -local max_a = 16 -local voltage_v = 230 - -local mqtt_connector = 1 -local mqtt_discover_topic = "CTEK/#" -local mqtt_state_topic_cfg = "CTEK/+/evse/status" -local mqtt_state_field = "state" -local mqtt_em_topic_cfg = "CTEK/+/evse/em" -local mqtt_max_stale_ms = 30000 - --- Last setpoint we successfully wrote. -local last_set_a = 0 -local sn_read = false -local serial = "" - --- MQTT-derived state. `mqtt_state_str` is the raw text we received last --- (uppercased); `mqtt_state_ts` is host.millis() at receipt. We compute --- charging/connected from `mqtt_state_str` when fresh. -local mqtt_state_str = "" -local mqtt_state_ts = 0 -local mqtt_state_topic_resolved = "" -local mqtt_subscribed_state = false - --- MQTT-derived live telemetry from the `em` topic. Preferred over Modbus --- when fresh (lower CCU poll load, decimal V/I/Hz). `mqtt_em_ts` is the --- host-side receipt time stamp. -local mqtt_em_power_w = 0 -local mqtt_em_energy_wh = 0 -local mqtt_em_l1_a, mqtt_em_l2_a, mqtt_em_l3_a = 0, 0, 0 -local mqtt_em_l1_v, mqtt_em_l2_v, mqtt_em_l3_v = 0, 0, 0 -local mqtt_em_hz = 0 -local mqtt_em_ts = 0 -local mqtt_em_topic_resolved = "" -local mqtt_subscribed_em = false - -local seen_topics = {} -- for one-shot discovery logging - ----------------------------------------------------------------------------- --- Helpers ----------------------------------------------------------------------------- - -local function clamp_amps(a) - if a == nil then return 0 end - a = math.floor(a + 0.5) - if a <= 0 then return 0 end - if a < min_a then return 0 end - if a > max_a then return max_a end - return a -end - -local function watts_to_amps(power_w) - if not power_w or power_w <= 0 then return 0 end - return math.floor((power_w / voltage_v / phases) + 0.5) -end - -local function decode_ascii(regs, n) - local s = "" - for i = 1, n do - local r = regs[i] or 0 - local hi = math.floor(r / 256) - local lo = r % 256 - if hi == 0 then break end - if hi >= 32 and hi < 127 then s = s .. string.char(hi) end - if lo == 0 then break end - if lo >= 32 and lo < 127 then s = s .. string.char(lo) end - end - return s -end - --- A register the CCU never answers costs a failed read on every poll for as --- long as the driver keeps asking. The host counts those failures against the --- poll whether or not the pcall caught them, so a driver that keeps reporting --- telemetry while permanently failing one read gets the whole site marked --- offline. Give a register three chances, then leave it alone until restart. --- Three rather than one: a single failure is not proof, the link may just have --- been slow. --- --- Poll-path reads only. driver_init's API-version check and read_setpoint run --- once at startup and driver_command only writes, so nothing bounded here can --- veto a charging command. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end - local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then - read_failures[addr] = nil - return regs - end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "CTEK-hybrid: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) - end - return nil -end - -local function write_setpoint(amps) - local err = host.modbus_write(REG_CHARGE_LIMIT, amps) - if err ~= nil and err ~= "" then - host.log("warn", "CTEK-hybrid: write charging limit failed: " .. tostring(err)) - return false - end - last_set_a = amps - return true -end - --- Startup readback only, called once from driver_init. Left unbounded on --- purpose: it never runs from driver_poll, so it costs no failed poll. -local function read_setpoint() - local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding") - if not ok or not regs or not regs[1] then return nil end - return regs[1] -end - --- Substitute and placeholders in a topic template. -local function expand_topic(tpl) - if not tpl or tpl == "" then return "" end - local t = tpl - t = string.gsub(t, "", serial) - t = string.gsub(t, "", tostring(mqtt_connector)) - return t -end - --- Match an inbound topic against an MQTT subscription pattern. --- `+` matches exactly one level; `#` matches the remainder (must be the --- final segment). Needed because the broker delivers the wildcard-expanded --- topic (e.g. CTEK/91728M03W4010406/evse1/status) while we hold the --- pattern (CTEK/+/evse1/status). -local function topic_match(pattern, topic) - if pattern == "" or topic == "" then return false end - if pattern == topic then return true end - -- Split on "/" cheaply. - local function split(s) - local out = {} - for seg in string.gmatch(s, "([^/]+)") do out[#out+1] = seg end - return out - end - local p = split(pattern) - local t = split(topic) - local pi = 1 - while pi <= #p do - local seg = p[pi] - if seg == "#" then - return pi == #p -- # only legal as final segment - end - local ts = t[pi] - if ts == nil then return false end - if seg ~= "+" and seg ~= ts then return false end - pi = pi + 1 - end - return pi - 1 == #t -end - --- Returns true if the topic template can be expanded right now (no --- unresolved waiting for the Modbus serial read). is always --- known at init. -local function topic_ready(tpl) - if tpl == "" then return false end - if string.find(tpl, "", 1, true) and serial == "" then - return false - end - return true -end - --- Late-bind topic subscriptions. Called both from driver_init (so topics --- without subscribe immediately) and from driver_poll on the first --- successful serial read. -local function maybe_subscribe_state() - if mqtt_subscribed_state then return false end - if not topic_ready(mqtt_state_topic_cfg) then return false end - mqtt_state_topic_resolved = expand_topic(mqtt_state_topic_cfg) - host.mqtt_subscribe(mqtt_state_topic_resolved) - host.log("info", "CTEK-hybrid: subscribed to state topic " .. mqtt_state_topic_resolved) - mqtt_subscribed_state = true - return true -end - -local function maybe_subscribe_em() - if mqtt_subscribed_em then return false end - if not topic_ready(mqtt_em_topic_cfg) then return false end - mqtt_em_topic_resolved = expand_topic(mqtt_em_topic_cfg) - host.mqtt_subscribe(mqtt_em_topic_resolved) - host.log("info", "CTEK-hybrid: subscribed to em topic " .. mqtt_em_topic_resolved) - mqtt_subscribed_em = true - return true -end - --- Extract a state code from a payload that may be raw text or JSON. -local function parse_state_payload(payload) - if not payload or payload == "" then return "" end - -- Strip surrounding whitespace + quotes for the trivial-text case. - local trimmed = payload:gsub("^%s+", ""):gsub("%s+$", "") - if mqtt_state_field == "" then - -- Raw-text mode: take the whole payload as the state code, but - -- strip JSON quoting in case the broker wraps it. - trimmed = trimmed:gsub('^"', ""):gsub('"$', "") - return string.upper(trimmed) - end - local ok, data = pcall(host.json_decode, payload) - if not ok or type(data) ~= "table" then - -- JSON decode failed — fall back to raw text so we still get a - -- usable code if the broker switches modes mid-flight. - return string.upper(trimmed) - end - local v = data[mqtt_state_field] - if v == nil then return "" end - return string.upper(tostring(v)) -end - --- Parse an `em` topic payload. CTEK CSOS publishes JSON of the form --- {"current":[a,a,a], "energy":Wh, "frequency":Hz, "power":W, --- "voltage":[v,v,v], "timestamp":"..."} --- Returns true on a clean parse so the caller can timestamp it. -local function parse_em_payload(payload) - if not payload or payload == "" then return false end - local ok, data = pcall(host.json_decode, payload) - if not ok or type(data) ~= "table" then return false end - - if tonumber(data.power) then mqtt_em_power_w = tonumber(data.power) end - if tonumber(data.energy) then mqtt_em_energy_wh = tonumber(data.energy) end - if tonumber(data.frequency) then mqtt_em_hz = tonumber(data.frequency) end - - local cur = data.current - if type(cur) == "table" then - mqtt_em_l1_a = tonumber(cur[1]) or mqtt_em_l1_a - mqtt_em_l2_a = tonumber(cur[2]) or mqtt_em_l2_a - mqtt_em_l3_a = tonumber(cur[3]) or mqtt_em_l3_a - end - local volt = data.voltage - if type(volt) == "table" then - mqtt_em_l1_v = tonumber(volt[1]) or mqtt_em_l1_v - mqtt_em_l2_v = tonumber(volt[2]) or mqtt_em_l2_v - mqtt_em_l3_v = tonumber(volt[3]) or mqtt_em_l3_v - end - return true -end - --- Map a CTEK/CSOS state code to (charging, connected, request_active). The --- state strings here are CTEK's internal codes as observed in CSOS firmware --- 4.9.x; if a future firmware adds new ones, the function returns nils → --- the caller falls back to Modbus heuristics for that frame only. --- --- IDLE / A / "DISC" — cable not present --- EVRD / B / "CONN" — vehicle connected, not charging yet --- CHRG / C — actively charging --- PAUS — vehicle connected, paused (we, EVSE, or grid) --- NCRQ — vehicle connected but NOT requesting current --- (car hit its own SoC target or its onboard --- schedule ended). Distinct from PAUS because --- the vehicle, not the EVSE, owns the refusal — --- waking it back into CHRG requires user action --- at the car. Loadpoint controller uses this --- to stop allocating PV surplus to a phantom --- sink. --- FAULT / ERR / "ERR_*" — fault → conservative: connected=true so the --- loadpoint UI shows the device, charging=false --- --- request_active is the third return: true when the vehicle is (or could --- imminently be) drawing current, false when the vehicle has explicitly --- refused. nil for plug-out / unknown states — caller defaults to true so --- non-NCRQ-aware drivers don't accidentally trip completion. -local function classify_state(code) - if code == nil or code == "" then return nil, nil, nil end - if code == "IDLE" or code == "A" or code == "DISC" or code == "DISCONNECTED" then - return false, false, nil - end - if code == "NCRQ" then - -- Connected, not charging, vehicle refusing — explicit signal. - return false, true, false - end - if code == "EVRD" or code == "B" or code == "CONN" or code == "CONNECTED" or code == "PAUS" or code == "PAUSED" then - return false, true, true - end - if code == "CHRG" or code == "C" or code == "CHARGING" then - return true, true, true - end - if code:find("^FAULT") or code:find("^ERR") or code == "F" then - return false, true, nil - end - -- Unknown code — caller falls back. - return nil, nil, nil -end - ----------------------------------------------------------------------------- --- Driver interface ----------------------------------------------------------------------------- - -function driver_init(config) - host.set_make("CTEK") - - if config then - if tonumber(config.phases) then - local p = math.floor(tonumber(config.phases)) - if p == 1 or p == 3 then phases = p end - end - if tonumber(config.min_a) then min_a = math.floor(tonumber(config.min_a)) end - if tonumber(config.max_a) then max_a = math.floor(tonumber(config.max_a)) end - if tonumber(config.voltage_v) then voltage_v = tonumber(config.voltage_v) end - - if tonumber(config.mqtt_connector) then - mqtt_connector = math.floor(tonumber(config.mqtt_connector)) - end - if type(config.mqtt_discover_topic) == "string" then - mqtt_discover_topic = config.mqtt_discover_topic - end - if type(config.mqtt_state_topic) == "string" then - mqtt_state_topic_cfg = config.mqtt_state_topic - end - if type(config.mqtt_state_json_field) == "string" then - mqtt_state_field = config.mqtt_state_json_field - end - if type(config.mqtt_em_topic) == "string" then - mqtt_em_topic_cfg = config.mqtt_em_topic - end - if tonumber(config.mqtt_max_stale_ms) then - mqtt_max_stale_ms = math.floor(tonumber(config.mqtt_max_stale_ms)) - end - end - - if min_a < 6 then min_a = 6 end - if max_a < min_a then max_a = min_a end - - -- Sanity-check API version. Same logic as drivers/ctek.lua. - local ok, api_regs = pcall(host.modbus_read, REG_API_VERSION, 2, "holding") - if ok and api_regs and api_regs[1] then - host.log("info", string.format( - "CTEK-hybrid: API v%d, status %d (expected v1)", - api_regs[1], api_regs[2] or 0)) - end - - host.log("info", string.format( - "CTEK-hybrid: init phases=%d min=%dA max=%dA V=%.0f connector=%d stale=%dms", - phases, min_a, max_a, voltage_v, mqtt_connector, mqtt_max_stale_ms)) - - local cur = read_setpoint() - if cur then - last_set_a = cur - host.log("info", "CTEK-hybrid: charge limit readback = " .. tostring(cur) .. "A") - end - - -- Discovery subscription — fires immediately so we capture topics even - -- before the serial is known. If the operator configured an explicit - -- state topic with no , we can also subscribe right now; the - -- serial-dependent case is deferred to driver_poll. - if mqtt_discover_topic ~= "" then - host.mqtt_subscribe(mqtt_discover_topic) - host.log("info", "CTEK-hybrid: discovery subscribed to " .. mqtt_discover_topic) - end - maybe_subscribe_state() - maybe_subscribe_em() -end - -function driver_poll() - -- One-shot serial read, anchors device identity to the EVSE serial. - if not sn_read then - local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding") - if sn_regs then - local sn = decode_ascii(sn_regs, 6) - if #sn > 0 then - serial = sn - host.set_sn(sn) - sn_read = true - -- Complete any subscriptions that needed the serial. - maybe_subscribe_state() - maybe_subscribe_em() - end - end - end - - -- Drain MQTT messages. We do this every poll regardless of whether - -- the state topic is resolved — messages on the discovery topic - -- still need to be drained or the host's buffer fills up. - local now = host.millis() - local messages = host.mqtt_messages() - if messages then - for _, msg in ipairs(messages) do - -- One-shot discovery logging — first time we see a topic, - -- print it with a preview of the payload. Helps the operator - -- pin down the right `mqtt_state_topic` for their broker. - if not seen_topics[msg.topic] then - seen_topics[msg.topic] = true - local preview = msg.payload or "" - if #preview > 80 then preview = preview:sub(1, 80) .. "…" end - host.log("info", "CTEK-hybrid: mqtt topic seen " - .. msg.topic .. " = " .. preview) - end - -- State topic (pattern may contain MQTT wildcards). - if topic_match(mqtt_state_topic_resolved, msg.topic) then - local code = parse_state_payload(msg.payload) - if code ~= "" then - if code ~= mqtt_state_str then - host.log("info", "CTEK-hybrid: state " .. mqtt_state_str .. " → " .. code) - end - mqtt_state_str = code - mqtt_state_ts = now - end - end - -- em topic (pattern may contain MQTT wildcards). - if topic_match(mqtt_em_topic_resolved, msg.topic) then - if parse_em_payload(msg.payload) then - mqtt_em_ts = now - end - end - end - end - - -- Control-block readback is cheap (2 regs) and required regardless of - -- telemetry source: we need the live charging-limit + max-assignment - -- for both the EV emit and the loadpoint clamp. - -- If the CCU stops answering this block the driver keeps its own - -- last_set_a — every setpoint the driver writes updates it — so giving up - -- costs the readback, not control. - local limit, max_assign = last_set_a, max_a - local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding") - if ctl then - limit = ctl[1] or last_set_a - max_assign = ctl[2] or max_a - last_set_a = limit - end - - -- Telemetry source selection. MQTT `em` is preferred — decimal V/I, real - -- frequency, lower load on the CCU. Modbus is the fallback when MQTT is - -- stale or never arrived (operator pointed `mqtt:` at the wrong broker, - -- CSOS local-MQTT disabled, etc.) — so a misconfigured broker never - -- takes telemetry offline. - local em_age_ms = (mqtt_em_ts == 0) and -1 or (now - mqtt_em_ts) - local em_fresh = (mqtt_em_ts ~= 0) and (em_age_ms <= mqtt_max_stale_ms) - local ev_w - local i_l1, i_l2, i_l3 - local v_l1, v_l2, v_l3 - local lifetime_wh - local hz = 0 - if em_fresh then - ev_w = mqtt_em_power_w - i_l1, i_l2, i_l3 = mqtt_em_l1_a, mqtt_em_l2_a, mqtt_em_l3_a - v_l1, v_l2, v_l3 = mqtt_em_l1_v, mqtt_em_l2_v, mqtt_em_l3_v - lifetime_wh = mqtt_em_energy_wh - hz = mqtt_em_hz - else - -- Modbus fallback: 9-reg telemetry block in a single transaction. - local tel = probe_read(REG_TELEMETRY, 9, "holding") - if tel then - lifetime_wh = host.decode_u32_be(tel[1], tel[2]) - i_l1 = (tel[3] or 0) / 1000 - i_l2 = (tel[4] or 0) / 1000 - i_l3 = (tel[5] or 0) / 1000 - v_l1 = (tel[6] or 0) / 10 - v_l2 = (tel[7] or 0) / 10 - v_l3 = (tel[8] or 0) / 10 - ev_w = tel[9] or 0 - else - host.log("warn", "CTEK-hybrid: Modbus telemetry fallback read failed") - ev_w = 0 - i_l1, i_l2, i_l3 = 0, 0, 0 - v_l1, v_l2, v_l3 = 0, 0, 0 - lifetime_wh = 0 - end - end - - -- Authoritative state: prefer MQTT when fresh. - local state_age_ms = (mqtt_state_ts == 0) and -1 or (now - mqtt_state_ts) - local state_fresh = (mqtt_state_ts ~= 0) and (state_age_ms <= mqtt_max_stale_ms) - local charging, connected, request_active - if state_fresh then - local c, k, r = classify_state(mqtt_state_str) - if c ~= nil and k ~= nil then - charging, connected, request_active = c, k, r - end - end - if charging == nil or connected == nil then - -- Modbus / em-derived heuristic. With em-fresh telemetry the - -- voltage + current numbers are accurate enough to trust the - -- current-based plug detection. - local max_phase_a = math.max(i_l1, i_l2, i_l3) - charging = (ev_w > 100) or (max_phase_a > 1.0) - connected = charging or (limit >= min_a and max_assign > 0) - -- Modbus can't distinguish "throttled to 0" from "car refusing"; - -- leave request_active unset so the Go side defaults to true and - -- keeps the legacy behaviour for non-NCRQ-aware code paths. - end - if request_active == nil then - request_active = true - end - - host.emit("ev", { - w = ev_w, - connected = connected, - charging = charging, - request_active = request_active, - max_a = limit, - phases = phases, - l1_v = v_l1, l2_v = v_l2, l3_v = v_l3, - l1_a = i_l1, l2_a = i_l2, l3_a = i_l3, - lifetime_wh = lifetime_wh, - state_code = mqtt_state_str, - }) - - host.emit_metric("ev_set_current_a", limit) - host.emit_metric("ev_max_assign_a", max_assign) - host.emit_metric("ev_l1_a", i_l1) - host.emit_metric("ev_l2_a", i_l2) - host.emit_metric("ev_l3_a", i_l3) - host.emit_metric("ev_l1_v", v_l1) - host.emit_metric("ev_l2_v", v_l2) - host.emit_metric("ev_l3_v", v_l3) - host.emit_metric("ev_power_w", ev_w) - host.emit_metric("ev_lifetime_wh", lifetime_wh) - if hz > 0 then host.emit_metric("grid_hz", hz) end - host.emit_metric("ev_mqtt_state_age_ms", state_age_ms < 0 and 0 or state_age_ms) - host.emit_metric("ev_mqtt_state_fresh", state_fresh and 1 or 0) - host.emit_metric("ev_mqtt_em_age_ms", em_age_ms < 0 and 0 or em_age_ms) - host.emit_metric("ev_mqtt_em_fresh", em_fresh and 1 or 0) - - return 5000 -end - -function driver_command(action, power_w, cmd) - if action == "init" or action == "deinit" then - return true - end - - if action == "ev_set_current" then - local amps = clamp_amps(watts_to_amps(power_w)) - host.log("debug", "CTEK-hybrid: ev_set_current " - .. tostring(power_w) .. "W → " .. tostring(amps) .. "A") - return write_setpoint(amps) - end - - if action == "ev_pause" then - return write_setpoint(0) - end - - if action == "ev_start" or action == "ev_resume" then - local amps = (last_set_a and last_set_a >= min_a) and last_set_a or max_a - return write_setpoint(amps) - end - - host.log("warn", "CTEK-hybrid: unknown action " .. tostring(action)) - return false -end - -function driver_default_mode() - -- Same stance as drivers/ctek.lua: leave the current limit alone so a - -- watchdog trip doesn't interrupt a charging session. -end - -function driver_cleanup() -end diff --git a/drivers/ctek_v2.lua b/drivers/ctek_v2.lua deleted file mode 100644 index 96f485b2..00000000 --- a/drivers/ctek_v2.lua +++ /dev/null @@ -1,328 +0,0 @@ --- CTEK Chargestorm EV Charger Driver (Automation API v2) --- Emits: EV --- Protocol: Modbus/TCP --- --- This file is the API v2 twin of drivers/ctek.lua. The two variants --- are identical in every respect EXCEPT the base register addresses --- (v1 uses 0x1xxx, v2 uses 0x2xxx). CTEK introduced the api_version --- selector in CSOS 4.8.2 — v2 is required whenever you run both the --- internal-meter Modbus server and the NANOGRID™ limit-control server --- on the same CCU (CSOS allows at most one of them on api_version=1). --- --- Hardware + firmware: --- Requires a CTEK Chargestorm Connected 2 or Connected 3 running --- CSOS (Chargestorm OS) firmware 4.9.3 or later. On the CCU: --- --- Automation → ModbusTCPEnable = true --- Automation → modbus_tcp_automation_api_version = 2 --- --- Use drivers/ctek.lua if the CCU is still on api_version=1. --- --- Unit identifier selects the outlet on dual-outlet stations: --- unit_id = 1 → EVSE1 (left outlet, or single-outlet station) --- unit_id = 2 → EVSE2 (right outlet) --- --- Register map (source: CTEK "Automation interface" v1.0, rev 6b4af7, --- API v2 column): --- --- Identity / meter type: --- 0x2000 API version (u16) --- 0x2001 API status, 0=OK (u16) --- 0x2002 EnergyMeterType (u16 enum) --- 0x2003..0x2008 Serial (12 ASCII chars, 2 per register, big-endian bytes) --- --- Telemetry (one contiguous read 0x2100..0x2108 = 9 regs): --- 0x2100..0x2101 Lifetime energy (Wh, u32 big-endian, high word first) --- 0x2102..0x2104 Per-phase current, L1/L2/L3 (u16 × 10⁻³ A, i.e. mA) --- 0x2105..0x2107 Per-phase voltage L1-N/L2-N/L3-N (u16 × 10⁻¹ V) --- 0x2108 Total active power (u16 W) --- --- Control: --- 0x2200 Charging limit (u16 A, read/write) — 0 disables --- charging; values 1..5 are treated as 0 by the --- charger (IEC 61851 minimum is 6 A). Setpoint is --- lost on charger restart. --- 0x2201 Maximum assignment (u16 A, read-only) — upper --- bound the charger will accept given current --- de-rating, schedules, NANOGRID™ curtailment, etc. --- --- Sign convention (SITE = positive W flows INTO the site): --- ev.w: always positive when charging — an EVSE is a one-way load; --- there's no vehicle-to-grid path on Chargestorm. --- --- Config example (config.yaml): --- drivers: --- - name: ctek --- lua: drivers/ctek_v2.lua --- capabilities: --- modbus: --- host: 192.168.1.190 --- port: 502 --- unit_id: 1 # 1 = EVSE1, 2 = EVSE2 --- config: --- phases: 3 # 1 or 3; default 3 --- min_a: 6 # minimum charge current (A); default 6 --- max_a: 16 # fuse-limited max (A); default 16 --- voltage_v: 230 # nominal per-phase voltage; default 230 - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "ctek-chargestorm-v2", - name = "CTEK Chargestorm (API v2)", - manufacturer = "CTEK", - version = "0.3.1", - protocols = { "modbus" }, - capabilities = { "ev" }, - description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v2 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.", - homepage = "https://www.ctek.com", - authors = { "FTW contributors" }, - tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, - verification_status = "beta", - verification_notes = "Register map per CTEK Automation interface v1.0 (API v2 offsets); charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - ----------------------------------------------------------------------------- --- Register addresses — API v2. --- Mirror of drivers/ctek.lua with every 0x1xxx offset shifted to 0x2xxx. --- Keep the two files in sync when register semantics change. ----------------------------------------------------------------------------- -local REG_API_VERSION = 0x2000 -local REG_API_STATUS = 0x2001 -local REG_METER_TYPE = 0x2002 -local REG_SERIAL_BASE = 0x2003 -- 6 regs → 12 ASCII chars -local REG_TELEMETRY = 0x2100 -- 9 regs: energy(2) + I(3) + V(3) + W(1) -local REG_CHARGE_LIMIT = 0x2200 -- r/w -local REG_MAX_ASSIGN = 0x2201 -- r/o - ----------------------------------------------------------------------------- --- Runtime config (overridden from config.yaml in driver_init) ----------------------------------------------------------------------------- -local phases = 3 -local min_a = 6 -local max_a = 16 -local voltage_v = 230 - -local last_set_a = 0 -local sn_read = false - ----------------------------------------------------------------------------- --- Helpers ----------------------------------------------------------------------------- - --- The host counts every failed modbus_read against the poll whether or not --- Lua caught it, so a register this CCU does not answer -- an older CSOS --- build without the serial block, say -- costs a failed read on every poll --- forever and the stale-telemetry watchdog takes the site offline while the --- driver goes on emitting. Ask three times, since one miss can just be a slow --- link, then leave the register alone. A restart re-probes. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end - local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then - read_failures[addr] = nil - return regs - end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "CTEK: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) - end - return nil -end - -local function clamp_amps(a) - if a == nil then return 0 end - a = math.floor(a + 0.5) - if a <= 0 then return 0 end - if a < min_a then return 0 end - if a > max_a then return max_a end - return a -end - -local function watts_to_amps(power_w) - if not power_w or power_w <= 0 then return 0 end - return math.floor((power_w / voltage_v / phases) + 0.5) -end - -local function decode_ascii(regs, n) - local s = "" - for i = 1, n do - local r = regs[i] or 0 - local hi = math.floor(r / 256) - local lo = r % 256 - if hi == 0 then break end - if hi >= 32 and hi < 127 then s = s .. string.char(hi) end - if lo == 0 then break end - if lo >= 32 and lo < 127 then s = s .. string.char(lo) end - end - return s -end - -local function write_setpoint(amps) - local err = host.modbus_write(REG_CHARGE_LIMIT, amps) - if err ~= nil and err ~= "" then - host.log("warn", "CTEK: write charging limit failed: " .. tostring(err)) - return false - end - last_set_a = amps - return true -end - --- Deliberately not bounded: this runs once from driver_init, so it costs no --- repeated failed read, and a counter shared with the poll would let one slow --- start eat into the poll's three attempts. -local function read_setpoint() - local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding") - if not ok or not regs or not regs[1] then return nil end - return regs[1] -end - ----------------------------------------------------------------------------- --- Driver interface ----------------------------------------------------------------------------- - -function driver_init(config) - host.set_make("CTEK") - - if config then - if tonumber(config.phases) then - local p = math.floor(tonumber(config.phases)) - if p == 1 or p == 3 then phases = p end - end - if tonumber(config.min_a) then min_a = math.floor(tonumber(config.min_a)) end - if tonumber(config.max_a) then max_a = math.floor(tonumber(config.max_a)) end - if tonumber(config.voltage_v) then voltage_v = tonumber(config.voltage_v) end - end - - if min_a < 6 then min_a = 6 end - if max_a < min_a then max_a = min_a end - - local ok, api_regs = pcall(host.modbus_read, REG_API_VERSION, 2, "holding") - if ok and api_regs and api_regs[1] then - local api_ver = api_regs[1] - local api_status = api_regs[2] or 0 - host.log("info", string.format( - "CTEK: API v%d, status %d (expected v2 from this driver; use drivers/ctek.lua for v1)", - api_ver, api_status)) - end - - host.log("info", string.format( - "CTEK: driver initialized (phases=%d, min=%dA, max=%dA, V=%.0f)", - phases, min_a, max_a, voltage_v)) - - local cur = read_setpoint() - if cur then - last_set_a = cur - host.log("info", "CTEK: charge limit readback = " .. tostring(cur) .. "A") - end -end - -function driver_poll() - if not sn_read then - local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding") - if sn_regs then - local sn = decode_ascii(sn_regs, 6) - if #sn > 0 then - host.set_sn(sn) - sn_read = true - end - end - end - - local tel = probe_read(REG_TELEMETRY, 9, "holding") - local ev_w = 0 - local i_l1, i_l2, i_l3 = 0, 0, 0 - local v_l1, v_l2, v_l3 = 0, 0, 0 - local lifetime_wh = 0 - if tel then - lifetime_wh = host.decode_u32_be(tel[1], tel[2]) - i_l1 = (tel[3] or 0) / 1000 - i_l2 = (tel[4] or 0) / 1000 - i_l3 = (tel[5] or 0) / 1000 - v_l1 = (tel[6] or 0) / 10 - v_l2 = (tel[7] or 0) / 10 - v_l3 = (tel[8] or 0) / 10 - ev_w = tel[9] or 0 - else - host.log("warn", "CTEK: telemetry block read failed") - end - - local limit, max_assign = last_set_a, max_a - local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding") - if ctl then - limit = ctl[1] or last_set_a - max_assign = ctl[2] or max_a - last_set_a = limit - end - - local max_phase_a = math.max(i_l1, i_l2, i_l3) - local charging = (ev_w > 100) or (max_phase_a > 1.0) - local connected = charging or (limit >= min_a and max_assign > 0) - - host.emit("ev", { - w = ev_w, - connected = connected, - charging = charging, - max_a = limit, - phases = phases, - l1_v = v_l1, l2_v = v_l2, l3_v = v_l3, - l1_a = i_l1, l2_a = i_l2, l3_a = i_l3, - lifetime_wh = lifetime_wh, - }) - - host.emit_metric("ev_set_current_a", limit) - host.emit_metric("ev_max_assign_a", max_assign) - host.emit_metric("ev_l1_a", i_l1) - host.emit_metric("ev_l2_a", i_l2) - host.emit_metric("ev_l3_a", i_l3) - host.emit_metric("ev_l1_v", v_l1) - host.emit_metric("ev_l2_v", v_l2) - host.emit_metric("ev_l3_v", v_l3) - host.emit_metric("ev_power_w", ev_w) - host.emit_metric("ev_lifetime_wh", lifetime_wh) - - return 5000 -end - -function driver_command(action, power_w, cmd) - if action == "init" or action == "deinit" then - return true - end - - if action == "ev_set_current" then - local amps = clamp_amps(watts_to_amps(power_w)) - host.log("debug", "CTEK: ev_set_current " - .. tostring(power_w) .. "W → " .. tostring(amps) .. "A") - return write_setpoint(amps) - end - - if action == "ev_pause" then - return write_setpoint(0) - end - - if action == "ev_start" or action == "ev_resume" then - local amps = (last_set_a and last_set_a >= min_a) and last_set_a or max_a - return write_setpoint(amps) - end - - host.log("warn", "CTEK: unknown action " .. tostring(action)) - return false -end - -function driver_default_mode() -end - -function driver_cleanup() -end diff --git a/drivers/deye.lua b/drivers/deye.lua deleted file mode 100644 index 27ce5357..00000000 --- a/drivers/deye.lua +++ /dev/null @@ -1,654 +0,0 @@ --- Deye Hybrid Inverter Driver --- Ported from sourceful-hugin/device-support/drivers/lua/deye.lua --- Emits: PV, Battery, Meter telemetry + battery control --- Protocol: Modbus TCP (holding registers throughout) --- Byte order: Little-Endian for multi-register U32 values - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "deye", - name = "Deye hybrid inverter", - manufacturer = "Deye", - version = "2.1.1", - protocols = { "modbus" }, - capabilities = { "meter", "pv", "battery" }, - description = "Deye SUN-SG series hybrid inverters via Modbus. Auto-detects LV vs HV battery at init.", - homepage = "https://www.deyeinverter.com", - authors = { "FTW contributors" }, - tested_models = { "SUN-SG03LP1", "SUN-SG04LP3" }, - verification_status = "experimental", - verification_notes = "Ported from a reference implementation. Not yet verified against live hardware on a FTW site.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} --- --- Register conventions (all holding, FC 0x03/0x06): --- 0 : device type code. High byte == 6 → HV battery variant. --- 3-7 : serial number, packed 2 bytes/register, big-endian within each word. --- 20-21 : rated power, U32 LE × 0.1 kW --- 141 : energy management mode (0=Selling First, 1=Zero Export To Load, --- 2=Zero Export To CT, 3=External EMS / forced) --- 143 : grid-charge enable (bit 0) --- 144 : power limit for forced charge/discharge (W) --- 217 : battery temperature, U16, actual C = (val - 1000) / 10 --- 516-519 : battery charge/discharge energy counters, U32 LE × 0.1 kWh --- 522-525 : grid import/export energy, U32 LE × 0.1 kWh --- 534-535 : total PV generation, U32 LE × 0.1 kWh --- 541 : heatsink temperature, U16 × 0.1 C --- 587-591 : battery V/SoC/power/current --- 598-612 : per-phase V/current, grid frequency --- 619, 622 : total + per-phase meter power (I16, W) --- 672-679 : PV power + MPPT V/A --- --- Sign convention (site view, see docs/site-convention.md): --- pv.w : always negative (generation flowing into the site) --- battery.w : positive = charging, negative = discharging --- meter.w : positive = importing, negative = exporting - -PROTOCOL = "modbus" - --- Cached across polls -local is_hv = false -local sn_read = false -local control_initialized = false -local rated_power_w = 0 --- Grid → battery charge current cap (reg 128). Sized to the install's main --- fuse / grid subscription, not the battery's max charge rate. Overridable --- via config.max_grid_charge_a; default 31 A matches Zap's init profile. -local grid_charge_current_a = 31 --- These limits are read during driver_init(), so their declarations must be --- above that function. Declaring them near the command helpers made init see --- nil globals instead of the intended driver-local defaults. -local soc_max = 100 -local soc_min = 20 - --- Registers this device has stopped answering. --- --- The host counts every failed host.modbus_read against the poll whether or --- not this driver caught the error — "driver_poll: N of M modbus reads --- failed". So a register retried on every poll costs a failed poll on every --- poll, and the stale-telemetry watchdog takes the driver offline. The site --- then reports nothing at all, which is worse than reporting one field less. --- --- Three attempts absorb a transient blip; after that we stop asking. A --- restart re-probes, so firmware that gains the register is picked up. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end - local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then - read_failures[addr] = nil - return regs - end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "Deye: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) - end - return nil -end - ----------------------------------------------------------------------------- --- Initialization ----------------------------------------------------------------------------- - -function driver_init(config) - host.set_make("Deye") - - -- Strict HV detect (reg 0 raw value == 6 is the HV variant per Deye - -- protocol docs). Earlier ports also accepted the value in the high - -- byte, but the Zap reference on real hardware uses strict equality - -- and we match that to avoid false HV positives on LV units. - local mode_regs = probe_read(0, 1, "holding") - if mode_regs then - local val = mode_regs[1] - is_hv = (val == 6) - host.log("info", string.format("Deye: device type 0x%04x (%s battery)", - val, is_hv and "HV" or "LV")) - else - host.log("warn", "Deye: device type read failed; assuming LV") - end - - -- Rated power drives curtailment ceiling and the init profile's max-sell - -- register. Prefer an operator-supplied value from YAML, else pull the - -- device-reported rating from regs 20-21 (U32 LE × 0.1 kW), else default. - if config and type(config) == "table" and tonumber(config.rated_w) then - rated_power_w = math.floor(tonumber(config.rated_w)) - else - local rated_regs = probe_read(20, 2, "holding") - if rated_regs then - rated_power_w = math.floor( - host.decode_u32_le(rated_regs[1], rated_regs[2]) * 0.1 * 1000) - end - end - if rated_power_w <= 0 then rated_power_w = 5000 end - - if config and type(config) == "table" and tonumber(config.max_grid_charge_a) then - local a = math.floor(tonumber(config.max_grid_charge_a)) - if a < 0 then a = 0 end - if a > 185 then a = 185 end -- register ceiling per Deye V105.3 spec - grid_charge_current_a = a - end - - local function clamp_soc(v) - v = math.floor(v) - if v < 0 then v = 0 end - if v > 100 then v = 100 end - return v - end - if config and type(config) == "table" then - if tonumber(config.soc_max) then soc_max = clamp_soc(tonumber(config.soc_max)) end - if tonumber(config.soc_min) then soc_min = clamp_soc(tonumber(config.soc_min)) end - if soc_min > soc_max then - host.log("warn", string.format( - "Deye: soc_min (%d) > soc_max (%d); swapping", soc_min, soc_max)) - soc_min, soc_max = soc_max, soc_min - end - end - - host.log("info", string.format( - "Deye: driver_init (rated=%.0fW, max_grid_charge=%dA, soc=%d..%d)", - rated_power_w, grid_charge_current_a, soc_min, soc_max)) -end - ----------------------------------------------------------------------------- --- Telemetry polling ----------------------------------------------------------------------------- - -function driver_poll() - -- Read serial number once. Registers 3..7 hold 10 ASCII bytes - -- (2 chars per register, big-endian within each word). - if not sn_read then - local sn_regs = probe_read(3, 5, "holding") - if sn_regs then - local sn = "" - for i = 1, 5 do - local hi = math.floor(sn_regs[i] / 256) - local lo = sn_regs[i] % 256 - if hi > 32 and hi < 127 then sn = sn .. string.char(hi) end - if lo > 32 and lo < 127 then sn = sn .. string.char(lo) end - end - if #sn > 0 then - host.set_sn(sn) - sn_read = true - end - end - end - - -- ---- PV ---- - - -- PV1-PV4 power: 672-675, U16 each (×1 LV, ×10 HV) - local pvw_regs = probe_read(672, 4, "holding") - local pv_total_w = 0 - if pvw_regs then - local pv_scale = is_hv and 10 or 1 - for i = 1, 4 do - pv_total_w = pv_total_w + pvw_regs[i] * pv_scale - end - end - - -- MPPT1 V/A: 676-677, U16 × 0.1 each - local m1_regs = probe_read(676, 2, "holding") - local mppt1_v, mppt1_a = 0, 0 - if m1_regs then - mppt1_v = m1_regs[1] * 0.1 - mppt1_a = m1_regs[2] * 0.1 - end - - -- MPPT2 V/A: 678-679, U16 × 0.1 each - local m2_regs = probe_read(678, 2, "holding") - local mppt2_v, mppt2_a = 0, 0 - if m2_regs then - mppt2_v = m2_regs[1] * 0.1 - mppt2_a = m2_regs[2] * 0.1 - end - - -- Total generation: 534-535, U32 LE × 0.1 kWh - local gen_regs = probe_read(534, 2, "holding") - local pv_gen_wh = 0 - if gen_regs then - pv_gen_wh = host.decode_u32_le(gen_regs[1], gen_regs[2]) * 0.1 * 1000 - end - - -- Rated power: 20-21, U32 LE × 0.1 kW - local rated_regs = probe_read(20, 2, "holding") - local rated_w = 0 - if rated_regs then - rated_w = host.decode_u32_le(rated_regs[1], rated_regs[2]) * 0.1 * 1000 - end - - -- Heatsink temperature: 541, U16 × 0.1 C - local temp_regs = probe_read(541, 1, "holding") - local heatsink_c = 0 - if temp_regs then - heatsink_c = temp_regs[1] * 0.1 - end - - host.emit("pv", { - w = -pv_total_w, -- negate: PV generation is negative in site convention - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - lifetime_wh = pv_gen_wh, - rated_w = rated_w, - temp_c = heatsink_c, - }) - host.emit_metric("pv_mppt1_v", mppt1_v) - host.emit_metric("pv_mppt1_a", mppt1_a) - host.emit_metric("pv_mppt2_v", mppt2_v) - host.emit_metric("pv_mppt2_a", mppt2_a) - host.emit_metric("inverter_temp_c", heatsink_c) - - -- ---- Battery ---- - - -- Battery voltage: 587, U16 (×0.01 LV, ×0.1 HV) - local bv_regs = probe_read(587, 1, "holding") - local bat_v = 0 - if bv_regs then - bat_v = bv_regs[1] * (is_hv and 0.1 or 0.01) - end - - -- Battery SoC: 588, U16 percent → 0..1 fraction - local bsoc_regs = probe_read(588, 1, "holding") - local bat_soc = 0 - if bsoc_regs then - bat_soc = bsoc_regs[1] / 100 - end - - -- Battery power: 590, I16 (×1 LV, ×10 HV). Deye native: positive = charging. - local bw_regs = probe_read(590, 1, "holding") - local bat_w = 0 - if bw_regs then - local bat_scale = is_hv and 10 or 1 - bat_w = host.decode_i16(bw_regs[1]) * bat_scale - end - - -- Battery current: 591, I16 × 0.01 A - local ba_regs = probe_read(591, 1, "holding") - local bat_a = 0 - if ba_regs then - bat_a = host.decode_i16(ba_regs[1]) * 0.01 - end - - -- Battery temperature: 217, U16 offset-encoded (actual = (val-1000)/10) - local btemp_regs = probe_read(217, 1, "holding") - local bat_temp = 0 - if btemp_regs then - bat_temp = (btemp_regs[1] - 1000) / 10 - end - - -- Battery charge energy: 516-517, U32 LE × 0.1 kWh - local bchg_regs = probe_read(516, 2, "holding") - local bat_charge_wh = 0 - if bchg_regs then - bat_charge_wh = host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 - end - - -- Battery discharge energy: 518-519, U32 LE × 0.1 kWh - local bdis_regs = probe_read(518, 2, "holding") - local bat_discharge_wh = 0 - if bdis_regs then - bat_discharge_wh = host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 - end - - -- Deye reports positive = charging already, which matches site convention. - host.emit("battery", { - w = bat_w, - v = bat_v, - a = bat_a, - soc = bat_soc, - temp_c = bat_temp, - charge_wh = bat_charge_wh, - discharge_wh = bat_discharge_wh, - }) - host.emit_metric("battery_dc_v", bat_v) - host.emit_metric("battery_dc_a", bat_a) - host.emit_metric("battery_temp_c", bat_temp) - - -- ---- Meter ---- - - -- Per-phase voltage: 598-600, U16 × 0.1 V - local lv_regs = probe_read(598, 3, "holding") - local l1_v, l2_v, l3_v = 0, 0, 0 - if lv_regs then - l1_v = lv_regs[1] * 0.1 - l2_v = lv_regs[2] * 0.1 - l3_v = lv_regs[3] * 0.1 - end - - -- Grid frequency: 609, U16 × 0.01 Hz - local hz_regs = probe_read(609, 1, "holding") - local hz = 0 - if hz_regs then - hz = hz_regs[1] * 0.01 - end - - -- Per-phase current: 610-612, I16 × 0.01 A - local la_regs = probe_read(610, 3, "holding") - local l1_a, l2_a, l3_a = 0, 0, 0 - if la_regs then - l1_a = host.decode_i16(la_regs[1]) * 0.01 - l2_a = host.decode_i16(la_regs[2]) * 0.01 - l3_a = host.decode_i16(la_regs[3]) * 0.01 - end - - -- Total meter power: 619, I16 W (positive = import, negative = export) - local tw_regs = probe_read(619, 1, "holding") - local meter_w = 0 - if tw_regs then - meter_w = host.decode_i16(tw_regs[1]) - end - - -- Per-phase power: 622-624, I16 W - local lw_regs = probe_read(622, 3, "holding") - local l1_w, l2_w, l3_w = 0, 0, 0 - if lw_regs then - l1_w = host.decode_i16(lw_regs[1]) - l2_w = host.decode_i16(lw_regs[2]) - l3_w = host.decode_i16(lw_regs[3]) - end - - -- Import energy: 522-523, U32 LE × 0.1 kWh - local imp_regs = probe_read(522, 2, "holding") - local import_wh = 0 - if imp_regs then - import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - - -- Export energy: 524-525, U32 LE × 0.1 kWh - local exp_regs = probe_read(524, 2, "holding") - local export_wh = 0 - if exp_regs then - export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 - end - - host.emit("meter", { - w = meter_w, - l1_w = l1_w, - l2_w = l2_w, - l3_w = l3_w, - l1_v = l1_v, - l2_v = l2_v, - l3_v = l3_v, - l1_a = l1_a, - l2_a = l2_a, - l3_a = l3_a, - hz = hz, - import_wh = import_wh, - export_wh = export_wh, - }) - host.emit_metric("meter_l1_w", l1_w) - host.emit_metric("meter_l2_w", l2_w) - host.emit_metric("meter_l3_w", l3_w) - host.emit_metric("meter_l1_v", l1_v) - host.emit_metric("meter_l2_v", l2_v) - host.emit_metric("meter_l3_v", l3_v) - host.emit_metric("meter_l1_a", l1_a) - host.emit_metric("meter_l2_a", l2_a) - host.emit_metric("meter_l3_a", l3_a) - host.emit_metric("grid_hz", hz) - - return 5000 -end - ----------------------------------------------------------------------------- --- Battery control ----------------------------------------------------------------------------- --- --- Register map (holding, all FC 0x06): --- 108 : Max battery charge current (A) --- 109 : Max battery discharge current (A) --- 128 : Charging current (A) --- 129 : Generator charge enable (0/1) --- 130 : Utility (grid) charge enable (0/1) --- 141 : Energy management mode --- 0 = Selling First (self-consume) --- 1 = Load First (self-consume, battery tops up after load) --- 2 = Zero Export To CT --- 3 = External EMS / Forced --- 142 : Limit control --- 0 = enable discharge --- 1 = no CT clamps --- 2 = extraposition enable (forced-setpoint gate) --- 143 : Max sell power (W, scaled HV/LV) — also curtail setpoint --- 145 : PV sell enable (0/1) --- 146 : Advanced peak-shaving enable --- 148 : TOU timestamp 1 --- 149 : TOU timestamp 2 --- 154 : Battery output (discharge) power (W, scaled HV/LV) --- 166 : Battery SoC target (percent, 20=min / 100=max) --- 172 : Charge enable (3 = grid + PV) --- 178 : Control-board special function (vendor magic 11816) --- 587 : Battery voltage (HV × 0.1 V, LV × 0.01 V) --- --- HV models encode 10 W per register unit on 143/154; LV models 1 W. --- Charging is expressed as current on reg 108 (A = W / V). --- Deye firmware tolerates ≥50 ms between consecutive holding writes. - -local REG_MAX_CHARGE_A = 108 -local REG_MAX_DISCHARGE_A = 109 -local REG_CHARGE_CURRENT = 128 -local REG_GEN_CHARGE_ENABLE = 129 -local REG_GRID_CHARGE_ENABLE = 130 -local REG_EMS_MODE = 141 -local REG_LIMIT_CONTROL = 142 -local REG_MAX_SELL_POWER = 143 -local REG_PV_SELL_ENABLE = 145 -local REG_PEAK_SHAVING = 146 -local REG_TOU_TS1 = 148 -local REG_TOU_TS2 = 149 -local REG_DISCHARGE_POWER = 154 -local REG_SOC_TARGET = 166 -local REG_CHARGE_ENABLE = 172 -local REG_SPECIAL_FUNC = 178 -local REG_BATTERY_VOLTAGE = 587 - -local EMS_LOAD_FIRST = 1 -- native self-consumption, battery tops up load -local EMS_EXTERNAL = 3 -- forced setpoints via reg 108/154 - --- SoC targets written to reg 166. Charge commands aim for soc_max, --- discharge commands floor at soc_min. Defaults match Zap; overridable --- via config.soc_max / config.soc_min. - -local CHARGE_CURRENT_DEFAULT_A = 31 -- matches Zap init profile - -local WRITE_DELAY_MS = 50 - -local function write_reg(addr, val) - local err = host.modbus_write(addr, val) - host.sleep(WRITE_DELAY_MS) - if err ~= nil and err ~= "" then - host.log("warn", string.format("Deye: write %d=%d failed: %s", - addr, val, tostring(err))) - return false - end - return true -end - -local function write_sequence(writes) - for i = 1, #writes do - if not write_reg(writes[i][1], writes[i][2]) then return false end - end - return true -end - -local function clamp_u16(v) - if v < 0 then return 0 end - if v > 65535 then return 65535 end - return math.floor(v) -end - --- HV registers are in 10 W units; LV in 1 W. Returns the register value. -local function scale_power(watts) - local abs_w = math.abs(watts) - if is_hv then abs_w = abs_w / 10 end - return clamp_u16(abs_w) -end - -local function read_battery_voltage() - local ok, regs = pcall(host.modbus_read, REG_BATTERY_VOLTAGE, 1, "holding") - if not (ok and regs) then return nil end - return regs[1] * (is_hv and 0.1 or 0.01) -end - --- Full init profile (13 writes). Reapplied whenever we re-enter forced --- mode after a cleanup/default-mode revert so the inverter has a known --- baseline for charge-current limits, peak-shaving and TOU boundaries. -local function initialize_control() - local max_sell = scale_power(rated_power_w) - local ok = write_sequence({ - { REG_SPECIAL_FUNC, 11816 }, - { REG_MAX_CHARGE_A, CHARGE_CURRENT_DEFAULT_A }, - { REG_MAX_DISCHARGE_A, CHARGE_CURRENT_DEFAULT_A }, - { REG_CHARGE_CURRENT, grid_charge_current_a }, - { REG_GEN_CHARGE_ENABLE, 1 }, - { REG_GRID_CHARGE_ENABLE, 1 }, - { REG_EMS_MODE, EMS_EXTERNAL }, - { REG_LIMIT_CONTROL, 2 }, - { REG_MAX_SELL_POWER, max_sell }, - { REG_PV_SELL_ENABLE, 1 }, - { REG_PEAK_SHAVING, 255 }, - { REG_TOU_TS1, 0 }, - { REG_TOU_TS2, 2355 }, - }) - control_initialized = ok - if ok then - host.log("info", string.format( - "Deye: control initialized (%s, rated=%dW, max_sell_reg=%d)", - is_hv and "HV" or "LV", rated_power_w, max_sell)) - else - host.log("error", "Deye: control init failed") - end - return ok -end - -local function ensure_initialized() - return control_initialized or initialize_control() -end - --- Site convention: power_w > 0 = charge, < 0 = discharge, 0 = stop both. --- Charging path converts watts to amps using the live battery voltage — --- the Deye charge register is current, not power. -local function set_battery_power(power_w) - if not ensure_initialized() then return false end - - if power_w > 0 then - local voltage = read_battery_voltage() - if not voltage or voltage <= 0.1 then - host.log("error", string.format( - "Deye: charge rejected, invalid battery voltage %s", - tostring(voltage))) - return false - end - local current = math.floor(power_w / voltage + 0.5) - if current < 1 then current = 1 end - current = clamp_u16(current) - - host.log("debug", string.format("Deye: charge %dW @ %.2fV → %dA", - power_w, voltage, current)) - - return write_sequence({ - { REG_SOC_TARGET, soc_max }, - { REG_MAX_CHARGE_A, current }, - { REG_CHARGE_ENABLE, 3 }, -- 3 = grid + PV - }) - end - - if power_w < 0 then - local power_val = scale_power(power_w) - host.log("debug", string.format("Deye: discharge %dW → reg=%d", - math.abs(power_w), power_val)) - return write_sequence({ - { REG_DISCHARGE_POWER, power_val }, - { REG_SOC_TARGET, soc_min }, - { REG_LIMIT_CONTROL, 0 }, -- enable discharge - }) - end - - -- Zero setpoint: hold forced mode but drive both directions to 0 so - -- the inverter idles without leaving external-EMS control. - host.log("debug", "Deye: setpoint 0W (hold)") - return write_sequence({ - { REG_MAX_CHARGE_A, 0 }, - { REG_DISCHARGE_POWER, 0 }, - }) -end - -local function enable_curtailment(power_w) - if not ensure_initialized() then return false end - if power_w < 0 then power_w = 0 end - local limit = scale_power(power_w) - host.log("debug", string.format("Deye: curtail → %dW (reg=%d)", - power_w, limit)) - return write_sequence({ - { REG_PV_SELL_ENABLE, 1 }, - { REG_MAX_SELL_POWER, limit }, - }) -end - -local function disable_curtailment() - if not ensure_initialized() then return false end - local limit = scale_power(rated_power_w) - host.log("debug", string.format("Deye: curtail disabled → %dW", rated_power_w)) - return write_sequence({ - { REG_PV_SELL_ENABLE, 1 }, - { REG_MAX_SELL_POWER, limit }, - }) -end - --- Drop the inverter back to native self-consumption. Mirrors Zap's --- applyDefaultSelfConsumptionMode(): Load First + PV sell + TOU on, --- grid-charging off, CT-clamp extraposition left enabled. -local function set_self_consumption() - local ok = write_sequence({ - { REG_LIMIT_CONTROL, 2 }, - { REG_EMS_MODE, EMS_LOAD_FIRST }, - { REG_PV_SELL_ENABLE, 1 }, - { REG_PEAK_SHAVING, 1 }, - { REG_GRID_CHARGE_ENABLE, 0 }, - }) - if ok then control_initialized = false end - return ok -end - -function driver_command(action, power_w, cmd) - if action == "init" then - return initialize_control() - elseif action == "battery" then - return set_battery_power(power_w or 0) - elseif action == "curtail" then - return enable_curtailment(power_w or 0) - elseif action == "curtail_disable" then - return disable_curtailment() - elseif action == "deinit" then - return set_self_consumption() - end - host.log("debug", "Deye: unsupported action: " .. tostring(action)) - return false -end - --- Watchdog fallback: always revert to autonomous self-consumption so the --- device doesn't get stuck in a forced mode when the EMS goes offline. -function driver_default_mode() - host.log("info", "Deye: watchdog → reverting to self-consumption") - set_self_consumption() -end - -function driver_cleanup() - pcall(set_self_consumption) - is_hv = false - sn_read = false - control_initialized = false - rated_power_w = 0 - grid_charge_current_a = 31 - soc_max = 100 - soc_min = 20 -end diff --git a/drivers/easee_cloud.lua b/drivers/easee_cloud.lua deleted file mode 100644 index 912513db..00000000 --- a/drivers/easee_cloud.lua +++ /dev/null @@ -1,714 +0,0 @@ --- Easee Cloud EV Charger Driver --- Emits: EV --- Protocol: HTTPS (Easee Cloud REST API) --- --- 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. --- --- Config example: --- drivers: --- - name: easee --- lua: drivers/easee_cloud.lua --- capabilities: --- http: --- allowed_hosts: ["api.easee.com"] --- config: --- email: "user@example.com" --- password: "secret" --- serial: "EHHZBKPF" # optional — auto-detected if omitted - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "easee-cloud", - name = "Easee Cloud", - manufacturer = "Easee", - version = "1.0.1", - protocols = { "http" }, - capabilities = { "ev" }, - description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", - homepage = "https://easee.com", - http_hosts = { "api.easee.com" }, - authors = { "FTW contributors" }, - tested_models = { "Home", "Charge" }, - verification_status = "production", - verified_by = { "frahlg@homelab-rpi:2d", "erikarenhill@fortytwo:1d" }, - verified_at = "2026-04-18", - verification_notes = "Observations API + lifecycle commands exercised against an Easee Home charger. Session state, op_mode labels, charge/pause/resume all verified.", -} - -PROTOCOL = "http" - -local BASE_URL = "https://api.easee.com/api" -local access_token = nil -local refresh_token = nil -local token_expires_at = 0 -- millis -local charger_serial = nil -local phases = 3 -- populated from config.phases (if present) in driver_init -local last_sent_phases = nil -- tracks the last phaseMode posted to Easee -local last_phase_change_ms = 0 -- monotonic ms of the last phase flip (hysteresis) - --- After every phaseMode change, Easee resets dynamicChargerCurrent to --- the static maxChargerCurrent value (16 A on a typical 16 A install). --- That resets sticks for ~5–10 s on the cloud side before our follow- --- up dynamicChargerCurrent write propagates back. To bound that --- window, we (a) re-write dynamicChargerCurrent on the next driver_poll --- after a phaseMode change, and (b) clamp maxChargerCurrent at init --- when an operator config field requests it (see driver_init below). -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 --- but no current actually flows), and above the max the cloud returns --- a 4xx. The min applies when amps > 0; commanding exactly 0 always --- pauses cleanly. -local EASEE_MIN_A = 6 -local EASEE_MAX_A = 32 - --- pick_phases is the driver-level phase decision: given the requested --- charging power, the per-phase fuse ceiling, the operator's mode --- preference, and the time since the last flip, return how many --- phases (1 or 3) to actually commit to this command. --- --- * mode "1p" / "3p": locked, no hysteresis --- * mode "auto" or empty + missing fuse data: legacy 3Φ fallback --- (preserves pre-switching behaviour for sites that don't pass --- fuse data in the cmd) --- * mode "auto" with fuse data: pick 1Φ while wantW is below both --- the configured split and the first deliverable 3Φ step. This --- avoids the physical dead zone between a 16 A 1Φ ceiling --- (3.68 kW at 230 V) and Easee's 6 A/phase 3Φ minimum (4.14 kW): --- the 1Φ command saturates safely instead of a 3Φ command mapping --- to 0 A. Hysteresis suppresses --- a flip when less than `hold_s` seconds have passed since the --- last change — Easee's contactor + cloud-API round-trip is --- ~5-10 s, so flapping at every controller tick would burn the --- contactor and never deliver useful power. -local function pick_phases(mode, want_w, voltage, max_a_per_phase, split_w, hold_s, now_ms) - local locked = mode - if locked == "1p" then return 1 end - if locked ~= "auto" then return 3 end -- "" / "3p" / unknown - - -- Effective split: operator override → site fuse → 230 V × 16 A default. - local s = split_w - if (s == nil) or (s <= 0) then - if voltage and voltage > 0 and max_a_per_phase and max_a_per_phase > 0 then - s = voltage * max_a_per_phase - else - s = 3680 - end - end - - -- Never switch to 3Φ before Easee can deliver its minimum current - -- on every phase. A lower phase_split_w (explicit or fuse-derived) - -- would otherwise turn e.g. 3.6–4.0 kW into 5 A/phase, which - -- per_phase_amps must correctly pause as 0 A. Staying on 1Φ may - -- saturate at the fuse ceiling for this narrow band, but it keeps - -- useful current flowing without exceeding the installation limit. - local v = (voltage and voltage > 0) and voltage or 230 - local min_3p_w = v * 3 * EASEE_MIN_A - if s < min_3p_w then s = min_3p_w end - - local desired = (want_w < s) and 1 or 3 - - -- Hysteresis: once we've committed to a phase count, hold it for - -- at least `hold_s` seconds before flipping the other way. - if last_sent_phases == nil then - return desired - end - if desired == last_sent_phases then - return desired - end - -- Default hold = 90 s. Field-measured against an Easee Home: a full - -- phaseMode flip + EV ramp to steady state takes 60–90 s; a 60 s - -- hold could let a flap-prone wantW trigger another transition - -- mid-ramp. 90 s gives one full ramp cycle before another flip. - local hold = (hold_s and hold_s > 0) and hold_s or 90 - if (now_ms - last_phase_change_ms) < (hold * 1000) then - return last_sent_phases - end - return desired -end - --- per_phase_amps converts the requested W into the dynamicChargerCurrent --- value Easee expects. Voltage and per-phase fuse ceiling come from --- the cmd (controller-supplied) so a 240 V or 220 V mains is handled --- without hard-coding 230. The result is rounded (not floored) and --- clamped to [0, EASEE_MAX_A] AND to the per-phase fuse ceiling so --- the breaker stays safe even if the controller sent a value above --- what the fuse tolerates. -local function per_phase_amps(power_w, voltage, p, max_a_per_phase) - local v = (voltage and voltage > 0) and voltage or 230 - local raw = ((power_w or 0) / v / p) + 0.5 - local amps = math.floor(raw) - if amps < 0 then amps = 0 end - if amps > EASEE_MAX_A then amps = EASEE_MAX_A end - if max_a_per_phase and max_a_per_phase > 0 and amps > max_a_per_phase then - amps = math.floor(max_a_per_phase) -- never breach the fuse - end - -- Below Easee's hardware minimum, pause cleanly instead of leaving - -- the charger in "I requested 4 A but nothing flows" limbo. - if amps > 0 and amps < EASEE_MIN_A then amps = 0 end - return amps -end - --- Easee error bodies have historically echoed submitted form data --- (credentials, tokens). Strip the body and keep only the status prefix --- so nothing sensitive ever lands in the driver log. -local function redact_http_err(err) - if err == nil then return "request failed" end - return tostring(err):match("^(HTTP %d+)") or "request failed" -end - --- ---- Auth helpers ---- - -local function login(email, password) - local body = host.json_encode({userName = email, password = password}) - local resp, err = host.http_post(BASE_URL .. "/accounts/login", body) - if err then - host.log("error", "Easee login failed: " .. redact_http_err(err)) - return false - end - local data = host.json_decode(resp) - if not data or not data.accessToken then - host.log("error", "Easee login: no accessToken in response") - return false - end - access_token = data.accessToken - refresh_token = data.refreshToken - -- expiresIn is seconds; convert to absolute millis - local expires_in = data.expiresIn or 3600 - token_expires_at = host.millis() + (expires_in * 1000) - 60000 -- refresh 1 min early - host.log("info", "Easee: logged in, token expires in " .. expires_in .. "s") - return true -end - -local function do_refresh() - if not access_token or not refresh_token then return false end - local body = host.json_encode({ - accessToken = access_token, - refreshToken = refresh_token - }) - local resp, err = host.http_post(BASE_URL .. "/accounts/refresh_token", body) - if err then - host.log("warn", "Easee token refresh failed: " .. redact_http_err(err)) - return false - end - local data = host.json_decode(resp) - if not data or not data.accessToken then - host.log("warn", "Easee refresh: no accessToken, will re-login") - return false - end - access_token = data.accessToken - refresh_token = data.refreshToken - local expires_in = data.expiresIn or 3600 - token_expires_at = host.millis() + (expires_in * 1000) - 60000 - host.log("debug", "Easee: token refreshed") - return true -end - -local function ensure_auth(email, password) - if access_token and host.millis() < token_expires_at then - return true - end - -- Try refresh first, fall back to full login - if access_token and do_refresh() then - return true - end - return login(email, password) -end - -local function auth_headers() - return {Authorization = "Bearer " .. (access_token or "")} -end - --- ---- API helpers ---- - -local function get_chargers() - local resp, err = host.http_get(BASE_URL .. "/chargers", auth_headers()) - if err then return nil, err end - return host.json_decode(resp), nil -end - --- Observation IDs (from developer.easee.com/docs/charger-observation-ids) -local OBS_DYN_CURRENT = 48 -local OBS_REASON_NO_CUR = 96 -local OBS_CABLE_LOCKED = 103 -local OBS_OP_MODE = 109 -local OBS_TOTAL_POWER = 120 -local OBS_SESSION_ENERGY = 121 -local OBS_LIFETIME_ENERGY = 124 -local OBS_CURRENT = 183 -local OBS_VOLTAGE = 194 - -local OBS_IDS = "48,96,103,109,120,121,124,183,194" - -local function get_observations(serial) - local url = "https://api.easee.com/state/" .. serial .. "/observations?ids=" .. OBS_IDS - local resp, err = host.http_get(url, auth_headers()) - if err then return nil, err end - local decoded = host.json_decode(resp) - if not decoded then return nil, "decode failed" end - local list = decoded.observations or decoded - local obs = {} - for _, item in ipairs(list) do - if item.id then - obs[item.id] = tonumber(item.value) or item.value - end - end - return obs, nil -end - --- ---- State mapping ---- --- Easee chargerOpMode (observation 109): --- 1 = disconnected (standby) --- 2 = awaiting start (connected, not charging) --- 3 = charging --- 4 = completed --- 5 = error --- 6 = ready to charge - -local OP_MODE_LABELS = { - [0] = "offline", - [1] = "disconnected", - [2] = "awaiting start", - [3] = "charging", - [4] = "completed", - [5] = "error", - [6] = "ready", -} - --- Easee ReasonForNoCurrent. 0 = charger OK (no reason to surface). --- Source: developer.easee.com/docs/enumerations -local REASON_LABELS = { - [0] = nil, -- no reason - [1] = "max circuit current too low", - [2] = "dynamic circuit current too low", - [3] = "offline fallback circuit current too low", - [4] = "circuit fuse too low", - [5] = "waiting in queue", - [6] = "waiting (other cars fully charged)", - [7] = "illegal grid type", - [8] = "no current request from primary", - [9] = "max dynamic charger current too low", - [10] = "phase imbalance", - [11] = "equalizer communication lost", - [25] = "equalizer dynamic limit too low", - [26] = "equalizer static limit too low", - [27] = "offline fallback equalizer too low", - [28] = "fuse limit reached", - [29] = "current limited by equalizer", - [30] = "current limited by offline equalizer", - [50] = "secondary unit not requesting current", - [51] = "max charger current too low", - [52] = "max dynamic charger current too low", - [53] = "charger disabled", - [54] = "pending scheduled charging", - [55] = "pending authorization", - [56] = "charger in error state", - [57] = "erratic EV", - [75] = "limited by cable rating", - [76] = "limited by schedule", - [77] = "limited by charger current", - [78] = "limited by dynamic charger current", - [79] = "car not drawing current", - [80] = "current ramping", - [81] = "limited by car", - -- 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 - --- read_settings GETs the charger's static config block (phaseMode, --- maxChargerCurrent, etc.) and surfaces it via the init log so the --- operator can spot a firmware-locked phaseMode that would silently --- ignore our writes. Easee's API uses `/config` for reads and --- `/settings` for writes — they're not symmetric. Returns the --- decoded table or nil + err string. Active firmware-lock detection --- (compare requested phaseMode after a write) is a follow-up; this --- driver's contribution is the diagnostic logging at init. -local function read_settings(serial) - local resp, err = host.http_get(BASE_URL .. "/chargers/" .. serial .. "/config", auth_headers()) - if err then return nil, redact_http_err(err) end - local decoded = host.json_decode(resp) - if decoded == nil then - return nil, "decode failed (non-JSON response)" - end - return decoded, nil -end - --- write_setting POSTs a single key:value into the charger's settings --- endpoint. Used at init for maxChargerCurrent (one-shot install --- clamp) and for phaseMode / dynamicChargerCurrent on every command. -local function write_setting(serial, body_table) - local _, err = host.http_post( - BASE_URL .. "/chargers/" .. serial .. "/settings", - host.json_encode(body_table), auth_headers()) - return err -end - -function driver_init(config) - host.set_make("Easee") - - email = config and config.email - password = config and config.password - charger_serial = config and config.serial - -- UI writes "" for an unselected