diff --git a/CHANGELOG.md b/CHANGELOG.md index 709e8ad..8b80696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,107 @@ # Changelog +## [Unreleased] + +### easee_cloud 1.3.8 + +- Reject power observations that move backwards in source time, and conflicting values at the same physical time. Compare RFC3339 instants with UTC offsets and fractional precision normalized. An old cloud zero can no longer replace a newer sample, nor can an old positive sample resurrect an observed stop. +- Connected chargers with missing or malformed power timestamps emit no fresh measurement. Unchanged valid values remain confirmable, and newer zero observations remain valid. A confirmed disconnect resets the session ordering. +- Reproduced with synthetic cloud responses; not yet verified on hardware. This does not prove that delayed packets caused every field balance mismatch. No command policy or charging limit is changed. + ## easee_cloud 1.3.7 - Keep a load-balancing ReasonForNoCurrent while the car charges, when the per-phase current is at least 2 A below the offer. Easee records the reason when it changes, so a limit that began before the last power change was dropped and FTW said the charger had given no reason. The offer is observation 48, or FTW's last write when that is missing. Other old reasons are still dropped while the car charges. - Report `current_limited_by = "load_balancer"` for codes 1–5, 10 and 25–30 while a car is connected. Core reads this name, not Easee's codes. The charger's own limits (77, 78) and the car are not the load balancer. - Match the labels for codes 6, 8–11, 25–30 and 77 to Easee's published table. Code 28 read "fuse limit reached"; Easee calls it "Current limited by Equalizer". The enumeration page joins `upstream_docs`. - Not yet seen on hardware. Easee's table defines the "Current limited by …" codes, but no raw observation from a load-balanced charger has been captured. +### home_assistant_vehicle 0.1.0 + +A new read-only driver for a car whose level Home Assistant already reads. A +Home Assistant automation publishes one JSON message over MQTT; the driver +subscribes, emits every 5 seconds and holds no Home Assistant token. It +reads the level, charge limit and charging state, and, when the automation +maps them, the charge cable (`plugged`) and the car's place (`at_site` from +a tracker in zone home). The automation may add `plugged_since` from +`last_changed`, but only when the integration reads the cable at least +every 30 seconds: `last_changed` is when Home Assistant read the change. +The header carries the automation for the Tesla Custom Integration. +Messages from a Model Y on that integration, recorded on 15 October 2026, +are the test fixtures. + +### tesla_cloud 0.2.0 + +Report the charge cable (`plugged`, from `conn_charge_cable`, else the +charging state). No cable time: the driver reads the car minutes apart. With +a site position from FTW and the optional `vehicle_location` scope, the same +`vehicle_data` read asks for `location_data`, and the driver emits the car's +distance from the site (`site_distance_m`), never its coordinates. A token +without the scope gets HTTP 403 for that read; the driver then reads +`charge_state` alone and asks again a day later. No request is added and no +car is woken. A level of 1 % goes as 0.01. + +### teslamate_vehicle 0.2.0 + +Report the charge cable from TeslaMate's `plugged_in`. A change is dated +(`plugged_since_ms`, by the box's clock) only when a live update carries it: +a `healthy` message within 10 seconds. A retained value, at subscribe or +redelivered after the broker reconnects, has no known time. With `geofence` +set to this site's TeslaMate geofence, the geofence topic gives `at_site`; +otherwise `latitude` and `longitude` give the distance from the site. The +coordinates are not emitted. A level of 1 % goes as 0.01. + +### vag_vehicle 0.3.0 + +Report the charge cable from the Data Act dataset's plug state. No cable +time: the portal writes every 15 minutes. A plug state other than connected +or disconnected is unknown, not unplugged. The datasets carry no position. +A level of 1 % goes as 0.01. + +### tesla_vehicle 0.2.6 + +A level of 1 % goes as 0.01. FTW reads a vehicle `soc` of 1 as 100 %, so a +car at 1 % looked full. + +### pixii_pv 0.3.3 + +Report zero PV generation when the measured phase sum is negative. Negating +that sum used to emit positive PV power, which FTW rejects, so fresh readings +could leave PV stale or missing after sunset. Sum the phases before limiting +generation to zero; keep their signed diagnostic values. Missing, invalid or +non-finite phase power emits no PV reading. Idle polls do not refresh PV. + +## pixii 2.1.9 + +A power cut left the driver offline until someone restarted it. Each register +keeps its own failure count and stops being read after three misses. When the +Pixii boots slower than FTW, every read fails for about fifteen seconds, +three polls at five seconds, and every register is retired. The device then +answers and the driver never asks again. + +Misses count on a poll where some other register answered. A poll where every +read fails leaves the counts unchanged: the device is unreachable. The next +poll that gets an answer clears the counts. Register 40288 on PowerShaper +firmware below CPU 2.0.23 still stops after three misses while the device is +answering other registers, and is tried again every five minutes. + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh` (srcfl/ftw#1620). + +## sungrow 1.6.0 + +Read the live values once a second. `driver_poll` returns 1000 ms. Each poll +reads grid, PV and battery power, SoC, per-phase values and the EMS readback. +Energy counters, rated power, temperature, frequency and fault bits keep the +five-second rate. Between those reads each blob carries their last values, but +only when its own power read succeeded this poll. FTW stores each emit whole, +and its energy ledger starts over whenever a counter drops out. + +A host that ignores the returned interval still polls every five seconds, and +then every poll reads everything, as before. FTW honours the return from +srcfl/ftw#1616. + +A read FTW refused while it waited to reconnect no longer counts toward +treating a register as absent. At one poll per second, a two-second wait would +otherwise mark a whole block absent after one dropped packet. ## Tests: no invented numbers diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 684bb0e..309df45 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.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.8 | 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 | @@ -80,6 +80,8 @@ Catalog source is not proof that a target can install or run a driver. | heishamon | 0.8.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | hello | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | hello | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| home_assistant_vehicle | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| home_assistant_vehicle | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | huawei | 2.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | huawei | 2.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | janitza | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | @@ -112,10 +114,10 @@ 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.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.8 | 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 | +| pixii | 2.1.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | @@ -156,20 +158,20 @@ 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.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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| tesla_cloud | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| tesla_vehicle | 0.2.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| tesla_vehicle | 0.2.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_cloud | 0.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_cloud | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_wall_connector | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_wall_connector | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| teslamate_vehicle | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| teslamate_vehicle | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| teslamate_vehicle | 0.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| teslamate_vehicle | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.2.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.2.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.3.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.3.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | victron | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index d67b63d..adbf916 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.7" + version: "1.3.8" ders: [ev] control: true firmware_versions: "" @@ -606,6 +606,19 @@ manufacturers: control: false firmware_versions: "" notes: "Community driver, untested" + - name: "Home Assistant" + model_families: + - name: "Vehicle (Home Assistant MQTT)" + variants: [Model Y] + regions: [] + protocols: + - protocol: mqtt + driver: "home_assistant_vehicle" + version: "0.1.0" + ders: [vehicle] + control: false + firmware_versions: "" + notes: "Read-only vehicle level, charge limit, charging state, charge cable and place from a JSON message a Home Assistant automation publishes. Tested with recorded messages from a Model Y on the Tesla Custom Integration; the cable and place fields have not run on a car. Does not wake or command the car." - name: "Hoymiles" model_families: - name: "HM Series" @@ -1021,7 +1034,7 @@ manufacturers: protocols: - protocol: mqtt driver: "pixii_pv" - version: "0.3.2" + version: "0.3.3" ders: [pv, meter] control: false firmware_versions: "" @@ -1032,7 +1045,7 @@ manufacturers: protocols: - protocol: modbus driver: "pixii" - version: "2.1.8" + version: "2.1.9" ders: [battery, meter] control: false firmware_versions: "" @@ -1749,7 +1762,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1760,7 +1773,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1771,7 +1784,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1782,7 +1795,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1793,7 +1806,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1806,7 +1819,7 @@ manufacturers: protocols: - protocol: http driver: "tesla_vehicle" - version: "0.2.5" + version: "0.2.6" ders: [vehicle] control: true firmware_versions: "" @@ -1817,22 +1830,22 @@ manufacturers: protocols: - protocol: http driver: "tesla_cloud" - version: "0.1.1" + version: "0.2.0" ders: [vehicle] control: false firmware_versions: "" - notes: "Read-only vehicle SoC, charge limit and charging state via the official Tesla Fleet API. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." + notes: "Read-only vehicle SoC, charge limit, charging state and charge cable via the official Tesla Fleet API. Grant the optional vehicle_location scope to report the car's distance from the site; the coordinates stay in the driver. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." - name: "Tesla Vehicle (TeslaMate)" variants: [Model 3, Model Y] regions: [] protocols: - protocol: mqtt driver: "teslamate_vehicle" - version: "0.1.1" + version: "0.2.0" ders: [vehicle] control: false firmware_versions: "" - notes: "Read-only vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." + notes: "Read-only vehicle SoC, charge limit, charging state, charge cable and place via TeslaMate MQTT. Set geofence to this site's TeslaMate geofence for the place. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." - name: "Tesla Wall Connector" variants: [Wall Connector Gen 3] regions: [] @@ -2064,7 +2077,7 @@ manufacturers: protocols: - protocol: http driver: "vag_vehicle" - version: "0.2.1" + version: "0.3.0" ders: [vehicle] control: false firmware_versions: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index aa717a8..02df8fb 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -346,6 +346,32 @@ make check `make check` regenerates `index.yaml`, `devices.yaml`, `support-status.json` and the manifest hashes. Commit what it produces — it fails if those are stale. +## A car says where it is plugged in + +A vehicle driver can help FTW tell which car is on a charger. Emit +`plugged`, and when the source knows them, `plugged_since_ms` and `at_site` +or `site_distance_m`. The fields are in +[`spec/host-api.md`](../spec/host-api.md#vehicle-ftw). + +- **Date only what you saw happen.** Send `plugged_since_ms` only when the + source reads the cable at least every 30 seconds. A car read every few + minutes shows a plug-in minutes late, and FTW would match it to the wrong + charger or to none. A value the driver saw at its first reading, or one a + broker redelivers after a reconnect, has no known time: leave it out. +- **Prefer the source's own idea of home.** A Home Assistant zone or a + TeslaMate geofence gives `at_site`. Measuring against + `host.site_location()` is the fallback: that position is a weather pin. +- **Keep the coordinates in the driver.** Emit only `site_distance_m`, + rounded to the metre. +- **Unknown is not false.** A cable state or a place the source does not + know is `nil`. A wrong `false` tells FTW the car is somewhere else. +- **No extra reads.** The cable and the place come with the reads the level + already needs. Never wake a car, or add a billed request, to learn them. +- **A level of 1 % goes as 0.01.** FTW reads a vehicle `soc` of 1 as 100 %. + +`home_assistant_vehicle`, `tesla_cloud`, `teslamate_vehicle` and +`vag_vehicle` show the patterns. + ## Testing The harness is at `drivers/tests/lua_harness/`. `host_mock.lua` lets you set diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index aec0a84..82f6d3e 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.7", + version = "1.3.8", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -283,6 +283,45 @@ local OBS_SESSION_START = 223 local OBS_IDS = "48,96,103,109,120,121,124,183,194,223" local last_power_observed_at = nil +local accepted_power_key = nil +local accepted_power_value = nil + +-- Compare RFC3339 source instants independently of fractional precision and +-- UTC offset. Wall-clock receipt time must not make an old packet current. +local function power_time_key(stamp) + if type(stamp) ~= "string" then return nil end + local year, month, day, hour, minute, second, suffix = stamp:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return nil end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if year < 1970 or month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return nil end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local lengths = {31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + if day < 1 or day > lengths[month] then return nil end + local fraction = "" + if suffix:sub(1, 1) == "." then + local digits, zone = suffix:match("^%.(%d+)(.*)$") + if not digits or #digits > 9 then return nil end + fraction, suffix = digits, zone + end + local offset = 0 + if suffix ~= "Z" then + local sign, oh, om = suffix:match("^([+-])(%d%d):(%d%d)$") + if not sign then return nil end + oh, om = tonumber(oh), tonumber(om) + if oh > 23 or om > 59 then return nil end + offset = (oh * 60 + om) * 60 * (sign == "+" and 1 or -1) + end + local function leaps(y) + return math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400) + end + local days = 365 * (year - 1970) + leaps(year - 1) - leaps(1969) + day - 1 + for m = 1, month - 1 do days = days + lengths[m] end + local seconds = days * 86400 + hour * 3600 + minute * 60 + second - offset + if seconds < 0 then return nil end + return string.format("%012d", seconds) .. "." .. fraction .. string.rep("0", 9 - #fraction) +end local validated_session_id = nil local observed_session_id = nil local departed_session_id = nil @@ -327,6 +366,26 @@ local function get_observations(serial) invalidate_session_proof() return nil, "missing session measurement" end + if mode >= 2 then + local key = power_time_key(timestamps[OBS_TOTAL_POWER]) + if not key then + invalidate_session_proof() + return nil, "missing or invalid power source timestamp" + end + if accepted_power_key and key < accepted_power_key then + invalidate_session_proof() + return nil, "out-of-order power observation" + end + if key == accepted_power_key and obs[OBS_TOTAL_POWER] ~= accepted_power_value then + invalidate_session_proof() + return nil, "conflicting power at one source timestamp" + end + accepted_power_key, accepted_power_value = key, obs[OBS_TOTAL_POWER] + elseif mode == 1 then + -- A confirmed disconnect ends the session. A subsequent connection + -- must establish its own source ordering (including a charger reboot). + accepted_power_key, accepted_power_value = nil, nil + end return obs, nil, timestamps end diff --git a/drivers/lua/home_assistant_vehicle.lua b/drivers/lua/home_assistant_vehicle.lua new file mode 100644 index 0000000..d3b33eb --- /dev/null +++ b/drivers/lua/home_assistant_vehicle.lua @@ -0,0 +1,331 @@ +-- Vehicle from Home Assistant (telemetry-only, via MQTT) +-- Emits: Vehicle (DerVehicle) +-- Protocol: MQTT (a JSON message a Home Assistant automation publishes) +-- +-- For a car whose level Home Assistant already reads, through the Tesla +-- Custom Integration or any other car integration. An automation in Home +-- Assistant publishes one JSON message to a topic the owner picks, every +-- 30 seconds and whenever a car entity changes. This driver only +-- subscribes: FTW holds no Home Assistant token, calls no Home Assistant +-- API and sends nothing back. It never wakes or commands the car. +-- +-- The message. Times are Unix seconds, as Home Assistant's as_timestamp +-- gives them. Leave out, or send null for, what Home Assistant does not +-- know. +-- available true when the car entities have values +-- soc_pct 0-100 +-- observed_at when the integration last read the car +-- source_age_s seconds since observed_at, by Home Assistant's clock +-- charge_limit_pct 0-100 +-- charging_state Charging | Starting | NoPower | Stopped | Complete | +-- Disconnected +-- plugged true when the charge cable is in, false when it is out +-- plugged_since when the cable entity took that state (last_changed); +-- send it only when the integration reads the cable at +-- least every 30 seconds, see below +-- at_site true when the car's tracker is in zone home, false +-- when it is somewhere else +-- source_age_s measures the car's reading, not the moment Home Assistant +-- forwards it, so a repeat never makes an old reading fresh. A message +-- older than the last one (smaller observed_at) is ignored. +-- +-- plugged, plugged_since and at_site let FTW tell which car was plugged in +-- (FTW docs/cars.md, rule 2). Home Assistant's zone home must be this site. +-- last_changed is when Home Assistant read the change, not the plug-in: an +-- integration that polls a car every few minutes dates a plug-in minutes +-- late, and FTW would match it to the wrong charger or none. FTW counts a +-- cable time only within a minute of a charger's plug-in, so leave +-- plugged_since out of the template unless the integration reads the cable +-- at least every 30 seconds while the car is at home. +-- +-- Config (MQTT host/port/user live on the capability grant, not here): +-- +-- drivers: +-- - name: tesla-ha +-- lua: drivers/home_assistant_vehicle.lua +-- capabilities: +-- mqtt: +-- host: 192.168.1.10 # the broker Home Assistant publishes to +-- port: 1883 +-- config: +-- topic: ftw/vehicle/tesla-ha/state +-- vin: "5YJ3E1EA1KF000000" # binds the reading to the car +-- make: Tesla # optional +-- model: Model Y # optional +-- +-- Automation for the Tesla Custom Integration with a car named "Tesla +-- Model Y". Other integrations name their entities differently; map them +-- in the template. The cable is binary_sensor._charger (device class +-- plug, with the car's charging_state as an attribute); the place is +-- device_tracker._location_tracker. +-- +-- - id: ftw_tesla_mqtt +-- alias: "FTW: Tesla via MQTT" +-- triggers: +-- - trigger: state +-- entity_id: +-- - sensor.tesla_model_y_battery +-- - sensor.tesla_model_y_data_last_update_time +-- - number.tesla_model_y_charge_limit +-- - binary_sensor.tesla_model_y_charger +-- - device_tracker.tesla_model_y_location_tracker +-- - trigger: time_pattern +-- seconds: "/30" +-- - trigger: homeassistant +-- event: start +-- actions: +-- - action: mqtt.publish +-- data: +-- topic: ftw/vehicle/tesla-ha/state +-- qos: 0 +-- retain: false +-- payload: >- +-- {% set cable = states.binary_sensor.tesla_model_y_charger %} +-- {% set known = cable is not none and cable.state in ['on', 'off'] %} +-- {% set tracker = 'device_tracker.tesla_model_y_location_tracker' %} +-- {% set observed = as_timestamp(states('sensor.tesla_model_y_data_last_update_time'), 0) %} +-- {% set soc = states('sensor.tesla_model_y_battery') | float(-1) %} +-- {% set charging = state_attr('binary_sensor.tesla_model_y_charger', 'charging_state') %} +-- {{ { +-- 'available': observed > 0 and soc >= 0 and soc <= 100, +-- 'soc_pct': soc, +-- 'charge_limit_pct': states('number.tesla_model_y_charge_limit') | float(-1), +-- 'charging_state': charging if charging else ('Disconnected' if known and cable.state == 'off' else ''), +-- 'observed_at': observed, +-- 'source_age_s': as_timestamp(now()) - observed, +-- 'plugged': (cable.state == 'on') if known else none, +-- 'at_site': is_state(tracker, 'home') if has_value(tracker) else none +-- } | to_json }} +-- mode: restart +-- +-- With an integration that reads the cable at least every 30 seconds, add +-- 'plugged_since': as_timestamp(cable.last_changed) if known else none, +-- +-- Publish non-retained: a retained message would replay an old reading at +-- every reconnect. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "home_assistant_vehicle", + name = "Vehicle (Home Assistant MQTT)", + manufacturer = "Home Assistant", + version = "0.1.0", + protocols = { "mqtt" }, + capabilities = { "vehicle" }, + read_only = true, + description = "Read-only vehicle level, charge limit, charging state, charge cable and place from a JSON message a Home Assistant automation publishes over MQTT. Does not wake or command the car.", + homepage = "https://www.home-assistant.io/integrations/mqtt/", + authors = { "FTW contributors" }, + tested_models = { "Model Y" }, + verification_status = "experimental", +} + +PROTOCOL = "mqtt" + +-- File-local so host.mqtt_subscribe(topic) stays a static topic expression +-- for the MQTT contract tests. +local topic = "ftw/vehicle/car/state" + +local POLL_MS = 5000 +local WATCHDOG_TIMEOUT_S = 60 +-- A car reading older than this is not fresh: Core's own live window. +local MAX_AGE_S = 300 +-- Home Assistant publishes every 30 seconds. After this long without a +-- message the broker or Home Assistant is gone, and the driver stops +-- emitting so Core sees the gap instead of a replay. +local SILENT_AFTER_MS = 900000 + +local CHARGING_STATES = { + Charging = true, Starting = true, NoPower = true, + Stopped = true, Complete = true, Disconnected = true, +} + +local vin = nil +-- The last accepted message, as it will be emitted. +local last = nil +-- observed_at (Unix seconds) of the last accepted message. +local observed_at = nil +-- host.millis() after which the last reading is older than MAX_AGE_S. +local expires_ms = 0 +-- host.millis() of the last message on the topic, valid or not. +local heard_ms = 0 + +-- Logs get pasted into public issues, and a VIN identifies the owner's +-- car. Every log line therefore shows only the VIN's last four characters. +local function log(level, message) + message = tostring(message) + if type(vin) == "string" and #vin > 4 then + local escaped = vin:gsub("%p", "%%%0") + message = message:gsub(escaped, "****" .. vin:sub(-4)) + end + host.log(level, message) +end + +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +local function percent(n) + return finite(n) and n >= 0 and n <= 100 +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + +local function bool_or_nil(v) + if v == true or v == false then return v end + return nil +end + +-- Unix ms on the box's clock. A Home Assistant clock a little ahead of the +-- box must not put a reading in the future, which Core refuses. +local function to_ms(seconds) + local ms = math.floor(seconds * 1000) + if host.unix_ms then + local now = host.unix_ms() + if finite(now) and ms > now then ms = math.floor(now) end + end + return ms +end + +local function decode(payload) + local ok, p = pcall(host.json_decode, payload) + if ok and type(p) == "table" then return p end + return nil +end + +-- A message whose reading is usable: a level, its time and its age. +local function usable(p) + return p.available == true and percent(p.soc_pct) + and finite(p.observed_at) and p.observed_at > 0 + and finite(p.source_age_s) and p.source_age_s >= 0 +end + +local function reading_from(p) + local r = { + soc = soc_value(p.soc_pct), + charge_limit_pct = percent(p.charge_limit_pct) and p.charge_limit_pct or nil, + charging_state = CHARGING_STATES[p.charging_state] and p.charging_state or nil, + soc_observed_at_ms = to_ms(p.observed_at), + plugged = bool_or_nil(p.plugged), + at_site = bool_or_nil(p.at_site), + } + if r.plugged ~= nil and finite(p.plugged_since) and p.plugged_since > 0 then + r.plugged_since_ms = to_ms(p.plugged_since) + end + return r +end + +-- Returns true when the message carried a newer car reading. +local function apply(p, now) + if not usable(p) then + -- Unavailable or invalid: the old value must not stay live. + expires_ms = 0 + return false + end + if observed_at ~= nil and p.observed_at < observed_at then + return false + end + local deadline = now + (MAX_AGE_S - p.source_age_s) * 1000 + local newer = observed_at == nil or p.observed_at > observed_at + if newer then + expires_ms = deadline + else + -- A repeat of the same reading may say it is older, never younger. + expires_ms = math.min(expires_ms, deadline) + end + observed_at = p.observed_at + last = reading_from(p) + return newer and p.source_age_s < MAX_AGE_S +end + +function driver_init(config) + config = config or {} + last, observed_at, expires_ms, heard_ms = nil, nil, 0, 0 + -- Home Assistant does not say the make; a wrong one is worse than none. + if type(config.make) == "string" and config.make ~= "" then + host.set_make(config.make) + end + if type(config.model) == "string" and config.model ~= "" and host.set_model then + host.set_model(config.model) + end + if type(config.vin) == "string" and config.vin ~= "" then + vin = config.vin + host.set_sn(vin) + else + vin = nil + log("error", "home_assistant_vehicle: config.vin required to bind the reading to a car") + end + if type(config.topic) == "string" and config.topic ~= "" then + topic = config.topic + else + log("error", "home_assistant_vehicle: config.topic required") + end + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(POLL_MS) + local err = host.mqtt_subscribe(topic) + if type(err) == "string" and err ~= "" then + log("error", "home_assistant_vehicle: subscribe failed: " .. err) + else + log("info", "home_assistant_vehicle: subscribed to " .. topic .. + " vin=" .. tostring(vin or "(missing)") .. " telemetry-only") + end +end + +function driver_poll() + host.set_poll_interval(POLL_MS) + local now = host.millis() + local fresh = false + local messages = host.mqtt_messages() + if not messages then messages = {} end + for _, msg in ipairs(messages) do + if type(msg) == "table" and msg.topic == topic then + heard_ms = now + local p = decode(msg.payload) + if p then + if apply(p, now) then fresh = true end + else + expires_ms = 0 + end + end + end + if not vin or not last or now - heard_ms > SILENT_AFTER_MS then + return POLL_MS + end + local stale = now >= expires_ms + host.emit("vehicle", { + soc = last.soc, + charge_limit_pct = last.charge_limit_pct, + charging_state = last.charging_state, + soc_observed_at_ms = last.soc_observed_at_ms, + plugged = last.plugged, + plugged_since_ms = last.plugged_since_ms, + at_site = last.at_site, + stale = stale, + soc_fresh = fresh and not stale, + }) + return POLL_MS +end + +function driver_command() + return false +end + +function driver_default_mode() + return true +end + +function driver_cleanup() + last, observed_at, expires_ms, heard_ms = nil, nil, 0, 0 + vin = nil + topic = "ftw/vehicle/car/state" +end diff --git a/drivers/lua/pixii.lua b/drivers/lua/pixii.lua index ea1931f..d2e8f8d 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.8", + version = "2.1.9", protocols = { "modbus" }, capabilities = { "battery", "meter" }, description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", @@ -97,35 +97,130 @@ end -- poll, and the stale-telemetry watchdog takes the driver offline. The site -- then reports nothing at all, which is worse than reporting one field less. -- PowerShaper firmware below CPU 2.0.23 answers Modbus exception 2 on 40288 --- (meter_energy_sf), which is how this was found in the field. +-- (meter_energy_sf) while the rest of the device is up, which is how this +-- was found in the field. -- --- Three attempts absorb a transient blip; after that we stop asking. A --- restart re-probes, so firmware that gains the register is picked up. +-- Three misses while other registers answer, and that register is left +-- alone. A poll where every read fails is a different event: the device is +-- unreachable (still booting after a power cut, or the link is down). Those +-- misses leave the counts unchanged. Counting them retired the whole map in +-- about fifteen seconds — three polls at five seconds — and the driver then +-- stayed dark until a restart, because the device came up to a map that was +-- no longer being read. The next poll that gets any answer clears the +-- counts, so a slow boot comes back on its own. +-- +-- A register that really is missing is probed again every five minutes, the +-- short end of a five-to-ten minute retry. That single miss is how a +-- firmware update is picked up; it replaces needing a restart, and it does +-- not hold every poll in a failed state. -- -- This replaces a scale-factor-only cache that gave up after one failure. -- Every read goes through here now: the scale factors were the fourteen -- registers that fix covered, and the value reads below are the twenty-three -- it left behind. local GIVE_UP_AFTER = 3 +local GIVE_UP_RETRY_MIN = 5 +local GIVE_UP_RETRY_MS = GIVE_UP_RETRY_MIN * 60 * 1000 local read_failures = {} +local given_up_since = {} + +-- Set for the body of driver_poll. Misses land in poll_failed and are +-- committed from finish_poll_reads, so a poll whose every read failed can +-- be dropped before it retires anything. +local polling = false +local poll_reads = 0 +local poll_ok = 0 +local poll_failed = {} +local poll_now = 0 +local unreachable_streak = false + +local function clock_ms() + if polling then return poll_now end + if type(host.millis) == "function" then return host.millis() end + return 0 +end + +local function note_failure(addr) + local failures = (read_failures[addr] or 0) + 1 + if failures > GIVE_UP_AFTER then failures = GIVE_UP_AFTER end + read_failures[addr] = failures + if failures < GIVE_UP_AFTER then return end + local already = given_up_since[addr] ~= nil + given_up_since[addr] = clock_ms() + if not already then + host.log("info", string.format( + "Pixii: register %d did not answer %d times while the device " .. + "was up; leaving it alone for %d minutes", + addr, GIVE_UP_AFTER, GIVE_UP_RETRY_MIN)) + end +end local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end + local failures = read_failures[addr] or 0 + if failures >= GIVE_UP_AFTER then + local since = given_up_since[addr] + if since ~= nil and (clock_ms() - since) < GIVE_UP_RETRY_MS then + return nil + end + end + if polling then poll_reads = poll_reads + 1 end local ok, regs = pcall(host.modbus_read, addr, count, kind) if ok and regs and regs[1] ~= nil then + if given_up_since[addr] ~= nil then + host.log("info", string.format( + "Pixii: register %d answered again", addr)) + end read_failures[addr] = nil + given_up_since[addr] = nil + if polling then + poll_ok = poll_ok + 1 + poll_failed[addr] = nil + end return regs end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "Pixii: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) + if polling then + poll_failed[addr] = true + else + note_failure(addr) end return nil end +local function begin_poll_reads() + poll_reads = 0 + poll_ok = 0 + poll_failed = {} + -- One sample for the whole poll. Sampling first keeps clock_ms() on + -- this instant while polling is set. + poll_now = 0 + if type(host.millis) == "function" then poll_now = host.millis() end + polling = true +end + +local function finish_poll_reads() + if poll_reads > 0 and poll_ok == 0 then + if not unreachable_streak then + host.log("info", + "Pixii: every register read failed; the device looks " .. + "unreachable, so register failure counts are unchanged") + end + unreachable_streak = true + elseif poll_ok > 0 then + if unreachable_streak then + host.log("info", + "Pixii: the device answered after an unreachable stretch; " .. + "register failure counts are cleared") + read_failures = {} + given_up_since = {} + unreachable_streak = false + end + for addr, _ in pairs(poll_failed) do + note_failure(addr) + end + end + polling = false +end + -- Read a single i16-typed scale factor register. SF=0 (x1 scaling) is the -- right fallback when the register is absent, and matches units where it -- exists but reads zero. @@ -359,6 +454,8 @@ function driver_poll() host.log("warn", "Pixii: heartbeat write failed: " .. tostring(hb_err)) end + begin_poll_reads() + -- Read serial number once from SunSpec Common Model (offset 52 from -- the common block → absolute 40052, 16 regs ASCII). if not sn_read then @@ -638,6 +735,7 @@ function driver_poll() if meter_var_ok then host.emit_metric("meter_var", meter_var) end host.emit_metric("grid_hz", meter_hz) + finish_poll_reads() return 5000 end diff --git a/drivers/lua/pixii_pv.lua b/drivers/lua/pixii_pv.lua index 2b50c6b..11d576d 100644 --- a/drivers/lua/pixii_pv.lua +++ b/drivers/lua/pixii_pv.lua @@ -14,7 +14,7 @@ DRIVER = { id = "pixii_pv", name = "Pixii PowerShaper (PV + meter)", manufacturer = "Pixii", - version = "0.3.2", + version = "0.3.3", protocols = { "mqtt" }, capabilities = { "pv", "meter" }, read_only = true, @@ -108,29 +108,33 @@ function driver_poll() -------------------------------------------------------------------------- -- PV (external-CT meter on the PV feed). - -- Pixii reports PV as positive generation; site convention requires - -- negative (power leaving the grid boundary into the loads), so negate. + -- Pixii reports generation as positive. A negative net reading means + -- no generation, so report zero PV and keep signed phase diagnostics. -------------------------------------------------------------------------- if pv_payload then - local w1 = tonumber(pv_payload.meter_w1) or 0 - local w2 = tonumber(pv_payload.meter_w2) or 0 - local w3 = tonumber(pv_payload.meter_w3) or 0 - local pv = {} - pv.w = -(w1 + w2 + w3) - local kwh_imp = tonumber(pv_payload.meter_kwh_imp) - if kwh_imp then pv.generation_wh = kwh_imp * 1000.0 end - host.emit("pv", pv) - - host.emit_metric("pv_w_total", pv.w) - host.emit_metric("pv_l1_w", -w1) - host.emit_metric("pv_l2_w", -w2) - host.emit_metric("pv_l3_w", -w3) - if pv_payload.meter_v1 then host.emit_metric("pv_l1_v", tonumber(pv_payload.meter_v1)) end - if pv_payload.meter_v2 then host.emit_metric("pv_l2_v", tonumber(pv_payload.meter_v2)) end - if pv_payload.meter_v3 then host.emit_metric("pv_l3_v", tonumber(pv_payload.meter_v3)) end - if pv_payload.meter_a1 then host.emit_metric("pv_l1_a", tonumber(pv_payload.meter_a1)) end - if pv_payload.meter_a2 then host.emit_metric("pv_l2_a", tonumber(pv_payload.meter_a2)) end - if pv_payload.meter_a3 then host.emit_metric("pv_l3_a", tonumber(pv_payload.meter_a3)) end + local w1 = tonumber(pv_payload.meter_w1) + local w2 = tonumber(pv_payload.meter_w2) + local w3 = tonumber(pv_payload.meter_w3) + local total = w1 and w2 and w3 and (w1 + w2 + w3) + -- Check before clamping: missing or non-finite power is not zero. + if total and total == total and total ~= math.huge and total ~= -math.huge then + local pv = {} + pv.w = -math.max(0, total) + local kwh_imp = tonumber(pv_payload.meter_kwh_imp) + if kwh_imp then pv.generation_wh = kwh_imp * 1000.0 end + host.emit("pv", pv) + + host.emit_metric("pv_w_total", pv.w) + host.emit_metric("pv_l1_w", -w1) + host.emit_metric("pv_l2_w", -w2) + host.emit_metric("pv_l3_w", -w3) + if pv_payload.meter_v1 then host.emit_metric("pv_l1_v", tonumber(pv_payload.meter_v1)) end + if pv_payload.meter_v2 then host.emit_metric("pv_l2_v", tonumber(pv_payload.meter_v2)) end + if pv_payload.meter_v3 then host.emit_metric("pv_l3_v", tonumber(pv_payload.meter_v3)) end + if pv_payload.meter_a1 then host.emit_metric("pv_l1_a", tonumber(pv_payload.meter_a1)) end + if pv_payload.meter_a2 then host.emit_metric("pv_l2_a", tonumber(pv_payload.meter_a2)) end + if pv_payload.meter_a3 then host.emit_metric("pv_l3_a", tonumber(pv_payload.meter_a3)) end + end end -------------------------------------------------------------------------- diff --git a/drivers/lua/sungrow.lua b/drivers/lua/sungrow.lua index 9c86e13..36efb80 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.10", + version = "1.6.0", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", @@ -224,11 +224,21 @@ end local MISSES_BEFORE_SKIP = 3 local miss_counts = {} +-- FTW refuses a read without sending it while it waits to reconnect. That +-- proves nothing about the register, and a two-second wait spans two polls +-- at one per second: counting it would mark a whole block absent after one +-- dropped packet. +local function never_sent(ok, first, second) + local why = ok and second or first + return type(why) == "string" and why:find("reconnect backoff", 1, true) ~= nil +end + local function optional_read(address, count, kind) if (miss_counts[address] or 0) >= MISSES_BEFORE_SKIP then return nil end - local ok, registers = pcall(host.modbus_read, address, count, kind) + local ok, registers, err = pcall(host.modbus_read, address, count, kind) + if never_sent(ok, registers, err) then return nil end -- Check every register asked for, not just the first. A short reply leaves -- the rest nil, and arithmetic on nil ends the whole poll. if ok and registers ~= nil then @@ -403,9 +413,34 @@ end ---------------------------------------------------------------------------- -- Telemetry polling +-- +-- The live view moves once a second, so power, SoC, per-phase values and the +-- EMS readback are read on every poll. Energy counters, nameplate, +-- temperature, frequency and fault bits change slowly and keep their +-- five-second rate. Between those full reads each blob carries their last +-- values, so a counter never drops out of the energy ledger -- but only on a +-- poll whose own power read succeeded, so they never outlive the device. +-- +-- A host that ignores the returned interval polls every five seconds, and +-- then every poll is a full read, as before. ---------------------------------------------------------------------------- +local POLL_MS = 1000 +local FULL_READ_MS = 5000 +local last_full_read_ms = nil +local slow = {} + +local function full_read_due(now_ms) + -- Half a poll early, so jitter cannot push each full read a second late. + return last_full_read_ms == nil + or now_ms - last_full_read_ms >= FULL_READ_MS - POLL_MS / 2 +end + function driver_poll() + local now_ms = host.millis() + local full = full_read_due(now_ms) + if full then last_full_read_ms = now_ms end + -- driver_init already asked once. Keep asking while the answer is unknown, -- up to DETECT_ATTEMPTS, then settle on probing. if model_family == nil and detect_tries < DETECT_ATTEMPTS then @@ -444,11 +479,14 @@ function driver_poll() -- reachable-but-stopped inverter is no longer misreported as merely 0 W. -- Hybrid only: an SG string inverter answers neither, and asking fails -- the whole poll. + -- The running state is read on full polls only; the fault flag it sets + -- stays with the host until a later full poll clears it. The flow status + -- signs the battery power, so it is read with it on every poll. local running_state = 0 local power_flow_status = 0 local running_state_known = false if hybrid_block_worth_reading() then - local running_regs = optional_read(12999, 1, "input") + local running_regs = full and optional_read(12999, 1, "input") if running_regs then running_state = running_regs[1] running_state_known = true @@ -502,35 +540,42 @@ function driver_poll() pv_source = "mppt_sum" end - -- PV lifetime energy: 13002-13003, U32 LE × 0.1 kWh + -- Slow readings, on full polls only: lifetime energy, rated power, + -- heatsink temperature and grid frequency. local pv_gen_wh = 0 - if hybrid_block_worth_reading() then - local pvgen_regs = optional_read(13002, 2, "input") - if pvgen_regs then - pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 + local rated_w = rated_ac_w + local heatsink_c = 0 + local hz = 0 + if full then + -- PV lifetime energy: 13002-13003, U32 LE × 0.1 kWh + if hybrid_block_worth_reading() then + local pvgen_regs = optional_read(13002, 2, "input") + if pvgen_regs then + pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 + end end - end - -- Rated power: 5000, U16 × 0.1 kW - local rated_regs = optional_read(5000, 1, "input") - local rated_w = rated_ac_w - if rated_regs then - rated_w = rated_regs[1] * 0.1 * 1000 - if rated_w > 0 then rated_ac_w = rated_w end - end + -- Rated power: 5000, U16 × 0.1 kW + local rated_regs = optional_read(5000, 1, "input") + if rated_regs then + rated_w = rated_regs[1] * 0.1 * 1000 + if rated_w > 0 then rated_ac_w = rated_w end + end - -- Heatsink temp: 5007, I16 × 0.1 C - local temp_regs = optional_read(5007, 1, "input") - local heatsink_c = 0 - if temp_regs then - heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 - end + -- Heatsink temp: 5007, I16 × 0.1 C + local temp_regs = optional_read(5007, 1, "input") + if temp_regs then + heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 + end - -- Grid frequency: 5241, U16 × 0.01 Hz - local hz_regs = optional_read(5241, 1, "input") - local hz = 0 - if hz_regs then - hz = hz_regs[1] * 0.01 + -- Grid frequency: 5241, U16 × 0.01 Hz + local hz_regs = optional_read(5241, 1, "input") + if hz_regs then + hz = hz_regs[1] * 0.01 + end + slow.pv_gen_wh, slow.heatsink_c, slow.hz = pv_gen_wh, heatsink_c, hz + elseif pv_regs or mppt_regs then + pv_gen_wh, heatsink_c = slow.pv_gen_wh, slow.heatsink_c end host.emit("pv", { @@ -550,27 +595,29 @@ function driver_poll() -- Diagnostics: long-format TS DB. Surface BOTH the primary-register -- reading and the MPPT-derived fallback so operators can see when -- the two disagree (= firmware register quirk) directly in the - -- metric browser. - host.emit_metric("pv_w_primary", pv_w_primary) - host.emit_metric("pv_w_mppt_sum", pv_w_mppt) - host.emit_metric("pv_raw_u32", pv_raw_u32) - host.emit_metric("pv_mppt1_v", mppt1_v) - host.emit_metric("pv_mppt1_a", mppt1_a) - host.emit_metric("pv_mppt1_w", mppt1_w) - host.emit_metric("pv_mppt2_v", mppt2_v) - host.emit_metric("pv_mppt2_a", mppt2_a) - host.emit_metric("pv_mppt2_w", mppt2_w) - host.emit_metric("inverter_temp_c", heatsink_c) - host.emit_metric("grid_hz", hz) - host.emit_metric("sungrow_running_state", running_state) - host.emit_metric("sungrow_power_flow_status", power_flow_status) - host.emit_metric("sungrow_pv_curtail_method", pv_curtail_method == "feed_in" and 2 or 1) + -- metric browser. Metrics keep the full-read rate. + if full then + host.emit_metric("pv_w_primary", pv_w_primary) + host.emit_metric("pv_w_mppt_sum", pv_w_mppt) + host.emit_metric("pv_raw_u32", pv_raw_u32) + host.emit_metric("pv_mppt1_v", mppt1_v) + host.emit_metric("pv_mppt1_a", mppt1_a) + host.emit_metric("pv_mppt1_w", mppt1_w) + host.emit_metric("pv_mppt2_v", mppt2_v) + host.emit_metric("pv_mppt2_a", mppt2_a) + host.emit_metric("pv_mppt2_w", mppt2_w) + host.emit_metric("inverter_temp_c", heatsink_c) + host.emit_metric("grid_hz", hz) + host.emit_metric("sungrow_running_state", running_state) + host.emit_metric("sungrow_power_flow_status", power_flow_status) + host.emit_metric("sungrow_pv_curtail_method", pv_curtail_method == "feed_in" and 2 or 1) + end -- Contiguous bitfields from documented input registers 13050-13079 -- (zero-based host addresses 13049-13078). One read gives enough evidence -- to distinguish thermal, grid, PV/DC, battery and BMS shutdowns. local fault_regs = nil - if hybrid_block_worth_reading() then + if full and hybrid_block_worth_reading() then fault_regs = optional_read(13049, 30, "input") end local fault_values = {} @@ -650,7 +697,7 @@ function driver_poll() if hybrid_block_worth_reading() then emsd = optional_read(13049, 3, "holding") end - if emsd then + if emsd and full 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]) @@ -673,21 +720,30 @@ function driver_poll() -- Energy counters are separate reads. Add each only if it answered: -- a counter reported as zero would look like a reset meter. - local bchg_regs = optional_read(13040, 2, "input") - if bchg_regs then - battery.charge_wh = - host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 - end + if full then + local bchg_regs = optional_read(13040, 2, "input") + if bchg_regs then + battery.charge_wh = + host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 + end - local bdis_regs = optional_read(13026, 2, "input") - if bdis_regs then - battery.discharge_wh = - host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 + local bdis_regs = optional_read(13026, 2, "input") + if bdis_regs then + battery.discharge_wh = + host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 + end + slow.charge_wh, slow.discharge_wh = battery.charge_wh, battery.discharge_wh + else + battery.charge_wh, battery.discharge_wh = slow.charge_wh, slow.discharge_wh end host.emit("battery", battery) - host.emit_metric("battery_dc_v", bat_v) - host.emit_metric("battery_dc_a", bat_a) + if full then + host.emit_metric("battery_dc_v", bat_v) + host.emit_metric("battery_dc_a", bat_a) + end + elseif full then + slow.charge_wh, slow.discharge_wh = nil, nil end -- Grid meter power: 5600-5601, I32 LE, watts (positive=import, negative=export) @@ -729,15 +785,20 @@ function driver_poll() -- rather than zeroed when absent: a counter reading zero looks like a -- meter that was reset. local import_wh, export_wh = nil, nil - if hybrid_block_worth_reading() then - local imp_regs = optional_read(13036, 2, "input") - if imp_regs then - import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - local exp_regs = optional_read(13045, 2, "input") - if exp_regs then - export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 + if full then + if hybrid_block_worth_reading() then + local imp_regs = optional_read(13036, 2, "input") + if imp_regs then + import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 + end + local exp_regs = optional_read(13045, 2, "input") + if exp_regs then + export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 + end end + slow.import_wh, slow.export_wh = import_wh, export_wh + elseif mw_regs then + import_wh, export_wh, hz = slow.import_wh, slow.export_wh, slow.hz end -- 5600-5601 relay the external meter on the known SH register map. Only an @@ -763,15 +824,17 @@ function driver_poll() import_wh = import_wh, export_wh = export_wh, }) - host.emit_metric("meter_l1_w", l1_w) - host.emit_metric("meter_l2_w", l2_w) - host.emit_metric("meter_l3_w", l3_w) - host.emit_metric("meter_l1_v", l1_v) - host.emit_metric("meter_l2_v", l2_v) - host.emit_metric("meter_l3_v", l3_v) - host.emit_metric("meter_l1_a", l1_a) - host.emit_metric("meter_l2_a", l2_a) - host.emit_metric("meter_l3_a", l3_a) + if full then + host.emit_metric("meter_l1_w", l1_w) + host.emit_metric("meter_l2_w", l2_w) + host.emit_metric("meter_l3_w", l3_w) + host.emit_metric("meter_l1_v", l1_v) + host.emit_metric("meter_l2_v", l2_v) + host.emit_metric("meter_l3_v", l3_v) + host.emit_metric("meter_l1_a", l1_a) + host.emit_metric("meter_l2_a", l2_a) + host.emit_metric("meter_l3_a", l3_a) + end -- Feed-in fallback translation: a PV allowance is the power that may -- serve local load, charge the battery, or leave the site. Sungrow's @@ -783,7 +846,7 @@ function driver_poll() local load_w = meter_w - bat_w + pv_w latest_non_export_w = math.max(0, load_w + math.max(0, bat_w)) - return 5000 + return POLL_MS end ---------------------------------------------------------------------------- diff --git a/drivers/lua/tesla_cloud.lua b/drivers/lua/tesla_cloud.lua index ae725df..e68e7eb 100644 --- a/drivers/lua/tesla_cloud.lua +++ b/drivers/lua/tesla_cloud.lua @@ -30,6 +30,18 @@ -- vehicle_device_data. Do not grant vehicle_cmds / vehicle_charging_cmds for -- this driver; it never posts a command. -- +-- Which car is plugged in (FTW docs/cars.md, rule 2). The same charge_state +-- read says whether a cable is in (conn_charge_cable, else charging_state). +-- The driver sends no plugged_since_ms: it reads the car minutes apart, so +-- a read that shows the cable in says nothing about when it went in. With +-- the optional vehicle_location scope and a site position from the host +-- (host.site_location), the read also asks for location_data, and the +-- driver reports only the car's distance from the site (site_distance_m), +-- never its coordinates. Without the scope Tesla refuses location_data; the +-- driver then reads charge_state alone and asks again after a day. Nothing +-- here adds a request or wakes the car: the cable and the place come with +-- the reads the level already needs. +-- -- Config: -- drivers: -- - name: tesla-cloud @@ -58,7 +70,7 @@ DRIVER = { id = "tesla_cloud", name = "Tesla Vehicle (Fleet API)", manufacturer = "Tesla", - version = "0.1.1", + version = "0.2.0", protocols = { "http" }, capabilities = { "vehicle" }, read_only = true, @@ -145,7 +157,14 @@ local last = { time_to_full = nil, charge_amps = nil, charger_actual_current = nil, + plugged = nil, + site_distance_m = nil, } +-- host.millis() before which the driver does not ask for location_data: +-- Tesla refused it (no vehicle_location scope). The owner may grant the +-- scope later, so it asks again after LOCATION_RETRY_MS. +local LOCATION_RETRY_MS = 86400000 +local location_denied_until = 0 local function redact_http_err(err) if err == nil then return "ok" end @@ -335,9 +354,64 @@ local function charge_state_from(decoded) return nil end -local function remember(cs) +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +-- The car's word on its cable: conn_charge_cable names the cable in the +-- port ("" when there is none), else the charging state says it. +local function plugged_from(cs) + local cable = cs.conn_charge_cable + if type(cable) == "string" and cable ~= "" then + return cable ~= "" + end + if type(cs.charging_state) == "string" and cs.charging_state ~= "" then + return cs.charging_state ~= "Disconnected" + end + return nil +end + +-- Great-circle distance in metres. +local function distance_m(lat1, lon1, lat2, lon2) + local r = math.pi / 180 + local dlat, dlon = (lat2 - lat1) * r, (lon2 - lon1) * r + local a = math.sin(dlat / 2) ^ 2 + + math.cos(lat1 * r) * math.cos(lat2 * r) * math.sin(dlon / 2) ^ 2 + return 2 * 6371000 * math.asin(math.min(1, math.sqrt(a))) +end + +local function site() + if not host.site_location then return nil end + local lat, lon = host.site_location() + if not finite(lat) or not finite(lon) then return nil end + return lat, lon +end + +-- The car's distance from the site, from location_data's drive_state. +local function site_distance_from(root) + local ds = type(root) == "table" and root.drive_state + if type(ds) ~= "table" then return nil end + local lat, lon = tonumber(ds.latitude), tonumber(ds.longitude) + local slat, slon = site() + if not finite(lat) or not finite(lon) or not slat then return nil end + if math.abs(lat) > 90 or math.abs(lon) > 180 or (lat == 0 and lon == 0) then return nil end + return math.floor(distance_m(slat, slon, lat, lon) + 0.5) +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + +local function remember(cs, root) local soc = tonumber(cs.battery_level) if soc == nil then return false end + last.plugged = plugged_from(cs) + last.site_distance_m = site_distance_from(root) last.soc = soc last.charge_limit = tonumber(cs.charge_limit_soc) last.charge_amps = tonumber(cs.charge_amps) @@ -368,28 +442,47 @@ local function emit_vehicle(fresh) return end host.emit("vehicle", { - soc = last.soc, + soc = soc_value(last.soc), charge_limit_pct = last.charge_limit, charging_state = last.charging_state, time_to_full_min = last.time_to_full, charge_amps = last.charge_amps, charger_actual_current = last.charger_actual_current, + plugged = last.plugged, + site_distance_m = last.site_distance_m, stale = false, soc_fresh = fresh, }) end -local function fetch_charge_state() +local function fetch_vehicle_data(with_location) local url = base_url .. "/api/1/vehicles/" .. vin .. "/vehicle_data?endpoints=charge_state" + if with_location then + url = url .. "%3Blocation_data" + end local resp, err = safe_http_get(url, auth_headers()) - if err then return nil, err end + if err then return nil, nil, err end local decoded, derr = decode_json(resp) - if derr then return nil, derr end + if derr then return nil, nil, derr end if type(decoded) == "table" and decoded.error then - return nil, tostring(decoded.error) + return nil, nil, tostring(decoded.error) end - return charge_state_from(decoded), nil + return charge_state_from(decoded), unwrap(decoded), nil +end + +-- charge_state, with location_data when the site has a position and Tesla +-- has not refused it lately. One request either way, so the place costs +-- nothing. +local function fetch_charge_state() + local with_location = host.millis() >= location_denied_until and site() ~= nil + local cs, root, err = fetch_vehicle_data(with_location) + if with_location and err and tostring(err):match("HTTP 403") then + location_denied_until = host.millis() + LOCATION_RETRY_MS + log("info", "tesla_cloud: no vehicle_location scope; reading charge_state alone for a day") + cs, root, err = fetch_vehicle_data(false) + end + return cs, root, err end local function wait(ms) @@ -487,11 +580,11 @@ function driver_poll() return POLL_MS end - local cs, ferr = fetch_charge_state() + local cs, root, ferr = fetch_charge_state() if ferr and tostring(ferr):match("HTTP 401") then token_expires_at = 0 if ensure_auth() then - cs, ferr = fetch_charge_state() + cs, root, ferr = fetch_charge_state() end end if ferr then @@ -509,7 +602,7 @@ function driver_poll() emit_vehicle(false) return POLL_MS end - if type(cs) ~= "table" or not remember(cs) then + if type(cs) ~= "table" or not remember(cs, root) then log("debug", "tesla_cloud: no battery_level in charge_state") wait(IDLE_EVERY_MS) emit_vehicle(false) @@ -536,6 +629,9 @@ function driver_cleanup() last.time_to_full = nil last.charge_amps = nil last.charger_actual_current = nil + last.plugged = nil + last.site_distance_m = nil + location_denied_until = 0 access_token = nil token_expires_at = 0 next_request_ms = 0 diff --git a/drivers/lua/tesla_vehicle.lua b/drivers/lua/tesla_vehicle.lua index f9d557c..25d2893 100644 --- a/drivers/lua/tesla_vehicle.lua +++ b/drivers/lua/tesla_vehicle.lua @@ -11,7 +11,7 @@ DRIVER = { id = "tesla_vehicle", name = "Tesla Vehicle (BLE Proxy)", manufacturer = "Tesla", - version = "0.2.5", + version = "0.2.6", protocols = { "http" }, capabilities = { "vehicle" }, description = "Vehicle SoC and charge limit via a local Tesla BLE proxy, with bounded telemetry wake and charge-start support.", @@ -81,13 +81,22 @@ local function busy(err) local es = tostring(err) return es:match("HTTP 408") or es:match("HTTP 503") or es:match("Command Disallowed") end +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + local function emit_last(fresh) if last.soc == nil then return end local now = wall_ms() local age = now and last.ts_ms > 0 and math.max(0, now - last.ts_ms) or nil if age and host.emit_metric then host.emit_metric("vehicle_soc_age_s", age / 1000, "s") end host.emit("vehicle", { - soc = last.soc, + soc = soc_value(last.soc), charge_limit_pct = last.limit, charging_state = last.state, time_to_full_min = last.ttf, diff --git a/drivers/lua/teslamate_vehicle.lua b/drivers/lua/teslamate_vehicle.lua index 4c64988..b7d911b 100644 --- a/drivers/lua/teslamate_vehicle.lua +++ b/drivers/lua/teslamate_vehicle.lua @@ -44,6 +44,7 @@ -- config: -- vin: "5YJ3E1EA1KF000000" # required — TeslaMate MQTT has no VIN -- car_id: 1 # TeslaMate id, usually starts at 1 +-- # geofence: Home # optional: this site's geofence name -- # topic_prefix: teslamate/cars/1 # optional override of the prefix -- -- Enable MQTT in TeslaMate (MQTT_HOST in its compose) before pointing FTW @@ -52,6 +53,19 @@ -- -- No HTTP. TeslaMate's UI and GraphQL are out of scope. If a later HTTP -- lookup is added, grant capabilities.http.allowed_hosts for that host only. +-- +-- Which car is plugged in (FTW docs/cars.md, rule 2). TeslaMate's +-- plugged_in topic gives the cable. A change gives plugged_since_ms, by the +-- box's clock, only when a live update carries it: a healthy message within +-- 10 seconds. A retained value, at subscribe or redelivered after the broker +-- reconnects, comes without one, and its time is unknown. +-- +-- The place: with `geofence` set to the name of this site's geofence in +-- TeslaMate, the geofence topic gives at_site. Without it, latitude and +-- longitude, with the site position from the host (host.site_location), +-- give site_distance_m; the car's coordinates are not emitted. The +-- geofence is the better of the two: the site position is a weather pin. +-- All of this comes with the updates TeslaMate already sends. DRIVER = { host_api_min = 1, @@ -59,7 +73,7 @@ DRIVER = { id = "teslamate_vehicle", name = "Tesla Vehicle (TeslaMate)", manufacturer = "Tesla", - version = "0.1.1", + version = "0.2.0", protocols = { "mqtt" }, capabilities = { "vehicle" }, read_only = true, @@ -106,6 +120,26 @@ end -- last published charge fields while the car was awake. TeslaMate's -- ISO `since` cannot be compared to host.millis() (monotonic uptime), -- and os.time is sandbox-forbidden in this repository. +-- The car's cable and place. Kept apart from `last`: TeslaMate sends them +-- when they change, and they do not make a level fresh. +local plug = { + plugged = nil, + since_ms = nil, + -- A change waiting for a healthy message: its unix ms, and host.millis() + -- when it arrived. + pending_ms = nil, + pending_at = nil, + lat = nil, + lon = nil, + geofence = nil, +} +-- host.millis() of the last healthy=true message. +local healthy_at = nil +-- This site's TeslaMate geofence, from config. +local home_geofence = nil +-- A plugged_in change and its healthy message arrive this close together. +local LIVE_WITHIN_MS = 10000 + local last = { seen_ms = 0, soc = nil, @@ -187,7 +221,62 @@ local function reset_incoming() incoming.charger_actual_current = nil end +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +-- Great-circle distance in metres. +local function distance_m(lat1, lon1, lat2, lon2) + local r = math.pi / 180 + local dlat, dlon = (lat2 - lat1) * r, (lon2 - lon1) * r + local a = math.sin(dlat / 2) ^ 2 + + math.cos(lat1 * r) * math.cos(lat2 * r) * math.sin(dlon / 2) ^ 2 + return 2 * 6371000 * math.asin(math.min(1, math.sqrt(a))) +end + +local function site_distance() + if not host.site_location or not finite(plug.lat) or not finite(plug.lon) then + return nil + end + local slat, slon = host.site_location() + if not finite(slat) or not finite(slon) then return nil end + return math.floor(distance_m(slat, slon, plug.lat, plug.lon) + 0.5) +end + +local function reset_plug() + plug.plugged, plug.since_ms, plug.lat, plug.lon = nil, nil, nil, nil + plug.pending_ms, plug.pending_at, plug.geofence = nil, nil, nil + healthy_at = nil +end + +-- A pending change becomes the cable's time when a healthy message came +-- within LIVE_WITHIN_MS of it, and is dropped once that cannot happen. +local function settle_plug(now) + if not plug.pending_at then return end + if healthy_at and math.abs(healthy_at - plug.pending_at) <= LIVE_WITHIN_MS then + plug.since_ms = plug.pending_ms + plug.pending_ms, plug.pending_at = nil, nil + elseif now - plug.pending_at > LIVE_WITHIN_MS then + plug.pending_ms, plug.pending_at = nil, nil + end +end + +local function at_site() + if not home_geofence then return nil end + return plug.geofence == home_geofence +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + local function reset_last() + reset_plug() last.seen_ms = 0 last.soc = nil last.charge_limit = nil @@ -247,12 +336,16 @@ local function emit_vehicle(fresh) return end host.emit("vehicle", { - soc = last.soc, + soc = soc_value(last.soc), charge_limit_pct = last.charge_limit, charging_state = last.charging_state, time_to_full_min = last.time_to_full, charge_amps = last.charge_amps, charger_actual_current = last.charger_actual_current, + plugged = plug.plugged, + plugged_since_ms = plug.since_ms, + at_site = at_site(), + site_distance_m = not home_geofence and site_distance() or nil, stale = last.healthy == false, soc_fresh = fresh, }) @@ -275,8 +368,33 @@ local function apply_message(msg) if key == "healthy" then local b = as_bool(p) if b ~= nil then last.healthy = b end + if b == true then healthy_at = host.millis() end return b == true end + if key == "plugged_in" then + local b = as_bool(p) + if b == nil then return false end + if plug.plugged ~= nil and b ~= plug.plugged then + plug.since_ms = nil + if host.unix_ms then + plug.pending_ms, plug.pending_at = host.unix_ms(), host.millis() + end + end + plug.plugged = b + return false + end + if key == "geofence" then + plug.geofence = p ~= nil and tostring(p) or "" + return false + end + if key == "latitude" or key == "longitude" then + local n = tonumber(p) + if n == nil or (key == "latitude" and math.abs(n) > 90) or math.abs(n) > 180 then + return false + end + if key == "latitude" then plug.lat = n else plug.lon = n end + return false + end if key == "model" then if payload_present(p) and host.set_model then host.set_model(model_name(p)) @@ -351,6 +469,10 @@ function driver_init(config) else base_topic = "teslamate/cars/1" end + home_geofence = nil + if config.geofence ~= nil and tostring(config.geofence) ~= "" then + home_geofence = tostring(config.geofence) + end if host.set_watchdog_timeout_s then host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) @@ -381,6 +503,7 @@ function driver_poll() for _, msg in ipairs(messages) do if apply_message(msg) then saw_charge = true end end + settle_plug(host.millis()) -- Only a charge-field message or a healthy heartbeat while TeslaMate says -- the car is awake starts (or refreshes) the age clock. Idle polls and @@ -400,6 +523,7 @@ end function driver_cleanup() reset_last() + home_geofence = nil vin = nil base_topic = "teslamate/cars/1" end diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index 2de1193..7246338 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -30,6 +30,13 @@ -- session Cookie header instead, and the driver stops emitting when it -- expires. -- +-- Which car is plugged in (FTW docs/cars.md, rule 2): the dataset's plug +-- state gives `plugged`. The portal writes a file about every 15 minutes, +-- too seldom to date a plug-in, so the driver sends no plugged_since_ms; a +-- fresh "disconnected" still tells Core this car is not the one plugged in. +-- The datasets this driver was written from carry no position, so it gives +-- no site_distance_m. +-- -- Age: the SoC data point carries the time the car measured it, and the -- portal's Date header gives the time now, so the driver reports the -- reading's age as `vehicle_soc_age_s` without a wall clock of its own. @@ -66,7 +73,7 @@ DRIVER = { id = "vag_vehicle", name = "VAG Vehicle (EU Data Act)", manufacturer = "Volkswagen Group", - version = "0.2.1", + version = "0.3.0", protocols = { "http" }, capabilities = { "vehicle" }, read_only = true, @@ -200,6 +207,8 @@ local last = { known_age = false, -- Seconds between the car's measurement and the read, when known. measured_age_s = nil, + -- The dataset's plug state; nil when unknown. + plugged = nil, } -- Logs get pasted into public issues, and a VIN identifies the owner's @@ -1059,17 +1068,39 @@ local function newest_dataset(list) return best end +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + local function emit_reading(soc, limit, state, ttf, fresh, stale) host.emit("vehicle", { - soc = soc, + soc = soc_value(soc), charge_limit_pct = limit, charging_state = state, time_to_full_min = ttf, + plugged = last.plugged, stale = stale, soc_fresh = fresh, }) end +-- "connected" and "disconnected" are the plug states the portal writes; +-- anything else is unknown, not unplugged. +local function remember_plug(point) + local v = point and lower(point.value) or "" + last.plugged = nil + if v == "connected" then + last.plugged = true + elseif v == "disconnected" then + last.plugged = false + end +end + -- The reading's age now: measured age at the read plus time since. local function emit_age(since_read_ms) if last.measured_age_s == nil then return nil end @@ -1323,6 +1354,7 @@ function driver_poll() local ttf = ttf_p and num(ttf_p.value) or nil if ttf == 65535 then ttf = nil end local state = map_charging_state(points) + remember_plug(lookup(points, PLUG_IDS)) local known_age = name ~= baseline_file local fresh = known_age @@ -1372,4 +1404,5 @@ function driver_cleanup() last.time_to_full = nil last.known_age = false last.ts_ms = 0 + last.plugged = nil end diff --git a/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua index fba1627..c90a018 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua @@ -17,10 +17,12 @@ local function boot() end -- reason_time before power_time: the reason predates the last power change. +local power_tick = 0 local function poll(mode, power_kw, reason, reason_time, offer_a) + power_tick = power_tick + 1 local obs = { {id=109, value=mode, timestamp="2026-10-08T05:00:00Z"}, - {id=120, value=power_kw, timestamp="2026-10-08T05:10:00Z"}, + {id=120, value=power_kw, timestamp=string.format("2026-10-08T05:10:00.%09dZ", power_tick)}, {id=121, value=12.5, timestamp="2026-10-08T05:10:00Z"}, {id=194, value=230, timestamp="2026-10-08T05:00:00Z"}, {id=96, value=reason, timestamp=reason_time}, diff --git a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua new file mode 100644 index 0000000..2faa2a0 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua @@ -0,0 +1,41 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +host.reset() +host._millis_step = 0 +host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' +host._http_responses["/config"] = '{"maxChargerCurrent":16}' +host._http_responses["/sessions/ongoing"] = '{}' +dofile("drivers/lua/easee_cloud.lua") +driver_init({email="test@example.invalid",password="test",serial="TEST123"}) +local function poll(w, stamp, mode) + host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=mode or 3,timestamp="2026-10-17T03:49:10Z"}, + {id=120,value=w,timestamp=stamp}, + {id=121,value=12.5,timestamp="2026-10-17T03:49:10Z"}, + }) + driver_poll() + local rows=host._emitted.ev or {} + return #rows, rows[#rows] +end +local count, row = poll(10.242,"2026-10-17T03:49:08.1Z") +assert(count==1 and row.w==10242,"initial measured power missing") +local n = poll(0,"2026-10-17T03:49:07Z") +assert(n==count,"older cloud zero replaced a newer power observation") +n = poll(0,"2026-10-17T05:49:08.1000000+02:00") +assert(n==count,"contradictory value at the same physical time was accepted") +n = poll(0,nil) +assert(n==count,"missing source time became fresh zero power") +n = poll(0,"invalid") +assert(n==count,"malformed source time became fresh power") +n = poll(0,"2026-02-30T03:49:08Z") +assert(n==count,"impossible date was accepted") +n,row = poll(10.242,"2026-10-17T05:49:08.1000000+02:00") +assert(n==count+1 and row.control_power_confirmed,"unchanged valid sample no longer confirmed") +count=n +n,row = poll(0,"2026-10-17T03:49:09.1Z",4) +assert(n==count+1 and row.w==0,"a newer observed stop was ignored") +count=n +n = poll(10.242,"2026-10-17T03:49:08.1Z") +assert(n==count,"old positive power resurrected a stopped charge") +n,row = poll(6.2,"2026-10-17T03:49:10Z") +assert(n==count+1 and row.w==6200,"fresh subsequent power did not recover") +print("Easee observation ordering: passed") diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index b5ea64e..0b67cc5 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -10,9 +10,12 @@ local function boot(ended) dofile(driver) driver_init({email="test@example.invalid",password="test",serial=serial}) end +local poll_tick = 0 local function poll(mode, session, energy, lifetime) + poll_tick = poll_tick + 1 + local power_stamp = string.format("2026-01-03T00:%02d:%02dZ", math.floor(poll_tick / 60), poll_tick % 60) local obs = { - {id=109,value=mode}, {id=120,value=mode == 3 and 4.3 or 0}, + {id=109,value=mode}, {id=120,value=mode == 3 and 4.3 or 0,timestamp=power_stamp}, {id=121,value=energy or 9}, {id=124,value=lifetime or 1009}, } if session ~= nil then table.insert(obs,{id=223,value=session}) end diff --git a/drivers/tests/lua_harness/test_home_assistant_vehicle.lua b/drivers/tests/lua_harness/test_home_assistant_vehicle.lua new file mode 100644 index 0000000..9bccb9e --- /dev/null +++ b/drivers/tests/lua_harness/test_home_assistant_vehicle.lua @@ -0,0 +1,199 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") + +-- Recorded on 15 October 2026 from a Model Y on the Tesla Custom +-- Integration (VIN replaced). The message the site's automation published: +local RECORDED = '{"available":true,"soc_pct":14.0,"charge_limit_pct":80.0,' .. + '"charging_state":"Stopped","observed_at":1792082304.0,"source_age_s":1476.321958065033}' +-- The entities behind it at that moment: +-- sensor.tesla_model_y_battery = 14 +-- sensor.tesla_model_y_data_last_update_time = 2026-10-15T16:38:24+00:00 +-- number.tesla_model_y_charge_limit = 80 +-- binary_sensor.tesla_model_y_charger = on, updated 16:38:24.065320+00:00, +-- {charging_state: Stopped, conn_charge_cable: IEC, device_class: plug} +-- The same moment rendered by the automation in the driver's header, with +-- the car's tracker in zone home and a fresh reading: +local OBSERVED = 1792082304.0 +local CABLE_CHANGED = 1792082304.06532 +-- JSON as Home Assistant's to_json writes it: numbers as floats. The +-- harness's decoder reads an integer above 2^31 wrongly in 32-bit Lua. +local function json(m) + local keys = {} + for k in pairs(m) do keys[#keys + 1] = k end + table.sort(keys) + local parts = {} + for _, k in ipairs(keys) do + local v, out = m[k], nil + if type(v) == "number" then out = string.format("%.6f", v) + elseif type(v) == "boolean" then out = tostring(v) + else out = string.format("%q", tostring(v)) end + parts[#parts + 1] = string.format("%q", k) .. ":" .. out + end + return "{" .. table.concat(parts, ",") .. "}" +end +local function message(over) + local m = { + available = true, soc_pct = 14.0, charge_limit_pct = 80.0, + charging_state = "Stopped", observed_at = OBSERVED, source_age_s = 20.0, + plugged = true, plugged_since = CABLE_CHANGED, at_site = true, + } + for k, v in pairs(over or {}) do + if v == "nil" then m[k] = nil else m[k] = v end + end + return json(m) +end + +-- The harness runs 32-bit Lua (LUA_32BITS), where an epoch in seconds is +-- exact only to 128 s and one in ms to about 131 s. FTW runs 64-bit floats. +-- So times step by 128 s here, and an expected ms value takes the same JSON +-- path the driver's input takes. +local STEP = 128 +local function ms(seconds) + return math.floor(host.json_decode('{"v":' .. string.format("%.6f", seconds) .. '}').v * 1000) +end + +local VIN = "5YJ3E1EA1KF000000" +local TOPIC = "ftw/vehicle/tesla-ha/state" +-- The box's clock, 20 s after the reading. +local box_ms = (OBSERVED + 20) * 1000 + +local function boot(cfg) + host.reset() + host.unix_ms = function() return box_ms end + dofile("drivers/lua/home_assistant_vehicle.lua") + driver_init(cfg or { topic = TOPIC, vin = VIN, make = "Tesla", model = "Model Y" }) +end + +local function send(payload, topic) + host._mqtt_buffer = { { topic = topic or TOPIC, payload = payload } } + driver_poll() +end + +local function last() + local rows = host._emitted.vehicle + if not rows or #rows == 0 then return nil end + return rows[#rows] +end + +local function count() + return host._emitted.vehicle and #host._emitted.vehicle or 0 +end + +local function subscribed() + for i = 1, #host._calls do + if host._calls[i].func == "mqtt_subscribe" then return host._calls[i].args[1] end + end +end + +boot() +assert(host._make == "Tesla" and host._model == "Model Y" and host._sn == VIN, "identity from config") +assert(subscribed() == TOPIC, "subscribes to the configured topic") + +-- The recorded message, as the site's automation sends it today: no cable +-- or place fields, and a reading 25 minutes old. +send(RECORDED) +local r = last() +assert(r, "the recorded message emits") +assert(r.soc == 14, "14 % as a percent, which every FTW reads alike, got " .. tostring(r.soc)) +assert(r.charge_limit_pct == 80, "80 % limit") +assert(r.charging_state == "Stopped", "charging state") +assert(r.soc_observed_at_ms == ms(OBSERVED), "the car's reading time") +assert(r.stale == true and r.soc_fresh == false, "a 25-minute-old reading is not fresh") +assert(r.plugged == nil and r.plugged_since_ms == nil and r.at_site == nil, + "an automation without cable or place says nothing about them") + +-- The same moment with the cable and place mapped. +boot() +send(message()) +r = last() +assert(r.soc_fresh == true and r.stale == false, "a 20 s old reading is fresh") +assert(r.plugged == true, "plugged from binary_sensor._charger") +assert(r.plugged_since_ms == ms(CABLE_CHANGED), "plugged_since_ms from last_changed, got " .. tostring(r.plugged_since_ms)) +assert(r.at_site == true, "at_site from the tracker in zone home") + +-- A replay keeps the cable and place and the reading's time, not fresh. +driver_poll() +r = last() +assert(r.soc_fresh == false and r.plugged == true and r.plugged_since_ms == ms(CABLE_CHANGED) and r.at_site == true, + "replay keeps the last word") + +-- Unplugged at the site, then away: the newest message wins. +box_ms = (OBSERVED + 10 * STEP) * 1000 +send(message({ observed_at = OBSERVED + STEP, plugged = false, plugged_since = OBSERVED + STEP })) +r = last() +assert(r.plugged == false and r.plugged_since_ms == ms(OBSERVED + STEP) and r.at_site == true, + "cable out") +send(message({ observed_at = OBSERVED + 8 * STEP, plugged = false, at_site = false })) +r = last() +assert(r.at_site == false, "the tracker left zone home") + +-- An older message cannot overwrite a newer one. +send(message({ observed_at = OBSERVED + 2 * STEP, plugged = true, at_site = true })) +r = last() +assert(r.plugged == false and r.at_site == false, "an out-of-order message is ignored") + +-- A cable time without a cable state means nothing. +boot() +send(message({ plugged = "nil" })) +r = last() +assert(r.plugged == nil and r.plugged_since_ms == nil, "no plugged, no plugged_since_ms") + +-- null from the template, and non-boolean values, are unknown. +boot() +send('{"available":true,"soc_pct":50,"observed_at":1792082304,"source_age_s":1,' .. + '"plugged":null,"plugged_since":null,"at_site":"home"}') +r = last() +assert(r.plugged == nil and r.at_site == nil and r.plugged_since_ms == nil, "null and strings are unknown") + +-- A Home Assistant clock ahead of the box cannot put a time in the future. +boot() +box_ms = (OBSERVED + 20) * 1000 +send(message({ observed_at = OBSERVED + 2 * STEP, plugged_since = OBSERVED + 2 * STEP, source_age_s = 0 })) +r = last() +assert(r.soc_observed_at_ms == math.floor(box_ms) and r.plugged_since_ms == math.floor(box_ms), + "times capped at the box's clock") + +-- An unavailable message leaves the old values stale, not live. +boot() +send(message()) +send('{"available":false,"observed_at":1792082400}') +r = last() +assert(r.stale == true and r.soc_fresh == false, "unavailable marks the reading stale") + +-- Invalid JSON, out-of-range levels and other topics. +boot() +send("not json") +assert(count() == 0, "nothing before a valid reading") +send(message({ soc_pct = 101 })) +assert(count() == 0, "a level above 100 % is refused") +send(message(), "ftw/vehicle/other/state") +assert(count() == 0, "another topic is ignored") + +-- 1 % or less goes as a fraction: FTW reads a plain 1 as 100 %. +boot() +send(message({ soc_pct = 1.0 })) +assert(last().soc == 0.01, "1 % as 0.01, got " .. tostring(last().soc)) +send(message({ observed_at = OBSERVED + STEP, soc_pct = 0.0 })) +assert(last().soc == 0, "0 % is 0") +send(message({ observed_at = OBSERVED + 2 * STEP, soc_pct = 2.0 })) +assert(last().soc == 2, "2 % stays a percent") + +-- No VIN: nothing is emitted, since the reading cannot be bound to a car. +boot({ topic = TOPIC }) +send(message()) +assert(count() == 0, "no VIN, no emit") + +-- Silence: after 15 minutes without a message the driver stops emitting. +boot() +send(message()) +local before = count() +host._millis_counter = host._millis_counter + 900001 +driver_poll() +assert(count() == before, "silent broker: no replay") + +-- Read-only: no publish, no command. +for i = 1, #host._calls do + assert(host._calls[i].func ~= "mqtt_publish", "the driver never publishes") +end +assert(driver_command() == false and driver_default_mode() == true, "read-only hooks") + +print("PASS home_assistant_vehicle") diff --git a/drivers/tests/lua_harness/test_tesla_cloud_plug.lua b/drivers/tests/lua_harness/test_tesla_cloud_plug.lua new file mode 100644 index 0000000..647cc2d --- /dev/null +++ b/drivers/tests/lua_harness/test_tesla_cloud_plug.lua @@ -0,0 +1,152 @@ +-- tesla_cloud: which car is plugged in. The cable from charge_state, the +-- place from location_data in the same read, never the coordinates, and no +-- cable time: reads minutes apart cannot date a plug-in. +dofile("drivers/tests/lua_harness/host_mock.lua") + +local VIN = "5YJ3E1EA1KF000000" +local AUTH = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" +local STATUS = "https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/" .. VIN +local DATA = STATUS .. "/vehicle_data?endpoints=charge_state" +local WITH_PLACE = DATA .. "%3Blocation_data" + +-- Charge-state values as a Model Y on an IEC cable reported them on +-- 15 October 2026 (Tesla Custom Integration attributes of +-- binary_sensor._charger), and Tesla's documented values with no cable. +local PLUGGED = { conn_charge_cable = "IEC", charge_port_latch = "Engaged", charging_state = "Stopped" } +local UNPLUGGED = { conn_charge_cable = "", charge_port_latch = "Disengaged", charging_state = "Disconnected" } +-- A public landmark, not anyone's home: Uppsala Cathedral, a car parked +-- beside it, and one at Stockholm Central. +local SITE = { 59.8580, 17.6336 } +local BESIDE = { 59.8583, 17.6340 } +local STOCKHOLM = { 59.3301, 18.0586 } + +local routes, gets = {}, {} +local site = SITE + +local function doc(cable, ts, pos) + local cs = { battery_level = 14, charge_limit_soc = 80, timestamp = ts } + for k, v in pairs(cable) do cs[k] = v end + local r = { vin = VIN, state = "online", charge_state = cs } + if pos then + r.drive_state = { latitude = pos[1], longitude = pos[2], timestamp = ts } + end + return host.json_encode({ response = r }) +end + +local function boot() + host.reset() + routes, gets = {}, {} + host.http_get = function(url) + gets[#gets + 1] = url + local r = routes[url] + if r == nil then error("http_get: no mock for " .. url) end + if type(r) == "table" then return nil, r.err end + return r + end + host.http_post = function(url) + if url == AUTH then + return host.json_encode({ access_token = "a", refresh_token = "r2", expires_in = 28800 }) + end + error("http_post: " .. url) + end + host.site_location = function() + if not site then return nil end + return site[1], site[2] + end + routes[STATUS] = host.json_encode({ response = { vin = VIN, state = "online" } }) + dofile("drivers/lua/tesla_cloud.lua") + driver_init({ client_id = "c", refresh_token = "r", vin = VIN, region = "eu" }) +end + +local function read(body) + routes[WITH_PLACE] = body + host._millis_counter = host._millis_counter + 1300000 + driver_poll() + local rows = host._emitted.vehicle + return rows[#rows] +end + +-- Plugged in beside the site: the cable, a short distance, no coordinates. +boot() +local r = read(doc(PLUGGED, 1000, BESIDE)) +assert(gets[#gets] == WITH_PLACE, "one read asks for location_data, got " .. tostring(gets[#gets])) +assert(r.plugged == true, "conn_charge_cable IEC is a cable") +assert(r.plugged_since_ms == nil, "no cable time") +assert(r.site_distance_m and r.site_distance_m > 20 and r.site_distance_m < 60, + "beside the site, got " .. tostring(r.site_distance_m)) +for k in pairs(r) do + assert(k ~= "latitude" and k ~= "longitude" and k ~= "drive_state", "coordinates left the driver: " .. k) +end + +-- Unplugged, then plugged in again: the cable follows, still with no time. +r = read(doc(UNPLUGGED, 2000, BESIDE)) +assert(r.plugged == false and r.plugged_since_ms == nil, "cable out") +host._millis_counter = host._millis_counter + 60000 +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(r.plugged == false and r.soc_fresh == false, "a replay keeps the cable") +r = read(doc(PLUGGED, 3000, BESIDE)) +assert(r.plugged == true and r.plugged_since_ms == nil, "cable in") + +-- Elsewhere. +r = read(doc(PLUGGED, 5000, STOCKHOLM)) +assert(r.site_distance_m > 50000, "Stockholm is far from Uppsala, got " .. tostring(r.site_distance_m)) + +-- No cable field: the charging state says it. +r = read(doc({ charging_state = "Disconnected" }, 6000, BESIDE)) +assert(r.plugged == false, "Disconnected is no cable") + +-- No site position: the read asks for charge_state alone and gives no distance. +site = nil +boot() +routes[DATA] = doc(PLUGGED, 1000, nil) +host._millis_counter = host._millis_counter + 1300000 +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(gets[#gets] == DATA, "no site, no location_data") +assert(r.plugged == true and r.site_distance_m == nil, "cable without place") +site = SITE + +-- A token without the vehicle_location scope: Tesla refuses location_data, +-- the driver reads charge_state alone and does not ask again. +boot() +routes[WITH_PLACE] = { err = "HTTP 403: missing scopes" } +routes[DATA] = doc(PLUGGED, 1000, nil) +host._millis_counter = host._millis_counter + 1300000 +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(r and r.plugged == true and r.site_distance_m == nil, "the level still arrives without the scope") +local asked = 0 +for _, url in ipairs(gets) do if url == WITH_PLACE then asked = asked + 1 end end +host._millis_counter = host._millis_counter + 1300000 +driver_poll() +local again = 0 +for _, url in ipairs(gets) do if url == WITH_PLACE then again = again + 1 end end +assert(asked == 1 and again == 1, "location_data is not asked again soon after a refusal") +-- A day later the owner may have granted the scope. +routes[WITH_PLACE] = doc(PLUGGED, 9000, BESIDE) +host._millis_counter = host._millis_counter + 86400000 +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(gets[#gets] == WITH_PLACE and r.site_distance_m and r.site_distance_m < 60, + "location_data asked again after a day") + +-- An asleep car is not read for its cable or place either. +boot() +routes[STATUS] = host.json_encode({ response = { vin = VIN, state = "asleep" } }) +host._millis_counter = host._millis_counter + 1300000 +driver_poll() +for _, url in ipairs(gets) do + assert(not url:find("vehicle_data", 1, true), "an asleep car was read") +end + +-- A level of 1 % goes as 0.01: FTW reads a plain 1 as 100 %. +boot() +routes[WITH_PLACE] = host.json_encode({ response = { vin = VIN, state = "online", + charge_state = { battery_level = 1, charge_limit_soc = 80, conn_charge_cable = "IEC", timestamp = 1000 } } }) +host._millis_counter = host._millis_counter + 1300000 +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(r.soc == 0.01, "1 % as 0.01, got " .. tostring(r.soc)) + +print("OK tesla_cloud plug") diff --git a/drivers/tests/lua_harness/test_tesla_vehicle.lua b/drivers/tests/lua_harness/test_tesla_vehicle.lua index 2a60c0e..96b6e53 100644 --- a/drivers/tests/lua_harness/test_tesla_vehicle.lua +++ b/drivers/tests/lua_harness/test_tesla_vehicle.lua @@ -152,4 +152,9 @@ body = "" driver_poll() assert(#posts == 0) host.reserve_vehicle_wake = reserve +-- A level of 1 % goes as 0.01: FTW reads a plain 1 as 100 %. +load(true) +body = data(1, math.floor(host.unix_ms()/1000)) +driver_poll() +assert(latest() and latest().soc == 0.01, "1 % as 0.01, got " .. tostring(latest() and latest().soc)) print("Tesla BLE recovery: cold boot, source age, errors, restart budget and wake-only refresh pass") diff --git a/drivers/tests/lua_harness/test_teslamate_vehicle_plug.lua b/drivers/tests/lua_harness/test_teslamate_vehicle_plug.lua new file mode 100644 index 0000000..7a5e651 --- /dev/null +++ b/drivers/tests/lua_harness/test_teslamate_vehicle_plug.lua @@ -0,0 +1,104 @@ +-- teslamate_vehicle: which car is plugged in. plugged_in gives the cable; +-- a change is dated only when a live update (a healthy message within 10 s) +-- carries it. The geofence, or else the distance from latitude and +-- longitude, gives the place; the coordinates never leave the driver. +dofile("drivers/tests/lua_harness/host_mock.lua") + +local VIN = "5YJ3E1EA1KF000000" +-- A public landmark, not anyone's home: Uppsala Cathedral, a car beside +-- it, and Stockholm Central. TeslaMate publishes each value as a string. +local SITE = { 59.8580, 17.6336 } +local clock = 5000 + +local function boot(site, cfg) + host.reset() + host.unix_ms = function() return clock end + host.site_location = function() + if not site then return nil end + return site[1], site[2] + end + dofile("drivers/lua/teslamate_vehicle.lua") + driver_init(cfg or { vin = VIN, car_id = 1 }) +end + +-- One poll's worth of messages, after `wait_ms` on both clocks. +local function send(pairs_, wait_ms) + wait_ms = wait_ms or 0 + clock = clock + wait_ms + host._millis_counter = host._millis_counter + wait_ms + local msgs = {} + for i = 1, #pairs_, 2 do + msgs[#msgs + 1] = { topic = "teslamate/cars/1/" .. pairs_[i], payload = pairs_[i + 1] } + end + host._mqtt_buffer = msgs + driver_poll() + local rows = host._emitted.vehicle + return rows and rows[#rows] +end + +-- The retained values at subscribe: plugged in beside the site. +boot(SITE) +local r = send({ "state", "online", "healthy", "true", "battery_level", "14", + "plugged_in", "true", "latitude", "59.8583", "longitude", "17.6340" }) +assert(r.plugged == true, "plugged_in true") +assert(r.plugged_since_ms == nil, "a retained value at subscribe is no change") +assert(r.at_site == nil, "no geofence configured, no at_site") +assert(r.site_distance_m and r.site_distance_m > 20 and r.site_distance_m < 60, + "beside the site, got " .. tostring(r.site_distance_m)) +for k in pairs(r) do + assert(k ~= "latitude" and k ~= "longitude", "coordinates left the driver: " .. k) +end + +-- The broker reconnects and redelivers a retained plugged_in that changed +-- meanwhile. No healthy message comes with it: its time is unknown. +r = send({ "plugged_in", "false" }, 60000) +assert(r.plugged == false and r.plugged_since_ms == nil, "a redelivered retained value is not dated") +r = send({}, 5000) +r = send({}, 6000) +assert(r.plugged_since_ms == nil, "still undated once a healthy message can no longer come") + +-- A live update: plugged_in with its healthy message. +local live = clock + 60000 +r = send({ "healthy", "true", "plugged_in", "true" }, 60000) +assert(r.plugged == true and r.plugged_since_ms == live, "a live change is dated when it arrived") +r = send({ "plugged_in", "true" }, 30000) +assert(r.plugged_since_ms == live, "a repeat is no change") + +-- The healthy message lands in the next poll, 5 s later. +local out = clock + 60000 +r = send({ "plugged_in", "false" }, 60000) +assert(r.plugged_since_ms == nil, "waiting for a healthy message") +r = send({ "healthy", "true" }, 5000) +assert(r.plugged == false and r.plugged_since_ms == out, "dated once its healthy message came") + +-- The car drives to Stockholm. +r = send({ "latitude", "59.3301", "longitude", "18.0586" }) +assert(r.site_distance_m > 50000, "far away, got " .. tostring(r.site_distance_m)) + +-- Junk is ignored. +r = send({ "plugged_in", "maybe", "latitude", "123" }) +assert(r.plugged == false and r.site_distance_m > 50000, "junk does not change the cable or place") + +-- No site position: the cable, no distance. +boot(nil) +r = send({ "state", "online", "healthy", "true", "battery_level", "14", + "plugged_in", "false", "latitude", "59.8583", "longitude", "17.6340" }) +assert(r.plugged == false and r.site_distance_m == nil, "no site, no distance") + +-- With this site's geofence named, the geofence topic says where the car is, +-- and the weather pin is not used. +boot(SITE, { vin = VIN, car_id = 1, geofence = "Home" }) +r = send({ "state", "online", "healthy", "true", "battery_level", "14", "geofence", "Home", + "latitude", "59.3301", "longitude", "18.0586" }) +assert(r.at_site == true and r.site_distance_m == nil, "in the Home geofence") +r = send({ "geofence", "Work" }, 60000) +assert(r.at_site == false, "in another geofence") +r = send({ "geofence", "" }, 60000) +assert(r.at_site == false, "in no geofence") + +-- A level of 1 % goes as 0.01: FTW reads a plain 1 as 100 %. +boot(SITE) +r = send({ "state", "online", "healthy", "true", "battery_level", "1" }) +assert(r.soc == 0.01, "1 % as 0.01, got " .. tostring(r.soc)) + +print("OK teslamate_vehicle plug") diff --git a/drivers/tests/lua_harness/test_vag_vehicle_plug.lua b/drivers/tests/lua_harness/test_vag_vehicle_plug.lua new file mode 100644 index 0000000..5d1fbee --- /dev/null +++ b/drivers/tests/lua_harness/test_vag_vehicle_plug.lua @@ -0,0 +1,73 @@ +-- VAG plug state: plugged, with no cable time (the portal writes every 15 +-- minutes), and a level of 1 % as 0.01. +-- Args: connected.zip unplugged.zip again.zip unknown.zip + +dofile("drivers/tests/lua_harness/host_mock.lua") + +local paths = { arg[1], arg[2], arg[3], arg[4] } +for i = 1, 4 do + assert(paths[i], "usage: test_vag_vehicle_plug.lua connected.zip unplugged.zip again.zip unknown.zip") +end +local function read_bin(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local VIN = "WVWZZZTESTVIN0001" +local REQ = "req-continuous-1" +local META = "/datarequest/vehicles/" .. VIN .. "/metadata/partial" +local LIST = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/list" +local DL = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/download" +local FILES = { + { name = "2026-09-26T07-00-00_partial.zip", createdOn = "2026-09-26T07:00:00Z" }, + { name = "2026-09-26T07-15-00_partial.zip", createdOn = "2026-09-26T07:15:00Z" }, + { name = "2026-09-26T07-30-00_partial.zip", createdOn = "2026-09-26T07:30:00Z" }, + { name = "2026-09-26T07-45-00_partial.zip", createdOn = "2026-09-26T07:45:00Z" }, +} + +local downloads = {} +local orig_http_get = host.http_get +function host.http_get(url, headers) + if string.find(url, DL, 1, true) then + local body = downloads[headers and headers.filename] + if body == nil then return nil, "HTTP 404" end + return body + end + return orig_http_get(url, headers) +end + +host.reset() +dofile("drivers/lua/vag_vehicle.lua") +driver_init({ vin = VIN, brand = "volkswagen", cookie = "SESSION=test; Path=/" }) +host._http_responses[META] = host.json_encode({ Identifier = REQ }) + +local listed = {} +local function next_file(i) + downloads[FILES[i].name] = read_bin(paths[i]) + listed[#listed + 1] = FILES[i] + host._http_responses[LIST] = host.json_encode(listed) + driver_poll() + local rows = host._emitted.vehicle + return rows[#rows] +end + +local r = next_file(1) +assert(r.plugged == true and r.plugged_since_ms == nil, "connected, no time") + +r = next_file(2) +assert(r.plugged == false and r.plugged_since_ms == nil, "disconnected, no time") + +driver_poll() +r = host._emitted.vehicle[#host._emitted.vehicle] +assert(r.plugged == false, "a replay keeps the plug state") + +r = next_file(3) +assert(r.plugged == true, "plugged in again") + +r = next_file(4) +assert(r.plugged == nil, "an unknown plug state is not unplugged") +assert(r.soc == 0.01, "1 % as 0.01, got " .. tostring(r.soc)) + +print("vag_vehicle plug: ok") diff --git a/drivers/tests/test_absent_optional_registers.py b/drivers/tests/test_absent_optional_registers.py index dbf918e..d92acce 100644 --- a/drivers/tests/test_absent_optional_registers.py +++ b/drivers/tests/test_absent_optional_registers.py @@ -90,7 +90,11 @@ def poll_with_absent(path: Path, absent: int, present: int, def test_pixii_gives_up_on_an_absent_scale_factor(): - """40288 meter_energy_sf is absent on PowerShaper firmware below CPU 2.0.23.""" + """40288 meter_energy_sf is absent on PowerShaper firmware below CPU 2.0.23. + + The clock stays put. Pixii retries a given-up register after five minutes; + these six polls are inside that window, so a read here means it never stopped. + """ out = poll_with_absent(ROOT / "drivers" / "lua" / "pixii.lua", absent=40288, present=40256, config="{host = '127.0.0.1', port = 502, unit_id = 1}") diff --git a/drivers/tests/test_easee_cloud_observation_order.py b/drivers/tests/test_easee_cloud_observation_order.py new file mode 100644 index 0000000..dbc168c --- /dev/null +++ b/drivers/tests/test_easee_cloud_observation_order.py @@ -0,0 +1,12 @@ +"""Late cloud packets must not replace a newer power observation.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_preserves_power_source_order(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_observation_order.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_home_assistant_vehicle.py b/drivers/tests/test_home_assistant_vehicle.py new file mode 100644 index 0000000..4d16b1e --- /dev/null +++ b/drivers/tests/test_home_assistant_vehicle.py @@ -0,0 +1,12 @@ +"""Home Assistant vehicle driver: telemetry only, cable and place from the automation.""" +from pathlib import Path +import subprocess + + +def test_home_assistant_vehicle_recorded_messages(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_home_assistant_vehicle.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_pixii_pv.py b/drivers/tests/test_pixii_pv.py new file mode 100644 index 0000000..d05965f --- /dev/null +++ b/drivers/tests/test_pixii_pv.py @@ -0,0 +1,104 @@ +"""Pixii's PV feed stays valid when the measured total falls below zero.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make test-driver ID=pixii_pv") + + +def run_lua(body: str) -> None: + script = ''' +package.path = "drivers/tests/lua_harness/?.lua;" .. package.path +require("host_mock") +host.reset() +dofile("drivers/lua/pixii_pv.lua") +driver_init({}) + +local function poll(payload) + host._mqtt_buffer = {{ + topic = "pixii/status/test-pixii/meter_ext6", + payload = payload, + }} + assert(driver_poll() == 1000) +end +''' + body + result = subprocess.run( + [str(LUA), "-e", script], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("phases", "expected"), + [ + ((1500, 1000, 500), -3000), + ((0, 0, 0), 0), + ((-1, -2, -3), 0), + ((50, -3, -2), -45), + ((-50, 3, 2), 0), + ((5, -3, -2), 0), + ], +) +def test_pixii_pv_reports_net_generation(phases, expected) -> None: + payload = json.dumps(dict( + meter_source="PV", meter_w1=phases[0], meter_w2=phases[1], + meter_w3=phases[2], meter_kwh_imp=12.5)) + run_lua(f''' +poll({json.dumps(payload)}) +local pv = host._emitted.pv +assert(pv and #pv == 1, "expected one PV reading") +assert(pv[1].w == {expected}, "PV W = " .. tostring(pv[1].w)) +assert(pv[1].w <= 0, "FTW rejects positive PV power") +assert(pv[1].generation_wh == 12500, "generation counter changed") +assert(host._metrics.pv_w_total.value == {expected}, "total metric differs") +assert(host._metrics.pv_l1_w.value == {-phases[0]}, "phase 1 changed") +assert(host._metrics.pv_l2_w.value == {-phases[1]}, "phase 2 changed") +assert(host._metrics.pv_l3_w.value == {-phases[2]}, "phase 3 changed") +''') + + +@pytest.mark.parametrize("phase", ("meter_w1", "meter_w2", "meter_w3")) +@pytest.mark.parametrize("bad_value", (None, "invalid", "1e400", "-1e400")) +def test_pixii_pv_does_not_turn_invalid_power_into_zero(phase, bad_value) -> None: + payload = dict(meter_source="PV", meter_w1=-1, meter_w2=-2, meter_w3=-3) + if bad_value is None: + del payload[phase] + else: + payload[phase] = bad_value + encoded = json.dumps(json.dumps(payload)) + run_lua(f''' +poll({encoded}) +assert(not host._emitted.pv, "invalid phase power emitted as PV") +assert(not host._metrics.pv_w_total, "invalid phase power emitted as total") +''') + + +def test_pixii_pv_sunset_idle_and_morning() -> None: + run_lua(''' +poll('{"meter_source":"PV","meter_w1":100,"meter_w2":100,"meter_w3":100}') +for _ = 1, 5 do + poll('{"meter_source":"PV","meter_w1":-1,"meter_w2":-2,"meter_w3":-3}') +end +assert(#host._emitted.pv == 6, "night readings stopped") +for i = 2, 6 do + assert(host._emitted.pv[i].w == 0, "night reading must show no generation") +end +host._emitted = {} +driver_poll() +assert(not host._emitted.pv, "idle poll replayed old PV data") + +host._mqtt_buffer = {{ + topic = "pixii/status/test-pixii/meter", + payload = '{"meter_w":-750}', +}} +driver_poll() +assert(host._emitted.meter[1].w == -750, "grid export sign changed") +assert(not host._emitted.pv, "grid-only message refreshed PV") +poll('{"meter_source":"PV","meter_w1":"1000","meter_w2":"2000","meter_w3":"3000"}') +assert(host._emitted.pv[1].w == -6000, "morning production did not return") +''') diff --git a/drivers/tests/test_pixii_unreachable.py b/drivers/tests/test_pixii_unreachable.py new file mode 100644 index 0000000..5120a6b --- /dev/null +++ b/drivers/tests/test_pixii_unreachable.py @@ -0,0 +1,177 @@ +"""An unreachable Pixii must not retire every register. + +Each register used to keep its own failure count and stop after three misses, +for the life of the process. That is the right behaviour for register 40288, +which old PowerShaper firmware never answers while the rest of the device is +up. It is the wrong behaviour when the whole device is down: three polls at +five seconds, about fifteen seconds, and every register is retired. A Pixii +that boots slower than FTW then answers into a driver that has stopped +asking, and telemetry stays dark until a restart. + +Misses count only when some other read in that poll succeeded. A poll of +nothing but failures leaves the counts alone. After that stretch, the next +poll that gets an answer clears them. A register that stays silent while the +device is up is still left alone, and is tried again five minutes later. +""" + +from pathlib import Path +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +pytestmark = pytest.mark.skipif(not LUA.exists(), reason="run make test-driver ID=pixii") + +RETRY_MS = 5 * 60 * 1000 + + +def run_lua(body: str) -> None: + script = ''' +package.path = "drivers/tests/lua_harness/?.lua;" .. package.path +require("host_mock") +host.reset() + +local function reads_of(addr, from_index) + local n = 0 + for i = from_index or 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" and call.args[1] == addr then + n = n + 1 + end + end + return n +end + +local function last(kind) + local emitted = host._emitted[kind] + assert(emitted and #emitted > 0, "no " .. kind .. " telemetry") + return emitted[#emitted] +end + +local function at(ms) + host._millis_counter = ms + return driver_poll() +end + +dofile("drivers/lua/pixii.lua") +''' + body + result = subprocess.run( + [str(LUA), "-e", script], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_pixii_recovers_when_the_device_boots_after_the_driver() -> None: + """The device is unreachable from init through more than three polls, then answers.""" + run_lua(''' +host._modbus_registers.holding[40168] = 2500 +host._modbus_registers.holding[40132] = 80 +host._modbus_registers.holding[40252] = 4200 +host._modbus_read_error = "connection refused" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) + +local from = #host._calls + 1 +for _ = 1, 6 do + local interval = driver_poll() + assert(interval == 5000, "poll interval " .. tostring(interval)) +end +assert(reads_of(40168, from) == 6, + "stopped reading battery power while the device was down, reads=" .. reads_of(40168, from)) +assert(reads_of(40252, from) == 6, + "stopped reading meter power while the device was down, reads=" .. reads_of(40252, from)) + +host._modbus_read_error = nil +from = #host._calls + 1 +local interval = driver_poll() +assert(interval == 5000, "poll interval " .. tostring(interval)) +assert(reads_of(40168, from) == 1, "battery power was not read once the device was up") +assert(reads_of(40252, from) == 1, "meter power was not read once the device was up") +local battery = last("battery") +local meter = last("meter") +assert(battery.w == 2500, "battery.w " .. tostring(battery.w)) +assert(battery.soc == 0.8, "battery.soc " .. tostring(battery.soc)) +assert(meter.w == 4200, "meter.w " .. tostring(meter.w)) +''') + + +def test_pixii_outage_does_not_add_to_an_existing_failure_count() -> None: + """Two real misses, then a stretch where nothing answers, must not retire the register.""" + run_lua(''' +host._modbus_registers.holding[40168] = 2500 +host._modbus_read_fail_addresses[40168] = "timeout" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) +driver_poll() +driver_poll() +assert(reads_of(40168) == 2, "battery power reads before the outage " .. reads_of(40168)) + +host._modbus_read_fail_addresses[40168] = nil +host._modbus_read_error = "connection refused" +local from = #host._calls + 1 +for _ = 1, 4 do driver_poll() end +assert(reads_of(40168, from) == 4, + "outage retired battery power, reads during the outage=" .. reads_of(40168, from)) + +host._modbus_read_error = nil +from = #host._calls + 1 +driver_poll() +assert(reads_of(40168, from) == 1, "battery power was not read after the outage") +local battery = last("battery") +assert(battery.w == 2500, "battery.w " .. tostring(battery.w)) +''') + + +def test_pixii_retries_an_absent_register_after_five_minutes() -> None: + """40288 stays quiet while the device is up, then gets one probe at five minutes.""" + run_lua(f''' +host._millis_step = 0 +host._modbus_read_fail_addresses[40288] = "Illegal Data Address" +driver_init({{host = "127.0.0.1", port = 502, unit_id = 1}}) +at(1000) +at(2000) +at(3000) +assert(reads_of(40288) == 3, "40288 reads before give-up " .. reads_of(40288)) + +local from = #host._calls + 1 +at(3000 + {RETRY_MS} - 1) +assert(reads_of(40288, from) == 0, "40288 was read before five minutes") + +from = #host._calls + 1 +at(3000 + {RETRY_MS}) +assert(reads_of(40288, from) == 1, "40288 was not probed at five minutes") + +from = #host._calls + 1 +at(3000 + {RETRY_MS}) +assert(reads_of(40288, from) == 0, "a failed retry was read again on the next poll") +assert(reads_of(40256, from) == 1, "a present scale factor stopped being read") +assert(last("battery") ~= nil) +assert(last("meter") ~= nil) +''') + + +def test_pixii_outage_clears_give_up_without_waiting_five_minutes() -> None: + """A register retired while the device was up is probed again after an unreachable stretch.""" + run_lua(''' +host._millis_step = 0 +host._modbus_registers.holding[40252] = 4200 +host._modbus_read_fail_addresses[40288] = "Illegal Data Address" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) +at(1000) +at(2000) +at(3000) +assert(reads_of(40288) == 3, "40288 reads before give-up " .. reads_of(40288)) + +local from = #host._calls + 1 +at(8000) +assert(reads_of(40288, from) == 0, "40288 was read after it was given up") + +host._modbus_read_error = "connection refused" +at(10000) +at(15000) +host._modbus_read_error = nil + +from = #host._calls + 1 +for i = 1, 5 do at(20000 + i * 5000) end +local n = reads_of(40288, from) +assert(n == 3, "40288 reads after the outage " .. n) +assert(last("meter").w == 4200, "meter.w " .. tostring(last("meter").w)) +''') diff --git a/drivers/tests/test_sungrow_model_family.py b/drivers/tests/test_sungrow_model_family.py index f459836..4714598 100644 --- a/drivers/tests/test_sungrow_model_family.py +++ b/drivers/tests/test_sungrow_model_family.py @@ -177,6 +177,8 @@ def test_fault_clears_on_a_running_state_of_zero() -> None: The previous version cleared the fault only on a non-zero value, so an inverter that recovered into 0x0000 stayed reported as faulted. + + The running state is read on full polls, five seconds apart. """ body = HEALTHY_HYBRID + f''' dofile("{DRIVER}") @@ -184,6 +186,7 @@ def test_fault_clears_on_a_running_state_of_zero() -> None: local states = {{}} local function poll_with(state) host._modbus_registers.input[12999] = {{state}} + host._millis_counter = host._millis_counter + 5000 pcall(driver_poll) states[#states + 1] = host._faulted and "1" or "0" end diff --git a/drivers/tests/test_sungrow_poll_cadence.py b/drivers/tests/test_sungrow_poll_cadence.py new file mode 100644 index 0000000..b7016f0 --- /dev/null +++ b/drivers/tests/test_sungrow_poll_cadence.py @@ -0,0 +1,203 @@ +"""Sungrow reads what the live view needs every second, and the rest every five. + +FTW sends live frames once a second. A site meter read every five seconds +makes the figures on a phone jump every five seconds. The driver now returns +1000 ms and splits its reads: power, SoC, per-phase values and the EMS +readback on every poll; counters, nameplate, temperature, frequency and fault +bits on a full poll every five seconds. + +Between full polls the blobs carry the slow values, because FTW stores each +emit whole and its energy ledger re-baselines whenever a counter disappears. +They ride only on a poll whose own power read succeeded. + +A host that ignores the returned interval polls every five seconds; then every +poll is a full read, exactly as before. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +HARNESS = ROOT / "drivers" / "tests" / "lua_harness" +DRIVER = ROOT / "drivers" / "lua" / "sungrow.lua" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + +# What the live view and Core's control need on every poll. +FAST_READS = { + "input:13000", "input:13019", "holding:13049", + "input:5016", "input:5010", + "input:5600", "input:5602", "input:5740", "input:5743", +} + +# Read on a full poll as well. +SLOW_READS = { + "input:12999", "input:13002", "input:5000", "input:5007", "input:5241", + "input:13049", "input:13040", "input:13026", "input:13036", "input:13045", +} + +HEALTHY_HYBRID = ''' +host._modbus_registers.input[4990] = {0x4131, 0x4232, 0x4333, 0x4434, 0x4535, + 0x4636, 0x4737, 0x4838, 0x4939, 0x4A30} +host._modbus_registers.input[4999] = {0x0E0E} +host._modbus_registers.input[5000] = {100} +host._modbus_registers.input[5007] = {412} +host._modbus_registers.input[5016] = {4000, 0} +host._modbus_registers.input[5241] = {4998} +host._modbus_registers.input[12999] = {0x0000} +host._modbus_registers.input[13000] = {0x0004} -- bit 2 set: discharging +host._modbus_registers.input[13002] = {5000, 0} +host._modbus_registers.input[13019] = {4800, 120, 900, 550} +host._modbus_registers.input[13026] = {1000, 0} +host._modbus_registers.input[13036] = {2000, 0} +host._modbus_registers.input[13040] = {3000, 0} +host._modbus_registers.input[13045] = {4000, 0} +host._modbus_registers.input[5600] = {1500, 0} +host._modbus_registers.input[5743] = {210, 62, 34} +''' + +# Polls n_polls times, `step_ms` apart on the host clock, and prints what each +# poll read, returned and emitted. +POLL_REPORT = ''' +dofile("{driver}") +driver_init({{}}) +for poll = 1, {n_polls} do + host._millis_counter = host._millis_counter + {step_ms} - host._millis_step + {before_poll} + local before = #host._calls + local m_before = #(host._emitted["meter"] or {{}}) + local b_before = #(host._emitted["battery"] or {{}}) + local p_before = #(host._emitted["pv"] or {{}}) + local ok, ret = pcall(driver_poll) + if not ok then print("POLL_ERROR " .. tostring(ret)) os.exit(1) end + local reads = {{}} + for i = before + 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" then + reads[#reads + 1] = call.args[3] .. ":" .. tostring(call.args[1]) + end + end + table.sort(reads) + print("READS" .. poll .. " " .. table.concat(reads, ",")) + print("RETURN" .. poll .. " " .. tostring(ret)) + local m = (host._emitted["meter"] or {{}})[m_before + 1] or {{}} + local b = (host._emitted["battery"] or {{}})[b_before + 1] or {{}} + local p = (host._emitted["pv"] or {{}})[p_before + 1] or {{}} + print("METER" .. poll .. " " .. tostring(m.w) .. " " .. tostring(m.import_wh) + .. " " .. tostring(m.export_wh) .. " " .. tostring(m.hz) + .. " " .. tostring(m.l1_a)) + print("BATTERY" .. poll .. " " .. tostring(b.w) .. " " .. tostring(b.soc) + .. " " .. tostring(b.charge_wh) .. " " .. tostring(b.discharge_wh)) + print("PV" .. poll .. " " .. tostring(p.w) .. " " .. tostring(p.lifetime_wh) + .. " " .. tostring(p.temp_c) .. " " .. tostring(p.rated_w)) +end +''' + + +def run_polls(n_polls: int, step_ms: int, setup: str = "", before_poll: str = "") -> dict[str, str]: + script = f''' +package.path = "{HARNESS}/?.lua;" .. package.path +require("host_mock") +host.reset() +{HEALTHY_HYBRID} +{setup} +''' + POLL_REPORT.format(driver=DRIVER, n_polls=n_polls, step_ms=step_ms, + before_poll=before_poll) + result = subprocess.run([str(LUA), "-e", script], + capture_output=True, text=True, cwd=ROOT) + assert result.returncode == 0, result.stdout + result.stderr + return dict(line.split(" ", 1) + for line in result.stdout.strip().splitlines() if " " in line) + + +def reads(out: dict[str, str], poll: int) -> set[str]: + return set(out[f"READS{poll}"].split(",")) + + +def test_driver_asks_for_one_second() -> None: + out = run_polls(2, 1000) + assert out["RETURN1"] == "1000" + assert out["RETURN2"] == "1000" + + +def test_reads_split_into_every_second_and_every_five() -> None: + # Polls at 1, 2, ... 11 s. The first is full; then one full read about + # every five seconds, never more than one apart from the next. + out = run_polls(11, 1000) + full = [poll for poll in range(1, 12) if SLOW_READS <= reads(out, poll)] + assert full == [1, 6, 11], f"full reads on polls {full}" + for poll in range(2, 6): + assert reads(out, poll) == FAST_READS, ( + f"poll {poll} read {sorted(reads(out, poll))}, not just what the " + f"live view needs") + + +def test_a_five_second_host_reads_everything_every_poll() -> None: + """Core before srcfl/ftw honoured driver_poll's return polls every 5 s.""" + out = run_polls(4, 5000) + for poll in range(1, 5): + assert SLOW_READS | FAST_READS <= reads(out, poll), ( + f"poll {poll} at five seconds skipped {sorted(SLOW_READS - reads(out, poll))}") + + +def test_fast_polls_carry_the_last_full_values() -> None: + """A counter that comes and goes makes FTW's energy ledger re-baseline.""" + change_power = ''' +if poll == 2 then + host._modbus_registers.input[5600] = {2500, 0} + host._modbus_registers.input[13019] = {4800, 120, 1200, 551} + host._modbus_registers.input[5016] = {5000, 0} + host._modbus_registers.input[13036] = {9999, 0} + host._modbus_registers.input[13040] = {9999, 0} + host._modbus_registers.input[13002] = {9999, 0} +end''' + out = run_polls(2, 1000, before_poll=change_power) + # Power and SoC move on the fast poll. + assert out["METER2"].split()[0] == "2500" + assert out["BATTERY2"].split()[:2] == ["-1200", "0.551"] + assert out["PV2"].split()[0] == "-5000" + # Counters, frequency, temperature and phase current stay as last read. + assert out["METER2"].split()[1:] == out["METER1"].split()[1:], out + assert out["BATTERY2"].split()[2:] == out["BATTERY1"].split()[2:], out + assert out["PV2"].split()[1:] == out["PV1"].split()[1:], out + + +def test_carried_values_need_this_polls_power_read() -> None: + """If the meter stops answering, its counters stop too.""" + drop_meter = ''' +if poll == 2 then + host._modbus_read_fail_addresses[5600] = "Illegal Data Address" +end''' + out = run_polls(2, 1000, before_poll=drop_meter) + meter = out["METER2"].split() + assert meter[1:3] == ["nil", "nil"], f"meter counters outlived the meter: {meter}" + + +def test_reconnect_backoff_does_not_count_as_a_missing_register() -> None: + """FTW refuses a read without sending it while it waits to reconnect. + + A two-second wait spans two polls at one per second. Counted as misses, + one dropped packet would mark the meter absent for the rest of the session. + """ + backoff = ''' +if poll >= 2 and poll <= 5 then + host._modbus_read_fail_addresses[5600] = "modbus reconnect backoff active for 2s" +else + host._modbus_read_fail_addresses[5600] = nil +end''' + out = run_polls(6, 1000, before_poll=backoff) + assert "input:5600" in reads(out, 6), "the meter was given up after a reconnect wait" + assert out["METER6"].split()[0] == "1500" + + +def test_a_refused_register_is_still_given_up() -> None: + refused = 'host._modbus_read_fail_addresses[5743] = "Illegal Data Address"' + out = run_polls(5, 1000, setup=refused) + assert "input:5743" in reads(out, 3) + assert "input:5743" not in reads(out, 4), "a refused register was read forever" diff --git a/drivers/tests/test_tesla_cloud.py b/drivers/tests/test_tesla_cloud.py index 1474d40..7014357 100644 --- a/drivers/tests/test_tesla_cloud.py +++ b/drivers/tests/test_tesla_cloud.py @@ -10,3 +10,12 @@ def test_tesla_cloud_telemetry_and_staleness(): cwd=root, text=True, capture_output=True, check=False, ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_tesla_cloud_cable_and_place(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_tesla_cloud_plug.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_teslamate_vehicle.py b/drivers/tests/test_teslamate_vehicle.py index 17f1671..979d923 100644 --- a/drivers/tests/test_teslamate_vehicle.py +++ b/drivers/tests/test_teslamate_vehicle.py @@ -10,3 +10,12 @@ def test_teslamate_vehicle_telemetry_and_staleness(): cwd=root, text=True, capture_output=True, check=False, ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_teslamate_vehicle_cable_and_place(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_teslamate_vehicle_plug.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_vag_vehicle.py b/drivers/tests/test_vag_vehicle.py index e81b249..a231788 100644 --- a/drivers/tests/test_vag_vehicle.py +++ b/drivers/tests/test_vag_vehicle.py @@ -110,3 +110,35 @@ def test_vag_vehicle_email_sign_in(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stdout + result.stderr assert "vag_vehicle login: ok" in result.stdout + + +def _plug_dataset(value: str, ts: str, soc: str = "63") -> str: + return json.dumps({"vin": "WVWZZZTESTVIN0001", "Data": [ + {"key": "162c2a75-edf4-3990-b8ed-7c600b3dbc40", "dataFieldName": "battery_level_HV.value", + "value": soc, "timestampUtc": ts}, + {"key": "c111830c-f959-30d2-859a-ea996190d864", "dataFieldName": "plug_state", + "value": value, "timestampUtc": ts}, + ]}) + + +def test_vag_vehicle_plug_state(tmp_path: Path) -> None: + fixtures = [ + ("connected", DATASET), + ("unplugged", _plug_dataset("disconnected", "2026-09-26T07:15:00Z")), + ("again", _plug_dataset("connected", "2026-09-26T07:30:00Z")), + ("unknown", _plug_dataset("invalid", "2026-09-26T07:45:00Z", soc="1")), + ] + paths = [] + for name, text in fixtures: + path = tmp_path / f"{name}.zip" + path.write_bytes(_zip(zipfile.ZIP_DEFLATED, text)) + paths.append(str(path)) + result = subprocess.run( + [str(ROOT / "lua55"), "drivers/tests/lua_harness/test_vag_vehicle_plug.lua", *paths], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "vag_vehicle plug: ok" in result.stdout diff --git a/index.yaml b/index.yaml index 9790fbb..08b701c 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.7" + version: "1.3.8" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 42628 - sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" + size_bytes: 45529 + sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" - name: "esphome_dsmr" version: "1.0.7" tier: core @@ -349,6 +349,16 @@ drivers: control: false size_bytes: 1362 sha256: "4f273fa6aaff3bb462c3ac141f1773a2b6985421633d002578ce647726aa0ba9" + - name: "home_assistant_vehicle" + version: "0.1.0" + tier: community + protocol: mqtt + connectivity: local + setup: [bridge] + ders: [vehicle] + control: false + size_bytes: 12635 + sha256: "a61c473a0f984f0cc8229e15472222e1c135b39c38ab2d2630b173afa8b7ca25" - name: "huawei" version: "2.1.3" tier: core @@ -498,23 +508,23 @@ drivers: size_bytes: 19344 sha256: "29f2bd2f73c0d39d809839ca60b40c3e4281f1dda267ba2b26e66bd9ab80c27b" - name: "pixii" - version: "2.1.8" + version: "2.1.9" tier: core protocol: modbus connectivity: local ders: [battery, meter] control: false - size_bytes: 29119 - sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" + size_bytes: 32519 + sha256: "77e233e2d24e970ebff8eaacd67967f57b651677d25ce7b57a1f97c3eaeb71fe" - name: "pixii_pv" - version: "0.3.2" + version: "0.3.3" tier: core protocol: mqtt connectivity: local ders: [pv, meter] control: false - size_bytes: 6699 - sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" + size_bytes: 6980 + sha256: "6b1a7c67e99a7b056d9a209800fc8ce3e63f47796846644eccaf8b31da3b988e" - name: "remne_p1ib" version: "0.1.0" tier: community @@ -700,34 +710,34 @@ drivers: size_bytes: 6631 sha256: "94ae5e0065f5793051acb89b7ef04a679b2898908dfac6f4ffd6e8deb77fa2c0" - name: "sungrow" - version: "1.5.10" + version: "1.6.0" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: true - size_bytes: 49876 - sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" + size_bytes: 52811 + sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" - name: "tesla_cloud" - version: "0.1.1" + version: "0.2.0" tier: community protocol: http connectivity: cloud setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 18216 - sha256: "ed608e4a4501f7a9534296989bb083bb7960ebf8456bd760d868cb26069ea484" + size_bytes: 22431 + sha256: "deab2cc377cdae9bbc32a14e686b05830b1e094154a090a401d60f666646653e" - name: "tesla_vehicle" - version: "0.2.5" + version: "0.2.6" tier: core protocol: http connectivity: local setup: [bridge] ders: [vehicle] control: true - size_bytes: 9696 - sha256: "d5b97c6ebbc376a1874898eb160f9ba8715d5a9cbf72b60794f126a99b11186d" + size_bytes: 10096 + sha256: "d1b8a2cbea20630dcab3cb306df0f6924b0f9ad7f31bcf367fb1d41ca7833350" - name: "tesla_wall_connector" version: "0.1.1" tier: community @@ -738,15 +748,15 @@ drivers: size_bytes: 8162 sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284" - name: "teslamate_vehicle" - version: "0.1.1" + version: "0.2.0" tier: community protocol: mqtt connectivity: local setup: [bridge] ders: [vehicle] control: false - size_bytes: 13638 - sha256: "2a6014b210c31aab07122051f4e30a91d5e75c9609be62a25b684620c97c0903" + size_bytes: 18280 + sha256: "b9f50760f46ab5e5f41f7a552136a77b44ba0d5fa3a85a2d6976f60a9d1e3679" - name: "tibber" version: "1.1.2" tier: core @@ -758,15 +768,15 @@ drivers: size_bytes: 12472 sha256: "3cf10b93755fa8b840375f53343e91b5efa98ddffe119a5679b43ee4bfb61c88" - name: "vag_vehicle" - version: "0.2.1" + version: "0.3.0" tier: community protocol: http connectivity: cloud setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 46561 - sha256: "01d3c6d62583e62ea42eaf7bb6b7f98e47b0360d8e65fbfd3210e64f42ccdf73" + size_bytes: 47864 + sha256: "fcaa4a44ce26438f3c89d26e8ed3056ea4a195a3516a9ca9ed698c4eca42c5d3" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 8836f4b..0d35a72 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.7" +version: "1.3.8" 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: 42628 +size_bytes: 45529 dkb_id: "easee_cloud" -sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" +sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" signature: "" bytecode_sha256: "" diff --git a/manifests/home_assistant_vehicle.yaml b/manifests/home_assistant_vehicle.yaml new file mode 100644 index 0000000..b8f185d --- /dev/null +++ b/manifests/home_assistant_vehicle.yaml @@ -0,0 +1,24 @@ +name: "home_assistant_vehicle" +version: "0.1.0" +tier: community +author: "Sourceful Labs AB" +protocol: mqtt +connectivity: local +setup: [bridge] +ders: [vehicle] +control: false +tested_devices: + - manufacturer: "Home Assistant" + model_family: "Vehicle (Home Assistant MQTT)" + variants: [Model Y] + regions: [] + firmware_versions: "" + notes: "Read-only vehicle level, charge limit, charging state, charge cable and place from a JSON message a Home Assistant automation publishes. Tested with recorded messages from a Model Y on the Tesla Custom Integration; the cable and place fields have not run on a car. Does not wake or command the car." + min_driver_version: "0.1.0" +min_host_version: "2.0.0" +size_bytes: 12635 +dkb_id: "home_assistant_vehicle" +sha256: "a61c473a0f984f0cc8229e15472222e1c135b39c38ab2d2630b173afa8b7ca25" +signature: "" + +bytecode_sha256: "" diff --git a/manifests/pixii.yaml b/manifests/pixii.yaml index 19e2a96..c8a2088 100644 --- a/manifests/pixii.yaml +++ b/manifests/pixii.yaml @@ -1,5 +1,5 @@ name: "pixii" -version: "2.1.8" +version: "2.1.9" 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: 29119 +size_bytes: 32519 dkb_id: "pixii" -sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" +sha256: "77e233e2d24e970ebff8eaacd67967f57b651677d25ce7b57a1f97c3eaeb71fe" signature: "" bytecode_sha256: "" diff --git a/manifests/pixii_pv.yaml b/manifests/pixii_pv.yaml index 2c26a15..35808f2 100644 --- a/manifests/pixii_pv.yaml +++ b/manifests/pixii_pv.yaml @@ -1,5 +1,5 @@ name: "pixii_pv" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -15,9 +15,9 @@ tested_devices: notes: "Read-only Pixii telemetry: PV (external CTs) + grid meter (meter_w). Battery / SoC comes from the Modbus driver, not this one." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 6699 +size_bytes: 6980 dkb_id: "pixii_pv" -sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" +sha256: "6b1a7c67e99a7b056d9a209800fc8ce3e63f47796846644eccaf8b31da3b988e" signature: "" bytecode_sha256: "" diff --git a/manifests/sungrow.yaml b/manifests/sungrow.yaml index 278ce98..0f32ba3 100644 --- a/manifests/sungrow.yaml +++ b/manifests/sungrow.yaml @@ -1,5 +1,5 @@ name: "sungrow" -version: "1.5.10" +version: "1.6.0" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -43,9 +43,9 @@ 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: 49876 +size_bytes: 52811 dkb_id: "sungrow_sh_rt" -sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" +sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" signature: "" bytecode_sha256: "" diff --git a/manifests/tesla_cloud.yaml b/manifests/tesla_cloud.yaml index 4da063f..1882f95 100644 --- a/manifests/tesla_cloud.yaml +++ b/manifests/tesla_cloud.yaml @@ -1,5 +1,5 @@ name: "tesla_cloud" -version: "0.1.1" +version: "0.2.0" tier: community author: "Sourceful Labs AB" protocol: http @@ -13,12 +13,12 @@ tested_devices: variants: [Model Y, Model 3] regions: [EU, NA, CN] firmware_versions: "" - notes: "Read-only vehicle SoC, charge limit and charging state via the official Tesla Fleet API. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." + notes: "Read-only vehicle SoC, charge limit, charging state and charge cable via the official Tesla Fleet API. Grant the optional vehicle_location scope to report the car's distance from the site; the coordinates stay in the driver. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 18216 +size_bytes: 22431 dkb_id: "tesla_cloud" -sha256: "ed608e4a4501f7a9534296989bb083bb7960ebf8456bd760d868cb26069ea484" +sha256: "deab2cc377cdae9bbc32a14e686b05830b1e094154a090a401d60f666646653e" signature: "" bytecode_sha256: "" diff --git a/manifests/tesla_vehicle.yaml b/manifests/tesla_vehicle.yaml index ff11a51..1e7cdd6 100644 --- a/manifests/tesla_vehicle.yaml +++ b/manifests/tesla_vehicle.yaml @@ -1,5 +1,5 @@ name: "tesla_vehicle" -version: "0.2.5" +version: "0.2.6" tier: core author: "Sourceful Labs AB" protocol: http @@ -16,9 +16,9 @@ tested_devices: notes: "Vehicle SoC and charge limit via a local BLE proxy. Telemetry recovery needs a host with unix_ms and reserve_vehicle_wake. No hardware recovery test yet." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 9696 +size_bytes: 10096 dkb_id: "tesla_vehicle" -sha256: "d5b97c6ebbc376a1874898eb160f9ba8715d5a9cbf72b60794f126a99b11186d" +sha256: "d1b8a2cbea20630dcab3cb306df0f6924b0f9ad7f31bcf367fb1d41ca7833350" signature: "" bytecode_sha256: "" diff --git a/manifests/teslamate_vehicle.yaml b/manifests/teslamate_vehicle.yaml index 7fa37bb..547a85d 100644 --- a/manifests/teslamate_vehicle.yaml +++ b/manifests/teslamate_vehicle.yaml @@ -1,5 +1,5 @@ name: "teslamate_vehicle" -version: "0.1.1" +version: "0.2.0" tier: community author: "Sourceful Labs AB" protocol: mqtt @@ -13,7 +13,7 @@ tested_devices: variants: [Model Y, Model 3] regions: [] firmware_versions: "" - notes: "Read-only vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." + notes: "Read-only vehicle SoC, charge limit, charging state, charge cable and place via TeslaMate MQTT. Set geofence to this site's TeslaMate geofence for the place. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." min_driver_version: "0.1.0" upstream_docs: - url: "https://docs.teslamate.org/docs/integrations/mqtt" @@ -21,9 +21,9 @@ upstream_docs: kind: api_docs url_stability: stable min_host_version: "2.0.0" -size_bytes: 13638 +size_bytes: 18280 dkb_id: "teslamate_vehicle" -sha256: "2a6014b210c31aab07122051f4e30a91d5e75c9609be62a25b684620c97c0903" +sha256: "b9f50760f46ab5e5f41f7a552136a77b44ba0d5fa3a85a2d6976f60a9d1e3679" signature: "" bytecode_sha256: "" diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index fe01c51..9ca9848 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -1,5 +1,5 @@ name: "vag_vehicle" -version: "0.2.1" +version: "0.3.0" tier: community author: "Sourceful Labs AB" protocol: http @@ -21,9 +21,9 @@ upstream_docs: kind: other url_stability: stable min_host_version: "2.0.0" -size_bytes: 46561 +size_bytes: 47864 dkb_id: "vag_vehicle" -sha256: "01d3c6d62583e62ea42eaf7bb6b7f98e47b0360d8e65fbfd3210e64f42ccdf73" +sha256: "fcaa4a44ce26438f3c89d26e8ed3056ea4a195a3516a9ca9ed698c4eca42c5d3" signature: "" bytecode_sha256: "" diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 2f8ce59..e09ca72 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -32,7 +32,8 @@ "set_watchdog_timeout_s", "persist_secret", "unix_ms", - "reserve_vehicle_wake" + "reserve_vehicle_wake", + "site_location" ], "decode": [ "decode_i16", diff --git a/spec/host-api.md b/spec/host-api.md index fab21df..49f5393 100644 --- a/spec/host-api.md +++ b/spec/host-api.md @@ -52,6 +52,20 @@ read with fresh values while its hardware is unavailable, so a driver that knows the difference must say so; the host cannot infer it from telemetry. Clear it with `false` once a later read shows the fault is gone. +### `host.unix_ms()` *(FTW)* +Returns wall-clock time in Unix milliseconds. `host.now_ms()` and +`host.millis()` are uptime and cannot be compared with a vendor's timestamp. + +### `host.site_location()` *(FTW, vehicle drivers)* +Returns the site's `latitude, longitude`, or `nil`. FTW gives it only to a +driver from its signed channel whose signed catalog entry lists `vehicle`; +a bundled or local file gets `nil`, whatever its own `DRIVER` table says. +The position is the site's weather pin, and FTW treats the pin left where +the settings page put it as no position. A driver measures its car's +distance from the site and emits `site_distance_m`; it never emits the +car's coordinates. Check that the function exists before calling it: an +older FTW and Blixt L1 do not have it. + ### `host.emit_metric(name, value, unit)` Emit one diagnostic value outside the DER telemetry schema. `unit` is optional. Use it for evidence an operator needs when a device misbehaves, not for @@ -90,19 +104,33 @@ driver's keys when they already report the right values. Leave out a value that was not read (`nil`). Never send a made-up zero. Follow the target host's wire rules for absent values. -#### Proposed data-models v3.0.0 migration - -[srcful-data-models#10](https://github.com/srcfl/srcful-data-models/pull/10) -proposes the `inverter` DER type, lowercase non-unit names, and `_ac` / `_dc` -postfixes for quantities that can describe either side. NovaCore's matching -change is [srcful-novacore#174](https://github.com/srcfl/srcful-novacore/pull/174). -These changes are still open. The reference on `main` currently describes -v2.0.0. - -Those proposed wire names do not change `host.emit` yet. Before a driver -uses them, its host must accept or map them, and any receiving API must -support them. Keep the current host keys until that work lands; a link to -a newer data model does not add host support. +#### `vehicle` *(FTW)* + +FTW also accepts `der_type` `"vehicle"`: a car's own telemetry, apart from the +charger it is plugged into. Besides `soc`, `charge_limit_pct`, +`charging_state`, `soc_fresh`, `soc_observed_at_ms` and `stale`, a vehicle +emit may say where the car's cable and the car are, so FTW can tell which car +was plugged in ([FTW docs/cars.md](https://github.com/srcfl/ftw/blob/master/docs/cars.md), +rule 2). Every field is optional; leave out what the source does not know. + +Send `soc` as a percent, except a level of 1 % or less, which goes as a +fraction (`0.01`). FTW reads 1 or less as a fraction and more as a percent; +FTW before 0.131 reads every value as a percent. A plain `1` would read as +100 %. + +| Field | Type | Meaning | +|---|---|---| +| `plugged` | bool | The car's own word on its charge cable: `true` in, `false` out. | +| `plugged_since_ms` | Unix ms | When the source read the cable take that state. Send it only when the source reads the cable at least every 30 seconds, and leave it out until the driver has seen a change: a value present at its first reading has no known time. It is the time of the read, not of the plug-in; Home Assistant's `last_changed` is when Home Assistant read it. | +| `at_site` | bool | `true` when the car is at this site, `false` when it is elsewhere, from the source's own idea of home (a Home Assistant zone, a TeslaMate geofence). | +| `site_distance_m` | metres | The car's distance from the site, from `host.site_location()`. FTW reads up to 300 m as here and over 5 km as elsewhere, and does not know in between: the site's position is a weather pin. `at_site` wins when both are given. | + +FTW names a car when its source read the cable go in within a minute of the +charger's plug-in, no other charger saw a plug-in in that minute, no source +of the car says it is elsewhere, and no other car says the same. A reading +the car's source made after the plug-in that says `at_site` false or the +cable out rules the car out. Read the cable and the place at the driver's +own cadence: never wake a car only to learn them. ## Modbus diff --git a/support-status.json b/support-status.json index b708e19..903ad1f 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.7", + "catalog_version": "1.3.8", "driver_id": "easee_cloud", "targets": { "blixt-l1": { @@ -798,6 +798,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "home_assistant_vehicle", + "targets": { + "blixt-l1": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + }, + "ftw-core": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "2.1.3", @@ -1136,7 +1157,7 @@ }, { "catalog_source": true, - "catalog_version": "2.1.8", + "catalog_version": "2.1.9", "driver_id": "pixii", "targets": { "blixt-l1": { @@ -1157,7 +1178,7 @@ }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "driver_id": "pixii_pv", "targets": { "blixt-l1": { @@ -1598,7 +1619,7 @@ }, { "catalog_source": true, - "catalog_version": "1.5.10", + "catalog_version": "1.6.0", "driver_id": "sungrow", "targets": { "blixt-l1": { @@ -1619,7 +1640,7 @@ }, { "catalog_source": true, - "catalog_version": "0.1.1", + "catalog_version": "0.2.0", "driver_id": "tesla_cloud", "targets": { "blixt-l1": { @@ -1640,7 +1661,7 @@ }, { "catalog_source": true, - "catalog_version": "0.2.5", + "catalog_version": "0.2.6", "driver_id": "tesla_vehicle", "targets": { "blixt-l1": { @@ -1682,7 +1703,7 @@ }, { "catalog_source": true, - "catalog_version": "0.1.1", + "catalog_version": "0.2.0", "driver_id": "teslamate_vehicle", "targets": { "blixt-l1": { @@ -1724,7 +1745,7 @@ }, { "catalog_source": true, - "catalog_version": "0.2.1", + "catalog_version": "0.3.0", "driver_id": "vag_vehicle", "targets": { "blixt-l1": {