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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## 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
Expand Down
12 changes: 6 additions & 6 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.6 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| esphome_dsmr | 1.0.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| etrel | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed |
Expand Down Expand Up @@ -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 |
| saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed |
Expand Down Expand Up @@ -154,8 +154,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 |
Expand Down
14 changes: 7 additions & 7 deletions devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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: ""
Expand Down Expand Up @@ -1736,7 +1736,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "sungrow"
version: "1.5.9"
version: "1.5.10"
ders: [battery, meter, pv]
control: true
firmware_versions: ""
Expand All @@ -1747,7 +1747,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "sungrow"
version: "1.5.9"
version: "1.5.10"
ders: [battery, meter, pv]
control: true
firmware_versions: ""
Expand All @@ -1758,7 +1758,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "sungrow"
version: "1.5.9"
version: "1.5.10"
ders: [battery, meter, pv]
control: true
firmware_versions: ""
Expand All @@ -1769,7 +1769,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "sungrow"
version: "1.5.9"
version: "1.5.10"
ders: [battery, meter, pv]
control: true
firmware_versions: ""
Expand All @@ -1780,7 +1780,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "sungrow"
version: "1.5.9"
version: "1.5.10"
ders: [battery, meter, pv]
control: true
firmware_versions: ""
Expand Down
38 changes: 38 additions & 0 deletions docs/WRITING-A-DRIVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
32 changes: 31 additions & 1 deletion drivers/lua/easee_cloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -681,13 +689,22 @@ 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,
charging = charging,
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],
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
15 changes: 11 additions & 4 deletions drivers/lua/pixii.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 32 additions & 16 deletions drivers/lua/sungrow.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -640,13 +641,35 @@ 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,
v = bat_v,
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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading