From 022db9fd0a13ae602402dc4c4312661df4f9ff56 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 11:26:00 +0200 Subject: [PATCH] fix(drivers): bundle the vehicle drivers that say where a car is plugged in Move the device-drivers pin from 76ddd58 to 5cc7bb1a (srcfl/device-drivers#182) and bundle home_assistant_vehicle, tesla_cloud, teslamate_vehicle and vag_vehicle beside tesla_vehicle. Between the two pins only those five drivers changed. fleetping's shipped list is regenerated from the pin. The end-to-end vehicle test now runs the bundled copies, and the testdata copies from #1660 go. Supersedes #1449. Co-Authored-By: Claude Opus 5.5 --- .changeset/bundle-vehicle-drivers.md | 5 + drivers/BUNDLED_SOURCE.json | 10 +- .../testdata/home_assistant_vehicle.lua | 331 --------- go/internal/drivers/testdata/tesla_cloud.lua | 640 ------------------ .../drivers/testdata/teslamate_vehicle.lua | 529 --------------- go/internal/drivers/vehicle_plugged_test.go | 15 +- go/internal/fleetping/shipped_gen.go | 6 +- 7 files changed, 22 insertions(+), 1514 deletions(-) create mode 100644 .changeset/bundle-vehicle-drivers.md delete mode 100644 go/internal/drivers/testdata/home_assistant_vehicle.lua delete mode 100644 go/internal/drivers/testdata/tesla_cloud.lua delete mode 100644 go/internal/drivers/testdata/teslamate_vehicle.lua diff --git a/.changeset/bundle-vehicle-drivers.md b/.changeset/bundle-vehicle-drivers.md new file mode 100644 index 000000000..b2e682381 --- /dev/null +++ b/.changeset/bundle-vehicle-drivers.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Bundle the vehicle drivers that say where a car is plugged in: `home_assistant_vehicle` (new: a car's level, cable and place from a Home Assistant automation over MQTT), `tesla_cloud`, `teslamate_vehicle` and `vag_vehicle`, and the updated `tesla_vehicle`. They report the charge cable, and where they can, whether the car is at this site, so the box can name the plugged-in car by its own word. A car at 1 % no longer reads as full. They do nothing until configured, and none wakes a car. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 40d02e1ff..350fe876b 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,17 +16,17 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "76ddd5846f18cb1a0d53d85508904d4e953421b2", + "commit": "5cc7bb1a65fe41c4f2cb28a7f546875313b2df85", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", "esphome_dsmr", "ferroamp", "ferroamp_dc2_v2x", "ferroamp_modbus", "foxess_h3_smart", "fronius", "fronius_smart_meter", "goodwe", - "growatt", "heishamon", "huawei", "kostal", "myuplink", "nibe_local", - "pixii", "pixii_pv", + "growatt", "heishamon", "home_assistant_vehicle", "huawei", "kostal", + "myuplink", "nibe_local", "pixii", "pixii_pv", "sdm630", "sma", "sma_pv", "sofar", "solaredge", "solaredge_legacy", "solaredge_pv", "solis", "solis_string", "sonnen", "sungrow", - "tesla_vehicle", "tesla_wall_connector", "tibber", "victron", "zap", - "zaptec_cloud", "zuidwijk_p1" + "tesla_cloud", "tesla_vehicle", "tesla_wall_connector", "teslamate_vehicle", + "tibber", "vag_vehicle", "victron", "zap", "zaptec_cloud", "zuidwijk_p1" ] } diff --git a/go/internal/drivers/testdata/home_assistant_vehicle.lua b/go/internal/drivers/testdata/home_assistant_vehicle.lua deleted file mode 100644 index d3b33eb3c..000000000 --- a/go/internal/drivers/testdata/home_assistant_vehicle.lua +++ /dev/null @@ -1,331 +0,0 @@ --- 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/go/internal/drivers/testdata/tesla_cloud.lua b/go/internal/drivers/testdata/tesla_cloud.lua deleted file mode 100644 index e68e7eb2a..000000000 --- a/go/internal/drivers/testdata/tesla_cloud.lua +++ /dev/null @@ -1,640 +0,0 @@ --- Tesla Vehicle Driver (telemetry-only, Tesla Fleet / official cloud API) --- Emits: Vehicle (DerVehicle) --- Protocol: HTTPS (Tesla Fleet API) --- --- Optional cloud SoC next to tesla_vehicle.lua (TeslaBLEProxy / VIN on the --- LAN). This file does not replace that driver. Cloud access is not a --- charging prerequisite: an asleep or unreachable car is a supported case, --- and Core already plans from a stated default or the plug-in slider. --- --- Telemetry only. No wake_up, charge_start, or other vehicle commands. --- GET /api/1/vehicles/{vin}/vehicle_data is a live call to the car and a --- billed request; Tesla says not to poll it regularly. GET --- /api/1/vehicles/{vin} returns the car's state without a live call and --- is not billed. So the driver checks the state every 5 minutes and reads --- charge_state only from a car that is already awake: every 4 minutes --- while it charges, every 20 minutes otherwise, and not on the first check --- after it wakes, so it does not stretch the car's own short wakes. A --- sleeping car is never woken from here. The host polls every minute; a --- poll between those checks sends nothing and replays the last reading. --- --- Vendor documents (public): --- https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-endpoints --- https://developer.tesla.com/docs/fleet-api/authentication/third-party-tokens --- https://developer.tesla.com/docs/fleet-api/billing-and-limits --- They are not in the manifest's upstream_docs: the page HTML changes --- with every deploy of Tesla's site, and the watcher hashes raw bytes. --- --- Auth is OAuth refresh_token against fleet-auth. Refresh tokens rotate and --- are persisted via host.persist_secret. Scopes needed: openid, offline_access, --- 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 --- lua: drivers/tesla_cloud.lua --- capabilities: --- http: --- allowed_hosts: --- - fleet-auth.prd.vn.cloud.tesla.com --- - fleet-api.prd.eu.vn.cloud.tesla.com --- - fleet-api.prd.na.vn.cloud.tesla.com --- - fleet-api.prd.cn.vn.cloud.tesla.com.cn --- config: --- client_id: "..." --- client_secret: "..." # optional for some Tesla app types --- refresh_token: "..." # from the Fleet API auth-code exchange --- vin: "5YJ3E1EA1KF000000" # optional; first vehicle if omitted --- region: eu # na | eu | cn --- --- Settings → Devices currently scaffolds {ip, vin} for any "vehicle" --- capability (the BLE proxy form). Edit the YAML for Fleet OAuth; do not --- point this driver at TeslaBLEProxy. - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "tesla_cloud", - name = "Tesla Vehicle (Fleet API)", - manufacturer = "Tesla", - version = "0.2.0", - protocols = { "http" }, - capabilities = { "vehicle" }, - read_only = true, - auth_post_path = "/oauth2/v3/token", - description = "Read-only Tesla vehicle SoC, charge limit and charging state via the official Fleet API. Optional next to the local BLE-proxy driver. Does not wake or command the car.", - homepage = "https://developer.tesla.com/docs/fleet-api", - http_hosts = { - "fleet-auth.prd.vn.cloud.tesla.com", - "fleet-api.prd.na.vn.cloud.tesla.com", - "fleet-api.prd.eu.vn.cloud.tesla.com", - "fleet-api.prd.cn.vn.cloud.tesla.com.cn", - }, - authors = { "FTW contributors" }, - tested_models = { "Model Y", "Model 3" }, - verification_status = "experimental", - config_secrets = { "client_secret", "refresh_token" }, -} - -PROTOCOL = "http" - --- Fixed: the POST that carries the refresh token and client secret only --- ever goes to Tesla's auth host. -local AUTH_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" -local REGION_URL = { - na = "https://fleet-api.prd.na.vn.cloud.tesla.com", - eu = "https://fleet-api.prd.eu.vn.cloud.tesla.com", - cn = "https://fleet-api.prd.cn.vn.cloud.tesla.com.cn", -} - --- Tesla VINs carry the model line in the fourth character. -local VIN_MODEL = { - S = "Model S", - ["3"] = "Model 3", - X = "Model X", - Y = "Model Y", - C = "Cybertruck", -} - --- The host ignores driver_poll's return value and keeps the interval last --- set with host.set_poll_interval, so every poll sets it. Requests to Tesla --- are paced separately by next_request_ms. -local POLL_MS = 60000 -local CHARGING_EVERY_MS = 240000 -- charging: the car is awake anyway -local IDLE_EVERY_MS = 1200000 -- awake, not charging: let it fall asleep -local ASLEEP_EVERY_MS = 300000 -- state check only: free, wakes nothing -local ERROR_RETRY_MS = 300000 -local BACKOFF_MS = 900000 -- failed token refresh or HTTP 429 --- Longer than IDLE_EVERY_MS, so an awake idle car stays listed between --- reads. Core itself stops using a vehicle SoC 5 minutes after it was read. -local STALE_AFTER_MS = 1500000 -local WATCHDOG_TIMEOUT_S = 300 - -local client_id = nil -local client_secret = nil -local refresh_token = nil -local access_token = nil -local token_expires_at = 0 -local vin = nil -local base_url = REGION_URL.eu -local next_request_ms = 0 --- Whether the car was online at the previous state check (nil: not --- checked yet since start). -local was_online = nil - --- 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 - --- Last vendor observation. seen_ms is host.millis() of the last successful --- charge_state read. Age is measured on that clock; Tesla's unix ms cannot --- be compared to host.millis(). -local last = { - seen_ms = 0, - soc = nil, - charge_limit = nil, - charging_state = nil, - 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 - return tostring(err):match("^(HTTP %d+)") or "request failed" -end - -local function url_encode(s) - return (tostring(s or ""):gsub("[^%w%-%.%_%~]", function(c) - return string.format("%%%02X", string.byte(c)) - end)) -end - -local function decode_json(raw) - if raw == nil or raw == "" then return nil, "empty body" end - local ok, data = pcall(host.json_decode, raw) - if not ok then return nil, tostring(data) end - if data == nil then return nil, "decode failed" end - return data, nil -end - -local function safe_http_get(url, headers) - local ok, resp, err = pcall(host.http_get, url, headers) - if not ok then return nil, tostring(resp) end - return resp, err -end - -local function safe_http_post(url, body, headers) - local ok, resp, err = pcall(host.http_post, url, body, headers) - if not ok then return nil, tostring(resp) end - return resp, err -end - -local function auth_headers() - return { - Authorization = "Bearer " .. (access_token or ""), - Accept = "application/json", - } -end - -local function unwrap(decoded) - if type(decoded) ~= "table" then return nil end - if type(decoded.response) == "table" then - if type(decoded.response.response) == "table" then - return decoded.response.response - end - return decoded.response - end - return decoded -end - -local function as_list(t) - if type(t) ~= "table" then return {} end - if t[1] ~= nil then return t end - if type(t.vehicles) == "table" then return t.vehicles end - return {} -end - -local function persist_refresh(new_token) - if not new_token or new_token == "" or new_token == refresh_token then - return - end - refresh_token = new_token - if not host.persist_secret then return end - local ok, perr = host.persist_secret("refresh_token", refresh_token) - if not ok then - log("warn", "tesla_cloud: could not persist rotated refresh_token: " .. tostring(perr)) - end -end - -local function fetch_token() - if not refresh_token or refresh_token == "" then - log("warn", "tesla_cloud: not connected — set refresh_token from a Tesla Fleet API auth-code exchange") - return false - end - if not client_id or client_id == "" then - log("error", "tesla_cloud: client_id required") - return false - end - local body = "grant_type=refresh_token" - .. "&client_id=" .. url_encode(client_id) - .. "&refresh_token=" .. url_encode(refresh_token) - if client_secret and client_secret ~= "" then - body = body .. "&client_secret=" .. url_encode(client_secret) - end - local resp, err = safe_http_post(AUTH_URL, body, { - ["Content-Type"] = "application/x-www-form-urlencoded", - Accept = "application/json", - }) - if err then - log("error", "tesla_cloud: token refresh failed: " .. redact_http_err(err)) - return false - end - local data, derr = decode_json(resp) - if derr or type(data) ~= "table" or not data.access_token then - log("error", "tesla_cloud: no access_token in refresh response") - return false - end - access_token = data.access_token - local expires_in = tonumber(data.expires_in) or 28800 - token_expires_at = host.millis() + (expires_in * 1000) - 60000 - persist_refresh(data.refresh_token) - return true -end - -local function ensure_auth() - if access_token and access_token ~= "" and host.millis() < token_expires_at then - return true - end - return fetch_token() -end - --- The model comes from the VIN, not display_name: that is the name the --- owner gave the car. -local function bind_identity(next_vin) - if not next_vin or next_vin == "" then return end - local s = tostring(next_vin) - if s == vin then return end - vin = s - host.set_sn(vin) - local model = VIN_MODEL[vin:sub(4, 4):upper()] - if model and host.set_model then - host.set_model(model) - end -end - -local function pick_vehicle(rows, want) - if type(rows) ~= "table" then return nil end - if want and want ~= "" then - for i = 1, #rows do - local row = rows[i] - if type(row) == "table" and tostring(row.vin or "") == want then - return row - end - end - return nil - end - for i = 1, #rows do - if type(rows[i]) == "table" and rows[i].vin then - return rows[i] - end - end - return nil -end - -local function list_vehicles() - local resp, err = safe_http_get(base_url .. "/api/1/vehicles", auth_headers()) - if err then return nil, err end - local data, derr = decode_json(resp) - if derr then return nil, derr end - return as_list(unwrap(data)), nil -end - -local function vehicle_row() - if vin and vin ~= "" then - local resp, err = safe_http_get(base_url .. "/api/1/vehicles/" .. vin, auth_headers()) - if not err then - local data, derr = decode_json(resp) - if not derr then - local row = unwrap(data) - if type(row) == "table" and (row.vin or row.state) then - return row, nil - end - end - end - if err and tostring(err):match("HTTP 401") then - return nil, err - end - end - local rows, lerr = list_vehicles() - if lerr then return nil, lerr end - local row = pick_vehicle(rows, vin) - if not row then - return nil, "no matching vehicle" - end - return row, nil -end - -local function charge_state_from(decoded) - local root = unwrap(decoded) - if type(root) ~= "table" then return nil end - if type(root.charge_state) == "table" then - return root.charge_state - end - if root.battery_level ~= nil then - return root - end - return nil -end - -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) - last.charger_actual_current = tonumber(cs.charger_actual_current) - local cs_state = cs.charging_state - if type(cs_state) ~= "string" then cs_state = nil end - last.charging_state = cs_state - local ttf_min = tonumber(cs.minutes_to_full_charge) - if ttf_min == nil then - local ttf_h = tonumber(cs.time_to_full_charge) - if ttf_h ~= nil then ttf_min = math.floor(ttf_h * 60 + 0.5) end - end - last.time_to_full = ttf_min - -- Successful vehicle_data is a new observation. Age is for failed or - -- asleep polls that would otherwise replay this cache forever. - last.seen_ms = host.millis() - return true -end - --- A replay repeats the last observation with soc_fresh=false, so Core keeps --- the time of that observation and ages it itself. Marking a replay stale --- would drop the car from Core between two reads. After STALE_AFTER_MS the --- driver stops emitting. -local function emit_vehicle(fresh) - if last.soc == nil or last.seen_ms == 0 then return end - local age = host.millis() - last.seen_ms - if age > STALE_AFTER_MS then - return - end - host.emit("vehicle", { - 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_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, nil, err end - local decoded, derr = decode_json(resp) - if derr then return nil, nil, derr end - if type(decoded) == "table" and decoded.error then - return nil, nil, tostring(decoded.error) - end - 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) - next_request_ms = host.millis() + ms -end - -local function is_charging(state) - return state == "Charging" or state == "Starting" -end - -function driver_init(config) - host.set_make("Tesla") - config = config or {} - client_id = config.client_id - client_secret = config.client_secret - refresh_token = config.refresh_token - if config.vin and tostring(config.vin) ~= "" then - bind_identity(config.vin) - end - local region = tostring(config.region or "eu"):lower() - base_url = REGION_URL[region] - if not base_url then - log("warn", "tesla_cloud: unknown region " .. region .. ", using eu") - region = "eu" - base_url = REGION_URL.eu - end - next_request_ms = 0 - if host.set_watchdog_timeout_s then - host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) - end - -- First poll soon; driver_poll sets POLL_MS on every path after that. - host.set_poll_interval(500) - if not client_id or client_id == "" or not refresh_token or refresh_token == "" then - log("error", "tesla_cloud: client_id and refresh_token required (Fleet API auth-code exchange)") - end - log("info", "tesla_cloud: init region=" .. region .. - " vin=" .. tostring(vin or "(discover)") .. - " telemetry-only") -end - -function driver_poll() - host.set_poll_interval(POLL_MS) - if not refresh_token or refresh_token == "" then - return POLL_MS - end - if host.millis() < next_request_ms then - emit_vehicle(false) - return POLL_MS - end - - if not ensure_auth() then - wait(BACKOFF_MS) - emit_vehicle(false) - return POLL_MS - end - - local row, err = vehicle_row() - if err and tostring(err):match("HTTP 401") then - token_expires_at = 0 - if ensure_auth() then - row, err = vehicle_row() - end - end - if err or type(row) ~= "table" then - log("warn", "tesla_cloud: vehicle status: " .. redact_http_err(err)) - wait(ERROR_RETRY_MS) - emit_vehicle(false) - return POLL_MS - end - - bind_identity(row.vin) - if not vin or vin == "" then - log("warn", "tesla_cloud: no VIN on account") - wait(ERROR_RETRY_MS) - return POLL_MS - end - - local state = tostring(row.state or ""):lower() - local online = state == "online" - local woke_just_now = online and was_online == false - was_online = online - if not online then - log("debug", "tesla_cloud: " .. vin .. " is " .. (state ~= "" and state or "unknown") .. - " — not calling vehicle_data") - wait(ASLEEP_EVERY_MS) - emit_vehicle(false) - return POLL_MS - end - if woke_just_now then - -- The car wakes briefly on its own. A live call now would keep it - -- awake; if it is still online at the next check, it is in use. - log("debug", "tesla_cloud: " .. vin .. " just woke — reading at the next check") - wait(ASLEEP_EVERY_MS) - emit_vehicle(false) - return POLL_MS - end - - 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, root, ferr = fetch_charge_state() - end - end - if ferr then - local es = tostring(ferr) - if es:match("HTTP 408") or es:match("vehicle unavailable") then - log("debug", "tesla_cloud: vehicle_data unavailable (asleep)") - wait(ASLEEP_EVERY_MS) - elseif es:match("HTTP 429") then - log("warn", "tesla_cloud: rate limited") - wait(BACKOFF_MS) - else - log("warn", "tesla_cloud: vehicle_data: " .. redact_http_err(ferr)) - wait(ERROR_RETRY_MS) - end - emit_vehicle(false) - return POLL_MS - end - 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) - return POLL_MS - end - - if is_charging(last.charging_state) then - wait(CHARGING_EVERY_MS) - else - wait(IDLE_EVERY_MS) - end - log("info", "tesla_cloud: emit soc=" .. tostring(last.soc) .. - " limit=" .. tostring(last.charge_limit) .. - " state=" .. tostring(last.charging_state)) - emit_vehicle(true) - return POLL_MS -end - -function driver_cleanup() - last.seen_ms = 0 - last.soc = nil - last.charge_limit = nil - last.charging_state = nil - 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 - was_online = nil - vin = nil -end diff --git a/go/internal/drivers/testdata/teslamate_vehicle.lua b/go/internal/drivers/testdata/teslamate_vehicle.lua deleted file mode 100644 index b7d911b52..000000000 --- a/go/internal/drivers/testdata/teslamate_vehicle.lua +++ /dev/null @@ -1,529 +0,0 @@ --- Tesla Vehicle Driver (telemetry-only, via TeslaMate MQTT) --- Emits: Vehicle (DerVehicle) --- Protocol: MQTT (TeslaMate retained topics teslamate/cars/{id}/…) --- --- Optional SoC next to tesla_vehicle.lua (TeslaBLEProxy on the LAN). --- This file does not replace that driver, and it is not a Fleet API client. --- FTW reads the TeslaMate instance the owner already runs. It does not talk --- to Tesla Fleet cloud and it does not ingest Home Assistant entities. --- Cloud access is not a charging prerequisite: an asleep or unreachable car --- is a supported case, and Core already plans from a stated default or the --- plug-in slider. --- --- Telemetry only. No wake, charge_start, or other vehicle command. TeslaMate --- MQTT is subscribe-only; this driver never publishes. --- --- TeslaMate does not publish VIN on MQTT (privacy). Bind identity from YAML: --- host.set_make("Tesla"), host.set_sn(VIN). Car id selects the topic prefix. --- --- Vendor document: --- https://docs.teslamate.org/docs/integrations/mqtt --- TeslaMate payloads are per-topic strings, retained, and published only --- when a value changes. `healthy` is the exception: it is not retained and --- goes out with every update TeslaMate makes, so a healthy=true message --- while the car is awake means TeslaMate has just read the car and the --- retained values still hold. While the car is asleep TeslaMate stops --- updating; retained values can be hours old. Age is host.millis() of the --- last awake observation (state online / charging / driving / updating). --- In between, the driver replays that observation with soc_fresh=false so --- Core ages it from the observation, and stops after STALE_AFTER_MS. A --- first subscribe that only sees an asleep car is not a live observation --- and emits nothing. --- --- Config (MQTT host/port/user live on the capability grant, not here): --- --- drivers: --- - name: teslamate --- lua: drivers/teslamate_vehicle.lua --- capabilities: --- mqtt: --- host: 192.168.1.10 # TeslaMate's broker (often Mosquitto) --- port: 1883 --- username: "" # optional --- password: "" # optional --- 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 --- at the broker. Settings → Devices currently scaffolds vehicle drivers as --- {ip, vin} (BLE proxy). Edit the YAML for TeslaMate MQTT. --- --- 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, - host_api_max = 1, - id = "teslamate_vehicle", - name = "Tesla Vehicle (TeslaMate)", - manufacturer = "Tesla", - version = "0.2.0", - protocols = { "mqtt" }, - capabilities = { "vehicle" }, - read_only = true, - description = "Read-only Tesla vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE). Does not wake or command the car.", - homepage = "https://docs.teslamate.org/docs/integrations/mqtt", - authors = { "FTW contributors" }, - tested_models = { "Model Y", "Model 3" }, - verification_status = "experimental", -} - -PROTOCOL = "mqtt" - --- File-local so host.mqtt_subscribe(base_topic .. "/#") stays a static --- topic expression for the MQTT contract tests. -local base_topic = "teslamate/cars/1" - -local POLL_INTERVAL_MS = 5000 -local STALE_AFTER_MS = 900000 -local WATCHDOG_TIMEOUT_S = 300 - --- TeslaMate's state topic also carries asleep, offline and suspended, and --- briefly start or unavailable. None of those is a live reading. -local AWAKE = { - online = true, - charging = true, - driving = true, - updating = true, -} - -local vin = nil - --- 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 - --- Last live (awake) observation. seen_ms is host.millis() when TeslaMate --- 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, - charge_limit = nil, - charging_state = nil, - time_to_full = nil, - charge_amps = nil, - charger_actual_current = nil, - tm_state = nil, - healthy = nil, -} - --- Fields collected from MQTT that have not yet been accepted as a live --- observation. Kept across polls so state and battery_level can arrive --- on different ticks. nil means not received; false means TeslaMate sent --- an empty value (it does for time_to_full_charge and --- charger_actual_current once charging ends). -local incoming = { - soc = nil, - charge_limit = nil, - charging_state = nil, - time_to_full = nil, - charge_amps = nil, - charger_actual_current = nil, -} - -local function payload_present(p) - if p == nil then return false end - local s = tostring(p) - return s ~= "" and s ~= "nil" and s ~= "null" -end - -local function as_bool(p) - if p == true then return true end - if p == false then return false end - local s = tostring(p or ""):lower() - if s == "true" or s == "1" then return true end - if s == "false" or s == "0" then return false end - return nil -end - -local function model_name(raw) - local s = tostring(raw or "") - if s == "3" or s == "Y" or s == "S" or s == "X" then - return "Model " .. s - end - return s -end - -local function normalize_prefix(s) - s = tostring(s or ""):gsub("/+$", "") - s = s:gsub("/#$", "") - return s -end - -local function topic_key(topic) - if type(topic) ~= "string" then return nil end - local prefix = base_topic .. "/" - if topic:sub(1, #prefix) ~= prefix then return nil end - local key = topic:sub(#prefix + 1) - -- TeslaMate charge fields are a single path segment. Reject - -- teslamate/cars/10/... when the prefix is teslamate/cars/1. - if key == "" or key:find("/", 1, true) then return nil end - return key -end - -local function hours_to_min(hours) - local h = tonumber(hours) - if h == nil then return nil end - return math.floor(h * 60 + 0.5) -end - -local function reset_incoming() - incoming.soc = nil - incoming.charge_limit = nil - incoming.charging_state = nil - incoming.time_to_full = nil - incoming.charge_amps = nil - 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 - last.charging_state = nil - last.time_to_full = nil - last.charge_amps = nil - last.charger_actual_current = nil - last.tm_state = nil - last.healthy = nil - reset_incoming() -end - -local function car_is_awake() - local state = last.tm_state - return type(state) == "string" and AWAKE[state] == true -end - -local function can_accept_fresh() - if not vin or vin == "" then return false end - if last.healthy == false then return false end - return car_is_awake() -end - -local function remember() - local soc = tonumber(incoming.soc) - if soc == nil then soc = last.soc end - if soc == nil then return false end - last.soc = soc - if incoming.charge_limit ~= nil then - last.charge_limit = tonumber(incoming.charge_limit) - end - if incoming.charge_amps ~= nil then - last.charge_amps = tonumber(incoming.charge_amps) - end - if incoming.charger_actual_current ~= nil then - last.charger_actual_current = tonumber(incoming.charger_actual_current) or nil - end - local cs = incoming.charging_state - if type(cs) == "string" and cs ~= "" then - last.charging_state = cs - end - if incoming.time_to_full ~= nil then - last.time_to_full = incoming.time_to_full or nil - end - last.seen_ms = host.millis() - return true -end - --- A replay repeats the last awake observation with soc_fresh=false, so Core --- keeps the time of that observation and ages it itself. Marking every --- replay stale would drop the car from Core between two MQTT updates. --- stale=true only when TeslaMate reports itself unhealthy. -local function emit_vehicle(fresh) - if last.soc == nil or last.seen_ms == 0 then return end - local age = host.millis() - last.seen_ms - if age > STALE_AFTER_MS then - return - end - host.emit("vehicle", { - 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, - }) -end - --- Returns true when this message shows TeslaMate has just read the car: a --- charge-field update, or the healthy=true it sends with every update. --- State and model do not refresh seen_ms on their own. -local function apply_message(msg) - if type(msg) ~= "table" then return false end - local key = topic_key(msg.topic) - if not key then return false end - local p = msg.payload - if key == "state" then - if payload_present(p) then - last.tm_state = tostring(p):lower() - end - return false - end - 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)) - end - return false - end - if key == "battery_level" then - local n = tonumber(p) - if n == nil then return false end - incoming.soc = n - return true - end - if key == "charge_limit_soc" then - local n = tonumber(p) - if n == nil then return false end - incoming.charge_limit = n - return true - end - if key == "charging_state" then - if not payload_present(p) then return false end - incoming.charging_state = tostring(p) - return true - end - if key == "time_to_full_charge" then - if not payload_present(p) then - incoming.time_to_full = false - return true - end - local mins = hours_to_min(p) - if mins == nil then return false end - incoming.time_to_full = mins - return true - end - if key == "charge_current_request" then - local n = tonumber(p) - if n == nil then return false end - incoming.charge_amps = n - return true - end - if key == "charger_actual_current" then - if not payload_present(p) then - incoming.charger_actual_current = false - return true - end - local n = tonumber(p) - if n == nil then return false end - incoming.charger_actual_current = n - return true - end - return false -end - -function driver_init(config) - host.set_make("Tesla") - config = config or {} - - if config.vin and tostring(config.vin) ~= "" then - vin = tostring(config.vin) - host.set_sn(vin) - else - log("error", "teslamate_vehicle: config.vin required (TeslaMate MQTT does not publish VIN)") - end - - if config.topic_prefix and tostring(config.topic_prefix) ~= "" then - base_topic = normalize_prefix(config.topic_prefix) - elseif config.base_topic and tostring(config.base_topic) ~= "" then - base_topic = normalize_prefix(config.base_topic) - elseif config.topic and tostring(config.topic) ~= "" then - base_topic = normalize_prefix(config.topic) - elseif config.car_id ~= nil and tostring(config.car_id) ~= "" then - base_topic = "teslamate/cars/" .. tostring(config.car_id) - 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) - end - host.set_poll_interval(500) - - -- Real host returns nil on success. The test mock returns true. - local err = host.mqtt_subscribe(base_topic .. "/#") - if type(err) == "string" and err ~= "" then - log("error", "teslamate_vehicle: subscribe failed: " .. err) - else - log("info", "teslamate_vehicle: subscribed to " .. base_topic .. "/#" .. - " vin=" .. tostring(vin or "(missing)") .. - " telemetry-only") - end -end - -function driver_poll() - host.set_poll_interval(POLL_INTERVAL_MS) - - if not vin or vin == "" then - return POLL_INTERVAL_MS - end - - local messages = host.mqtt_messages() - if not messages then messages = {} end - local saw_charge = false - 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 - -- asleep retained values must not look like a new BMS reading. - if can_accept_fresh() and saw_charge and remember() then - log("info", "teslamate_vehicle: emit soc=" .. tostring(last.soc) .. - " limit=" .. tostring(last.charge_limit) .. - " state=" .. tostring(last.charging_state) .. - " tm=" .. tostring(last.tm_state)) - emit_vehicle(true) - else - emit_vehicle(false) - end - - return POLL_INTERVAL_MS -end - -function driver_cleanup() - reset_last() - home_geofence = nil - vin = nil - base_topic = "teslamate/cars/1" -end diff --git a/go/internal/drivers/vehicle_plugged_test.go b/go/internal/drivers/vehicle_plugged_test.go index 909df91b2..bec19b2b5 100644 --- a/go/internal/drivers/vehicle_plugged_test.go +++ b/go/internal/drivers/vehicle_plugged_test.go @@ -14,13 +14,12 @@ import ( ) // The vehicle drivers that say where a car's cable and the car are, run in -// this host through to the observations the cars runtime reads. testdata -// holds copies of srcfl/device-drivers#182 (home_assistant_vehicle 0.1.0, -// tesla_cloud 0.2.0, teslamate_vehicle 0.2.0) until the driver pin moves. +// this host through to the observations the cars runtime reads. They are the +// bundled copies (drivers/BUNDLED_SOURCE.json). -func vehicleTestdata(name string) string { +func bundledDriver(name string) string { _, thisFile, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(thisFile), "testdata", name+".lua") + return filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "drivers", name+".lua") } // A signed vehicle driver's policy: host.site_location answers it. @@ -76,7 +75,7 @@ func TestHomeAssistantVehicleSaysWhereTheCarIs(t *testing.T) { env := vehicleEnv("tesla-ha") mq := &fakeMQTT{} env.WithMQTT(mq) - d, err := NewLuaDriver(vehicleTestdata("home_assistant_vehicle"), env) + d, err := NewLuaDriver(bundledDriver("home_assistant_vehicle"), env) if err != nil { t.Fatal(err) } @@ -111,7 +110,7 @@ func TestTeslaMateVehicleSaysWhereTheCarIs(t *testing.T) { mq := &fakeMQTT{} env.WithMQTT(mq) env.SiteLocation = uppsala - d, err := NewLuaDriverWithPolicy(vehicleTestdata("teslamate_vehicle"), env, signedVehicle("teslamate_vehicle")) + d, err := NewLuaDriverWithPolicy(bundledDriver("teslamate_vehicle"), env, signedVehicle("teslamate_vehicle")) if err != nil { t.Fatal(err) } @@ -144,7 +143,7 @@ func TestTeslaMateVehicleSaysWhereTheCarIs(t *testing.T) { } func TestTeslaCloudSaysWhereTheCarIs(t *testing.T) { - src, err := os.ReadFile(vehicleTestdata("tesla_cloud")) + src, err := os.ReadFile(bundledDriver("tesla_cloud")) if err != nil { t.Fatal(err) } diff --git a/go/internal/fleetping/shipped_gen.go b/go/internal/fleetping/shipped_gen.go index 1975ba0ce..d8423e331 100644 --- a/go/internal/fleetping/shipped_gen.go +++ b/go/internal/fleetping/shipped_gen.go @@ -6,7 +6,7 @@ package fleetping // // An array rather than a slice, so it is fixed in every sense: the length // is part of the type and nothing can append to it at run time. -var shippedDrivers = [40]string{ +var shippedDrivers = [44]string{ "ambibox_v2x", "ctek", "ctek_hybrid", @@ -23,6 +23,7 @@ var shippedDrivers = [40]string{ "goodwe", "growatt", "heishamon", + "home_assistant_vehicle", "huawei", "kostal", "myuplink", @@ -40,9 +41,12 @@ var shippedDrivers = [40]string{ "solis_string", "sonnen", "sungrow", + "tesla_cloud", "tesla_vehicle", "tesla_wall_connector", + "teslamate_vehicle", "tibber", + "vag_vehicle", "victron", "zap", "zaptec_cloud",