From 3d9467bd48f81eccf355c45c56eebcb03b55b6ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 07:07:18 +0000 Subject: [PATCH 1/3] feat(tesla_cloud): add telemetry-only Fleet API vehicle driver Optional cloud SoC next to tesla_vehicle (BLE proxy). Emits DerVehicle, binds Tesla+VIN, ages vendor data, and never wakes or commands the car. Signed-off-by: Cursor Agent --- CHANGELOG.md | 4 + SUPPORT_STATUS.md | 2 + devices.yaml | 11 + drivers/lua/tesla_cloud.lua | 475 ++++++++++++++++++ .../tests/lua_harness/test_tesla_cloud.lua | 265 ++++++++++ drivers/tests/test_tesla_cloud.py | 12 + index.yaml | 10 + manifests/tesla_cloud.yaml | 24 + support-status.json | 21 + 9 files changed, 824 insertions(+) create mode 100644 drivers/lua/tesla_cloud.lua create mode 100644 drivers/tests/lua_harness/test_tesla_cloud.lua create mode 100644 drivers/tests/test_tesla_cloud.py create mode 100644 manifests/tesla_cloud.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab0890..a2c8b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## tesla_cloud 0.1.0 + +Read-only Tesla vehicle telemetry from the official Fleet API: SoC, charge limit, charging state and time-to-full. Identity is Tesla + VIN. Vendor data is aged and emission stops when it is stale. The driver never posts wake, charge_start or any other car command, and it does not call vehicle_data while the car is asleep. Optional next to `tesla_vehicle` (TeslaBLEProxy on the LAN). Cloud access is not a charging prerequisite. + ## esphome_dsmr 1.0.6 The catalog entry `esphome-dsmr` is folded into this one: it was the same driver for the same device under a second id. `esphome_dsmr` declares `replaces = { "esphome-dsmr" }` in its `DRIVER` table, so a host can move a device that runs the old entry to this driver. Every published `esphome-dsmr` artifact stays in the signed channel's history, so an installed copy keeps running until then. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 7cdcef9..b1d2e96 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -156,6 +156,8 @@ 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.5.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | | sungrow | 1.5.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_cloud | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_cloud | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_vehicle | 0.2.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_vehicle | 0.2.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_wall_connector | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 48aff76..77f4dac 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1798,6 +1798,17 @@ manufacturers: control: true firmware_versions: "" notes: "Read-only vehicle SoC + charge limit via Tesla API-compatible HTTP endpoint (e.g. TeslaBLEProxy)." + - name: "Tesla Vehicle (Fleet API)" + variants: [Model 3, Model Y] + regions: [CN, EU, NA] + protocols: + - protocol: http + driver: "tesla_cloud" + version: "0.1.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." - name: "Tesla Wall Connector" variants: [Wall Connector Gen 3] regions: [] diff --git a/drivers/lua/tesla_cloud.lua b/drivers/lua/tesla_cloud.lua new file mode 100644 index 0000000..000400e --- /dev/null +++ b/drivers/lua/tesla_cloud.lua @@ -0,0 +1,475 @@ +-- 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 and is expensive; +-- this driver asks for it only when GET /api/1/vehicles/{vin} already says +-- the car is online, so a sleeping car is not woken from here. +-- +-- Vendor documents (login-gated Tesla developer portal — not watchable): +-- https://developer.tesla.com/docs/fleet-api/authentication/third-party-tokens +-- https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-endpoints +-- 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. +-- +-- 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.1.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", "access_token" }, +} + +PROTOCOL = "http" + +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", +} + +local POLL_ONLINE_MS = 60000 +local POLL_ASLEEP_MS = 300000 +local STALE_AFTER_MS = 900000 +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 auth_url = AUTH_URL + +-- Last vendor observation. seen_ms is host.millis() when this vendor +-- timestamp (or this successful parse, if Tesla omitted timestamp) first +-- arrived. Age is measured on that clock; Tesla's unix ms cannot be +-- compared to host.millis(). +local last = { + seen_ms = 0, + vendor_ts = nil, + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, +} + +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 + host.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 + host.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 + host.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 + host.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 + host.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 + +local function bind_identity(next_vin, model) + if next_vin and next_vin ~= "" then + vin = tostring(next_vin) + host.set_sn(vin) + end + if model and model ~= "" and host.set_model then + host.set_model(tostring(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, nil end + if type(root.charge_state) == "table" then + return root.charge_state, root + end + if root.battery_level ~= nil then + return root, root + end + return nil, root +end + +local function remember(cs, vendor_ts) + local soc = tonumber(cs.battery_level) + if soc == nil then return false end + 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 + last.vendor_ts = vendor_ts + -- 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 + +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 = 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, + stale = not fresh, + soc_fresh = fresh, + }) +end + +local function fetch_charge_state() + local url = base_url .. "/api/1/vehicles/" .. vin + .. "/vehicle_data?endpoints=charge_state" + 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) +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.access_token and config.access_token ~= "" then + access_token = config.access_token + token_expires_at = host.millis() + 300000 + end + if config.vin and tostring(config.vin) ~= "" then + bind_identity(config.vin, nil) + end + local region = tostring(config.region or "eu"):lower() + base_url = REGION_URL[region] or REGION_URL.eu + if config.base_url and tostring(config.base_url) ~= "" then + base_url = tostring(config.base_url):gsub("/$", "") + end + if config.auth_url and tostring(config.auth_url) ~= "" then + auth_url = tostring(config.auth_url) + end + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(500) + host.log("info", "tesla_cloud: init region=" .. region .. + " vin=" .. tostring(vin or "(discover)") .. + " telemetry-only") +end + +function driver_poll() + if not refresh_token and not access_token then + return POLL_ASLEEP_MS + end + if not ensure_auth() then + emit_vehicle(false) + return POLL_ASLEEP_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 then + host.log("warn", "tesla_cloud: vehicle status: " .. redact_http_err(err)) + emit_vehicle(false) + return POLL_ASLEEP_MS + end + if type(row) ~= "table" then + emit_vehicle(false) + return POLL_ASLEEP_MS + end + + bind_identity(row.vin, row.display_name) + if not vin or vin == "" then + host.log("warn", "tesla_cloud: no VIN on account") + return POLL_ASLEEP_MS + end + + local state = tostring(row.state or ""):lower() + if state ~= "online" then + host.log("debug", "tesla_cloud: " .. vin .. " is " .. (state ~= "" and state or "unknown") .. + " — not calling vehicle_data") + emit_vehicle(false) + host.set_poll_interval(POLL_ASLEEP_MS) + return POLL_ASLEEP_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 + host.log("debug", "tesla_cloud: vehicle_data unavailable (asleep)") + elseif es:match("HTTP 429") then + host.log("warn", "tesla_cloud: rate limited") + emit_vehicle(false) + return 180000 + else + host.log("warn", "tesla_cloud: vehicle_data: " .. redact_http_err(ferr)) + end + emit_vehicle(false) + host.set_poll_interval(POLL_ONLINE_MS) + return POLL_ONLINE_MS + end + if type(cs) ~= "table" then + host.log("debug", "tesla_cloud: no charge_state") + emit_vehicle(false) + return POLL_ONLINE_MS + end + + if root and (not vin or vin == "") then + bind_identity(root.vin, root.display_name) + end + if root and type(root.vehicle_config) == "table" and root.vehicle_config.car_type then + bind_identity(nil, root.vehicle_config.car_type) + end + + if not remember(cs, tonumber(cs.timestamp)) then + emit_vehicle(false) + return POLL_ONLINE_MS + end + host.log("info", "tesla_cloud: emit soc=" .. tostring(last.soc) .. + " limit=" .. tostring(last.charge_limit) .. + " state=" .. tostring(last.charging_state)) + emit_vehicle(true) + host.set_poll_interval(POLL_ONLINE_MS) + return POLL_ONLINE_MS +end + +function driver_cleanup() + last.seen_ms = 0 + last.vendor_ts = nil + 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 + access_token = nil + token_expires_at = 0 +end diff --git a/drivers/tests/lua_harness/test_tesla_cloud.lua b/drivers/tests/lua_harness/test_tesla_cloud.lua new file mode 100644 index 0000000..6897cc0 --- /dev/null +++ b/drivers/tests/lua_harness/test_tesla_cloud.lua @@ -0,0 +1,265 @@ +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 EU = "https://fleet-api.prd.eu.vn.cloud.tesla.com" + +local routes = {} +local posted = {} +local gets = {} + +local function json(tbl) + return host.json_encode(tbl) +end + +local function route_get(url) + table.insert(gets, url) + if routes[url] ~= nil then + local r = routes[url] + if type(r) == "table" and r.err then + error(r.err) + end + return r + end + for pattern, body in pairs(routes) do + if type(pattern) == "string" and url:find(pattern, 1, true) then + if type(body) == "table" and body.err then + error(body.err) + end + return body + end + end + error("http_get: no mock for " .. tostring(url)) +end + +local function route_post(url, body, headers) + table.insert(posted, {url = url, body = body, headers = headers}) + if routes[url] ~= nil then + local r = routes[url] + if type(r) == "table" and r.err then + return nil, r.err + end + return r + end + for pattern, resp in pairs(routes) do + if type(pattern) == "string" and url:find(pattern, 1, true) then + if type(resp) == "table" and resp.err then + return nil, resp.err + end + return resp + end + end + error("http_post: no mock for " .. tostring(url)) +end + +host.http_get = function(url) + return route_get(url) +end +host.http_post = function(url, body, headers) + return route_post(url, body, headers) +end + +local function vehicle_doc(state) + return json({ + response = { + vin = VIN, + state = state, + display_name = "Home", + } + }) +end + +local function charge_doc(fields) + fields = fields or {} + local cs = { + battery_level = fields.soc or 67, + charge_limit_soc = fields.limit or 80, + charging_state = fields.state or "Charging", + minutes_to_full_charge = fields.ttf or 42, + charge_amps = fields.amps or 16, + charger_actual_current = fields.actual or 15, + timestamp = fields.ts or 1710000000000, + } + return json({ + response = { + vin = VIN, + state = "online", + charge_state = cs, + vehicle_config = { car_type = "modely" }, + } + }) +end + +local function token_doc(refresh) + return json({ + access_token = "access-1", + refresh_token = refresh or "refresh-2", + expires_in = 28800, + token_type = "Bearer", + }) +end + +local function boot(cfg) + host.reset() + routes = {} + posted = {} + gets = {} + host.http_get = function(url) return route_get(url) end + host.http_post = function(url, body, headers) return route_post(url, body, headers) end + routes[AUTH] = token_doc() + routes[EU .. "/api/1/vehicles/" .. VIN] = vehicle_doc("online") + routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = charge_doc() + dofile("drivers/lua/tesla_cloud.lua") + driver_init(cfg or { + client_id = "app-1", + client_secret = "secret-1", + refresh_token = "refresh-1", + vin = VIN, + region = "eu", + }) +end + +local function last_vehicle() + local rows = host._emitted.vehicle + if not rows or #rows == 0 then return nil end + return rows[#rows] +end + +local function count_gets(needle) + local n = 0 + for i = 1, #gets do + if gets[i]:find(needle, 1, true) then n = n + 1 end + end + return n +end + +local function count_posts(needle) + local n = 0 + for i = 1, #posted do + if posted[i].url:find(needle, 1, true) then n = n + 1 end + end + return n +end + +boot() +assert(host._make == "Tesla", "set_make Tesla") +assert(host._sn == VIN, "set_sn from config VIN") +driver_poll() +local sample = last_vehicle() +assert(sample, "online car must emit DerVehicle") +assert(sample.soc == 67, "soc") +assert(sample.charge_limit_pct == 80, "charge_limit_pct") +assert(sample.charging_state == "Charging", "charging_state") +assert(sample.time_to_full_min == 42, "time_to_full_min") +assert(sample.charge_amps == 16, "charge_amps") +assert(sample.charger_actual_current == 15, "charger_actual_current") +assert(sample.stale == false, "fresh emit is not stale") +assert(sample.soc_fresh == true, "soc_fresh") +assert(host._model == "Home" or host._model == "modely", "model from vehicle") +assert(count_posts("/oauth2/v3/token") == 1, "one token refresh") +assert(count_posts("/wake_up") == 0, "must not wake") +assert(count_posts("/command/") == 0, "must not command the car") +assert(count_gets("/vehicle_data") == 1, "vehicle_data once while online") + +-- Rotated refresh_token is persisted. +local persisted = false +for _, call in ipairs(host._calls) do + if call.func == "persist_secret" and call.args[1] == "refresh_token" and call.args[2] == "refresh-2" then + persisted = true + end +end +assert(persisted, "rotated refresh_token not persisted") + +-- Asleep: status only, no vehicle_data, cache marked stale then dropped. +boot() +driver_poll() +assert(last_vehicle() and last_vehicle().soc_fresh == true, "prime cache") +local after_fresh = #(host._emitted.vehicle) +gets = {} +routes[EU .. "/api/1/vehicles/" .. VIN] = vehicle_doc("asleep") +routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = { err = "should not fetch" } +driver_poll() +assert(count_gets("/vehicle_data") == 0, "asleep car must not call vehicle_data") +assert(#(host._emitted.vehicle) == after_fresh + 1, "replay cache while young") +assert(last_vehicle().stale == true, "replay is stale") +assert(last_vehicle().soc_fresh == false, "replay is not fresh") +assert(last_vehicle().soc == 67, "replay keeps SoC") + +host._millis_counter = host._millis_counter + 900001 +local before_stale = #(host._emitted.vehicle) +driver_poll() +assert(#(host._emitted.vehicle) == before_stale, "stop emitting when stale") +assert(count_posts("/wake_up") == 0, "asleep poll must not wake") + +-- 408 on vehicle_data does not invent a SoC. +boot() +routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = { err = "HTTP 408 vehicle unavailable" } +driver_poll() +assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0, "408 invented telemetry") + +-- Missing battery_level is not a zero SoC. +boot() +routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = json({ + response = { vin = VIN, charge_state = { charging_state = "Stopped" } } +}) +driver_poll() +assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0, "empty charge_state invented SoC") + +-- Discover VIN from the account list. +boot({ + client_id = "app-1", + refresh_token = "refresh-1", + region = "eu", +}) +assert(host._sn == nil, "no VIN at init") +routes[EU .. "/api/1/vehicles"] = json({ + response = { + { vin = VIN, state = "online", display_name = "Home" }, + } +}) +-- Status by VIN is unknown until discovered; list is the first lookup. +routes[EU .. "/api/1/vehicles/"] = nil +driver_poll() +assert(host._sn == VIN, "discovered VIN") +assert(last_vehicle() and last_vehicle().soc == 67, "emit after discover") + +-- Region NA builds the North America Fleet URL. +boot({ + client_id = "app-1", + refresh_token = "refresh-1", + vin = VIN, + region = "na", +}) +local NA = "https://fleet-api.prd.na.vn.cloud.tesla.com" +routes[NA .. "/api/1/vehicles/" .. VIN] = vehicle_doc("online") +routes[NA .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = charge_doc({ soc = 41 }) +driver_poll() +assert(last_vehicle() and last_vehicle().soc == 41, "NA region") +for i = 1, #gets do + assert(not gets[i]:find("fleet-api.prd.eu", 1, true), "NA poll hit EU: " .. gets[i]) +end + +-- time_to_full_charge hours → minutes. +boot() +routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = json({ + response = { + vin = VIN, + charge_state = { + battery_level = 50, + charge_limit_soc = 80, + charging_state = "Charging", + time_to_full_charge = 1.5, + } + } +}) +driver_poll() +assert(last_vehicle().time_to_full_min == 90, "hours converted to minutes") + +-- Failed auth does not POST wake or charge_start. +boot({ client_id = "app-1", vin = VIN, region = "eu" }) +driver_poll() +assert(count_posts("/wake_up") == 0) +assert(count_posts("/charge_start") == 0) +assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0) + +print("OK tesla_cloud") diff --git a/drivers/tests/test_tesla_cloud.py b/drivers/tests/test_tesla_cloud.py new file mode 100644 index 0000000..1474d40 --- /dev/null +++ b/drivers/tests/test_tesla_cloud.py @@ -0,0 +1,12 @@ +"""Tesla Fleet API vehicle driver: telemetry only, no wake or car control.""" +from pathlib import Path +import subprocess + + +def test_tesla_cloud_telemetry_and_staleness(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_tesla_cloud.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/index.yaml b/index.yaml index baeff0e..b056657 100644 --- a/index.yaml +++ b/index.yaml @@ -698,6 +698,16 @@ drivers: control: true size_bytes: 48885 sha256: "9847c4281d4387b5d4c6a2e1d91c592ca2cbf32c06b872be2d68d46fe307f823" + - name: "tesla_cloud" + version: "0.1.0" + tier: community + protocol: http + connectivity: cloud + setup: [vendor_portal] + ders: [vehicle] + control: false + size_bytes: 15518 + sha256: "a8d3d6c4f8090e6df4679520008487ca7359b7da6864e4a59610657cf270ad75" - name: "tesla_vehicle" version: "0.2.3" tier: core diff --git a/manifests/tesla_cloud.yaml b/manifests/tesla_cloud.yaml new file mode 100644 index 0000000..4ea8e1f --- /dev/null +++ b/manifests/tesla_cloud.yaml @@ -0,0 +1,24 @@ +name: "tesla_cloud" +version: "0.1.0" +tier: community +author: "Sourceful Labs AB" +protocol: http +connectivity: cloud +setup: [vendor_portal] +ders: [vehicle] +control: false +tested_devices: + - manufacturer: "Tesla" + model_family: "Tesla Vehicle (Fleet API)" + 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." + min_driver_version: "0.1.0" +min_host_version: "2.0.0" +size_bytes: 15518 +dkb_id: "tesla_cloud" +sha256: "a8d3d6c4f8090e6df4679520008487ca7359b7da6864e4a59610657cf270ad75" +signature: "" + +bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index cbbac3b..6845ee5 100644 --- a/support-status.json +++ b/support-status.json @@ -1596,6 +1596,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "tesla_cloud", + "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": "0.2.3", From 84ce7fb1a39ffed6c87c06606d3e89b7a8ac6965 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 07:09:20 +0000 Subject: [PATCH 2/3] test: allow tesla_cloud the scoped OAuth POST exemption Read-only Fleet API telemetry still has to refresh a token. The signed channel already scopes that POST to auth_post_path; pin tesla_cloud next to myuplink. Signed-off-by: Cursor Agent --- tests/test_ftw_repository.py | 44 ++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/tests/test_ftw_repository.py b/tests/test_ftw_repository.py index 0bd1fe3..b6ac002 100644 --- a/tests/test_ftw_repository.py +++ b/tests/test_ftw_repository.py @@ -982,29 +982,32 @@ def test_a_read_only_driver_may_still_sign_in( ) -> None: """Reading after authenticating is still reading. - myuplink cannot actuate anything -- driver_command refuses every command it - is handed -- but it reads nothing until it has exchanged a refresh token, - and it exchanges it with a POST. Denying that POST would have cost a - read-only driver every reading it takes, so read-only would have been - unusable for the drivers that most obviously deserve it. + A telemetry-only cloud driver cannot actuate anything, but it reads + nothing until it has exchanged a refresh token, and it exchanges it + with a POST. Denying that POST would have cost a read-only driver + every reading it takes. """ manifest, output = build(tmp_path, keypair) - myuplink = next(d for d in manifest["drivers"] if d["id"] == "myuplink") - artifact = (output / Path(myuplink["url"]).name).read_text() + expected = { + "myuplink": "/oauth/token", + "tesla_cloud": "/oauth2/v3/token", + } + for driver_id, auth_path in expected.items(): + driver = next(d for d in manifest["drivers"] if d["id"] == driver_id) + artifact = (output / Path(driver["url"]).name).read_text() - assert myuplink["read_only"] is True - assert myuplink["control_enabled"] is False - assert myuplink["metadata"]["auth_post_path"] == "/oauth/token" - assert "http.post" in myuplink["permissions"] + assert driver["read_only"] is True, driver_id + assert driver["control_enabled"] is False, driver_id + assert driver["metadata"]["auth_post_path"] == auth_path, driver_id + assert "http.post" in driver["permissions"], driver_id - # The exemption is scoped, not a hole: POST reaches the real host function - # only for a URL ending in the declared path. - assert 'local __sourceful_ftw_auth_path = "/oauth/token"' in artifact - assert "path:sub(-#__sourceful_ftw_auth_path) == __sourceful_ftw_auth_path" in artifact - assert "POST is allowed only for authentication" in artifact - # Everything else a read-only driver must not do is still refused. - for denied in ("modbus_write", "modbus_write_multi", "mqtt_publish", "serial_write"): - assert f"host.{denied} = __sourceful_ftw_write_denied" in artifact + # The exemption is scoped, not a hole: POST reaches the real host + # function only for a URL ending in the declared path. + assert f'local __sourceful_ftw_auth_path = "{auth_path}"' in artifact, driver_id + assert "path:sub(-#__sourceful_ftw_auth_path) == __sourceful_ftw_auth_path" in artifact + assert "POST is allowed only for authentication" in artifact + for denied in ("modbus_write", "modbus_write_multi", "mqtt_publish", "serial_write"): + assert f"host.{denied} = __sourceful_ftw_write_denied" in artifact, driver_id def test_signing_in_is_declared_or_it_does_not_happen( @@ -1022,7 +1025,8 @@ def test_signing_in_is_declared_or_it_does_not_happen( else: assert "auth_post_path" not in driver["metadata"], driver["id"] assert "http.post" not in driver["permissions"], driver["id"] - assert exempt == ["myuplink"], f"unexpected drivers allowed to POST: {exempt}" + assert exempt == ["myuplink", "tesla_cloud"], ( + f"unexpected drivers allowed to POST: {exempt}") def test_auth_post_path_must_be_a_path_and_must_mean_something( From 0019078166f19ffcd952f0474b0b1fd98689b08e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 26 Sep 2026 13:00:02 +0200 Subject: [PATCH 3/3] fix(tesla_cloud): pace Fleet API calls and stop hammering auth on errors The host ignores driver_poll's return value and keeps the interval last set with host.set_poll_interval. Several error paths never set it, so a rejected refresh token left the 500 ms startup interval in place and the driver would post to Tesla's auth host twice a second. Every poll now sets a 60 s interval, and requests to Tesla are paced on their own: - the car's state (free, no live call) every 5 minutes; - vehicle_data (billed, live) every 4 minutes while charging and every 20 minutes otherwise, and not on the first check after the car wakes; - 5 minutes after an error, 15 after a failed refresh or a 429. Replays were marked stale, which would drop the car from Core between reads; they now carry soc_fresh=false only. config.base_url, config.auth_url and config.access_token are gone: the refresh token and client secret go only to Tesla's auth host. The model comes from the VIN, not display_name, which is the owner's name for the car. The Fleet API docs are public; the driver comment says so, and why they stay out of upstream_docs. Signed-off-by: Fredrik Ahlgren Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- devices.yaml | 2 +- drivers/lua/tesla_cloud.lua | 222 +++++++++++------- .../tests/lua_harness/test_tesla_cloud.lua | 201 +++++++++++++--- index.yaml | 4 +- manifests/tesla_cloud.yaml | 6 +- 6 files changed, 309 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c8b0f..252f8e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## tesla_cloud 0.1.0 -Read-only Tesla vehicle telemetry from the official Fleet API: SoC, charge limit, charging state and time-to-full. Identity is Tesla + VIN. Vendor data is aged and emission stops when it is stale. The driver never posts wake, charge_start or any other car command, and it does not call vehicle_data while the car is asleep. Optional next to `tesla_vehicle` (TeslaBLEProxy on the LAN). Cloud access is not a charging prerequisite. +Read-only Tesla vehicle telemetry from the official Fleet API: SoC, charge limit, charging state and time to full. Identity is Tesla plus the VIN, and the model comes from the VIN. The driver checks the car's state on the free vehicle endpoint every 5 minutes and calls the billed `vehicle_data` only for 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. It never wakes the car or sends a command. The refresh token and client secret go only to Tesla's auth host, and a rejected token waits 15 minutes before the next try. Between reads the driver replays the last reading with `soc_fresh=false`, and it stops after 25 minutes. Optional next to `tesla_vehicle` (TeslaBLEProxy). Cloud access is not a charging prerequisite. Not yet run against a car. ## esphome_dsmr 1.0.6 diff --git a/devices.yaml b/devices.yaml index 77f4dac..c7ec14a 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1808,7 +1808,7 @@ manufacturers: 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." + 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." - name: "Tesla Wall Connector" variants: [Wall Connector Gen 3] regions: [] diff --git a/drivers/lua/tesla_cloud.lua b/drivers/lua/tesla_cloud.lua index 000400e..81ec566 100644 --- a/drivers/lua/tesla_cloud.lua +++ b/drivers/lua/tesla_cloud.lua @@ -8,13 +8,23 @@ -- 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 and is expensive; --- this driver asks for it only when GET /api/1/vehicles/{vin} already says --- the car is online, so a sleeping car is not woken from here. +-- 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 (login-gated Tesla developer portal — not watchable): --- https://developer.tesla.com/docs/fleet-api/authentication/third-party-tokens +-- 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 @@ -64,11 +74,13 @@ DRIVER = { authors = { "FTW contributors" }, tested_models = { "Model Y", "Model 3" }, verification_status = "experimental", - config_secrets = { "client_secret", "refresh_token", "access_token" }, + 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", @@ -76,9 +88,27 @@ local REGION_URL = { cn = "https://fleet-api.prd.cn.vn.cloud.tesla.com.cn", } -local POLL_ONLINE_MS = 60000 -local POLL_ASLEEP_MS = 300000 -local STALE_AFTER_MS = 900000 +-- 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 @@ -88,15 +118,16 @@ local access_token = nil local token_expires_at = 0 local vin = nil local base_url = REGION_URL.eu -local auth_url = AUTH_URL - --- Last vendor observation. seen_ms is host.millis() when this vendor --- timestamp (or this successful parse, if Tesla omitted timestamp) first --- arrived. Age is measured on that clock; Tesla's unix ms cannot be --- compared to host.millis(). +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 + +-- 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, - vendor_ts = nil, soc = nil, charge_limit = nil, charging_state = nil, @@ -188,7 +219,7 @@ local function fetch_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, { + local resp, err = safe_http_post(AUTH_URL, body, { ["Content-Type"] = "application/x-www-form-urlencoded", Accept = "application/json", }) @@ -215,13 +246,17 @@ local function ensure_auth() return fetch_token() end -local function bind_identity(next_vin, model) - if next_vin and next_vin ~= "" then - vin = tostring(next_vin) - host.set_sn(vin) - end - if model and model ~= "" and host.set_model then - host.set_model(tostring(model)) +-- 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 @@ -279,17 +314,17 @@ end local function charge_state_from(decoded) local root = unwrap(decoded) - if type(root) ~= "table" then return nil, nil end + if type(root) ~= "table" then return nil end if type(root.charge_state) == "table" then - return root.charge_state, root + return root.charge_state end if root.battery_level ~= nil then - return root, root + return root end - return nil, root + return nil end -local function remember(cs, vendor_ts) +local function remember(cs) local soc = tonumber(cs.battery_level) if soc == nil then return false end last.soc = soc @@ -305,13 +340,16 @@ local function remember(cs, vendor_ts) if ttf_h ~= nil then ttf_min = math.floor(ttf_h * 60 + 0.5) end end last.time_to_full = ttf_min - last.vendor_ts = vendor_ts -- 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 @@ -325,7 +363,7 @@ local function emit_vehicle(fresh) time_to_full_min = last.time_to_full, charge_amps = last.charge_amps, charger_actual_current = last.charger_actual_current, - stale = not fresh, + stale = false, soc_fresh = fresh, }) end @@ -334,13 +372,21 @@ local function fetch_charge_state() local url = base_url .. "/api/1/vehicles/" .. vin .. "/vehicle_data?endpoints=charge_state" local resp, err = safe_http_get(url, auth_headers()) - if err then return nil, nil, err end + if err then return nil, err end local decoded, derr = decode_json(resp) - if derr then return nil, nil, derr end + if derr then return nil, derr end if type(decoded) == "table" and decoded.error then - return nil, nil, tostring(decoded.error) + return nil, tostring(decoded.error) end - return charge_state_from(decoded) + return charge_state_from(decoded), nil +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) @@ -349,37 +395,44 @@ function driver_init(config) client_id = config.client_id client_secret = config.client_secret refresh_token = config.refresh_token - if config.access_token and config.access_token ~= "" then - access_token = config.access_token - token_expires_at = host.millis() + 300000 - end if config.vin and tostring(config.vin) ~= "" then - bind_identity(config.vin, nil) + bind_identity(config.vin) end local region = tostring(config.region or "eu"):lower() - base_url = REGION_URL[region] or REGION_URL.eu - if config.base_url and tostring(config.base_url) ~= "" then - base_url = tostring(config.base_url):gsub("/$", "") - end - if config.auth_url and tostring(config.auth_url) ~= "" then - auth_url = tostring(config.auth_url) + base_url = REGION_URL[region] + if not base_url then + host.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 + host.log("error", "tesla_cloud: client_id and refresh_token required (Fleet API auth-code exchange)") + end host.log("info", "tesla_cloud: init region=" .. region .. " vin=" .. tostring(vin or "(discover)") .. " telemetry-only") end function driver_poll() - if not refresh_token and not access_token then - return POLL_ASLEEP_MS + 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_ASLEEP_MS + return POLL_MS end local row, err = vehicle_row() @@ -389,81 +442,83 @@ function driver_poll() row, err = vehicle_row() end end - if err then + if err or type(row) ~= "table" then host.log("warn", "tesla_cloud: vehicle status: " .. redact_http_err(err)) + wait(ERROR_RETRY_MS) emit_vehicle(false) - return POLL_ASLEEP_MS - end - if type(row) ~= "table" then - emit_vehicle(false) - return POLL_ASLEEP_MS + return POLL_MS end - bind_identity(row.vin, row.display_name) + bind_identity(row.vin) if not vin or vin == "" then host.log("warn", "tesla_cloud: no VIN on account") - return POLL_ASLEEP_MS + wait(ERROR_RETRY_MS) + return POLL_MS end local state = tostring(row.state or ""):lower() - if state ~= "online" then + local online = state == "online" + local woke_just_now = online and was_online == false + was_online = online + if not online then host.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. + host.log("debug", "tesla_cloud: " .. vin .. " just woke — reading at the next check") + wait(ASLEEP_EVERY_MS) emit_vehicle(false) - host.set_poll_interval(POLL_ASLEEP_MS) - return POLL_ASLEEP_MS + return POLL_MS end - local cs, root, ferr = fetch_charge_state() + local cs, 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() + cs, ferr = fetch_charge_state() end end if ferr then local es = tostring(ferr) if es:match("HTTP 408") or es:match("vehicle unavailable") then host.log("debug", "tesla_cloud: vehicle_data unavailable (asleep)") + wait(ASLEEP_EVERY_MS) elseif es:match("HTTP 429") then host.log("warn", "tesla_cloud: rate limited") - emit_vehicle(false) - return 180000 + wait(BACKOFF_MS) else host.log("warn", "tesla_cloud: vehicle_data: " .. redact_http_err(ferr)) + wait(ERROR_RETRY_MS) end emit_vehicle(false) - host.set_poll_interval(POLL_ONLINE_MS) - return POLL_ONLINE_MS + return POLL_MS end - if type(cs) ~= "table" then - host.log("debug", "tesla_cloud: no charge_state") + if type(cs) ~= "table" or not remember(cs) then + host.log("debug", "tesla_cloud: no battery_level in charge_state") + wait(IDLE_EVERY_MS) emit_vehicle(false) - return POLL_ONLINE_MS + return POLL_MS end - if root and (not vin or vin == "") then - bind_identity(root.vin, root.display_name) - end - if root and type(root.vehicle_config) == "table" and root.vehicle_config.car_type then - bind_identity(nil, root.vehicle_config.car_type) - end - - if not remember(cs, tonumber(cs.timestamp)) then - emit_vehicle(false) - return POLL_ONLINE_MS + if is_charging(last.charging_state) then + wait(CHARGING_EVERY_MS) + else + wait(IDLE_EVERY_MS) end host.log("info", "tesla_cloud: emit soc=" .. tostring(last.soc) .. " limit=" .. tostring(last.charge_limit) .. " state=" .. tostring(last.charging_state)) emit_vehicle(true) - host.set_poll_interval(POLL_ONLINE_MS) - return POLL_ONLINE_MS + return POLL_MS end function driver_cleanup() last.seen_ms = 0 - last.vendor_ts = nil last.soc = nil last.charge_limit = nil last.charging_state = nil @@ -472,4 +527,7 @@ function driver_cleanup() last.charger_actual_current = nil access_token = nil token_expires_at = 0 + next_request_ms = 0 + was_online = nil + vin = nil end diff --git a/drivers/tests/lua_harness/test_tesla_cloud.lua b/drivers/tests/lua_harness/test_tesla_cloud.lua index 6897cc0..cfcaf55 100644 --- a/drivers/tests/lua_harness/test_tesla_cloud.lua +++ b/drivers/tests/lua_harness/test_tesla_cloud.lua @@ -3,6 +3,17 @@ 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 EU = "https://fleet-api.prd.eu.vn.cloud.tesla.com" +local STATUS = EU .. "/api/1/vehicles/" .. VIN +local DATA = STATUS .. "/vehicle_data?endpoints=charge_state" + +-- Mirrors the driver's pacing constants. +local POLL_MS = 60000 +local CHARGING_EVERY_MS = 240000 +local IDLE_EVERY_MS = 1200000 +local ASLEEP_EVERY_MS = 300000 +local STALE_AFTER_MS = 1500000 +local ERROR_RETRY_MS = 300000 +local BACKOFF_MS = 900000 local routes = {} local posted = {} @@ -41,29 +52,15 @@ local function route_post(url, body, headers) end return r end - for pattern, resp in pairs(routes) do - if type(pattern) == "string" and url:find(pattern, 1, true) then - if type(resp) == "table" and resp.err then - return nil, resp.err - end - return resp - end - end error("http_post: no mock for " .. tostring(url)) end -host.http_get = function(url) - return route_get(url) -end -host.http_post = function(url, body, headers) - return route_post(url, body, headers) -end - local function vehicle_doc(state) return json({ response = { vin = VIN, state = state, + -- The owner's name for the car. It must never become the model. display_name = "Home", } }) @@ -85,7 +82,6 @@ local function charge_doc(fields) vin = VIN, state = "online", charge_state = cs, - vehicle_config = { car_type = "modely" }, } }) end @@ -107,8 +103,8 @@ local function boot(cfg) host.http_get = function(url) return route_get(url) end host.http_post = function(url, body, headers) return route_post(url, body, headers) end routes[AUTH] = token_doc() - routes[EU .. "/api/1/vehicles/" .. VIN] = vehicle_doc("online") - routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = charge_doc() + routes[STATUS] = vehicle_doc("online") + routes[DATA] = charge_doc() dofile("drivers/lua/tesla_cloud.lua") driver_init(cfg or { client_id = "app-1", @@ -125,6 +121,11 @@ local function last_vehicle() return rows[#rows] end +local function emitted() + local rows = host._emitted.vehicle + return rows and #rows or 0 +end + local function count_gets(needle) local n = 0 for i = 1, #gets do @@ -141,9 +142,24 @@ local function count_posts(needle) return n end +-- The host keeps the interval last set with host.set_poll_interval. +local function last_poll_interval() + local ms = nil + for _, call in ipairs(host._calls) do + if call.func == "set_poll_interval" then ms = call.args[1] end + end + return ms +end + +local function advance(ms) + host._millis_counter = host._millis_counter + ms +end + +-- Online and charging: one read, a fresh DerVehicle, the model from the VIN. boot() assert(host._make == "Tesla", "set_make Tesla") assert(host._sn == VIN, "set_sn from config VIN") +assert(host._model == "Model 3", "model from the VIN, got " .. tostring(host._model)) driver_poll() local sample = last_vehicle() assert(sample, "online car must emit DerVehicle") @@ -155,11 +171,12 @@ assert(sample.charge_amps == 16, "charge_amps") assert(sample.charger_actual_current == 15, "charger_actual_current") assert(sample.stale == false, "fresh emit is not stale") assert(sample.soc_fresh == true, "soc_fresh") -assert(host._model == "Home" or host._model == "modely", "model from vehicle") +assert(host._model == "Model 3", "display_name must not become the model") assert(count_posts("/oauth2/v3/token") == 1, "one token refresh") assert(count_posts("/wake_up") == 0, "must not wake") assert(count_posts("/command/") == 0, "must not command the car") assert(count_gets("/vehicle_data") == 1, "vehicle_data once while online") +assert(last_poll_interval() == POLL_MS, "poll interval after a read") -- Rotated refresh_token is persisted. local persisted = false @@ -170,40 +187,109 @@ for _, call in ipairs(host._calls) do end assert(persisted, "rotated refresh_token not persisted") --- Asleep: status only, no vehicle_data, cache marked stale then dropped. +-- Between reads a poll calls nothing and replays. The replay is not stale: +-- a stale flag would drop the car from Core until the next read. +local gets_before = #gets +driver_poll() +assert(#gets == gets_before, "a poll before the next read is due must not call Tesla") +local replay = last_vehicle() +assert(replay.soc == 67, "replay keeps SoC") +assert(replay.soc_fresh == false, "replay is not a new observation") +assert(replay.stale == false, "replay inside the window must not be stale") + +-- Charging: the next read comes after CHARGING_EVERY_MS, not before. +advance(CHARGING_EVERY_MS - 10000) +driver_poll() +assert(count_gets("/vehicle_data") == 1, "no read before the charging interval") +advance(20000) +driver_poll() +assert(count_gets("/vehicle_data") == 2, "read again once the charging interval has passed") +assert(last_vehicle().soc_fresh == true, "a new read is fresh") + +-- Awake but not charging: leave the car IDLE_EVERY_MS to fall asleep. +boot() +routes[DATA] = charge_doc({ state = "Stopped" }) +driver_poll() +assert(count_gets("/vehicle_data") == 1, "idle: first read") +advance(CHARGING_EVERY_MS + 1000) +driver_poll() +assert(count_gets("/vehicle_data") == 1, "an idle car is not read every few minutes") +advance(IDLE_EVERY_MS) +driver_poll() +assert(count_gets("/vehicle_data") == 2, "idle car read again after IDLE_EVERY_MS") + +-- Asleep: a state check only, no vehicle_data; replay while young, then stop. boot() driver_poll() assert(last_vehicle() and last_vehicle().soc_fresh == true, "prime cache") -local after_fresh = #(host._emitted.vehicle) +routes[STATUS] = vehicle_doc("asleep") +routes[DATA] = { err = "should not fetch" } gets = {} -routes[EU .. "/api/1/vehicles/" .. VIN] = vehicle_doc("asleep") -routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = { err = "should not fetch" } +advance(CHARGING_EVERY_MS + 1000) +local before = emitted() driver_poll() +assert(count_gets("/api/1/vehicles/" .. VIN) == 1, "asleep: one state check") assert(count_gets("/vehicle_data") == 0, "asleep car must not call vehicle_data") -assert(#(host._emitted.vehicle) == after_fresh + 1, "replay cache while young") -assert(last_vehicle().stale == true, "replay is stale") +assert(emitted() == before + 1, "replay cache while young") assert(last_vehicle().soc_fresh == false, "replay is not fresh") +assert(last_vehicle().stale == false, "young replay is not stale") assert(last_vehicle().soc == 67, "replay keeps SoC") - -host._millis_counter = host._millis_counter + 900001 -local before_stale = #(host._emitted.vehicle) +gets = {} +advance(ASLEEP_EVERY_MS - 10000) +driver_poll() +assert(#gets == 0, "asleep: no request before the next state check") +advance(20000) +before = emitted() driver_poll() -assert(#(host._emitted.vehicle) == before_stale, "stop emitting when stale") +assert(count_gets("/api/1/vehicles/" .. VIN) == 1, "asleep: state checked again") +assert(emitted() == before + 1, "still young: replay") +advance(STALE_AFTER_MS) +before = emitted() +driver_poll() +assert(emitted() == before, "stop emitting when stale") +assert(count_gets("/vehicle_data") == 0, "an asleep car is never read") assert(count_posts("/wake_up") == 0, "asleep poll must not wake") +-- A car that just woke is not read on that check: its own short wakes end +-- by themselves, and a live call would stretch them. +boot() +routes[STATUS] = vehicle_doc("asleep") +driver_poll() +assert(count_gets("/vehicle_data") == 0, "asleep at start") +routes[STATUS] = vehicle_doc("online") +advance(ASLEEP_EVERY_MS + 1000) +driver_poll() +assert(count_gets("/vehicle_data") == 0, "first check after waking does not read") +advance(ASLEEP_EVERY_MS + 1000) +driver_poll() +assert(count_gets("/vehicle_data") == 1, "still awake at the next check: read") +assert(last_vehicle() and last_vehicle().soc_fresh == true, "read after waking is fresh") + -- 408 on vehicle_data does not invent a SoC. boot() -routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = { err = "HTTP 408 vehicle unavailable" } +routes[DATA] = { err = "HTTP 408 vehicle unavailable" } +driver_poll() +assert(emitted() == 0, "408 invented telemetry") + +-- 429 backs off longer than a plain error. +boot() +routes[DATA] = { err = "HTTP 429 too many requests" } +driver_poll() +assert(count_gets("/vehicle_data") == 1, "429: first read") +advance(ERROR_RETRY_MS + 1000) +driver_poll() +assert(count_gets("/vehicle_data") == 1, "429: no read inside the back-off") +advance(BACKOFF_MS) driver_poll() -assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0, "408 invented telemetry") +assert(count_gets("/vehicle_data") == 2, "429: read after the back-off") -- Missing battery_level is not a zero SoC. boot() -routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = json({ +routes[DATA] = json({ response = { vin = VIN, charge_state = { charging_state = "Stopped" } } }) driver_poll() -assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0, "empty charge_state invented SoC") +assert(emitted() == 0, "empty charge_state invented SoC") -- Discover VIN from the account list. boot({ @@ -217,10 +303,9 @@ routes[EU .. "/api/1/vehicles"] = json({ { vin = VIN, state = "online", display_name = "Home" }, } }) --- Status by VIN is unknown until discovered; list is the first lookup. -routes[EU .. "/api/1/vehicles/"] = nil driver_poll() assert(host._sn == VIN, "discovered VIN") +assert(host._model == "Model 3", "model from the discovered VIN") assert(last_vehicle() and last_vehicle().soc == 67, "emit after discover") -- Region NA builds the North America Fleet URL. @@ -241,7 +326,7 @@ end -- time_to_full_charge hours → minutes. boot() -routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state"] = json({ +routes[DATA] = json({ response = { vin = VIN, charge_state = { @@ -255,11 +340,49 @@ routes[EU .. "/api/1/vehicles/" .. VIN .. "/vehicle_data?endpoints=charge_state" driver_poll() assert(last_vehicle().time_to_full_min == 90, "hours converted to minutes") --- Failed auth does not POST wake or charge_start. -boot({ client_id = "app-1", vin = VIN, region = "eu" }) +-- Config cannot send the secrets or requests to another host. +boot({ + client_id = "app-1", + client_secret = "secret-1", + refresh_token = "refresh-1", + vin = VIN, + region = "eu", + base_url = "https://attacker.example", + auth_url = "https://attacker.example/oauth2/v3/token", + access_token = "planted", +}) +driver_poll() +assert(#posted == 1 and posted[1].url == AUTH, "token POST must go to Tesla's auth host") +for i = 1, #gets do + assert(gets[i]:find(EU, 1, true) == 1, "GET went to " .. gets[i]) +end +assert(last_vehicle() and last_vehicle().soc_fresh == true, "reads through Tesla's hosts") + +-- A refresh token Tesla rejects: one POST, then back off. The poll interval +-- must not stay at the 500 ms startup value, or the driver would post to +-- Tesla's auth host twice a second. +boot() +routes[AUTH] = { err = "HTTP 400: invalid_grant" } +driver_poll() +assert(count_posts("/oauth2/v3/token") == 1, "one refresh attempt") +assert(last_poll_interval() == POLL_MS, "failed refresh left the startup poll interval") +for _ = 1, 5 do + advance(POLL_MS) + driver_poll() +end +assert(count_posts("/oauth2/v3/token") == 1, "no second refresh inside the back-off") +advance(BACKOFF_MS) driver_poll() +assert(count_posts("/oauth2/v3/token") == 2, "retry after the back-off") +assert(emitted() == 0, "failed auth invented telemetry") assert(count_posts("/wake_up") == 0) assert(count_posts("/charge_start") == 0) -assert(host._emitted.vehicle == nil or #(host._emitted.vehicle) == 0) + +-- No refresh token: no POST, no emit, and the poll interval is still set. +boot({ client_id = "app-1", vin = VIN, region = "eu" }) +driver_poll() +assert(count_posts("/oauth2/v3/token") == 0, "no refresh without a token") +assert(last_poll_interval() == POLL_MS, "missing token left the startup poll interval") +assert(emitted() == 0) print("OK tesla_cloud") diff --git a/index.yaml b/index.yaml index b056657..bc5db5a 100644 --- a/index.yaml +++ b/index.yaml @@ -706,8 +706,8 @@ drivers: setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 15518 - sha256: "a8d3d6c4f8090e6df4679520008487ca7359b7da6864e4a59610657cf270ad75" + size_bytes: 17903 + sha256: "4f9a43703e787a5e72f7a86964bc21f89ade869e88fe2213e1931c55c241f34c" - name: "tesla_vehicle" version: "0.2.3" tier: core diff --git a/manifests/tesla_cloud.yaml b/manifests/tesla_cloud.yaml index 4ea8e1f..79aa9b3 100644 --- a/manifests/tesla_cloud.yaml +++ b/manifests/tesla_cloud.yaml @@ -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." + 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." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 15518 +size_bytes: 17903 dkb_id: "tesla_cloud" -sha256: "a8d3d6c4f8090e6df4679520008487ca7359b7da6864e4a59610657cf270ad75" +sha256: "4f9a43703e787a5e72f7a86964bc21f89ade869e88fe2213e1931c55c241f34c" signature: "" bytecode_sha256: ""