From 83b73cedcc83ddbec6eb7e380ef405fd15c09f95 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 6 Oct 2026 11:01:36 +0200 Subject: [PATCH 1/2] fix(tesla_vehicle): recover SoC with bounded telemetry wakes Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 7 + SUPPORT_STATUS.md | 4 +- devices.yaml | 4 +- drivers/lua/tesla_vehicle.lua | 627 +++++------------- .../tests/lua_harness/test_tesla_vehicle.lua | 151 +++++ drivers/tests/test_tesla_vehicle.py | 12 + index.yaml | 6 +- manifests/tesla_vehicle.yaml | 18 +- spec/host-api-profile.json | 4 +- support-status.json | 2 +- 10 files changed, 350 insertions(+), 485 deletions(-) create mode 100644 drivers/tests/lua_harness/test_tesla_vehicle.lua create mode 100644 drivers/tests/test_tesla_vehicle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b358dd3..15884dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## tesla_vehicle 0.2.5 + +- Recover missing, partial and old SoC with a telemetry-only wake and read. +- Keep the BMS timestamp; cached successful responses do not renew SoC age. +- Use Core's saved wake budget across restarts: at least 90 seconds between attempts, at most three in 30 minutes. Keep 408/503 backoff. +- Hosts without the new wake budget and wall-clock functions do not run recovery. Physical Tesla BLE acceptance remains untested. + ## heishamon 0.8.0 Read `hp_power_w` from `main/Heat_Power_Consumption` (TOP16), with a diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 40e329d..bc95e00 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -160,8 +160,8 @@ Catalog source is not proof that a target can install or run a driver. | sungrow | 1.5.10 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| tesla_vehicle | 0.2.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| tesla_vehicle | 0.2.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_wall_connector | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_wall_connector | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | teslamate_vehicle | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 592c11c..95cdd73 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1806,11 +1806,11 @@ manufacturers: protocols: - protocol: http driver: "tesla_vehicle" - version: "0.2.4" + version: "0.2.5" ders: [vehicle] control: true firmware_versions: "" - notes: "Read-only vehicle SoC + charge limit via Tesla API-compatible HTTP endpoint (e.g. TeslaBLEProxy)." + notes: "Vehicle SoC and charge limit via a local BLE proxy. Telemetry recovery needs a host with unix_ms and reserve_vehicle_wake. No hardware recovery test yet." - name: "Tesla Vehicle (Fleet API)" variants: [Model 3, Model Y] regions: [CN, EU, NA] diff --git a/drivers/lua/tesla_vehicle.lua b/drivers/lua/tesla_vehicle.lua index 8e44253..17e354c 100644 --- a/drivers/lua/tesla_vehicle.lua +++ b/drivers/lua/tesla_vehicle.lua @@ -1,27 +1,9 @@ --- Tesla Vehicle Driver (read-only, via TeslaBLEProxy on local LAN) --- Emits: Vehicle (DerVehicle) --- Protocol: HTTP (Tesla Owner API shape — /api/1/vehicles/{VIN}/vehicle_data) --- --- Fetches the vehicle's own SoC and charge_limit so FTW can --- show the real "24 / 50 %" in the EV bubble and let the loadpoint --- manager prefer the truth over its delivered-Wh inference. Designed --- to talk to TeslaBLEProxy running on the same LAN --- (https://github.com/wimaha/TeslaBleHttpProxy), which translates --- HTTP/JSON to BLE under the hood. No Tesla cloud credentials, no --- OAuth token, no internet round-trip — the proxy IS the key to the --- vehicle. --- --- Config: two fields, that's it. --- --- drivers: --- - name: tesla-garage --- lua: drivers/tesla_vehicle.lua --- capabilities: --- http: --- allowed_hosts: ["192.168.1.50"] # IP of the proxy --- config: --- ip: "192.168.1.50" --- vin: "5YJ3E1EA1KF000000" +-- Tesla vehicle telemetry through TeslaBleHttpProxy on the local LAN. +-- Config: ip (proxy address, optionally host:port), vin (paired vehicle). +-- Source timestamp: the proxy converts the BMS timestamp to Unix seconds. +-- Recovery needs Core's unix_ms and reserve_vehicle_wake host functions. +-- Older hosts keep reading but cannot automatically wake or trust source age. +-- https://github.com/wimaha/TeslaBleHttpProxy DRIVER = { host_api_min = 1, @@ -29,10 +11,11 @@ DRIVER = { id = "tesla_vehicle", name = "Tesla Vehicle (BLE Proxy)", manufacturer = "Tesla", - version = "0.2.4", + version = "0.2.5", protocols = { "http" }, capabilities = { "vehicle" }, - description = "Read-only vehicle SoC + charge limit via Tesla API-compatible HTTP endpoint (e.g. TeslaBLEProxy).", + description = "Vehicle SoC and charge limit via a local Tesla BLE proxy, with bounded telemetry wake and charge-start support.", + telemetry_wake = true, homepage = "https://github.com/wimaha/TeslaBleHttpProxy", authors = { "FTW contributors" }, tested_models = { "Model Y", "Model 3" }, @@ -41,466 +24,179 @@ DRIVER = { PROTOCOL = "http" --- Runtime state. base_url is built from the `ip` config field. --- TeslaBLEProxy listens on :8080 by default — hardcoded since that's --- what the project ships. Poll + staleness are tuned in-driver; --- operators touch only `ip` + `vin` in YAML. -local PROXY_PORT = 8080 -local POLL_INTERVAL_MS = 60000 --- Faster cadence while the car is actively charging — the loadpoint --- controller needs sub-watchdog-timeout resolution on amps + state to --- avoid spurious "EV stopped" cascades. At the steady 60 s poll cadence --- + HTTP RTT, every poll lands just past the 60 s site watchdog and --- the driver gets repeatedly flagged stale, which spams wallbox-cycle --- pause/resume and wake-kicks on the loadpoint side. +local PROXY_PORT = 8080 +local POLL_INTERVAL_MS = 60000 local POLL_INTERVAL_CHARGING_MS = 30000 +local SOURCE_MAX_AGE_MS = 300000 +local BUSY_BACKOFF_MS = 180000 +local READ_AFTER_WAKE_MS = 5000 +local base_url, vin +local wake_read_pending = false +local recovery_not_before_ms = 0 +local last = { ts_ms = 0 } --- Per-driver watchdog tolerance, registered with the host at init via --- host.set_watchdog_timeout_s. Vehicle telemetry runs on a slower --- natural cadence than mains drivers (BLE wake + cloud RTT) so 60 s --- (the site default) is too tight and produces frequent stale flaps --- that cascade into loadpoint wake-kick / wallbox-cycle spam. 5 min --- gives plenty of headroom for occasional BLE wake-up retries while --- still alerting on a truly-hung proxy. -local WATCHDOG_TIMEOUT_S = 300 - --- While Charging, if no fresh telemetry has flowed for this long the --- driver forces a BLE wake on the next poll regardless of the normal --- 30/60 min wake cadence. Catches the case where the proxy returned --- cached "Charging" once and then the car went silent — without the --- forced wake we'd happily keep emitting that stale Charging reading --- and the loadpoint controller would never see the real state. Only --- fires while last.charging_state == "Charging"; parked cars don't --- need active recovery (and shouldn't have their 12 V battery --- drained by speculative wakes). -local CHARGING_FORCE_WAKE_AFTER_MS = 150000 -- 2.5 min -local STALE_AFTER_MS = 900000 --- Every WAKEUP_INTERVAL_MS we attach `wakeup=true` to ONE poll so the --- proxy forces a BLE wake. Without this the proxy serves cached data --- indefinitely while the car sleeps and our SoC slowly drifts from --- reality. --- --- Two cadences: the conservative IDLE one (30 min) protects the 12 V --- battery from constant BLE wakes when the car is parked + asleep; --- the tighter CHARGING one (15 min) keeps SoC and charge_limit_pct --- fresh while the car is on the wall so the MPC's planning targets --- track the operator's in-app settings without 30-minute lag. The --- car is already drawing AC power during a session, so the BLE-wake --- battery-drain concern doesn't apply. -local WAKEUP_INTERVAL_MS = 1800000 -- 30 min, idle -local WAKEUP_INTERVAL_CHARGING_MS = 900000 -- 15 min, while charging - --- When a non-wake poll fails (timeout, connection refused, anything --- that isn't the proxy explicitly saying "BLE busy"), the most likely --- cause is "car asleep and proxy gave up". Don't wait for the 30-min --- periodic wake — arm a wake-retry on the very next poll. The retry --- only fires if we haven't already attempted a wake within the recent --- gap, so a flapping proxy can't trigger a wake storm. -local FAILURE_RETRY_INTERVAL_MS = 5000 -- schedule the retry this soon -local WAKE_RETRY_MIN_GAP_MS = 10000 -- don't wake more than once per ~10 s - -local base_url = nil -local vin = nil -local last_wakeup_ms = 0 -local last_wake_attempt_ms = 0 -- includes both periodic + retry wakes -local pending_wake_retry = false -- set by failed poll, consumed by next poll - --- Logs get pasted into public issues, and a VIN identifies the owner's --- car. Every log line therefore shows only the VIN's last four characters. local function log(level, message) message = tostring(message) if type(vin) == "string" and #vin > 4 then - local escaped = vin:gsub("%p", "%%%0") - message = message:gsub(escaped, "****" .. vin:sub(-4)) + message = message:gsub(vin:gsub("%p", "%%%0"), "****" .. vin:sub(-4)) end host.log(level, message) end --- Cached last-known reading so we can keep publishing a value while --- the vehicle is asleep. Tesla returns 408 "vehicle unavailable" when --- the car is in deep sleep; we treat that as "use last-known" until --- STALE_AFTER_MS elapses. -local last = { - ts_ms = 0, - soc = nil, - charge_limit = nil, - charging_state = nil, - time_to_full = nil, - charge_amps = nil, - charger_actual_current = nil, -} - -local function auth_headers() - -- TeslaBLEProxy on the LAN doesn't require a bearer token; it - -- authenticates against the car via BLE itself. Plain JSON Accept - -- header is all we need. - return { ["Accept"] = "application/json" } +local function headers() return { Accept = "application/json" } end +local function get(url) + local ok, body, err = pcall(host.http_get, url, headers()) + if not ok then return nil, "request failed" end + return body, err end - -local function safe_http_err(err) - if err == nil then return "ok" end - return tostring(err):match("^(HTTP %d+)") or "request failed" +local function post(url) + local ok, body, err = pcall(host.http_post, url, "{}", headers()) + if not ok then return nil, "request failed" end + return body, err 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 +local function decode(body) + if type(body) ~= "string" or body == "" then return nil end + local ok, data = pcall(host.json_decode, body) + if ok and type(data) == "table" then return data end 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 +local function finite(value) + return type(value) == "number" and value == value and value ~= math.huge and value ~= -math.huge +end +local function wall_ms() + if not host.unix_ms then return nil end + return host.unix_ms() +end +local function source_ms(value) + value = tonumber(value) + if not finite(value) or value <= 0 then return nil end + -- Current BLE proxy uses seconds; Tesla Owner API variants use milliseconds. + if value < 100000000000 then value = value * 1000.0 end + return math.floor(value) +end +local function set_interval(ms) + host.set_poll_interval(ms) + return ms +end +local function busy(err) + local es = tostring(err) + return es:match("HTTP 408") or es:match("HTTP 503") or es:match("Command Disallowed") +end +local function emit_last(fresh) + if last.soc == nil then return end + local now = wall_ms() + local age = now and last.ts_ms > 0 and math.max(0, now - last.ts_ms) or nil + if age and host.emit_metric then host.emit_metric("vehicle_soc_age_s", age / 1000, "s") end + host.emit("vehicle", { + soc = last.soc, + charge_limit_pct = last.limit, + charging_state = last.state, + time_to_full_min = last.ttf, + charge_amps = last.amps, + charger_actual_current = last.actual, + soc_observed_at_ms = last.ts_ms > 0 and last.ts_ms or nil, + soc_fresh = fresh == true, + stale = not age or age > SOURCE_MAX_AGE_MS, + }) end -local function safe_json_decode(body) - if body == nil then return nil end - local ok, data = pcall(host.json_decode, body) - if not ok then return nil, tostring(data) end - return data, nil +local function wake_vehicle() + local now = host.millis() + if now < recovery_not_before_ms then return false end + if not host.reserve_vehicle_wake then + log("warn", "tesla: telemetry wake needs a host with a durable wake budget") + recovery_not_before_ms = now + 1800000 + return false + end + local allowed, retry_ms, err = host.reserve_vehicle_wake() + if not allowed then + recovery_not_before_ms = now + math.max(1000, tonumber(retry_ms) or 1800000) + if err then log("warn", "tesla: telemetry wake budget unavailable") end + return false + end + -- Save-before-send happens in the host. Even a failed request consumes budget. + recovery_not_before_ms = now + math.max(1000, tonumber(retry_ms) or 90000) + -- Dedicated POST cannot be bypassed by vehicle_data's cache-hit path. + local body, request_err = post(base_url .. "/api/1/vehicles/" .. vin .. "/command/wake_up") + if request_err then + if busy(request_err) then recovery_not_before_ms = now + BUSY_BACKOFF_MS end + log("warn", "tesla: telemetry wake request failed") + return false + end + local reply = decode(body) + if not reply or not (reply.result == true or (type(reply.response) == "table" and reply.response.result == true)) then + log("warn", "tesla: telemetry wake was not accepted") + return false + end + wake_read_pending = true + set_interval(READ_AFTER_WAKE_MS) + log("info", "tesla: telemetry wake accepted; waiting for a new BMS observation") + return true end function driver_init(config) - if not config then - log("error", "tesla: config required (ip + vin)") - return - end + config = config or {} + vin = type(config.vin) == "string" and config.vin:match("^%s*(.-)%s*$"):upper() or nil local ip = config.ip - vin = config.vin - - if not ip or ip == "" then - log("error", "tesla: `ip` required (LAN address of TeslaBLEProxy)") - return - end - if not vin or vin == "" then - log("error", "tesla: `vin` required (vehicle VIN the proxy is paired to)") + if type(vin) ~= "string" or vin == "" or type(ip) ~= "string" or ip == "" then + log("error", "tesla: ip and vin required") return end - - -- Accept bare IP (uses PROXY_PORT default) or host:port. We split - -- on the LAST colon so IPv6-in-brackets-plus-port works too, though - -- the typical config is "192.168.1.50" or "192.168.1.50:1234". - local host_part, port_part = ip:match("^(.*):(%d+)$") - if host_part and port_part then - base_url = "http://" .. host_part .. ":" .. port_part - else - base_url = "http://" .. ip .. ":" .. tostring(PROXY_PORT) - end - + local address, port = ip:match("^(.*):(%d+)$") + base_url = "http://" .. (address or ip) .. ":" .. (port or tostring(PROXY_PORT)) host.set_make("Tesla") - host.set_sn(tostring(vin)) - -- Loosen the watchdog so the loadpoint controller doesn't see brief - -- BLE-wake stalls as "driver offline → revert to autonomous". - if host.set_watchdog_timeout_s then - host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) - end - -- Two-phase poll cadence: - -- 1. Init pumps the interval down to 500 ms so the registry's - -- initial timer fires almost immediately. The first poll - -- thus runs ≤ 1 s after startup / restart / hot-reload — - -- no 60-second blank window where the dashboard says - -- "no vehicle data". - -- 2. driver_poll bumps the interval back up to POLL_INTERVAL_MS - -- (60 s) before returning, so steady-state polling is - -- conservative on BLE wake-ups + Tesla cloud rate. - -- The registry re-reads PollInterval() on every iteration, so the - -- mid-flight change takes effect immediately. - host.set_poll_interval(500) - log("info", "tesla: driver initialized vin=" .. tostring(vin) .. - " proxy=" .. base_url .. - " poll_s=" .. tostring(POLL_INTERVAL_MS / 1000) .. - " (first poll within 1s)") -end - --- emit_last sends the cached reading with a stale flag computed from --- the cached timestamp. Called when the HTTP poll fails or the --- vehicle is asleep, so the UI keeps showing a number instead of a --- blank. -local function emit_last() - if last.soc == nil then - return - end - local age = host.millis() - last.ts_ms - local stale = age > STALE_AFTER_MS - 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 = stale, - -- Cached replay is not a fresh observation. FTW treats a missing - -- soc_fresh as fresh, so mark it false whenever we re-emit last. - soc_fresh = false, - }) + host.set_sn(vin) + if host.set_watchdog_timeout_s then host.set_watchdog_timeout_s(300) end + set_interval(500) end function driver_poll() - if not base_url or not vin then - return 10000 - end - - -- Bump the steady-state interval back up after the (deliberately - -- short) init interval that gets us our first reading inside a - -- second of startup. Idempotent — running set_poll_interval with the - -- same value every poll is a no-op cost-wise. - -- - -- Cadence depends on whether the car was Charging at the last - -- successful poll: 30 s while charging (keeps us under the 60 s - -- watchdog so the loadpoint controller doesn't see spurious stale - -- flags), 60 s otherwise (parked / disconnected — no urgency). - local steady_ms = POLL_INTERVAL_MS - if last.charging_state == "Charging" then - steady_ms = POLL_INTERVAL_CHARGING_MS - end - host.set_poll_interval(steady_ms) - - -- endpoints=charge_state narrows the response to just what we - -- care about (SoC + limit + charging_state + time_to_full). - -- - -- wakeup=true is OFF on the steady-state poll. The proxy returns - -- cached data from its own background sync (typically ≤ 5 s - -- fresh, fine for our 60 s poll cadence + pokes). Forcing a BLE - -- wake on every poll caused HTTP 503 "Command Disallowed" storms - -- when the driver was poked alongside regular polls. - -- - -- BUT once every WAKEUP_INTERVAL_MS (30 min) we DO request a - -- wakeup so cached data can't drift forever while the car sleeps. - -- The cadence is anchored to driver-process uptime, so the FIRST - -- poll after startup intentionally does NOT force a wakeup. Reason: - -- a crash-loop (the host wedges, restarts every few seconds) would - -- otherwise hammer Tesla's BLE radio at multiple wakeups per minute, - -- draining the 12 V battery and likely hitting Tesla's "Command - -- Disallowed" rate limit. The tradeoff is that immediately after a - -- restart the dashboard may show up to 30 min of stale SoC; the - -- operator can press "Verify connection" in settings to force a - -- one-shot wake, or wait for the next scheduled 30-min wake. - local now = host.millis() - -- Cadence depends on whether the last-known state was Charging — see - -- WAKEUP_INTERVAL_CHARGING_MS comment above. "Charging" comes from - -- the Tesla payload's charge_state.charging_state field; anything - -- else (Stopped / Complete / Disconnected / unknown) uses the - -- conservative 30-min cadence. - local wake_cadence_ms = (last.charging_state == "Charging") - and WAKEUP_INTERVAL_CHARGING_MS or WAKEUP_INTERVAL_MS - local do_wakeup = false - if pending_wake_retry and ((now - last_wake_attempt_ms) >= WAKE_RETRY_MIN_GAP_MS) then - -- Previous poll failed; we armed a retry. Consume it. - do_wakeup = true - pending_wake_retry = false - log("info", "tesla: wake-retry firing after previous poll failure") - elseif (last_wakeup_ms > 0) and ((now - last_wakeup_ms) >= wake_cadence_ms) then - do_wakeup = true - elseif last.charging_state == "Charging" and last.ts_ms > 0 and - ((now - last.ts_ms) >= CHARGING_FORCE_WAKE_AFTER_MS) and - ((now - last_wake_attempt_ms) >= WAKE_RETRY_MIN_GAP_MS) then - -- Stale-while-charging recovery. The car was last seen Charging - -- but no fresh telemetry has flowed for >2.5 min — likely the - -- proxy returned cached data and the car went BLE-silent. Force - -- a wake on this poll rather than continuing to emit_last() the - -- stale "Charging" reading. Gated to Charging only so a parked - -- car doesn't get its 12 V drained by speculative wakes. - do_wakeup = true - log("info", "tesla: stale-while-charging force-wake (" .. - tostring(math.floor((now - last.ts_ms) / 1000)) .. - "s since last emit)") - end - if last_wakeup_ms == 0 then - -- Anchor the periodic cadence at "now" so the first FORCED wake - -- fires exactly wake_cadence_ms after startup, not on first poll. - last_wakeup_ms = now - end - local url = base_url .. "/api/1/vehicles/" .. vin .. - "/vehicle_data?endpoints=charge_state" - if do_wakeup then - url = url .. "&wakeup=true" - last_wakeup_ms = now - last_wake_attempt_ms = now - log("info", "tesla: forcing BLE wakeup on this poll" .. - " (cadence=" .. tostring(wake_cadence_ms / 60000) .. "min" .. - " charging=" .. tostring(last.charging_state == "Charging") .. ")") - end - -- host.http_get returns (body_string, nil) or (nil, error_string) — - -- first return is the body directly, NOT a table with .body. The - -- earlier tesla_vehicle iterations treated it as a table and got - -- length 0 on every poll, which silently emit_last()'d the driver. - local body, err = safe_http_get(url, auth_headers()) - if err ~= nil then - -- 503 "Command Disallowed" or 408 timeouts mean the proxy's - -- BLE radio is busy (usually because we just poked or the car - -- just started charging and the radio negotiation hasn't - -- cleared). Back off to a longer interval rather than - -- continuing to hammer at the steady-state rate — every poll - -- in this state piles onto the rate-limit and prolongs it. - -- Recovery is automatic when the next emit succeeds: - -- driver_poll resets to POLL_INTERVAL_MS at the top of the - -- next call. Don't arm a wake-retry for these — the radio is - -- already busy; adding another wake won't help. - local es = tostring(err) - if es:match("HTTP 503") or es:match("HTTP 408") or es:match("Command Disallowed") then - log("debug", "tesla: proxy busy (BLE busy) — backing off 3 min") - emit_last() - return 180000 -- 3 min - end - -- Any other error (most commonly the host's 15 s HTTP timeout - -- when the car is asleep and the proxy's read of charge_state - -- never returns) — the car is probably asleep and the next - -- ordinary poll would just time out again. Arm a wake-retry - -- so the next poll attaches `&wakeup=true` and forces the - -- proxy to BLE-wake before reading. We don't arm if THIS poll - -- already woke (no point retrying with another wake — that's - -- the case the proxy is genuinely failing) — fall back to the - -- normal interval and let the periodic cadence catch up. - log("warn", "tesla: poll HTTP error: " .. es) - emit_last() - if not do_wakeup then - pending_wake_retry = true - log("info", "tesla: wake-retry armed (next poll will force BLE wake)") - return FAILURE_RETRY_INTERVAL_MS - end - return steady_ms - end - if not body or body == "" then - log("warn", "tesla: empty body from proxy") - emit_last() - return steady_ms - end - - local decoded, derr = safe_json_decode(body) - if derr or not decoded then - log("warn", "tesla: json decode failed: " .. tostring(derr)) - emit_last() - return steady_ms - end - - -- Response shapes (in the wild): - -- 1. TeslaBLEProxy double-wrapped: - -- { response = { response = { charge_state = {...} } } } - -- 2. Tesla Owner API envelope: - -- { response = { charge_state = {...} } } - -- 3. Bare: { charge_state = {...} } - local charge_state = nil - if type(decoded) == "table" then - if type(decoded.response) == "table" then - if type(decoded.response.response) == "table" and - decoded.response.response.charge_state then - charge_state = decoded.response.response.charge_state - elseif decoded.response.charge_state then - charge_state = decoded.response.charge_state - end + if not base_url then return set_interval(10000) end + local steady = last.state == "Charging" and POLL_INTERVAL_CHARGING_MS or POLL_INTERVAL_MS + wake_read_pending = false + local body, err = get(base_url .. "/api/1/vehicles/" .. vin .. "/vehicle_data?endpoints=charge_state") + if err and busy(err) then + emit_last(false) + recovery_not_before_ms = math.max(recovery_not_before_ms, host.millis() + BUSY_BACKOFF_MS) + return set_interval(BUSY_BACKOFF_MS) + end + local data = decode(body) + local cs = data and (data.charge_state or + (type(data.response) == "table" and (data.response.charge_state or + (type(data.response.response) == "table" and data.response.response.charge_state)))) + local soc = type(cs) == "table" and tonumber(cs.battery_level) or nil + local observed = type(cs) == "table" and source_ms(cs.timestamp) or nil + local now = wall_ms() + local usable = not err and finite(soc) and soc >= 0 and soc <= 100 and + observed and now and observed <= now + if usable then + local fresh = observed > last.ts_ms + if fresh then + local ttf = tonumber(cs.minutes_to_full_charge) + if not ttf and tonumber(cs.time_to_full_charge) then ttf = tonumber(cs.time_to_full_charge) * 60 end + last = { ts_ms = observed, soc = soc, limit = tonumber(cs.charge_limit_soc), + state = type(cs.charging_state) == "string" and cs.charging_state or nil, + ttf = ttf, amps = tonumber(cs.charge_amps), actual = tonumber(cs.charger_actual_current) } end - if not charge_state and decoded.charge_state then - charge_state = decoded.charge_state - end - end - if not charge_state then - log("debug", "tesla: no charge_state in response") - emit_last() - return steady_ms - end - - local soc = tonumber(charge_state.battery_level) - local limit = tonumber(charge_state.charge_limit_soc) - -- Per-vehicle in-app current limit. The car negotiates DOWN to - -- this amperage regardless of what the wallbox offers; surface it - -- so operators can see "wallbox commands 16A but car capped to 5A" - -- mismatches without digging into the raw proxy response. - local charge_amps = tonumber(charge_state.charge_amps) - local charger_actual_current = tonumber(charge_state.charger_actual_current) - -- Field name depends on source: - -- - TeslaBLEProxy emits `minutes_to_full_charge` (integer minutes). - -- - Tesla Owner API emits `time_to_full_charge` (fractional hours). - local ttf_min = tonumber(charge_state.minutes_to_full_charge) - if ttf_min == nil then - local ttf_h = tonumber(charge_state.time_to_full_charge) - if ttf_h ~= nil then ttf_min = math.floor(ttf_h * 60 + 0.5) end - end - local cs = charge_state.charging_state - if type(cs) ~= "string" then cs = nil end - - if soc ~= nil then - last.soc = soc - last.charge_limit = limit - last.charging_state = cs - last.time_to_full = ttf_min - last.charge_amps = charge_amps - last.charger_actual_current = charger_actual_current - last.ts_ms = host.millis() - - log("info", "tesla: emit soc=" .. tostring(soc) .. - " limit=" .. tostring(limit) .. - " state=" .. tostring(cs) .. - " amps=" .. tostring(charge_amps) .. - "/" .. tostring(charger_actual_current)) - local emit_err = host.emit("vehicle", { - soc = soc, - charge_limit_pct = limit, - charging_state = cs, - time_to_full_min = ttf_min, - charge_amps = charge_amps, - charger_actual_current = charger_actual_current, - stale = false, - }) - if emit_err then - log("warn", "tesla: emit returned error: " .. tostring(emit_err)) + emit_last(fresh) + if now - observed <= SOURCE_MAX_AGE_MS and observed >= last.ts_ms then + return set_interval(last.state == "Charging" and POLL_INTERVAL_CHARGING_MS or POLL_INTERVAL_MS) end else - -- Malformed response (no battery_level) → keep last-known. - emit_last() + emit_last(false) end - - return steady_ms + -- Includes HTTP 200 with empty, partial, invalid or stale data. + if wake_vehicle() then return set_interval(READ_AFTER_WAKE_MS) end + return set_interval(steady) end function driver_command(action, _, _) - -- Generic vehicle wake. The Go side fires `wake_up` whenever it - -- wants fresh telemetry without side effects — schedule edits, - -- the rising edge of wallbox-delivering-power, operator clicks - -- "Refresh". Any vehicle driver can implement this against its - -- own back-end; nothing here is Tesla-specific at the protocol - -- level. We hit the proxy's dedicated wake endpoint (rather than - -- the GET-with-wake the poll uses) so the response confirms - -- "wake initiated" instead of returning vehicle data — cleaner - -- separation between "wake" and "read". - -- - -- Reset the periodic-wake anchor so we don't immediately re-fire - -- a second wake on the next 30/15-min cadence right after the - -- caller already woke the car. Also clear any pending failure - -- retry — if a previous poll failed and armed a retry, the - -- caller's wake just supersedes it. if action == "wake_up" or action == "ev_wake" then - if not base_url or not vin then - log("warn", "tesla: wake_up before init") - return false - end - local url = base_url .. "/api/1/vehicles/" .. vin .. "/command/wake_up" - local body, err = safe_http_post(url, "{}", auth_headers()) - if err then - local es = tostring(err) - if es:match("HTTP 503") or es:match("HTTP 408") then - log("debug", "tesla: wake_up busy, will retry on next caller: " .. es) - return false - end - log("warn", "tesla: wake_up failed: " .. es) - return false - end - last_wakeup_ms = host.millis() - last_wake_attempt_ms = last_wakeup_ms - pending_wake_retry = false - local snippet = (body and #body > 0) and body:sub(1, 200) or "(empty body)" - log("info", "tesla: wake_up sent: " .. snippet) - return true + if not base_url then return false end + -- A refresh always requests a read, even when another caller reserved wake. + local accepted = wake_vehicle() + set_interval(READ_AFTER_WAKE_MS) + return accepted or wake_read_pending end - -- Wake-and-start support. The loadpoint controller fires the - -- generic `charge_start` action (defined as a cross-driver - -- protocol — any vehicle driver can implement it) when the - -- matched vehicle detached mid-session and won't accept current - -- from the wallbox. We translate that to the Tesla BLE command; - -- other vehicle drivers (BMW, Audi, Polestar via their own - -- proxies) implement the same action against their own back-end - -- — the Go side has no Tesla-specific knowledge. if action == "charge_start" or action == "ev_start" then if not base_url or not vin then log("warn", "tesla: charge_start before init") @@ -510,7 +206,7 @@ function driver_command(action, _, _) -- Empty JSON object body — TeslaBLEProxy's command endpoints -- accept GET-ish POSTs; some Tesla SDKs send `{}` for parity -- with the cloud API. Either form works on the proxy. - local body, err = safe_http_post(url, "{}", auth_headers()) + local body, err = post(url) if err then local es = tostring(err) -- 503 / "Command Disallowed" means the proxy's BLE radio is @@ -536,23 +232,10 @@ function driver_command(action, _, _) return false end -function driver_default_mode() - -- Nothing to do — there's no output state to reset. -end - +function driver_default_mode() end function driver_cleanup() - -- Reset every persistent piece of state so a hot-reload looks - -- identical to a fresh process start (the `last_wakeup_ms` reset - -- in particular re-anchors the periodic cadence so we don't fire - -- a wakeup the moment the reloaded driver runs its first poll). - last.soc = nil - last.charge_limit = nil - last.charging_state = nil - last.time_to_full = nil - last.charge_amps = nil - last.charger_actual_current = nil - last.ts_ms = 0 - last_wakeup_ms = 0 - last_wake_attempt_ms = 0 - pending_wake_retry = false + base_url, vin = nil, nil + last = { ts_ms = 0 } + wake_read_pending = false + recovery_not_before_ms = 0 end diff --git a/drivers/tests/lua_harness/test_tesla_vehicle.lua b/drivers/tests/lua_harness/test_tesla_vehicle.lua new file mode 100644 index 0000000..70e0cfd --- /dev/null +++ b/drivers/tests/lua_harness/test_tesla_vehicle.lua @@ -0,0 +1,151 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +local VIN = "5YJ3E1EA1KF000000" +local now, epoch = 1000, 1760000000000 +local body, request_err, wake_err +local gets, posts, saved = {}, {}, {} +local mock_log = host.log +function host.log(level, message) + assert(not tostring(message):find(VIN, 1, true), "full VIN in log") + return mock_log(level, message) +end +function host.set_poll_interval(ms) host._poll_interval_ms = ms end +function host.millis() return now end +function host.unix_ms() return epoch + now end +function host.http_get(url) + assert(not url:find("wakeup=true", 1, true), "wake must bypass the data cache") + gets[#gets+1] = url + return body, request_err +end +function host.http_post(url) + posts[#posts+1] = url + assert(not url:find("charge_start", 1, true), "telemetry recovery started charging") + return host.json_encode({response = {result = true}}), wake_err +end +function host.reserve_vehicle_wake() + local key = host._sn + local c = saved[key] + if not c or now - c.start >= 1800000 then c = { start = now, last = 0, count = 0 }; saved[key] = c end + if c.count >= 3 then return false, 1800000 - (now - c.start) end + if c.count > 0 and now - c.last < 90000 then return false, 90000 - (now - c.last) end + c.count, c.last = c.count + 1, now + return true, 90000 +end +local function load(reset_budget) + if reset_budget then saved = {}; now = 1000 end + dofile("drivers/lua/tesla_vehicle.lua") + driver_init({ip="192.0.2.1", vin=VIN}) + gets, posts = {}, {} + body, request_err, wake_err = nil, nil, nil + host._emitted = {} +end +local function data(soc, timestamp) + return host.json_encode({response={charge_state={battery_level=soc, timestamp=timestamp, + charge_limit_soc=80, charging_state="Complete"}}}) +end +local function latest() + local readings = host._emitted.vehicle + return readings and readings[#readings] +end + +-- Cold boot, HTTP 200 without battery_level: immediate bounded wake, then read. +load(true) +body = data(nil, math.floor(host.unix_ms()/1000)) +assert(driver_poll() == 5000) +assert(#posts == 1 and posts[1]:find("/command/wake_up", 1, true)) +assert(not latest()) +now = now + 5000 +body = data(100, math.floor(host.unix_ms()/1000)) +assert(driver_poll() == 60000) +assert(latest().soc == 100 and latest().soc_fresh == true and latest().stale == false) +assert(#posts == 1, "read-after-wake issued a second wake") + +-- A successful repeated cached payload keeps its BMS timestamp and becomes stale. +local observed = latest().soc_observed_at_ms +now = now + 60000 +driver_poll() +assert(latest().soc_fresh == false and latest().soc_observed_at_ms == observed) +now = now + 600000 +driver_poll() +assert(latest().soc_fresh == false and latest().stale == true) +assert(latest().soc_observed_at_ms == observed) +assert(#posts == 2) + +-- Restart and rename cannot replenish the VIN-keyed wake budget. +load(true) +body = "" +driver_poll() +assert(#posts == 1) +now = now + 1000 +load(false) +body = "" +driver_poll() +assert(#posts == 0, "restart renewed wake budget") +for i=1,2 do + now = now + 90000 + driver_poll() +end +assert(#posts == 2) +now = now + 90000 +driver_poll() +assert(#posts == 2, "more than three wakes in a 30-minute window") + +-- Ordinary errors recover; proxy busy responses retain the three-minute backoff. +load(true) +request_err = "HTTP 500 failure" +driver_poll() +assert(#posts == 1) +for _, err in ipairs({"HTTP 408 unavailable", "HTTP 503 busy"}) do + load(true) + request_err = err + assert(driver_poll() == 180000 and #posts == 0) + now = now + 180000 + request_err, body = nil, "" + assert(driver_poll() == 5000 and #posts == 1) +end + +-- Invalid timestamps and malformed successful responses never become fresh SoC. +for _, value in ipairs({0, -1, math.floor((epoch+3600000)/1000)}) do + load(true) + body = data(80, value) + driver_poll() + assert(not latest() and #posts == 1) +end +load(true) +body = "invalid JSON" +driver_poll() +assert(#posts == 1 and not latest()) + +-- An old first observation is retained with its actual age, not receipt time. +load(true) +local old_seconds = math.floor((host.unix_ms()-600000)/1000) +body = data(90, old_seconds) +driver_poll() +assert(latest().stale == true) +assert(latest().soc_observed_at_ms == old_seconds*1000.0) + +-- A goal refresh is wake-only and schedules an early read even during cooldown. +load(true) +assert(driver_command("wake_up") == true) +local count = #posts +driver_command("wake_up") +assert(#posts == count and host._poll_interval_ms == 5000) + +-- A vehicle-side rejection is not a successful wake. +load(true) +local good_post = host.http_post +function host.http_post(url) + posts[#posts+1] = url + return host.json_encode({response={result=false}}) +end +assert(driver_command("wake_up") == false) +host.http_post = good_post + +-- A host without durable reservations fails closed; reads continue. +load(true) +local reserve = host.reserve_vehicle_wake +host.reserve_vehicle_wake = nil +body = "" +driver_poll() +assert(#posts == 0) +host.reserve_vehicle_wake = reserve +print("Tesla BLE recovery: cold boot, source age, errors, restart budget and wake-only refresh pass") diff --git a/drivers/tests/test_tesla_vehicle.py b/drivers/tests/test_tesla_vehicle.py new file mode 100644 index 0000000..c6e7530 --- /dev/null +++ b/drivers/tests/test_tesla_vehicle.py @@ -0,0 +1,12 @@ +"""Tesla BLE telemetry recovery without starting a charge.""" +from pathlib import Path +import subprocess + + +def test_tesla_vehicle_recovery(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_tesla_vehicle.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 3dff6c9..fde49eb 100644 --- a/index.yaml +++ b/index.yaml @@ -719,15 +719,15 @@ drivers: size_bytes: 18216 sha256: "ed608e4a4501f7a9534296989bb083bb7960ebf8456bd760d868cb26069ea484" - name: "tesla_vehicle" - version: "0.2.4" + version: "0.2.5" tier: core protocol: http connectivity: local setup: [bridge] ders: [vehicle] control: true - size_bytes: 24166 - sha256: "0d76b44cd01af02aea995648006148649e8c6d88e97416e37b2f509d5eecddf9" + size_bytes: 9530 + sha256: "b5f35eb3f36b104ad2440742ee9cd08088ed6295cb60eaacc1edbcdb1dea422d" - name: "tesla_wall_connector" version: "0.1.1" tier: community diff --git a/manifests/tesla_vehicle.yaml b/manifests/tesla_vehicle.yaml index 87d0f0b..38db58b 100644 --- a/manifests/tesla_vehicle.yaml +++ b/manifests/tesla_vehicle.yaml @@ -1,5 +1,5 @@ name: "tesla_vehicle" -version: "0.2.4" +version: "0.2.5" tier: core author: "Sourceful Labs AB" protocol: http @@ -13,12 +13,22 @@ tested_devices: variants: [Model Y, Model 3] regions: [] firmware_versions: "" - notes: "Read-only vehicle SoC + charge limit via Tesla API-compatible HTTP endpoint (e.g. TeslaBLEProxy)." + notes: "Vehicle SoC and charge limit via a local BLE proxy. Telemetry recovery needs a host with unix_ms and reserve_vehicle_wake. No hardware recovery test yet." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 24166 +size_bytes: 9530 dkb_id: "tesla_vehicle" -sha256: "0d76b44cd01af02aea995648006148649e8c6d88e97416e37b2f509d5eecddf9" +sha256: "b5f35eb3f36b104ad2440742ee9cd08088ed6295cb60eaacc1edbcdb1dea422d" signature: "" bytecode_sha256: "" + +upstream_docs: + - url: "https://github.com/wimaha/TeslaBleHttpProxy/blob/5f400b573eb3bb9d55ff807a9ec81e01b5f5ce2d/internal/api/models/statesConverter.go" + title: "BLE charge state and Unix timestamp mapping" + kind: api_docs + url_stability: committed + - url: "https://github.com/wimaha/TeslaBleHttpProxy/blob/5f400b573eb3bb9d55ff807a9ec81e01b5f5ce2d/internal/api/handlers/tesla.go" + title: "Vehicle-data cache and wake commands" + kind: api_docs + url_stability: committed diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index a6f3b9d..18afd0f 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -30,7 +30,9 @@ "set_poll_interval", "millis", "set_watchdog_timeout_s", - "persist_secret" + "persist_secret", + "unix_ms", + "reserve_vehicle_wake" ], "decode": [ "decode_i16", diff --git a/support-status.json b/support-status.json index 5179564..e822c1c 100644 --- a/support-status.json +++ b/support-status.json @@ -1640,7 +1640,7 @@ }, { "catalog_source": true, - "catalog_version": "0.2.4", + "catalog_version": "0.2.5", "driver_id": "tesla_vehicle", "targets": { "blixt-l1": { From fdad907e0431c201a0102be03607023a5379687a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 6 Oct 2026 11:05:29 +0200 Subject: [PATCH 2/2] test(tesla_vehicle): reject unsuccessful nested wake responses Signed-off-by: Fredrik Ahlgren --- drivers/lua/tesla_vehicle.lua | 10 +++++++++- drivers/tests/lua_harness/test_tesla_vehicle.lua | 16 ++++++++++------ index.yaml | 4 ++-- manifests/tesla_vehicle.yaml | 4 ++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/drivers/lua/tesla_vehicle.lua b/drivers/lua/tesla_vehicle.lua index 17e354c..f9d557c 100644 --- a/drivers/lua/tesla_vehicle.lua +++ b/drivers/lua/tesla_vehicle.lua @@ -123,7 +123,15 @@ local function wake_vehicle() return false end local reply = decode(body) - if not reply or not (reply.result == true or (type(reply.response) == "table" and reply.response.result == true)) then + local accepted = false + local result = reply + for _ = 1, 3 do + if type(result) ~= "table" then break end + if result.result == false then accepted = false; break end + if result.result == true then accepted = true end + result = result.response + end + if not accepted then log("warn", "tesla: telemetry wake was not accepted") return false end diff --git a/drivers/tests/lua_harness/test_tesla_vehicle.lua b/drivers/tests/lua_harness/test_tesla_vehicle.lua index 70e0cfd..2a60c0e 100644 --- a/drivers/tests/lua_harness/test_tesla_vehicle.lua +++ b/drivers/tests/lua_harness/test_tesla_vehicle.lua @@ -130,14 +130,18 @@ local count = #posts driver_command("wake_up") assert(#posts == count and host._poll_interval_ms == 5000) --- A vehicle-side rejection is not a successful wake. -load(true) +-- HTTP 200 and an outer success must not hide an inner rejection. local good_post = host.http_post -function host.http_post(url) - posts[#posts+1] = url - return host.json_encode({response={result=false}}) +for _, reply in ipairs({{}, {response={result=false}}, + {result=true,response={result=false}}, {result=false,response={result=true}}, + {response={result=true,response={result=false}}}}) do + load(true) + function host.http_post(url) + posts[#posts+1] = url + return host.json_encode(reply) + end + assert(driver_command("wake_up") == false) end -assert(driver_command("wake_up") == false) host.http_post = good_post -- A host without durable reservations fails closed; reads continue. diff --git a/index.yaml b/index.yaml index fde49eb..2dfe701 100644 --- a/index.yaml +++ b/index.yaml @@ -726,8 +726,8 @@ drivers: setup: [bridge] ders: [vehicle] control: true - size_bytes: 9530 - sha256: "b5f35eb3f36b104ad2440742ee9cd08088ed6295cb60eaacc1edbcdb1dea422d" + size_bytes: 9696 + sha256: "d5b97c6ebbc376a1874898eb160f9ba8715d5a9cbf72b60794f126a99b11186d" - name: "tesla_wall_connector" version: "0.1.1" tier: community diff --git a/manifests/tesla_vehicle.yaml b/manifests/tesla_vehicle.yaml index 38db58b..ff11a51 100644 --- a/manifests/tesla_vehicle.yaml +++ b/manifests/tesla_vehicle.yaml @@ -16,9 +16,9 @@ tested_devices: notes: "Vehicle SoC and charge limit via a local BLE proxy. Telemetry recovery needs a host with unix_ms and reserve_vehicle_wake. No hardware recovery test yet." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 9530 +size_bytes: 9696 dkb_id: "tesla_vehicle" -sha256: "b5f35eb3f36b104ad2440742ee9cd08088ed6295cb60eaacc1edbcdb1dea422d" +sha256: "d5b97c6ebbc376a1874898eb160f9ba8715d5a9cbf72b60794f126a99b11186d" signature: "" bytecode_sha256: ""