diff --git a/CHANGELOG.md b/CHANGELOG.md index b950ca2..50ebcc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ ## [Unreleased] +### 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 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index a9ebd61..ab20856 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -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 | @@ -158,18 +160,18 @@ Catalog source is not proof that a target can install or run a driver. | sonnen | 2.0.3 | 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.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 | +| 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 f14fbca..0d32524 100644 --- a/devices.yaml +++ b/devices.yaml @@ -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" @@ -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 f75acdb..9bbf6e8 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/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/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_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_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_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 0817bb3..e853918 100644 --- a/index.yaml +++ b/index.yaml @@ -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 @@ -709,25 +719,25 @@ drivers: 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/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/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 18afd0f..92d2e4a 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 0a93501..b511b34 100644 --- a/support-status.json +++ b/support-status.json @@ -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", @@ -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": {