diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e90fd..ce3bca1 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 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. + ## teslamate_vehicle 0.1.0 Read-only Tesla vehicle telemetry from TeslaMate over MQTT: SoC, charge limit, charging state and time to full. Identity is Tesla plus the VIN from YAML, since TeslaMate does not publish the VIN. A reading is fresh only when TeslaMate reports the car awake and has just read it: a changed charge field, or the `healthy` message it sends with every update. In between, the driver replays the last reading with `soc_fresh=false`, and it stops after 15 minutes. It never publishes, wakes the car or starts a charge, and it talks only to the owner's MQTT broker, not to Tesla's cloud or Home Assistant. Optional next to `tesla_vehicle` (TeslaBLEProxy). Not yet run against a car. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index b3393fc..a5e3ca2 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 5da74ef..4dbf261 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. Not yet run against a car." - name: "Tesla Vehicle (TeslaMate)" variants: [Model 3, Model Y] regions: [] diff --git a/drivers/lua/tesla_cloud.lua b/drivers/lua/tesla_cloud.lua new file mode 100644 index 0000000..81ec566 --- /dev/null +++ b/drivers/lua/tesla_cloud.lua @@ -0,0 +1,533 @@ +-- 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. +-- +-- 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" }, +} + +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 + +-- 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, +} + +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 + +-- 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 remember(cs) + 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 + -- 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 = 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 = false, + 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, err end + local decoded, derr = decode_json(resp) + if derr then return nil, derr end + if type(decoded) == "table" and decoded.error then + return nil, tostring(decoded.error) + end + 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) + 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 + 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() + 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 + host.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 + host.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 + 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) + return POLL_MS + end + + local cs, ferr = fetch_charge_state() + if ferr and tostring(ferr):match("HTTP 401") then + token_expires_at = 0 + if ensure_auth() then + cs, ferr = fetch_charge_state() + 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") + wait(BACKOFF_MS) + else + host.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) then + host.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 + host.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 + 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 new file mode 100644 index 0000000..cfcaf55 --- /dev/null +++ b/drivers/tests/lua_harness/test_tesla_cloud.lua @@ -0,0 +1,388 @@ +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 = {} +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 + error("http_post: no mock for " .. tostring(url)) +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", + } + }) +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, + } + }) +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[STATUS] = vehicle_doc("online") + routes[DATA] = 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 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 + 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 + +-- 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") +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 == "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 +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") + +-- 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") +routes[STATUS] = vehicle_doc("asleep") +routes[DATA] = { err = "should not fetch" } +gets = {} +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(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") +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(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[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(count_gets("/vehicle_data") == 2, "429: read after the back-off") + +-- Missing battery_level is not a zero SoC. +boot() +routes[DATA] = json({ + response = { vin = VIN, charge_state = { charging_state = "Stopped" } } +}) +driver_poll() +assert(emitted() == 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" }, + } +}) +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. +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[DATA] = 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") + +-- 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) + +-- 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/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 e71f4f8..47d1746 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: 17903 + sha256: "4f9a43703e787a5e72f7a86964bc21f89ade869e88fe2213e1931c55c241f34c" - 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..79aa9b3 --- /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. Not yet run against a car." + min_driver_version: "0.1.0" +min_host_version: "2.0.0" +size_bytes: 17903 +dkb_id: "tesla_cloud" +sha256: "4f9a43703e787a5e72f7a86964bc21f89ade869e88fe2213e1931c55c241f34c" +signature: "" + +bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index dda28cf..43a9dcd 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", 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(