diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f56a9..7ad8bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ previously failed at init with `config.host is required`. No runtime change. Read-only site meter from a Remne P1IB bridge, a Wi-Fi P1/HAN reader common in Sweden. The driver reads the bridge's `/meterData` JSON: net and per-phase active power, voltage, current and lifetime import and export energy, with reactive power and Wi-Fi signal as diagnostic metrics. It uses HTTP rather than the bridge's MQTT output because P1IB publishes MQTT only when a value moves past its hysteresis, so a steady site would look stale to Core and stop dispatch. Freshness comes from the bridge's good-telegram counter: the driver emits once per new telegram, never re-emits the rolling window, and logs once when no telegram has arrived for three telegram intervals. Identity is the bridge MAC, and the model is the meter string it reports. `connection_defaults` declares an empty `host` so FTW setup passes the entered IP as `config.host`. Run in FTW v0.138.2-beta.1 (native, Debian 13) against two P1IB units (hardware rev F, firmware 757b45d) on Landis+Gyr E360 meters, one as site meter: both healthy, readings fresh, and site power within one telegram of the same meter's reading in Home Assistant. +## sungrow 1.5.10 + +Report the active forced setpoint from the existing holding-register read. +Identify the separate grid meter on known hybrid models when it reads power +or phase current; a meterless install claims none. Failed PV, meter or +setpoint reads cannot supply control evidence. No extra Modbus requests. + +## easee_cloud 1.3.6 + +Keep the source timestamp for control evidence when the cloud repeats a power +observation, and mark the unchanged value confirmed while the cloud still +hears from the charger, so a steady charge stays measured. Missing power or +source time cannot confirm a command's effect. The live-status display and +polling rate stay unchanged. + +## pixii 2.1.7 + +Read the setpoint on every poll, including outside troubleshooting mode. Emit +its site-signed value and measured AC power for command feedback; AC power +leaves SunSpec's generator frame like the setpoint, so a charge is positive. +Missing reads remain unknown. No change to commands, heartbeat or safety +policy. Not yet verified on hardware. + +## easee_cloud 1.3.5 + +Report the charger’s configured current ceiling separately from its dynamic +current offer. Refresh it once a minute and preserve its age when a read +fails, so Core can explain an 8 A ceiling even while the car charges. + ## pixii 2.1.6 Record the SunSpec status specifications in the manifest so the upstream diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index c20c7c0..c4832e9 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -46,8 +46,8 @@ Catalog source is not proof that a target can install or run a driver. | deye | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | esphome_dsmr | 1.0.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | | esphome_dsmr | 1.0.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | etrel | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | @@ -112,8 +112,8 @@ Catalog source is not proof that a target can install or run a driver. | p1_hdlc | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | p1_meter | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | pixii_pv | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | pixii_pv | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | @@ -156,8 +156,8 @@ Catalog source is not proof that a target can install or run a driver. | solis_string | 1.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | sonnen | 2.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | sonnen | 2.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| sungrow | 1.5.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| sungrow | 1.5.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.5.10 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.5.10 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_vehicle | 0.2.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index f160bb4..5a47765 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.4" + version: "1.3.6" ders: [ev] control: true firmware_versions: "" @@ -1032,7 +1032,7 @@ manufacturers: protocols: - protocol: modbus driver: "pixii" - version: "2.1.6" + version: "2.1.7" ders: [battery, meter] control: false firmware_versions: "" @@ -1749,7 +1749,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.9" + version: "1.5.10" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1760,7 +1760,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.9" + version: "1.5.10" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1771,7 +1771,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.9" + version: "1.5.10" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1782,7 +1782,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.9" + version: "1.5.10" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1793,7 +1793,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.9" + version: "1.5.10" ders: [battery, meter, pv] control: true firmware_versions: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index 07db7e3..f75acdb 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -375,3 +375,41 @@ moves the manifest and the `DRIVER` table together. 37 drivers came from FTW in #27; `baselines/ftw/drivers/` records what they were. All of them have changed since, and the same rules apply to every driver. + +## Control feedback + +Optional `host.emit` fields let Core compare commands with device readings in +all modes. They never grant control or replace a safety limit: + +- `setpoint_w`: a fresh device register read, in site signs. Do not echo a + command or emit zero when the read fails. +- `control_power_w`: fresh measured power at the command's boundary, in site + signs. Pixii uses AC power here and retains DC power in `w`. + Set `control_power_available=false` when that measurement fails, so Core + cannot fall back to DC and claim a response. +- `control_power_observed_at`: the power source's RFC 3339 timestamp. Keep it + unchanged when a cloud poll returns the same observation. Control checks + must not treat a new HTTP reply as a new physical sample. If power or its + required source time is missing, set `control_power_available=false`. +- `control_power_confirmed`: set it only when the source records power on + change and this poll shows the source still hears from the device, as with + Easee's cloud. Core then treats the unchanged value as current when it + arrives. Never set it for a cache that may have lost the device. +- `device_limit_a`: the charger's own configured current ceiling, separate + from the dynamic offer in `max_a`. `device_limit_age_s` is time since the + successful settings read. Failed reads must not reset its age. Core stops + treating it as a current limit after two minutes. + +A device reading makes a response measured, nothing more. Core calls it +confirmed only when an identified, separate site meter shows a matching +measured change. Driver output must never claim independent confirmation +from another field of the same sensor. + +A meter may report `power_origin="external_meter"` when its documented +register map reads a separate physical site meter through the inverter and +that meter is present: a meterless install must not claim it. +This identifies the sensor, not a separate network connection. Never use it +for power calculated from the inverter's own battery, PV or load readings. +Use `power_origin="derived"` for such calculated meter values. Failed reads +must set `control_power_available=false`; sensor identity alone cannot +confirm a command. diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 38aa5df..f8a04fe 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -25,7 +25,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.4", + version = "1.3.6", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -458,6 +458,8 @@ local REASON_LABELS = { } local email, password, configured_max_a +local device_limit_a, device_limit_read_ms +local settings_poll_ms = 0 -- read_settings GETs the charger's static config block (phaseMode, -- maxChargerCurrent, etc.) and surfaces it via the init log so the @@ -554,12 +556,18 @@ function driver_init(config) if settings then local fw_pm = tonumber(settings.phaseMode) local fw_max = tonumber(settings.maxChargerCurrent) + if fw_max and fw_max >= 0 and fw_max <= EASEE_MAX_A then + device_limit_a, device_limit_read_ms = fw_max, host.millis() + end + settings_poll_ms = host.millis() host.log("info", "Easee: firmware settings — phaseMode=" .. tostring(fw_pm) .. " (1=1p,2=auto,3=3p), maxChargerCurrent=" .. tostring(fw_max) .. "A") -- Apply the operator's max_charger_current clamp if it differs. if configured_max_a and fw_max ~= configured_max_a then local werr = write_setting(charger_serial, {maxChargerCurrent = configured_max_a}) if werr == nil then + device_limit_a, device_limit_read_ms = nil, nil -- wait for readback + settings_poll_ms = host.millis() - 60000 host.log("info", "Easee: maxChargerCurrent clamped to " .. tostring(configured_max_a) .. " A (was " .. tostring(fw_max) .. " A)") else @@ -681,6 +689,7 @@ function driver_poll() request_active = false end + local limit_age_s = device_limit_read_ms and (host.millis() - device_limit_read_ms) / 1000 host.emit("ev", { w = power_w, connected = connected, @@ -688,6 +697,14 @@ function driver_poll() request_active = request_active, session_wh = session_wh, power_observed_at = power_observed_at, + -- Control verification needs distinct source samples. A fresh cloud + -- reply alone does not establish a new physical power measurement. + control_power_observed_at = timestamps[OBS_TOTAL_POWER], + control_power_available = obs[OBS_TOTAL_POWER] ~= nil and timestamps[OBS_TOTAL_POWER] ~= nil, + -- The cloud records power only on change, and get_observations rejects + -- a charger it no longer hears from. This poll therefore confirms that + -- an unchanged value is still current, so a steady charge stays measured. + control_power_confirmed = obs[OBS_TOTAL_POWER] ~= nil and timestamps[OBS_TOTAL_POWER] ~= nil, power_max_age_s = 180, energy_observed_at = timestamps[OBS_SESSION_ENERGY], state_observed_at = timestamps[OBS_OP_MODE], @@ -699,6 +716,8 @@ function driver_poll() reason_no_current_label = reason_code and REASON_LABELS[reason_code], -- nil if 0/ok, string otherwise is_online = is_online, cable_locked = cable_locked, + device_limit_a = device_limit_a, + device_limit_age_s = limit_age_s, max_a = dyn_current, -- last-set dynamic limit (echoes our write, may lag) actual_amps_per_phase = actual_amps_per_phase, -- live per-phase A derived from totalPower phases = phases, -- our committed phase count (1 or 3) @@ -742,6 +761,17 @@ function driver_poll() host.emit_metric("ev_dynamic_current_a", dyn_current) end + -- Static ceiling and dynamic offer are different. Refresh the ceiling + -- once a minute and report its age even when a later read fails. + if now - settings_poll_ms >= 60000 then + settings_poll_ms = now + local settings = read_settings(charger_serial) + local limit = settings and tonumber(settings.maxChargerCurrent) + if limit and limit >= 0 and limit <= EASEE_MAX_A then + device_limit_a, device_limit_read_ms = limit, host.millis() + end + end + return 5000 end diff --git a/drivers/lua/pixii.lua b/drivers/lua/pixii.lua index e1f0dc7..5759ce9 100644 --- a/drivers/lua/pixii.lua +++ b/drivers/lua/pixii.lua @@ -29,7 +29,7 @@ DRIVER = { id = "pixii", name = "Pixii PowerShaper", manufacturer = "Pixii", - version = "2.1.6", + version = "2.1.7", protocols = { "modbus" }, capabilities = { "battery", "meter" }, description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", @@ -269,9 +269,8 @@ local function read_battery_status() } end -local function emit_troubleshooting_metrics() +local function emit_troubleshooting_metrics(setpoint_pixii_w) host.emit_metric("pixii_heartbeat_counter", hb_tick) - local setpoint_pixii_w = read_i32_be(REG_SETPOINT_HI) if setpoint_pixii_w ~= nil then host.emit_metric("pixii_setpoint_native_w", setpoint_pixii_w) host.emit_metric("pixii_setpoint_ems_w", -setpoint_pixii_w) @@ -465,12 +464,20 @@ function driver_poll() end local status = read_battery_status() + -- Read on every poll: a successful write does not prove the setpoint + -- stayed in place. Failure omits the value; it must not invent zero. + local setpoint_pixii_w = read_i32_be(REG_SETPOINT_HI) if troubleshooting then - emit_troubleshooting_metrics() + emit_troubleshooting_metrics(setpoint_pixii_w) end local battery = { w = bat_w, + setpoint_w = setpoint_pixii_w and -setpoint_pixii_w, + -- SunSpec W is in the generator frame, like the setpoint: positive + -- means power out of the inverter. Site signs charge positive. + control_power_w = acw_regs and -ac_w, + control_power_available = acw_regs ~= nil, v = bat_v, a = bat_a, temp_c = temp_c, diff --git a/drivers/lua/sungrow.lua b/drivers/lua/sungrow.lua index 56aa82d..9c86e13 100644 --- a/drivers/lua/sungrow.lua +++ b/drivers/lua/sungrow.lua @@ -9,7 +9,7 @@ DRIVER = { id = "sungrow", name = "Sungrow SH Hybrid Inverter", manufacturer = "Sungrow", - version = "1.5.9", + version = "1.5.10", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", @@ -535,6 +535,7 @@ function driver_poll() host.emit("pv", { w = -pv_w, -- negative = generation (EMS convention) + control_power_available = pv_regs ~= nil or mppt_regs ~= nil, mppt1_v = mppt1_v, mppt1_a = mppt1_a, mppt2_v = mppt2_v, @@ -640,6 +641,21 @@ function driver_poll() end end + -- EMS state diagnostics — what the inverter *actually* has latched + -- in its control registers right now. With the #164 write-order fix + -- these should track whatever the dispatcher sent last tick; any + -- drift between target and ems_force_w points at external writers + -- (iSolarCloud, HA integration, another EMS) racing the driver. + local emsd = nil + if hybrid_block_worth_reading() then + emsd = optional_read(13049, 3, "holding") + end + if emsd then + host.emit_metric("sungrow_ems_mode", emsd[1]) -- 0=self, 2=forced, 3=ext + host.emit_metric("sungrow_force_cmd", emsd[2]) -- 0xAA=170 chg, 0xBB=187 dis, 0xCC=204 stop + host.emit_metric("sungrow_force_w", emsd[3]) + end + if bat_regs then local battery = { w = bat_w, @@ -647,6 +663,13 @@ function driver_poll() a = bat_a, soc = bat_soc, } + -- The holding registers, not the last command, supply this value. + -- Force power is inactive in self-consumption and external EMS modes. + if emsd and emsd[1] == 2 then + if emsd[2] == 0xAA then battery.setpoint_w = emsd[3] + elseif emsd[2] == 0xBB then battery.setpoint_w = -emsd[3] + elseif emsd[2] == 0xCC then battery.setpoint_w = 0 end + end -- Energy counters are separate reads. Add each only if it answered: -- a counter reported as zero would look like a reset meter. @@ -667,21 +690,6 @@ function driver_poll() host.emit_metric("battery_dc_a", bat_a) end - -- EMS state diagnostics — what the inverter *actually* has latched - -- in its control registers right now. With the #164 write-order fix - -- these should track whatever the dispatcher sent last tick; any - -- drift between target and ems_force_w points at external writers - -- (iSolarCloud, HA integration, another EMS) racing the driver. - local emsd = nil - if hybrid_block_worth_reading() then - emsd = optional_read(13049, 3, "holding") - end - if emsd then - host.emit_metric("sungrow_ems_mode", emsd[1]) -- 0=self, 2=forced, 3=ext - host.emit_metric("sungrow_force_cmd", emsd[2]) -- 0xAA=170 chg, 0xBB=187 dis, 0xCC=204 stop - host.emit_metric("sungrow_force_w", emsd[3]) - end - -- Grid meter power: 5600-5601, I32 LE, watts (positive=import, negative=export) local mw_regs = optional_read(5600, 2, "input") local meter_w = 0 @@ -732,8 +740,16 @@ function driver_poll() end end + -- 5600-5601 relay the external meter on the known SH register map. Only an + -- installed meter reads anything there: a meterless install reads zero + -- power and zero phase current, so it claims no separate meter. + local meter_origin = nil + local meter_reads = mw_regs ~= nil and (meter_w ~= 0 or l1_a + l2_a + l3_a > 0) + if model_family == "hybrid" and meter_reads then meter_origin = "external_meter" end host.emit("meter", { w = meter_w, + power_origin = meter_origin, + control_power_available = mw_regs ~= nil, l1_w = l1_w, l2_w = l2_w, l3_w = l3_w, diff --git a/drivers/tests/lua_harness/test_easee_cloud_power_time.lua b/drivers/tests/lua_harness/test_easee_cloud_power_time.lua index 2ab256d..f6620b3 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_power_time.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_power_time.lua @@ -3,8 +3,9 @@ dofile("drivers/tests/lua_harness/host_mock.lua") -- keep reporting that old change time as when the power was observed. local driver = "drivers/lua/easee_cloud.lua" host.reset() +host._millis_step = 0 host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' -host._http_responses["/config"] = '{}' +host._http_responses["/config"] = '{"maxChargerCurrent":8}' host._http_responses["/sessions/ongoing"] = '{}' dofile(driver) driver_init({email="test@example.invalid",password="test",serial="TEST123"}) @@ -23,9 +24,17 @@ end local first = poll(3, 4.92, "2026-09-25T00:21:20Z") assert(first.power_observed_at == "2026-09-25T00:21:20Z", "a new value lost its source time") +assert(first.control_power_observed_at == "2026-09-25T00:21:20Z" and first.control_power_available, + "control evidence lost the measured source time") +assert(first.device_limit_a == 8 and first.device_limit_age_s == 0, "static ceiling was lost") +assert(first.max_a == nil, "static ceiling replaced dynamic readback") local steady = poll(3, 4.92, "2026-09-25T00:21:20Z") assert(steady.power_observed_at == nil, "an unchanged value kept its old change time: " .. tostring(steady.power_observed_at)) +assert(steady.control_power_observed_at == first.control_power_observed_at, + "cached cloud power became a new control measurement") +assert(first.control_power_confirmed and steady.control_power_confirmed, + "a poll of a charger the cloud still hears from must confirm the unchanged value") local changed = poll(3, 6.30, "2026-09-25T00:29:20Z") assert(changed.power_observed_at == "2026-09-25T00:29:20Z", "a changed value lost its source time") @@ -34,3 +43,13 @@ local _, before = poll(3, 6.30, "2026-09-25T00:29:20Z") local _, after = poll(0, 6.30, "2026-09-25T00:29:20Z") assert(after == before, "an offline charger emitted a sample") print("Easee power time: passed") + +host._millis_counter = host._millis_counter + 60000 +host._http_responses["/config"] = '{"maxChargerCurrent":16}' +poll(3, 6.30, "2026-09-25T00:29:20Z") -- optional settings refresh follows power emit +local refreshed = poll(3, 6.30, "2026-09-25T00:29:20Z") +assert(refreshed.device_limit_a == 16 and refreshed.device_limit_age_s == 0, "ceiling did not refresh") +host._millis_counter = host._millis_counter + 180000 +host._http_responses["/config"] = '{}' +local missing = poll(3, 6.30, "2026-09-25T00:29:20Z") +assert(missing.device_limit_age_s == 180, "missing read made cached limit look fresh") diff --git a/drivers/tests/test_pixii_status.py b/drivers/tests/test_pixii_status.py index 9a319e3..3277df8 100644 --- a/drivers/tests/test_pixii_status.py +++ b/drivers/tests/test_pixii_status.py @@ -93,3 +93,20 @@ def test_pixii_known_status_can_clear_calibration(status: int) -> None: assert(host._metrics.battery_charge_status_code.value == {status}) assert(host._metrics.battery_control_mode_code.value == 0, "remote control code 0 is valid") ''') + + +def test_pixii_control_readback_without_troubleshooting(): + run_lua(''' +host._modbus_registers.holding[39905] = {0, 1500} +host._modbus_registers.holding[40083] = -1400 +local battery = poll(3) +assert(battery.setpoint_w == -1500, "setpoint must use site signs outside TS mode") +assert(battery.control_power_available == true) +-- SunSpec W is generator frame: -1400 W flows into the inverter, a charge. +assert(battery.control_power_w == 1400, "command feedback must use site-signed AC, not DC") +host._modbus_read_fail_addresses[39905] = "timeout" +host._modbus_read_fail_addresses[40083] = "timeout" +battery = poll(3) +assert(battery.setpoint_w == nil, "failed read invented zero") +assert(battery.control_power_w == nil and battery.control_power_available == false) +''') diff --git a/drivers/tests/test_sungrow_model_family.py b/drivers/tests/test_sungrow_model_family.py index 175ad61..f459836 100644 --- a/drivers/tests/test_sungrow_model_family.py +++ b/drivers/tests/test_sungrow_model_family.py @@ -951,3 +951,70 @@ def test_a_success_puts_the_release_back() -> None: f"the block is there, and a driver that will not hand back a device it " f"can hand back is the worse failure. A restart also re-probes, but " f"waiting for one is not the answer when the evidence is already in.") + + +@pytest.mark.parametrize("mode,command,expected", [ + (2, 0xAA, "1500"), (2, 0xBB, "-1500"), (2, 0xCC, "0"), + (0, 0xAA, "nil"), (3, 0xAA, "nil"), (2, 0, "nil"), +]) +def test_sungrow_setpoint_comes_from_active_registers(mode, command, expected): + out = run_lua(HEALTHY_HYBRID + f''' +dofile("{DRIVER}") +driver_init({{}}) +host._modbus_registers.holding[13049] = {{{mode}, {command}, 1500}} +driver_poll() +print("SETPOINT " .. tostring(host._emitted.battery[1].setpoint_w)) +''') + assert out["SETPOINT"] == expected + + +def test_sungrow_failed_setpoint_read_does_not_reuse_last_value(): + out = run_lua(HEALTHY_HYBRID + f''' +dofile("{DRIVER}") +driver_init({{}}) +host._modbus_registers.holding[13049] = {{2, 0xBB, 1500}} +driver_poll() +host._modbus_read_fail_addresses[13049] = "timeout" +driver_poll() +print("BEFORE " .. tostring(host._emitted.battery[1].setpoint_w)) +print("AFTER " .. tostring(host._emitted.battery[2].setpoint_w)) +''') + assert out == {"BEFORE": "-1500", "AFTER": "nil"} + + +@pytest.mark.parametrize("fixture,origin", [(HEALTHY_HYBRID, "external_meter"), (STRING_INVERTER, "nil")]) +def test_sungrow_meter_origin_requires_known_hybrid_map(fixture, origin): + out = run_lua(fixture + f''' +dofile("{DRIVER}") +driver_init({{}}) +driver_poll() +print("ORIGIN " .. tostring(host._emitted.meter[1].power_origin)) +print("AVAILABLE " .. tostring(host._emitted.meter[1].control_power_available)) +''') + assert out == {"ORIGIN": origin, "AVAILABLE": "true"} + + +def test_sungrow_meterless_hybrid_claims_no_separate_meter(): + out = run_lua(HEALTHY_HYBRID + f''' +host._modbus_registers.input[5600] = {{0, 0}} +host._modbus_registers.input[5743] = {{0, 0, 0}} +dofile("{DRIVER}") +driver_init({{}}) +driver_poll() +print("ORIGIN " .. tostring(host._emitted.meter[1].power_origin)) +''') + assert out == {"ORIGIN": "nil"} + + +def test_sungrow_failed_flow_reads_cannot_confirm_control(): + out = run_lua(HEALTHY_HYBRID + f''' +dofile("{DRIVER}") +driver_init({{}}) +for _, addr in ipairs({{5600, 5016, 5010}}) do + host._modbus_read_fail_addresses[addr] = "timeout" +end +driver_poll() +print("METER " .. tostring(host._emitted.meter[1].control_power_available)) +print("PV " .. tostring(host._emitted.pv[1].control_power_available)) +''') + assert out == {"METER": "false", "PV": "false"} diff --git a/index.yaml b/index.yaml index 1587ae7..9318db6 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.4" + version: "1.3.6" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 39308 - sha256: "46581eb77e9d7e5fc4b0937ea2c5fa560afef749b50a42dd0353682fada87624" + size_bytes: 41070 + sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" - name: "esphome_dsmr" version: "1.0.7" tier: core @@ -498,14 +498,14 @@ drivers: size_bytes: 19344 sha256: "29f2bd2f73c0d39d809839ca60b40c3e4281f1dda267ba2b26e66bd9ab80c27b" - name: "pixii" - version: "2.1.6" + version: "2.1.7" tier: core protocol: modbus connectivity: local ders: [battery, meter] control: false - size_bytes: 28604 - sha256: "160868e3c7836a87fd33eb26800023ed741c556b376b5e0ab10a4f0097cc3c63" + size_bytes: 29105 + sha256: "bd8fe1e850ce66e83e107116d7e236c499cca51f75872feb03efde58f95d6e41" - name: "pixii_pv" version: "0.3.2" tier: core @@ -700,14 +700,14 @@ drivers: size_bytes: 6631 sha256: "94ae5e0065f5793051acb89b7ef04a679b2898908dfac6f4ffd6e8deb77fa2c0" - name: "sungrow" - version: "1.5.9" + version: "1.5.10" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: true - size_bytes: 48885 - sha256: "9847c4281d4387b5d4c6a2e1d91c592ca2cbf32c06b872be2d68d46fe307f823" + size_bytes: 49876 + sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" - name: "tesla_cloud" version: "0.1.0" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 6d6a0ba..b50d5a4 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.4" +version: "1.3.6" tier: core author: "Sourceful Labs AB" protocol: http @@ -16,9 +16,9 @@ tested_devices: notes: "Easee Home/Charge via Cloud REST API. No local protocol needed." min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 39308 +size_bytes: 41070 dkb_id: "easee_cloud" -sha256: "46581eb77e9d7e5fc4b0937ea2c5fa560afef749b50a42dd0353682fada87624" +sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" signature: "" bytecode_sha256: "" diff --git a/manifests/pixii.yaml b/manifests/pixii.yaml index b9565e5..f48f5a1 100644 --- a/manifests/pixii.yaml +++ b/manifests/pixii.yaml @@ -1,5 +1,5 @@ name: "pixii" -version: "2.1.6" +version: "2.1.7" tier: core author: "Sourceful Labs AB and community contributors" protocol: modbus @@ -24,9 +24,9 @@ upstream_docs: title: "SunSpec model 802: battery charge and control states" kind: register_map url_stability: stable -size_bytes: 28604 +size_bytes: 29105 dkb_id: "pixii" -sha256: "160868e3c7836a87fd33eb26800023ed741c556b376b5e0ab10a4f0097cc3c63" +sha256: "bd8fe1e850ce66e83e107116d7e236c499cca51f75872feb03efde58f95d6e41" signature: "" bytecode_sha256: "" diff --git a/manifests/sungrow.yaml b/manifests/sungrow.yaml index b68639c..278ce98 100644 --- a/manifests/sungrow.yaml +++ b/manifests/sungrow.yaml @@ -1,5 +1,5 @@ name: "sungrow" -version: "1.5.9" +version: "1.5.10" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -43,12 +43,18 @@ tested_devices: notes: "Commercial/industrial three-phase string inverter. Needs 1.5.3: 1.2.1 stopped polling the SH battery registers this family does not answer, and 1.5.3 refuses a battery dispatch instead of writing them. A zero-watt command is still written on every family from 1.5.4 -- it is the release, and on a model without the registers it fails harmlessly." min_driver_version: "1.5.3" min_host_version: "2.0.0" -size_bytes: 48885 +size_bytes: 49876 dkb_id: "sungrow_sh_rt" -sha256: "9847c4281d4387b5d4c6a2e1d91c592ca2cbf32c06b872be2d68d46fe307f823" +sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 changelog: "" + +upstream_docs: + - url: "https://info-support.sungrowpower.com/application/pdf/2025/07/17/SHRT%20User%20Manual.pdf" + title: "SH-RT user manual: separate Smart Energy Meter connected over RS485, section 6.8" + kind: manual + url_stability: stable diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 00062e6..deb7a8c 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -156,7 +156,11 @@ "available_charge_Wh", "available_discharge_Wh", "available_charge_W", - "available_discharge_W" + "available_discharge_W", + "setpoint_w", + "control_power_w", + "control_power_available", + "control_power_observed_at" ], "inverter": [ "W", @@ -178,7 +182,8 @@ "pv": [ "W", "total_generation_Wh", - "mppts" + "mppts", + "control_power_available" ], "meter": [ "W", @@ -193,7 +198,16 @@ "L3_A", "L3_W", "total_import_Wh", - "total_export_Wh" + "total_export_Wh", + "power_origin", + "control_power_available" + ], + "ev": [ + "control_power_available", + "control_power_observed_at", + "control_power_confirmed", + "device_limit_a", + "device_limit_age_s" ] }, "repeating_structures": "A repeating structure is a plural-named array, not numbered keys. pv.mppts is a list of {V, A, W} whose length is what the device physically has. The catalog's mppt1_v/mppt2_v cannot represent a four-MPPT inverter at all.", @@ -203,7 +217,7 @@ ], "reason": "@srcful/data-models has no agreed v2x_charger shape, and Blixt has no v2x driver to take the naming from. The 13 chargers here keep the short keys until one exists; guessing a schema would be worse than saying it is undecided." }, - "streams_vs_ders": "inverter is an emit STREAM, not a DER type: a battery or hybrid driver reports its AC stage (W, per-phase, rated_W, available_import_W / available_export_W headroom) on host.emit(\"inverter\", ...) while the device itself is declared as ders: [battery] (or [battery, pv]). Blixt L1 feeds the headroom keys into its arbitrator clamp and reads them by exact name — they have no lowercase fallback." + "streams_vs_ders": "inverter is an emit STREAM, not a DER type: a battery or hybrid driver reports its AC stage (W, per-phase, rated_W, available_import_W / available_export_W headroom) on host.emit(\"inverter\", ...) while the device itself is declared as ders: [battery] (or [battery, pv]). Blixt L1 feeds the headroom keys into its arbitrator clamp and reads them by exact name \u2014 they have no lowercase fallback." }, "write_result_semantics": { "ftw-core": "returns error string on failure, nothing on success; never raises", diff --git a/support-status.json b/support-status.json index 1ed3aa1..721fa2d 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.4", + "catalog_version": "1.3.6", "driver_id": "easee_cloud", "targets": { "blixt-l1": { @@ -1136,7 +1136,7 @@ }, { "catalog_source": true, - "catalog_version": "2.1.6", + "catalog_version": "2.1.7", "driver_id": "pixii", "targets": { "blixt-l1": { @@ -1598,7 +1598,7 @@ }, { "catalog_source": true, - "catalog_version": "1.5.9", + "catalog_version": "1.5.10", "driver_id": "sungrow", "targets": { "blixt-l1": {