From 9a94802dbb6a2ff711be57bade5904e219ddca88 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 22 Jul 2026 11:58:38 +0200 Subject: [PATCH] Add GoodWe ET field candidate Signed-off-by: Fredrik Ahlgren --- SUPPORT_STATUS.md | 4 +- THIRD_PARTY_NOTICES.md | 28 ++ devices.yaml | 112 ----- drivers/lua/GUIDELINES.md | 4 +- drivers/lua/goodwe.lua | 387 +++++++++--------- drivers/tests/lua_harness/goodwe_fixtures.lua | 76 ++++ drivers/tests/lua_harness/host_mock.lua | 7 + .../lua_harness/test_goodwe_failures.lua | 85 ++-- .../lua_harness/test_goodwe_ftw_contract.lua | 20 +- .../lua_harness/test_goodwe_profiles.lua | 92 +++++ drivers/tests/test_goodwe_failures.py | 25 +- drivers/tests/test_goodwe_ftw_contract.py | 2 +- drivers/tests/test_goodwe_profiles.py | 96 +++++ index.yaml | 6 +- manifests/goodwe.yaml | 80 +--- packages/v1/goodwe/PILOT.md | 71 ++++ packages/v1/goodwe/package-source.json | 101 +++++ support-status-overrides.json | 8 + support-status.json | 14 +- tests/test_goodwe_package.py | 35 ++ 20 files changed, 814 insertions(+), 439 deletions(-) create mode 100644 drivers/tests/lua_harness/goodwe_fixtures.lua create mode 100644 drivers/tests/lua_harness/test_goodwe_profiles.lua create mode 100644 drivers/tests/test_goodwe_profiles.py create mode 100644 packages/v1/goodwe/PILOT.md create mode 100644 packages/v1/goodwe/package-source.json create mode 100644 tests/test_goodwe_package.py diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index fc0e097..9333001 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -50,8 +50,8 @@ Catalog source is not proof that a target can install or run a driver. | goe | 1.0.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | goe_http | 1.0.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | goe_http | 1.0.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goodwe | 1.0.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goodwe | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| goodwe | 1.0.2 | ftw-core | contract_passed | 1.0.2 | — | gw8kn_et_hk3000_required | — | profile_selected | no | +| goodwe | 1.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | growatt | 1.0.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | growatt | 1.0.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | hardybarth | 1.0.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ab7fa3..ea5bf4d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,3 +4,31 @@ Copyright © 1994–2024 Lua.org, PUC-Rio. Lua is distributed under the MIT license. The full notice is retained at the end of `tools/lua55-src/lua.h`. + +## GoodWe GW8KN-ET register profile + +The `gw8kn-et-hk3000` register profile in `drivers/lua/goodwe.lua` derives from +`srcfl/hugin-drivers/drivers/goodwe_et.lua` at commit +`3125960a80b5237e3a5ac609963ddb1302367938`. frekes81 contributed the source. + +MIT License + +Copyright (c) 2026 Sourceful Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/devices.yaml b/devices.yaml index 84afac3..8aa6b98 100644 --- a/devices.yaml +++ b/devices.yaml @@ -464,118 +464,6 @@ manufacturers: control: false firmware_versions: "" notes: "Three-phase hybrid inverter, tested" - - name: "GoodWe" - model_families: - - name: "BH Series (Single-Phase Hybrid LV)" - variants: [BH10, BH5, BH8] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "BP Series (Three-Phase AC-Coupled)" - variants: [BP10, BP5, BP8] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "BT Series (Three-Phase Hybrid LV)" - variants: [BT10, BT5, BT6, BT8] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "DNS Series (Single-Phase String)" - variants: [DNS3, DNS4, DNS5, DNS6] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "DT Series (Three-Phase String)" - variants: [DT10, DT15, DT17, DT20, DT25] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "EH Series (Single-Phase Hybrid)" - variants: [EH10, EH15, EH5, EH8] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "EM Series (Single-Phase Hybrid)" - variants: [EM3, EM5] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "ES Series (Single-Phase Hybrid)" - variants: [ES3.6, ES4.6, ES5.0] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "ET Series (Three-Phase Hybrid)" - variants: [ET15, ET16, ET20, ET25, ET30] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - - name: "XS Series (Micro)" - variants: [XS2, XS3] - regions: [] - protocols: - - protocol: modbus - driver: "goodwe" - version: "1.0.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - name: "Growatt" model_families: - name: "MAX Series (Three-Phase Commercial)" diff --git a/drivers/lua/GUIDELINES.md b/drivers/lua/GUIDELINES.md index 63bf1e7..f5d144b 100644 --- a/drivers/lua/GUIDELINES.md +++ b/drivers/lua/GUIDELINES.md @@ -119,7 +119,9 @@ if ok and regs then end ``` -Never let an I/O error crash your tick. A failed read returns an error; pcall catches it. Use `0` as fallback for failed reads, not `nil`. +Never let an I/O error crash your tick. A failed read returns an error; pcall +catches it. Do not emit a fabricated zero. Return without an emit when a core +read fails, and omit only fields whose reads are truly optional. ### 6. Return early when there's nothing to do diff --git a/drivers/lua/goodwe.lua b/drivers/lua/goodwe.lua index 2cea6c3..927ffbc 100644 --- a/drivers/lua/goodwe.lua +++ b/drivers/lua/goodwe.lua @@ -1,24 +1,27 @@ --- GoodWe ET/EH/BT/BH/ES/DT Series Inverter Driver --- Emits: PV, Battery, Meter --- Register type: HOLDING (FC 0x03) --- Byte order: Big-Endian for multi-register values --- Port: 502 --- Community tier (untested) +-- GoodWe read-only inverter driver. +-- +-- Register type: holding registers (Modbus FC03). +-- Multi-register values use the high word first. +-- +-- The gw8kn-et-hk3000 profile is based on the MIT-licensed field profile in +-- srcfl/hugin-drivers@3125960a80b5237e3a5ac609963ddb1302367938. frekes81 +-- contributed that profile from a GW8KN-ET with an HK3000 meter. FTW hardware +-- approval still needs the pilot in packages/v1/goodwe/PILOT.md. DRIVER = { id = "goodwe", - name = "GoodWe hybrid and string inverter", + name = "GoodWe inverter", manufacturer = "GoodWe", - version = "1.0.1", + version = "1.0.2", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, capabilities = { "pv", "battery", "meter" }, - description = "GoodWe ET, EH, BT, BH, ES, EM, BP, DT, DNS, and XS inverter families.", - authors = { "Sourceful contributors" }, + description = "GoodWe telemetry with explicit register-map profiles.", + authors = { "frekes81", "Sourceful contributors" }, tested_models = {}, verification_status = "experimental", - verification_notes = "Community driver; physical model and firmware coverage still needs verification.", + verification_notes = "GW8KN-ET and HK3000 need the documented FTW hardware pilot before stable use.", read_only = true, connection_defaults = { port = 502, @@ -28,214 +31,203 @@ DRIVER = { PROTOCOL = "modbus" -function driver_init(config) - host.set_make("GoodWe") -end - -function driver_poll() - -- ---- PV ---- - - -- PV total power: 35105-35106, U32 BE, 0.1W - local ok_pvw, pvw_regs = pcall(host.modbus_read, 35105, 2, "holding") - local pv_w = nil - if ok_pvw and pvw_regs and pvw_regs[1] and pvw_regs[2] then - pv_w = host.decode_u32_be(pvw_regs[1], pvw_regs[2]) * 0.1 - end +local profile = nil - -- PV1 voltage: 35103, U16 × 0.1V; PV1 current: 35104, U16 × 0.1A - local ok_m1, m1_regs = pcall(host.modbus_read, 35103, 2, "holding") - local mppt1_v, mppt1_a = nil, nil - if ok_m1 and m1_regs and m1_regs[1] and m1_regs[2] then - mppt1_v = m1_regs[1] * 0.1 - mppt1_a = m1_regs[2] * 0.1 +local function holding(address, count) + local ok, registers = pcall(host.modbus_read, address, count, "holding") + if not ok then + error("GoodWe holding read failed at " .. address) end - - -- PV2 voltage: 35109, U16 × 0.1V; PV2 current: 35110, U16 × 0.1A - local ok_m2, m2_regs = pcall(host.modbus_read, 35109, 2, "holding") - local mppt2_v, mppt2_a = nil, nil - if ok_m2 and m2_regs and m2_regs[1] and m2_regs[2] then - mppt2_v = m2_regs[1] * 0.1 - mppt2_a = m2_regs[2] * 0.1 - end - - -- Grid frequency: 35113, U16 × 0.01Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 35113, 1, "holding") - local hz = nil - if ok_hz and hz_regs and hz_regs[1] then - hz = hz_regs[1] * 0.01 - end - - -- PV generation energy: 35191-35192, U32 BE × 0.1 kWh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 35191, 2, "holding") - local pv_gen_wh = nil - if ok_pvgen and pvgen_regs and pvgen_regs[1] and pvgen_regs[2] then - pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 - end - - -- Emit PV telemetry (W always negative for generation) - if pv_w ~= nil then - host.emit("pv", { - w = -pv_w, - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - lifetime_wh = pv_gen_wh, - }) - end - - -- ---- Battery ---- - - -- Battery voltage: 35178, U16 × 0.1V - local ok_bv, bv_regs = pcall(host.modbus_read, 35178, 1, "holding") - local bat_v = nil - if ok_bv and bv_regs and bv_regs[1] then - bat_v = bv_regs[1] * 0.1 - end - - -- Battery current: 35179, I16 × 0.1A - local ok_ba, ba_regs = pcall(host.modbus_read, 35179, 1, "holding") - local bat_a = nil - if ok_ba and ba_regs and ba_regs[1] then - bat_a = host.decode_i16(ba_regs[1]) * 0.1 - end - - -- Battery power: 35180, I16, W (positive=charge, negative=discharge) - local ok_bw, bw_regs = pcall(host.modbus_read, 35180, 1, "holding") - local bat_w = nil - if ok_bw and bw_regs and bw_regs[1] then - bat_w = host.decode_i16(bw_regs[1]) - end - - -- Battery SoC: 35182, U16, % - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 35182, 1, "holding") - local bat_soc = nil - if ok_bsoc and bsoc_regs and bsoc_regs[1] then - bat_soc = bsoc_regs[1] / 100 -- percent to fraction + if type(registers) ~= "table" or #registers < count then + error("GoodWe holding read at " .. address .. " returned fewer than " .. count .. " registers") end + return registers +end - -- Battery temperature: 35183, I16 × 0.1C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 35183, 1, "holding") - local bat_temp = nil - if ok_btemp and btemp_regs and btemp_regs[1] then - bat_temp = host.decode_i16(btemp_regs[1]) * 0.1 +local function nonnegative_i16(raw, address) + local value = host.decode_i16(raw) + if value < 0 then + error("GoodWe holding register " .. address .. " returned an invalid negative power") end + return value +end - -- Emit Battery telemetry - if bat_w ~= nil then +local function emit_battery(w, soc, v, a, temp_c) + if v > 0 or soc > 0 or w ~= 0 then host.emit("battery", { - w = bat_w, - v = bat_v, - a = bat_a, - soc = bat_soc, - temp_c = bat_temp, + w = w, + soc = soc, + v = v, + a = a, + temp_c = temp_c, }) end +end - -- ---- Meter ---- - - -- Meter total power: 35140-35141, I32 BE, W - -- GoodWe: positive = export from grid perspective, negate for our convention (positive=import) - local ok_mw, mw_regs = pcall(host.modbus_read, 35140, 2, "holding") - local meter_w = nil - if ok_mw and mw_regs and mw_regs[1] and mw_regs[2] then - meter_w = -host.decode_i32_be(mw_regs[1], mw_regs[2]) - end - - -- Per-phase meter power: L1=35132, L2=35134, L3=35136, I32 BE each pair - local ok_l1w, l1w_regs = pcall(host.modbus_read, 35132, 2, "holding") - local l1_w = nil - if ok_l1w and l1w_regs and l1w_regs[1] and l1w_regs[2] then - l1_w = -host.decode_i32_be(l1w_regs[1], l1w_regs[2]) - end - - local ok_l2w, l2w_regs = pcall(host.modbus_read, 35134, 2, "holding") - local l2_w = nil - if ok_l2w and l2w_regs and l2w_regs[1] and l2w_regs[2] then - l2_w = -host.decode_i32_be(l2w_regs[1], l2w_regs[2]) - end - - local ok_l3w, l3w_regs = pcall(host.modbus_read, 35136, 2, "holding") - local l3_w = nil - if ok_l3w and l3w_regs and l3w_regs[1] and l3w_regs[2] then - l3_w = -host.decode_i32_be(l3w_regs[1], l3w_regs[2]) - end - - -- Grid voltages: L1=35121, L2=35123, L3=35125, U16 × 0.1V - local ok_lv1, lv1_regs = pcall(host.modbus_read, 35121, 1, "holding") - local l1_v = nil - if ok_lv1 and lv1_regs and lv1_regs[1] then - l1_v = lv1_regs[1] * 0.1 - end - - local ok_lv2, lv2_regs = pcall(host.modbus_read, 35123, 1, "holding") - local l2_v = nil - if ok_lv2 and lv2_regs and lv2_regs[1] then - l2_v = lv2_regs[1] * 0.1 - end +local function poll_community_v1() + -- Keep the 1.0.1 read boundaries until this profile has its own raw + -- fixture and hardware test. Read all 22 fields before the first emit. + local pv_total = holding(35105, 2) + local mppt1 = holding(35103, 2) + local mppt2 = holding(35109, 2) + local frequency = holding(35113, 1) + local pv_energy = holding(35191, 2) + local battery_voltage = holding(35178, 1) + local battery_current = holding(35179, 1) + local battery_power = holding(35180, 1) + local battery_soc = holding(35182, 1) + local battery_temp = holding(35183, 1) + local meter_total = holding(35140, 2) + local meter_l1_power = holding(35132, 2) + local meter_l2_power = holding(35134, 2) + local meter_l3_power = holding(35136, 2) + local meter_l1_voltage = holding(35121, 1) + local meter_l2_voltage = holding(35123, 1) + local meter_l3_voltage = holding(35125, 1) + local meter_l1_current = holding(35122, 1) + local meter_l2_current = holding(35124, 1) + local meter_l3_current = holding(35126, 1) + local import_energy = holding(35195, 2) + local export_energy = holding(35199, 2) + + local pv_w = host.decode_u32_be(pv_total[1], pv_total[2]) * 0.1 + host.emit("pv", { + w = -pv_w, + mppt1_v = mppt1[1] * 0.1, + mppt1_a = mppt1[2] * 0.1, + mppt2_v = mppt2[1] * 0.1, + mppt2_a = mppt2[2] * 0.1, + lifetime_wh = host.decode_u32_be(pv_energy[1], pv_energy[2]) * 100, + }) + + local bat_w = host.decode_i16(battery_power[1]) + local bat_soc = battery_soc[1] / 100 + emit_battery( + bat_w, + bat_soc, + battery_voltage[1] * 0.1, + host.decode_i16(battery_current[1]) * 0.1, + host.decode_i16(battery_temp[1]) * 0.1 + ) + + host.emit("meter", { + w = -host.decode_i32_be(meter_total[1], meter_total[2]), + l1_w = -host.decode_i32_be(meter_l1_power[1], meter_l1_power[2]), + l2_w = -host.decode_i32_be(meter_l2_power[1], meter_l2_power[2]), + l3_w = -host.decode_i32_be(meter_l3_power[1], meter_l3_power[2]), + l1_v = meter_l1_voltage[1] * 0.1, + l2_v = meter_l2_voltage[1] * 0.1, + l3_v = meter_l3_voltage[1] * 0.1, + l1_a = meter_l1_current[1] * 0.1, + l2_a = meter_l2_current[1] * 0.1, + l3_a = meter_l3_current[1] * 0.1, + hz = frequency[1] * 0.01, + import_wh = host.decode_u32_be(import_energy[1], import_energy[2]) * 100, + export_wh = host.decode_u32_be(export_energy[1], export_energy[2]) * 100, + }) - local ok_lv3, lv3_regs = pcall(host.modbus_read, 35125, 1, "holding") - local l3_v = nil - if ok_lv3 and lv3_regs and lv3_regs[1] then - l3_v = lv3_regs[1] * 0.1 - end + return 5000 +end - -- Grid currents: L1=35122, L2=35124, L3=35126, U16 × 0.1A - local ok_la1, la1_regs = pcall(host.modbus_read, 35122, 1, "holding") +local function poll_gw8kn_et_hk3000() + -- 35107..35110: PV1 V, A, ignored 35109 and I16 string power. + local pv = holding(35107, 4) + if not pv then return 5000 end + + -- 35123: grid frequency. + local frequency = holding(35123, 1) + if not frequency then return 5000 end + + -- 35125..35135: inverter phase power at offsets 0, 5 and 10. + local inverter_phase = holding(35125, 11) + if not inverter_phase then return 5000 end + + -- 35138..35140: I16 inverter total, ignored 35139 and I16 meter total. + local ac_total = holding(35138, 3) + if not ac_total then return 5000 end + + -- 35145..35157: phase voltage at offsets 0, 6 and 12. + local meter_voltage = holding(35145, 13) + if not meter_voltage then return 5000 end + + -- 35164..35168: phase load at offsets 0, 2 and 4. + local load = holding(35164, 5) + if not load then return 5000 end + + -- 35178..35183: battery V, ignored current, W, reserved, SoC and temp. + local battery = holding(35178, 6) + if not battery then return 5000 end + + -- 35195..35199: import counter, ignored 35197 and export counter. + local energy = holding(35195, 5) + if not energy then return 5000 end + + local l1_v = meter_voltage[1] * 0.1 + local l2_v = meter_voltage[7] * 0.1 + local l3_v = meter_voltage[13] * 0.1 + local pv_w = nonnegative_i16(ac_total[1], 35138) + local l1_w = load[1] - host.decode_i16(inverter_phase[1]) + local l2_w = load[3] - host.decode_i16(inverter_phase[6]) + local l3_w = load[5] - host.decode_i16(inverter_phase[11]) local l1_a = nil - if ok_la1 and la1_regs and la1_regs[1] then - l1_a = la1_regs[1] * 0.1 - end - - local ok_la2, la2_regs = pcall(host.modbus_read, 35124, 1, "holding") local l2_a = nil - if ok_la2 and la2_regs and la2_regs[1] then - l2_a = la2_regs[1] * 0.1 - end - - local ok_la3, la3_regs = pcall(host.modbus_read, 35126, 1, "holding") local l3_a = nil - if ok_la3 and la3_regs and la3_regs[1] then - l3_a = la3_regs[1] * 0.1 - end + if l1_v > 0 then l1_a = l1_w / l1_v end + if l2_v > 0 then l2_a = l2_w / l2_v end + if l3_v > 0 then l3_a = l3_w / l3_v end + + host.emit("pv", { + w = -pv_w, + mppt1_v = pv[1] * 0.1, + mppt1_a = pv[2] * 0.1, + }) + + local bat_soc = battery[5] / 100 + emit_battery( + host.decode_i16(battery[3]), + bat_soc, + battery[1] * 0.1, + nil, + host.decode_i16(battery[6]) * 0.1 + ) + + host.emit("meter", { + w = -host.decode_i16(ac_total[3]), + 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 = frequency[1] * 0.01, + import_wh = host.decode_u32_be(energy[1], energy[2]) * 100, + export_wh = host.decode_u32_be(energy[4], energy[5]) * 100, + }) - -- Total import energy: 35195-35196, U32 BE × 0.1 kWh - local ok_imp, imp_regs = pcall(host.modbus_read, 35195, 2, "holding") - local import_wh = nil - if ok_imp and imp_regs and imp_regs[1] and imp_regs[2] then - import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end + return 5000 +end - -- Total export energy: 35199-35200, U32 BE × 0.1 kWh - local ok_exp, exp_regs = pcall(host.modbus_read, 35199, 2, "holding") - local export_wh = nil - if ok_exp and exp_regs and exp_regs[1] and exp_regs[2] then - export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.1 * 1000 - end - -- Emit Meter telemetry - if meter_w ~= nil then - 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, - }) +function driver_init(config) + config = config or {} + profile = tostring(config.profile or "community-v1") + if profile ~= "community-v1" and profile ~= "gw8kn-et-hk3000" then + error("unsupported GoodWe register profile: " .. profile) end + host.set_make("GoodWe") +end - return 5000 +function driver_poll() + if profile == "gw8kn-et-hk3000" then + return poll_gw8kn_et_hk3000() + end + return poll_community_v1() end -function driver_command(action, power_w, cmd) +function driver_command(action, power_w, command) return false end @@ -243,5 +235,4 @@ function driver_default_mode() end function driver_cleanup() - -- nothing to clean up end diff --git a/drivers/tests/lua_harness/goodwe_fixtures.lua b/drivers/tests/lua_harness/goodwe_fixtures.lua new file mode 100644 index 0000000..a869468 --- /dev/null +++ b/drivers/tests/lua_harness/goodwe_fixtures.lua @@ -0,0 +1,76 @@ +-- Synthetic fixtures. Replace or add masked hardware captures after the pilot. + +return { + ["community-v1"] = { + batches = { + {35105, 2}, {35103, 2}, {35109, 2}, {35113, 1}, {35191, 2}, + {35178, 1}, {35179, 1}, {35180, 1}, {35182, 1}, {35183, 1}, + {35140, 2}, {35132, 2}, {35134, 2}, {35136, 2}, + {35121, 1}, {35123, 1}, {35125, 1}, + {35122, 1}, {35124, 1}, {35126, 1}, + {35195, 2}, {35199, 2}, + }, + registers = { + [35103] = {3500, 80}, + [35105] = {0, 50000}, + [35109] = {3400, 70}, + [35113] = {5000}, + [35191] = {0, 2000}, + [35178] = {5000}, + [35179] = {65436}, + [35180] = {1200}, + [35182] = {75}, + [35183] = {250}, + [35140] = {65535, 64036}, + [35132] = {65535, 65036}, + [35134] = {65535, 65036}, + [35136] = {65535, 65036}, + [35121] = {2300}, + [35123] = {2310}, + [35125] = {2290}, + [35122] = {50}, + [35124] = {55}, + [35126] = {60}, + [35195] = {0, 1000}, + [35199] = {0, 500}, + }, + failure_address = 35134, + expected = { + pv_w = -5000, + meter_w = 1500, + l1_w = 500, + battery_w = 1200, + soc = 0.75, + hz = 50, + import_wh = 100000, + export_wh = 50000, + }, + }, + ["gw8kn-et-hk3000"] = { + batches = { + {35107, 4}, {35123, 1}, {35125, 11}, {35138, 3}, + {35145, 13}, {35164, 5}, {35178, 6}, {35195, 5}, + }, + registers = { + [35107] = {3500, 80, 0, 2800}, + [35123] = {5000}, + [35125] = {1000, 0, 0, 0, 0, 1100, 0, 0, 0, 0, 1200}, + [35138] = {5000, 0, 64036}, + [35145] = {2300, 0, 0, 0, 0, 0, 2310, 0, 0, 0, 0, 0, 2290}, + [35164] = {1500, 0, 1600, 0, 1700}, + [35178] = {5000, 0, 1200, 0, 75, 250}, + [35195] = {0, 1000, 0, 0, 500}, + }, + failure_address = 35145, + expected = { + pv_w = -5000, + meter_w = 1500, + l1_w = 500, + battery_w = 1200, + soc = 0.75, + hz = 50, + import_wh = 100000, + export_wh = 50000, + }, + }, +} diff --git a/drivers/tests/lua_harness/host_mock.lua b/drivers/tests/lua_harness/host_mock.lua index 5929c1d..4fbe243 100644 --- a/drivers/tests/lua_harness/host_mock.lua +++ b/drivers/tests/lua_harness/host_mock.lua @@ -50,6 +50,7 @@ host._modbus_write_fail_at = nil host._modbus_write_attempts = 0 host._modbus_read_error = nil host._modbus_read_fail_addresses = {} +host._modbus_read_short_counts = {} -- Internal counters host._millis_counter = 0 @@ -86,6 +87,7 @@ function host.reset() host._modbus_write_attempts = 0 host._modbus_read_error = nil host._modbus_read_fail_addresses = {} + host._modbus_read_short_counts = {} host._serial_buffer = "" host._millis_counter = 0 end @@ -163,6 +165,11 @@ function host.modbus_read(addr, count, kind) error(tostring(read_error)) end + local short_count = host._modbus_read_short_counts[addr] + if short_count ~= nil and short_count >= 0 and short_count < count then + count = short_count + end + local reg_map = host._modbus_registers[kind] if not reg_map then error("modbus_read: no register data for kind '" .. kind .. "'") diff --git a/drivers/tests/lua_harness/test_goodwe_failures.lua b/drivers/tests/lua_harness/test_goodwe_failures.lua index 0478f97..d60c6be 100644 --- a/drivers/tests/lua_harness/test_goodwe_failures.lua +++ b/drivers/tests/lua_harness/test_goodwe_failures.lua @@ -1,38 +1,67 @@ -local scenario = arg[1] -if not scenario then - error("failure scenario is required") +local profile = arg[1] +local scenario = arg[2] +if not profile or not scenario then + error("profile and failure scenario are required") end dofile("drivers/tests/lua_harness/host_mock.lua") -host.reset() -dofile("drivers/lua/goodwe.lua") -driver_init({}) +local fixtures = dofile("drivers/tests/lua_harness/goodwe_fixtures.lua") +local fixture = fixtures[profile] +if not fixture then error("unknown fixture profile: " .. tostring(profile)) end + +local function load_fixture() + host.reset() + for address, registers in pairs(fixture.registers) do + host._modbus_registers.holding[address] = registers + end + dofile("drivers/lua/goodwe.lua") + driver_init({profile = profile}) +end + +local function emission_count() + local count = 0 + for _, stream in ipairs({"pv", "battery", "meter"}) do + count = count + ((host._emitted[stream] and #host._emitted[stream]) or 0) + end + return count +end + +load_fixture() -local expected = {pv = true, battery = true, meter = true} if scenario == "all" then host._modbus_read_error = "simulated Modbus timeout" - expected = {} -elseif scenario == "pv" then - host._modbus_read_fail_addresses[35105] = "simulated PV power timeout" - expected.pv = nil -elseif scenario == "battery" then - host._modbus_read_fail_addresses[35180] = "simulated battery power timeout" - expected.battery = nil -elseif scenario == "meter" then - host._modbus_read_fail_addresses[35140] = "simulated meter power timeout" - expected.meter = nil + local ok = pcall(driver_poll) + if ok then error("total timeout did not fail the poll") end + if emission_count() ~= 0 then error("total timeout emitted telemetry") end +elseif scenario == "middle" then + host._modbus_read_fail_addresses[fixture.failure_address] = "simulated batch timeout" + local ok = pcall(driver_poll) + if ok then error("middle batch timeout did not fail the poll") end + if emission_count() ~= 0 then error("middle batch timeout emitted telemetry") end +elseif scenario == "short" then + local last = fixture.batches[#fixture.batches] + host._modbus_read_short_counts[last[1]] = last[2] - 1 + local ok = pcall(driver_poll) + if ok then error("short batch did not fail the poll") end + if emission_count() ~= 0 then error("short batch emitted telemetry") end +elseif scenario == "sentinel" then + if profile ~= "gw8kn-et-hk3000" then error("sentinel is only a GW8KN fixture") end + fixture.registers[35138][1] = 65535 + local ok = pcall(driver_poll) + if ok then error("negative PV sentinel did not fail the poll") end + if emission_count() ~= 0 then error("negative PV sentinel emitted telemetry") end +elseif scenario == "recover" then + driver_poll() + if emission_count() ~= 3 then error("first complete poll did not emit all streams") end + host._modbus_read_fail_addresses[fixture.failure_address] = "simulated mute session" + local ok = pcall(driver_poll) + if ok then error("mute session did not fail the poll") end + if emission_count() ~= 3 then error("failed recovery poll emitted telemetry") end + host._modbus_read_fail_addresses = {} + driver_poll() + if emission_count() ~= 6 then error("post-reconnect poll did not recover") end else error("unknown failure scenario: " .. scenario) end -local ok, poll_error = pcall(driver_poll) -if not ok then - error("driver_poll raised: " .. tostring(poll_error)) -end - -for _, stream in ipairs({"pv", "battery", "meter"}) do - local emitted = (host._emitted[stream] and #host._emitted[stream] > 0) or false - if emitted ~= (expected[stream] == true) then - error(string.format("%s emission=%s, expected=%s", stream, tostring(emitted), tostring(expected[stream] == true))) - end -end +if host._modbus_write_attempts ~= 0 then error("read-only failure path attempted a write") end diff --git a/drivers/tests/lua_harness/test_goodwe_ftw_contract.lua b/drivers/tests/lua_harness/test_goodwe_ftw_contract.lua index 4f79c86..03c3fb5 100644 --- a/drivers/tests/lua_harness/test_goodwe_ftw_contract.lua +++ b/drivers/tests/lua_harness/test_goodwe_ftw_contract.lua @@ -21,7 +21,7 @@ end dofile("drivers/lua/goodwe.lua") -if type(DRIVER) ~= "table" or DRIVER.id ~= "goodwe" or DRIVER.version ~= "1.0.1" then +if type(DRIVER) ~= "table" or DRIVER.id ~= "goodwe" or DRIVER.version ~= "1.0.2" then error("GoodWe FTW identity metadata is wrong") end if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 or DRIVER.read_only ~= true then @@ -30,25 +30,27 @@ end if type(DRIVER.protocols) ~= "table" or DRIVER.protocols[1] ~= "modbus" then error("GoodWe FTW protocol metadata is wrong") end +if DRIVER.connection_defaults.unit_id ~= 1 then + error("GoodWe legacy connection default changed") +end if type(driver_init) ~= "function" or type(driver_poll) ~= "function" or type(driver_command) ~= "function" or type(driver_default_mode) ~= "function" then error("GoodWe FTW lifecycle is incomplete") end -host._modbus_registers.holding[35105] = {0, 100} -host._modbus_registers.holding[35140] = {0, 100} -host._modbus_registers.holding[35191] = {0, 20} -host._modbus_registers.holding[35195] = {0, 30} -host._modbus_registers.holding[35199] = {0, 40} +local fixture = dofile("drivers/tests/lua_harness/goodwe_fixtures.lua")["gw8kn-et-hk3000"] +for address, registers in pairs(fixture.registers) do + host._modbus_registers.holding[address] = registers +end -driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) +driver_init({profile = "gw8kn-et-hk3000"}) local ok, poll_error = pcall(driver_poll) if not ok then error("GoodWe failed against the FTW v1 host: " .. tostring(poll_error)) end if host._make ~= "GoodWe" then error("GoodWe did not report its make") end -if not host._emitted.pv or host._emitted.pv[1].w ~= -10 then +if not host._emitted.pv or host._emitted.pv[1].w ~= -5000 then error("GoodWe FTW PV decode or sign is wrong") end -if not host._emitted.meter or host._emitted.meter[1].w ~= -100 then +if not host._emitted.meter or host._emitted.meter[1].w ~= 1500 then error("GoodWe FTW meter decode or sign is wrong") end diff --git a/drivers/tests/lua_harness/test_goodwe_profiles.lua b/drivers/tests/lua_harness/test_goodwe_profiles.lua new file mode 100644 index 0000000..abd9c7f --- /dev/null +++ b/drivers/tests/lua_harness/test_goodwe_profiles.lua @@ -0,0 +1,92 @@ +local profile = arg[1] +local mode = arg[2] or "import-charge" +local init_mode = arg[3] or "explicit" +if not profile then error("profile is required") end + +dofile("drivers/tests/lua_harness/host_mock.lua") +host.reset() +local fixtures = dofile("drivers/tests/lua_harness/goodwe_fixtures.lua") +local fixture = fixtures[profile] +if not fixture then error("unknown fixture profile: " .. tostring(profile)) end +if mode == "export-discharge" then + if profile == "community-v1" then + fixture.registers[35132] = {0, 500} + fixture.registers[35134] = {0, 500} + fixture.registers[35136] = {0, 500} + fixture.registers[35140] = {0, 1500} + fixture.registers[35180][1] = 64336 + else + fixture.registers[35138][3] = 1500 + fixture.registers[35164][1] = 500 + fixture.registers[35164][3] = 600 + fixture.registers[35164][5] = 700 + fixture.registers[35178][3] = 64336 + end + fixture.expected.meter_w = -1500 + fixture.expected.l1_w = -500 + fixture.expected.battery_w = -1200 +elseif mode == "zero-voltage" then + if profile ~= "gw8kn-et-hk3000" then error("zero-voltage is only a GW8KN fixture") end + fixture.registers[35145][1] = 0 + fixture.registers[35145][7] = 0 + fixture.registers[35145][13] = 0 +elseif mode == "night-zero" then + if profile ~= "gw8kn-et-hk3000" then error("night-zero is only a GW8KN fixture") end + fixture.registers[35138][1] = 0 + fixture.expected.pv_w = 0 +elseif mode ~= "import-charge" then + error("unknown fixture mode: " .. mode) +end +for address, registers in pairs(fixture.registers) do + host._modbus_registers.holding[address] = registers +end + +dofile("drivers/lua/goodwe.lua") +if init_mode == "legacy-default" then + driver_init({}) +else + driver_init({profile = profile}) +end +driver_poll() + +local function close(actual, expected, name) + if actual == nil or math.abs(actual - expected) > 0.000001 then + error(string.format("%s=%s, expected=%s", name, tostring(actual), tostring(expected))) + end +end + +local expected = fixture.expected +close(host._emitted.pv[1].w, expected.pv_w, "pv.w") +close(host._emitted.meter[1].w, expected.meter_w, "meter.w") +close(host._emitted.meter[1].l1_w, expected.l1_w, "meter.l1_w") +close(host._emitted.battery[1].w, expected.battery_w, "battery.w") +close(host._emitted.battery[1].soc, expected.soc, "battery.soc") +close(host._emitted.meter[1].hz, expected.hz, "meter.hz") +close(host._emitted.meter[1].import_wh, expected.import_wh, "meter.import_wh") +close(host._emitted.meter[1].export_wh, expected.export_wh, "meter.export_wh") +if mode == "zero-voltage" then + if host._emitted.meter[1].l1_a ~= nil or host._emitted.meter[1].l2_a ~= nil or + host._emitted.meter[1].l3_a ~= nil then + error("zero voltage fabricated a phase current") + end +end + +local reads = {} +for _, call in ipairs(host._calls) do + if call.func == "modbus_read" then table.insert(reads, call.args) end +end +if #reads ~= #fixture.batches then + error(string.format("profile %s made %d reads, expected %d", profile, #reads, #fixture.batches)) +end +for index, batch in ipairs(fixture.batches) do + local read = reads[index] + if read[1] ~= batch[1] or read[2] ~= batch[2] or read[3] ~= "holding" then + error(string.format("read %d was %s/%s/%s", index, tostring(read[1]), tostring(read[2]), tostring(read[3]))) + end +end + +local writes_before = host._modbus_write_attempts +if driver_command("battery", 1000, {}) ~= false then error("read-only command was accepted") end +driver_default_mode() +driver_cleanup() +if host._modbus_write_attempts ~= writes_before then error("read-only lifecycle attempted a write") end diff --git a/drivers/tests/test_goodwe_failures.py b/drivers/tests/test_goodwe_failures.py index 83ee607..023f744 100644 --- a/drivers/tests/test_goodwe_failures.py +++ b/drivers/tests/test_goodwe_failures.py @@ -1,4 +1,4 @@ -"""GoodWe must not turn failed Modbus reads into fresh zero data.""" +"""GoodWe failed reads must not become fresh zero or partial telemetry.""" from pathlib import Path import subprocess @@ -7,14 +7,17 @@ ROOT = Path(__file__).resolve().parents[2] +PROFILES = ["community-v1", "gw8kn-et-hk3000"] -@pytest.mark.parametrize("scenario", ["all", "pv", "battery", "meter"]) -def test_goodwe_suppresses_stream_with_failed_primary_read(scenario): +@pytest.mark.parametrize("profile", PROFILES) +@pytest.mark.parametrize("scenario", ["all", "middle", "short", "recover"]) +def test_goodwe_poll_is_atomic_and_recovers(profile, scenario): result = subprocess.run( [ str(ROOT / "lua55"), "drivers/tests/lua_harness/test_goodwe_failures.lua", + profile, scenario, ], cwd=ROOT, @@ -23,3 +26,19 @@ def test_goodwe_suppresses_stream_with_failed_primary_read(scenario): check=False, ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_goodwe_negative_pv_sentinel_fails_before_emit(): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_goodwe_failures.lua", + "gw8kn-et-hk3000", + "sentinel", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_goodwe_ftw_contract.py b/drivers/tests/test_goodwe_ftw_contract.py index fb8ef04..24554ff 100644 --- a/drivers/tests/test_goodwe_ftw_contract.py +++ b/drivers/tests/test_goodwe_ftw_contract.py @@ -17,7 +17,7 @@ def test_goodwe_public_source_has_ftw_managed_metadata(): source, target="ftw-core", read_only=True, - package_version="1.0.1", + package_version="1.0.2", runtime_abi="gopher-lua-source-v1", ) diff --git a/drivers/tests/test_goodwe_profiles.py b/drivers/tests/test_goodwe_profiles.py new file mode 100644 index 0000000..aca302a --- /dev/null +++ b/drivers/tests/test_goodwe_profiles.py @@ -0,0 +1,96 @@ +"""Replay synthetic GoodWe fixtures and pin each Modbus batch.""" + +from pathlib import Path +import subprocess + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize("profile", ["community-v1", "gw8kn-et-hk3000"]) +@pytest.mark.parametrize("mode", ["import-charge", "export-discharge"]) +def test_goodwe_profile_fixture(profile, mode): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_goodwe_profiles.lua", + profile, + mode, + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_goodwe_rejects_unknown_register_profile(): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "-e", + ( + 'dofile("drivers/tests/lua_harness/host_mock.lua");' + 'dofile("drivers/lua/goodwe.lua");' + 'driver_init({profile="auto"})' + ), + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "unsupported GoodWe register profile" in result.stderr + + +def test_goodwe_empty_legacy_config_selects_community_v1(): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_goodwe_profiles.lua", + "community-v1", + "import-charge", + "legacy-default", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_goodwe_zero_voltage_omits_derived_phase_current(): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_goodwe_profiles.lua", + "gw8kn-et-hk3000", + "zero-voltage", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_goodwe_raw_zero_pv_is_valid_at_night(): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_goodwe_profiles.lua", + "gw8kn-et-hk3000", + "night-zero", + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/index.yaml b/index.yaml index d8217e9..20c2665 100644 --- a/index.yaml +++ b/index.yaml @@ -185,13 +185,13 @@ drivers: size_bytes: 3340 sha256: "48b6d9ded6043e9b42a09bf03bd8edbab6363b18781e2279e957eee237556d25" - name: "goodwe" - version: "1.0.1" + version: "1.0.2" tier: community protocol: modbus ders: [pv, battery, meter] control: false - size_bytes: 8055 - sha256: "be88e2cee62989558551ff0516211981323c6c76946f604495eb3f478f3f61e8" + size_bytes: 7634 + sha256: "117f7cb0273b2be6d7b4f2d01b798272e41817626a8b9e4365ae9517a5dbcc92" - name: "growatt" version: "1.0.0" tier: community diff --git a/manifests/goodwe.yaml b/manifests/goodwe.yaml index 322d2b9..a70ed9a 100644 --- a/manifests/goodwe.yaml +++ b/manifests/goodwe.yaml @@ -1,82 +1,12 @@ name: "goodwe" -version: "1.0.1" +version: "1.0.2" tier: community -author: "Sourceful Labs AB" +author: "frekes81 and Sourceful contributors" protocol: modbus ders: [pv, battery, meter] control: false -tested_devices: - - manufacturer: "GoodWe" - model_family: "ET Series (Three-Phase Hybrid)" - variants: [ET15, ET16, ET20, ET25, ET30] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "EH Series (Single-Phase Hybrid)" - variants: [EH5, EH8, EH10, EH15] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "BT Series (Three-Phase Hybrid LV)" - variants: [BT5, BT6, BT8, BT10] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "BH Series (Single-Phase Hybrid LV)" - variants: [BH5, BH8, BH10] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "ES Series (Single-Phase Hybrid)" - variants: [ES3.6, ES4.6, ES5.0] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "EM Series (Single-Phase Hybrid)" - variants: [EM3, EM5] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "BP Series (Three-Phase AC-Coupled)" - variants: [BP5, BP8, BP10] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "DT Series (Three-Phase String)" - variants: [DT10, DT15, DT17, DT20, DT25] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "DNS Series (Single-Phase String)" - variants: [DNS3, DNS4, DNS5, DNS6] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" - - manufacturer: "GoodWe" - model_family: "XS Series (Micro)" - variants: [XS2, XS3] - regions: [] - firmware_versions: "" - notes: "Community driver, requires v2 LAN+WiFi dongle" - min_driver_version: "1.0.0" -min_host_version: "2.0.0" +tested_devices: [] +min_host_version: "1.10.0-beta.1" size_bytes: 0 dkb_id: "" sha256: "" @@ -85,4 +15,4 @@ signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 -changelog: "Reject failed reads and add the FTW v1 read-only runtime contract." +changelog: "Add an explicit GW8KN-ET/HK3000 profile, batch reads, and fail-closed snapshots." diff --git a/packages/v1/goodwe/PILOT.md b/packages/v1/goodwe/PILOT.md new file mode 100644 index 0000000..ab6bdde --- /dev/null +++ b/packages/v1/goodwe/PILOT.md @@ -0,0 +1,71 @@ +# GoodWe GW8KN-ET field pilot + +Version 1.0.2 is read-only. It grants only `modbus.read` and must not write +register 47040 or any other register. Use it only with an exact commit and +artifact hash outside the stable channel until this pilot passes. + +## Required setup facts + +Record the inverter model, HK3000 presence, dongle model, inverter, ARM, DSP +and dongle firmware, network type, Modbus unit id and FTW version. Mask the IP +address and serial number before adding logs to a public pull request. + +Set the host's Modbus capability unit id to 247 unless the site proves another +id. The Lua config cannot inspect or validate the host's unit id. Select the +new register map by name: + +```yaml +config: + profile: gw8kn-et-hk3000 +``` + +Do not choose a profile from a value that looks plausible. The same addresses +have different widths and meanings in the two source maps. + +## Successful poll batches + +All reads use holding registers and Modbus FC03. The driver emits nothing until +all eight batches succeed. + +The GW8KN-ET profile makes eight transactions per successful poll. The input +driver made 13 without a detected battery and 15 with one. The legacy +`community-v1` profile keeps its 22 known read boundaries. An empty old config +selects only that legacy profile; an explicit unknown profile fails init. + +| Start | Count | Used addresses | Ignored gaps | +|---:|---:|---|---| +| 35107 | 4 | 35107, 35108, 35110 | 35109 | +| 35123 | 1 | 35123 | none | +| 35125 | 11 | 35125, 35130, 35135 | 35126–35129, 35131–35134 | +| 35138 | 3 | 35138, 35140 | 35139 | +| 35145 | 13 | 35145, 35151, 35157 | 35146–35150, 35152–35156 | +| 35164 | 5 | 35164, 35166, 35168 | 35165, 35167 | +| 35178 | 6 | 35178, 35180, 35182, 35183 | 35179, 35181 | +| 35195 | 5 | 35195–35196, 35198–35199 | 35197 | + +The gaps stay within each source-reported register block. The batch layout has +only synthetic replay coverage until the field pilot records raw responses. + +## Pass checks + +1. Capture at least ten full snapshots during PV generation and grid import. + Capture export and battery charge or discharge when those states occur. +2. Compare raw batch words with each emitted snapshot. Meter import must be + positive, export negative, PV negative, battery charge positive, battery + discharge negative and SoC within 0..1. Energy counters must not fall. +3. Compare the same energy interval with SEMS. Set the allowed difference + before the test; the proposed stable limit is 10 percent. +4. Block only the inverter network path for more than 90 seconds. Failed reads + must emit no zero or partial snapshot. FTW must mark the driver offline and + idle or release batteries, then recover without a driver or dongle restart. +5. Hard-power-cycle only the FTW controller three times. Leave the inverter and + dongle powered. Each run must recover without a dongle power cycle, without + reconnect spam and without fresh status before a full snapshot. Record time + to recovery. The proposed stable limit is five minutes for all three runs. +6. Run for at least 24 hours in daylight. Check for silent zero periods, + false freshness, unexplained energy gaps and any Modbus write attempt. + +Stable stops on any write, wrong register width, partial or zero snapshot after +an error, false `LastSuccess`, reconnect loop, blocked safety action or need for +a dongle power cycle. Add masked fixtures, three reboot timelines and the +24-hour result to the draft pull request before stable promotion. diff --git a/packages/v1/goodwe/package-source.json b/packages/v1/goodwe/package-source.json new file mode 100644 index 0000000..fb9480f --- /dev/null +++ b/packages/v1/goodwe/package-source.json @@ -0,0 +1,101 @@ +{ + "schema_version": "sourceful.driver-package-source/v1", + "package_id": "com.sourceful.driver.goodwe", + "version": "1.0.2", + "channel": "beta", + "display_name": "GoodWe GW8KN-ET observe-only pilot", + "identity": { + "schema": "sourceful.hardware-identity/v1", + "make": "driver_reported", + "serial": "unavailable", + "host_fallbacks": ["mac", "endpoint"], + "persistent_state_owner": "host" + }, + "source": { + "repository": "https://github.com/srcfl/device-drivers", + "path": "drivers/lua/goodwe.lua" + }, + "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", + "device_matches": [ + { + "manufacturer": "GoodWe", + "model_family": "ET Series with HK3000 meter", + "variants": ["GW8KN-ET"], + "regions": [] + } + ], + "capabilities": { + "telemetry": ["battery", "meter", "pv"], + "control": [] + }, + "permissions": ["modbus.read"], + "telemetry": { + "schema": "sourceful.telemetry/v2", + "sign_convention": "sourceful.site-import-positive/v1", + "streams": [ + { + "kind": "battery", + "power_field": "w", + "meaning": "Positive is charging and negative is discharging." + }, + { + "kind": "meter", + "power_field": "w", + "meaning": "Positive is grid import and negative is grid export at the site boundary." + }, + { + "kind": "pv", + "power_field": "w", + "meaning": "PV generation is negative because it reduces site import." + } + ] + }, + "commands": [], + "read_only": true, + "default_mode": { + "strategy": "not_applicable", + "description": "The observe-only target never changes inverter state." + }, + "lease_policy": { + "required_for_control": false, + "expiry_action": "not_applicable" + }, + "rollback": { + "strategy": "install_previous_verified_package", + "state_owner": "host", + "automatic": false + }, + "compatibility": [ + { + "target": "ftw-core", + "artifact_id": "ftw.lua51.source", + "host": { + "product": "ftw", + "min_version": "1.10.0-beta.1", + "max_version_exclusive": "2.0.0" + }, + "runtime": { + "name": "gopher-lua", + "semantics": "lua-5.1", + "version": "1.1.2", + "abi": "gopher-lua-source-v1", + "host_api": { + "profile": "sourceful.host/ftw-core/v1", + "min": 1, + "max": 1 + } + }, + "control_enabled": false + } + ], + "artifact_inputs": [ + { + "artifact_id": "ftw.lua51.source", + "target": "ftw-core", + "media_type": "application/vnd.sourceful.lua.source", + "input_path": "drivers/lua/goodwe.lua", + "transform": "copy", + "extension": "lua" + } + ] +} diff --git a/support-status-overrides.json b/support-status-overrides.json index 9ff1ea5..d748f16 100644 --- a/support-status-overrides.json +++ b/support-status-overrides.json @@ -7,6 +7,14 @@ "note": "Target differs materially from the FTW legacy driver. Golden telemetry, identity, fault, default-mode parity and physical HIL are required before activation." } }, + "goodwe": { + "ftw-core": { + "target_conformance": "contract_passed", + "hil": "gw8kn_et_hk3000_required", + "legacy_parity": "profile_selected", + "note": "Version 1.0.2 is an observe-only GW8KN-ET/HK3000 candidate. Stable needs the masked fixture, three hard-reboot recoveries and a 24-hour field pass." + } + }, "sdm630": { "ftw-core": { "target_conformance": "contract_passed", diff --git a/support-status.json b/support-status.json index 4df5265..66c53cb 100644 --- a/support-status.json +++ b/support-status.json @@ -646,9 +646,9 @@ }, { "catalog_source": true, - "catalog_version": "1.0.1", + "catalog_version": "1.0.2", "driver_id": "goodwe", - "package_id": null, + "package_id": "com.sourceful.driver.goodwe", "targets": { "blixt-l1": { "candidate_package_version": null, @@ -661,14 +661,14 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, + "candidate_package_version": "1.0.2", "control_enabled": false, - "hil": "not_recorded", + "hil": "gw8kn_et_hk3000_required", "historical_signed_beta_version": null, - "legacy_parity": "not_assessed", - "note": "", + "legacy_parity": "profile_selected", + "note": "Version 1.0.2 is an observe-only GW8KN-ET/HK3000 candidate. Stable needs the masked fixture, three hard-reboot recoveries and a 24-hour field pass.", "stable_package_version": null, - "target_conformance": "not_assessed" + "target_conformance": "contract_passed" } } }, diff --git a/tests/test_goodwe_package.py b/tests/test_goodwe_package.py new file mode 100644 index 0000000..d6165d9 --- /dev/null +++ b/tests/test_goodwe_package.py @@ -0,0 +1,35 @@ +"""Pin the GoodWe field candidate to the FTW read-only package contract.""" + +import json +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from driver_package import validate_document # noqa: E402 + + +def test_goodwe_package_is_ftw_only_and_read_only(): + package = json.loads( + (ROOT / "packages/v1/goodwe/package-source.json").read_text(encoding="utf-8") + ) + validate_document(package) + + assert package["version"] == "1.0.2" + assert package["permissions"] == ["modbus.read"] + assert package["read_only"] is True + assert package["commands"] == [] + assert package["capabilities"]["control"] == [] + assert [item["target"] for item in package["compatibility"]] == ["ftw-core"] + assert package["compatibility"][0]["control_enabled"] is False + assert package["compatibility"][0]["host"]["min_version"] == "1.10.0-beta.1" + + +def test_goodwe_source_has_no_write_or_profile_guess_path(): + source = (ROOT / "drivers/lua/goodwe.lua").read_text(encoding="utf-8") + assert "host.modbus_write" not in source + assert 'config.profile or "community-v1"' in source + assert 'profile ~= "community-v1"' in source + assert 'profile ~= "gw8kn-et-hk3000"' in source