diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab0890..98e90fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. + ## 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..b3393fc 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -160,6 +160,8 @@ Catalog source is not proof that a target can install or run a driver. | 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 | | tesla_wall_connector | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| teslamate_vehicle | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| teslamate_vehicle | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 48aff76..5da74ef 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 (TeslaMate)" + variants: [Model 3, Model Y] + regions: [] + protocols: + - protocol: mqtt + driver: "teslamate_vehicle" + version: "0.1.0" + ders: [vehicle] + control: false + firmware_versions: "" + notes: "Read-only vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." - name: "Tesla Wall Connector" variants: [Wall Connector Gen 3] regions: [] diff --git a/drivers/lua/teslamate_vehicle.lua b/drivers/lua/teslamate_vehicle.lua new file mode 100644 index 0000000..31bf77d --- /dev/null +++ b/drivers/lua/teslamate_vehicle.lua @@ -0,0 +1,394 @@ +-- Tesla Vehicle Driver (telemetry-only, via TeslaMate MQTT) +-- Emits: Vehicle (DerVehicle) +-- Protocol: MQTT (TeslaMate retained topics teslamate/cars/{id}/…) +-- +-- Optional SoC next to tesla_vehicle.lua (TeslaBLEProxy on the LAN). +-- This file does not replace that driver, and it is not a Fleet API client. +-- FTW reads the TeslaMate instance the owner already runs. It does not talk +-- to Tesla Fleet cloud and it does not ingest Home Assistant entities. +-- Cloud access is not a charging prerequisite: an asleep or unreachable car +-- is a supported case, and Core already plans from a stated default or the +-- plug-in slider. +-- +-- Telemetry only. No wake, charge_start, or other vehicle command. TeslaMate +-- MQTT is subscribe-only; this driver never publishes. +-- +-- TeslaMate does not publish VIN on MQTT (privacy). Bind identity from YAML: +-- host.set_make("Tesla"), host.set_sn(VIN). Car id selects the topic prefix. +-- +-- Vendor document: +-- https://docs.teslamate.org/docs/integrations/mqtt +-- TeslaMate payloads are per-topic strings, retained, and published only +-- when a value changes. `healthy` is the exception: it is not retained and +-- goes out with every update TeslaMate makes, so a healthy=true message +-- while the car is awake means TeslaMate has just read the car and the +-- retained values still hold. While the car is asleep TeslaMate stops +-- updating; retained values can be hours old. Age is host.millis() of the +-- last awake observation (state online / charging / driving / updating). +-- In between, the driver replays that observation with soc_fresh=false so +-- Core ages it from the observation, and stops after STALE_AFTER_MS. A +-- first subscribe that only sees an asleep car is not a live observation +-- and emits nothing. +-- +-- Config (MQTT host/port/user live on the capability grant, not here): +-- +-- drivers: +-- - name: teslamate +-- lua: drivers/teslamate_vehicle.lua +-- capabilities: +-- mqtt: +-- host: 192.168.1.10 # TeslaMate's broker (often Mosquitto) +-- port: 1883 +-- username: "" # optional +-- password: "" # optional +-- config: +-- vin: "5YJ3E1EA1KF000000" # required — TeslaMate MQTT has no VIN +-- car_id: 1 # TeslaMate id, usually starts at 1 +-- # topic_prefix: teslamate/cars/1 # optional override of the prefix +-- +-- Enable MQTT in TeslaMate (MQTT_HOST in its compose) before pointing FTW +-- at the broker. Settings → Devices currently scaffolds vehicle drivers as +-- {ip, vin} (BLE proxy). Edit the YAML for TeslaMate MQTT. +-- +-- No HTTP. TeslaMate's UI and GraphQL are out of scope. If a later HTTP +-- lookup is added, grant capabilities.http.allowed_hosts for that host only. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "teslamate_vehicle", + name = "Tesla Vehicle (TeslaMate)", + manufacturer = "Tesla", + version = "0.1.0", + protocols = { "mqtt" }, + capabilities = { "vehicle" }, + read_only = true, + description = "Read-only Tesla vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE). Does not wake or command the car.", + homepage = "https://docs.teslamate.org/docs/integrations/mqtt", + authors = { "FTW contributors" }, + tested_models = { "Model Y", "Model 3" }, + verification_status = "experimental", +} + +PROTOCOL = "mqtt" + +-- File-local so host.mqtt_subscribe(base_topic .. "/#") stays a static +-- topic expression for the MQTT contract tests. +local base_topic = "teslamate/cars/1" + +local POLL_INTERVAL_MS = 5000 +local STALE_AFTER_MS = 900000 +local WATCHDOG_TIMEOUT_S = 300 + +-- TeslaMate's state topic also carries asleep, offline and suspended, and +-- briefly start or unavailable. None of those is a live reading. +local AWAKE = { + online = true, + charging = true, + driving = true, + updating = true, +} + +local vin = nil + +-- Last live (awake) observation. seen_ms is host.millis() when TeslaMate +-- last published charge fields while the car was awake. TeslaMate's +-- ISO `since` cannot be compared to host.millis() (monotonic uptime), +-- and os.time is sandbox-forbidden in this repository. +local last = { + seen_ms = 0, + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, + tm_state = nil, + healthy = nil, +} + +-- Fields collected from MQTT that have not yet been accepted as a live +-- observation. Kept across polls so state and battery_level can arrive +-- on different ticks. nil means not received; false means TeslaMate sent +-- an empty value (it does for time_to_full_charge and +-- charger_actual_current once charging ends). +local incoming = { + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, +} + +local function payload_present(p) + if p == nil then return false end + local s = tostring(p) + return s ~= "" and s ~= "nil" and s ~= "null" +end + +local function as_bool(p) + if p == true then return true end + if p == false then return false end + local s = tostring(p or ""):lower() + if s == "true" or s == "1" then return true end + if s == "false" or s == "0" then return false end + return nil +end + +local function model_name(raw) + local s = tostring(raw or "") + if s == "3" or s == "Y" or s == "S" or s == "X" then + return "Model " .. s + end + return s +end + +local function normalize_prefix(s) + s = tostring(s or ""):gsub("/+$", "") + s = s:gsub("/#$", "") + return s +end + +local function topic_key(topic) + if type(topic) ~= "string" then return nil end + local prefix = base_topic .. "/" + if topic:sub(1, #prefix) ~= prefix then return nil end + local key = topic:sub(#prefix + 1) + -- TeslaMate charge fields are a single path segment. Reject + -- teslamate/cars/10/... when the prefix is teslamate/cars/1. + if key == "" or key:find("/", 1, true) then return nil end + return key +end + +local function hours_to_min(hours) + local h = tonumber(hours) + if h == nil then return nil end + return math.floor(h * 60 + 0.5) +end + +local function reset_incoming() + incoming.soc = nil + incoming.charge_limit = nil + incoming.charging_state = nil + incoming.time_to_full = nil + incoming.charge_amps = nil + incoming.charger_actual_current = nil +end + +local function reset_last() + last.seen_ms = 0 + last.soc = nil + last.charge_limit = nil + last.charging_state = nil + last.time_to_full = nil + last.charge_amps = nil + last.charger_actual_current = nil + last.tm_state = nil + last.healthy = nil + reset_incoming() +end + +local function car_is_awake() + local state = last.tm_state + return type(state) == "string" and AWAKE[state] == true +end + +local function can_accept_fresh() + if not vin or vin == "" then return false end + if last.healthy == false then return false end + return car_is_awake() +end + +local function remember() + local soc = tonumber(incoming.soc) + if soc == nil then soc = last.soc end + if soc == nil then return false end + last.soc = soc + if incoming.charge_limit ~= nil then + last.charge_limit = tonumber(incoming.charge_limit) + end + if incoming.charge_amps ~= nil then + last.charge_amps = tonumber(incoming.charge_amps) + end + if incoming.charger_actual_current ~= nil then + last.charger_actual_current = tonumber(incoming.charger_actual_current) or nil + end + local cs = incoming.charging_state + if type(cs) == "string" and cs ~= "" then + last.charging_state = cs + end + if incoming.time_to_full ~= nil then + last.time_to_full = incoming.time_to_full or nil + end + last.seen_ms = host.millis() + return true +end + +-- A replay repeats the last awake observation with soc_fresh=false, so Core +-- keeps the time of that observation and ages it itself. Marking every +-- replay stale would drop the car from Core between two MQTT updates. +-- stale=true only when TeslaMate reports itself unhealthy. +local function emit_vehicle(fresh) + if last.soc == nil or last.seen_ms == 0 then return end + local age = host.millis() - last.seen_ms + if age > STALE_AFTER_MS then + return + end + host.emit("vehicle", { + soc = 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 = last.healthy == false, + soc_fresh = fresh, + }) +end + +-- Returns true when this message shows TeslaMate has just read the car: a +-- charge-field update, or the healthy=true it sends with every update. +-- State and model do not refresh seen_ms on their own. +local function apply_message(msg) + if type(msg) ~= "table" then return false end + local key = topic_key(msg.topic) + if not key then return false end + local p = msg.payload + if key == "state" then + if payload_present(p) then + last.tm_state = tostring(p):lower() + end + return false + end + if key == "healthy" then + local b = as_bool(p) + if b ~= nil then last.healthy = b end + return b == true + end + if key == "model" then + if payload_present(p) and host.set_model then + host.set_model(model_name(p)) + end + return false + end + if key == "battery_level" then + local n = tonumber(p) + if n == nil then return false end + incoming.soc = n + return true + end + if key == "charge_limit_soc" then + local n = tonumber(p) + if n == nil then return false end + incoming.charge_limit = n + return true + end + if key == "charging_state" then + if not payload_present(p) then return false end + incoming.charging_state = tostring(p) + return true + end + if key == "time_to_full_charge" then + if not payload_present(p) then + incoming.time_to_full = false + return true + end + local mins = hours_to_min(p) + if mins == nil then return false end + incoming.time_to_full = mins + return true + end + if key == "charge_current_request" then + local n = tonumber(p) + if n == nil then return false end + incoming.charge_amps = n + return true + end + if key == "charger_actual_current" then + if not payload_present(p) then + incoming.charger_actual_current = false + return true + end + local n = tonumber(p) + if n == nil then return false end + incoming.charger_actual_current = n + return true + end + return false +end + +function driver_init(config) + host.set_make("Tesla") + config = config or {} + + if config.vin and tostring(config.vin) ~= "" then + vin = tostring(config.vin) + host.set_sn(vin) + else + host.log("error", "teslamate_vehicle: config.vin required (TeslaMate MQTT does not publish VIN)") + end + + if config.topic_prefix and tostring(config.topic_prefix) ~= "" then + base_topic = normalize_prefix(config.topic_prefix) + elseif config.base_topic and tostring(config.base_topic) ~= "" then + base_topic = normalize_prefix(config.base_topic) + elseif config.topic and tostring(config.topic) ~= "" then + base_topic = normalize_prefix(config.topic) + elseif config.car_id ~= nil and tostring(config.car_id) ~= "" then + base_topic = "teslamate/cars/" .. tostring(config.car_id) + else + base_topic = "teslamate/cars/1" + end + + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(500) + + -- Real host returns nil on success. The test mock returns true. + local err = host.mqtt_subscribe(base_topic .. "/#") + if type(err) == "string" and err ~= "" then + host.log("error", "teslamate_vehicle: subscribe failed: " .. err) + else + host.log("info", "teslamate_vehicle: subscribed to " .. base_topic .. "/#" .. + " vin=" .. tostring(vin or "(missing)") .. + " telemetry-only") + end +end + +function driver_poll() + host.set_poll_interval(POLL_INTERVAL_MS) + + if not vin or vin == "" then + return POLL_INTERVAL_MS + end + + local messages = host.mqtt_messages() + if not messages then messages = {} end + local saw_charge = false + for _, msg in ipairs(messages) do + if apply_message(msg) then saw_charge = true end + end + + -- Only a charge-field message or a healthy heartbeat while TeslaMate says + -- the car is awake starts (or refreshes) the age clock. Idle polls and + -- asleep retained values must not look like a new BMS reading. + if can_accept_fresh() and saw_charge and remember() then + host.log("info", "teslamate_vehicle: emit soc=" .. tostring(last.soc) .. + " limit=" .. tostring(last.charge_limit) .. + " state=" .. tostring(last.charging_state) .. + " tm=" .. tostring(last.tm_state)) + emit_vehicle(true) + else + emit_vehicle(false) + end + + return POLL_INTERVAL_MS +end + +function driver_cleanup() + reset_last() + vin = nil + base_topic = "teslamate/cars/1" +end diff --git a/drivers/tests/lua_harness/test_teslamate_vehicle.lua b/drivers/tests/lua_harness/test_teslamate_vehicle.lua new file mode 100644 index 0000000..6dd0c8c --- /dev/null +++ b/drivers/tests/lua_harness/test_teslamate_vehicle.lua @@ -0,0 +1,299 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") + +local VIN = "5YJ3E1EA1KF000000" + +local function boot(cfg) + host.reset() + dofile("drivers/lua/teslamate_vehicle.lua") + driver_init(cfg or { vin = VIN, car_id = 1 }) +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_publish() + local n = 0 + for i = 1, #host._calls do + if host._calls[i].func == "mqtt_publish" then n = n + 1 end + end + return n +end + +local function subscribed_topic() + for i = 1, #host._calls do + local c = host._calls[i] + if c.func == "mqtt_subscribe" then return c.args[1] end + end + return nil +end + +local function push(fields) + fields = fields or {} + local prefix = fields.prefix or "teslamate/cars/1" + local msgs = {} + local function add(key, value) + if value ~= nil then + msgs[#msgs + 1] = { topic = prefix .. "/" .. key, payload = tostring(value) } + end + end + add("state", fields.state) + add("healthy", fields.healthy) + add("model", fields.model) + add("battery_level", fields.soc) + add("charge_limit_soc", fields.limit) + add("charging_state", fields.charging_state) + add("time_to_full_charge", fields.ttf_h) + add("charge_current_request", fields.amps) + add("charger_actual_current", fields.actual) + host._mqtt_buffer = msgs +end + +boot() +assert(host._make == "Tesla", "set_make Tesla") +assert(host._sn == VIN, "set_sn from config VIN") +assert(subscribed_topic() == "teslamate/cars/1/#", "default subscribe prefix") + +push({ + state = "charging", + healthy = "true", + model = "Y", + soc = 67, + limit = 80, + charging_state = "Charging", + ttf_h = 0.7, + amps = 16, + actual = 15, +}) +driver_poll() +local sample = last_vehicle() +assert(sample, "awake 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, "hours converted to minutes") +assert(sample.charge_amps == 16, "charge_amps from charge_current_request") +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 Y", "model letter mapped") +assert(count_publish() == 0, "must not publish MQTT") + +-- time_to_full_charge 1.5 h → 90 min. +boot() +push({ + state = "charging", + healthy = "true", + soc = 50, + limit = 80, + charging_state = "Charging", + ttf_h = 1.5, +}) +driver_poll() +assert(last_vehicle().time_to_full_min == 90, "1.5 hours → 90 min") + +-- Retained asleep at first subscribe is not a live observation. +boot() +push({ + state = "asleep", + healthy = "true", + soc = 67, + limit = 80, + charging_state = "Complete", +}) +driver_poll() +assert(last_vehicle() == nil, "asleep retained must not invent a fresh SoC") + +-- Missing battery_level is not a zero SoC. +boot() +push({ + state = "online", + healthy = "true", + charging_state = "Stopped", + limit = 80, +}) +driver_poll() +assert(last_vehicle() == nil, "no battery_level invented SoC") + +-- Unhealthy logger is not a live observation. +boot() +push({ + state = "charging", + healthy = "false", + soc = 41, + limit = 80, + charging_state = "Charging", +}) +driver_poll() +assert(last_vehicle() == nil, "unhealthy TeslaMate must not emit fresh") + +-- Live charge, then the car sleeps: replay stale, then stop. +boot() +push({ + state = "charging", + healthy = "true", + soc = 67, + limit = 80, + charging_state = "Charging", + ttf_h = 0.5, +}) +driver_poll() +assert(last_vehicle() and last_vehicle().soc_fresh == true, "prime cache") +local after_fresh = #(host._emitted.vehicle) +host._mqtt_buffer = { + { topic = "teslamate/cars/1/state", payload = "asleep" }, +} +driver_poll() +assert(#(host._emitted.vehicle) == after_fresh + 1, "replay cache while young") +assert(last_vehicle().stale == false, "a young replay must not drop the car from Core") +assert(last_vehicle().soc_fresh == false, "replay is not fresh") +assert(last_vehicle().soc == 67, "replay keeps SoC") +assert(count_publish() == 0, "asleep poll must not publish") + +host._millis_counter = host._millis_counter + 900001 +local before_stale = #(host._emitted.vehicle) +host._mqtt_buffer = {} +driver_poll() +assert(#(host._emitted.vehicle) == before_stale, "stop emitting when stale") + +-- Idle poll after a live reading also ages out. +boot() +push({ + state = "online", + healthy = "true", + soc = 55, + limit = 90, + charging_state = "Disconnected", +}) +driver_poll() +assert(last_vehicle() and last_vehicle().soc == 55, "online parked emits") +host._mqtt_buffer = {} +driver_poll() +assert(last_vehicle().soc_fresh == false, "idle replay is not fresh") +assert(last_vehicle().stale == false, "a young idle replay is not stale") +host._millis_counter = host._millis_counter + 900001 +local n = #(host._emitted.vehicle) +host._mqtt_buffer = {} +driver_poll() +assert(#(host._emitted.vehicle) == n, "idle poll stops after STALE_AFTER_MS") + +-- car_id and topic_prefix select the MQTT prefix. +boot({ vin = VIN, car_id = 2 }) +assert(subscribed_topic() == "teslamate/cars/2/#", "car_id subscribe") +push({ + prefix = "teslamate/cars/2", + state = "charging", + healthy = "true", + soc = 33, + limit = 70, + charging_state = "Charging", +}) +-- A leftover car 1 topic must not leak into car 2. +host._mqtt_buffer[#host._mqtt_buffer + 1] = { + topic = "teslamate/cars/1/battery_level", payload = "99", +} +driver_poll() +assert(last_vehicle() and last_vehicle().soc == 33, "car_id isolates topics") + +boot({ vin = VIN, topic_prefix = "teslamate/cars/4/#" }) +assert(subscribed_topic() == "teslamate/cars/4/#", "topic_prefix strips /#") +push({ + prefix = "teslamate/cars/4", + state = "charging", + healthy = "true", + soc = 12, + limit = 50, + charging_state = "Starting", +}) +driver_poll() +assert(last_vehicle() and last_vehicle().charging_state == "Starting", "topic_prefix") + +-- VIN is required; no identity, no emit. +boot({ car_id = 1 }) +assert(host._sn == nil, "no VIN at init") +push({ + state = "charging", + healthy = "true", + soc = 80, + charging_state = "Charging", +}) +driver_poll() +assert(last_vehicle() == nil, "missing VIN must not emit") +assert(count_publish() == 0, "missing VIN must not publish") + +-- TeslaMate "nil" payload is absence, not zero. +boot() +push({ + state = "charging", + healthy = "true", + soc = 40, + limit = 80, + charging_state = "Charging", +}) +driver_poll() +host._mqtt_buffer = { + { topic = "teslamate/cars/1/state", payload = "charging" }, + { topic = "teslamate/cars/1/healthy", payload = "true" }, + { topic = "teslamate/cars/1/charging_state", payload = "nil" }, +} +driver_poll() +assert(last_vehicle().charging_state == "Charging", "nil payload must not wipe state") + +-- TeslaMate publishes a value only when it changes, but sends healthy with +-- every update. A healthy=true while awake is a new observation of the +-- retained values. +boot() +push({ state = "online", healthy = "true", soc = 70, limit = 80, charging_state = "Stopped" }) +driver_poll() +assert(last_vehicle() and last_vehicle().soc_fresh == true, "prime") +host._millis_counter = host._millis_counter + 600000 +host._mqtt_buffer = { { topic = "teslamate/cars/1/healthy", payload = "true" } } +driver_poll() +assert(last_vehicle().soc_fresh == true, "healthy heartbeat while awake refreshes") +assert(last_vehicle().soc == 70, "heartbeat keeps the retained SoC") +host._millis_counter = host._millis_counter + 600000 +host._mqtt_buffer = {} +driver_poll() +assert(last_vehicle().soc_fresh == false, "no heartbeat, no fresh reading") + +-- A heartbeat while TeslaMate has suspended polling is not a car reading. +boot() +push({ state = "online", healthy = "true", soc = 70, limit = 80, charging_state = "Stopped" }) +driver_poll() +local n_primed = #(host._emitted.vehicle) +host._mqtt_buffer = { + { topic = "teslamate/cars/1/state", payload = "suspended" }, + { topic = "teslamate/cars/1/healthy", payload = "true" }, +} +driver_poll() +assert(#(host._emitted.vehicle) == n_primed + 1, "suspended: replay") +assert(last_vehicle().soc_fresh == false, "suspended heartbeat is not fresh") + +-- TeslaMate reporting itself unhealthy marks the replay stale. +host._mqtt_buffer = { { topic = "teslamate/cars/1/healthy", payload = "false" } } +driver_poll() +assert(last_vehicle().stale == true, "unhealthy TeslaMate marks the replay stale") +assert(last_vehicle().soc_fresh == false, "unhealthy is not fresh") + +-- When charging ends TeslaMate sends an empty time_to_full_charge and +-- charger_actual_current. The old values must not linger. +boot() +push({ state = "charging", healthy = "true", soc = 60, limit = 80, + charging_state = "Charging", ttf_h = 0.7, actual = 16 }) +driver_poll() +assert(last_vehicle().time_to_full_min == 42, "prime time to full") +host._mqtt_buffer = { + { topic = "teslamate/cars/1/state", payload = "online" }, + { topic = "teslamate/cars/1/charging_state", payload = "Complete" }, + { topic = "teslamate/cars/1/time_to_full_charge", payload = "" }, + { topic = "teslamate/cars/1/charger_actual_current", payload = "" }, +} +driver_poll() +assert(last_vehicle().charging_state == "Complete", "state moves on") +assert(last_vehicle().time_to_full_min == nil, "empty time_to_full_charge clears it") +assert(last_vehicle().charger_actual_current == nil, "empty charger_actual_current clears it") + +assert(count_publish() == 0, "driver must never mqtt_publish") +print("OK teslamate_vehicle") diff --git a/drivers/tests/test_teslamate_vehicle.py b/drivers/tests/test_teslamate_vehicle.py new file mode 100644 index 0000000..17f1671 --- /dev/null +++ b/drivers/tests/test_teslamate_vehicle.py @@ -0,0 +1,12 @@ +"""TeslaMate MQTT vehicle driver: telemetry only, no wake or car control.""" +from pathlib import Path +import subprocess + + +def test_teslamate_vehicle_telemetry_and_staleness(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_teslamate_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 baeff0e..e71f4f8 100644 --- a/index.yaml +++ b/index.yaml @@ -717,6 +717,16 @@ drivers: control: false size_bytes: 8162 sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284" + - name: "teslamate_vehicle" + version: "0.1.0" + tier: community + protocol: mqtt + connectivity: local + setup: [bridge] + ders: [vehicle] + control: false + size_bytes: 13260 + sha256: "916cc84bd8df4080ccccbcc9bc968082771e847a6c1f020cfeefc52d4db68a43" - name: "tibber" version: "1.1.2" tier: core diff --git a/manifests/teslamate_vehicle.yaml b/manifests/teslamate_vehicle.yaml new file mode 100644 index 0000000..555d562 --- /dev/null +++ b/manifests/teslamate_vehicle.yaml @@ -0,0 +1,29 @@ +name: "teslamate_vehicle" +version: "0.1.0" +tier: community +author: "Sourceful Labs AB" +protocol: mqtt +connectivity: local +setup: [bridge] +ders: [vehicle] +control: false +tested_devices: + - manufacturer: "Tesla" + model_family: "Tesla Vehicle (TeslaMate)" + variants: [Model Y, Model 3] + regions: [] + firmware_versions: "" + notes: "Read-only vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE proxy). Does not wake or command the car. Not yet run against a car." + min_driver_version: "0.1.0" +upstream_docs: + - url: "https://docs.teslamate.org/docs/integrations/mqtt" + title: "TeslaMate MQTT topics" + kind: api_docs + url_stability: stable +min_host_version: "2.0.0" +size_bytes: 13260 +dkb_id: "teslamate_vehicle" +sha256: "916cc84bd8df4080ccccbcc9bc968082771e847a6c1f020cfeefc52d4db68a43" +signature: "" + +bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index cbbac3b..dda28cf 100644 --- a/support-status.json +++ b/support-status.json @@ -1638,6 +1638,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "teslamate_vehicle", + "targets": { + "blixt-l1": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + }, + "ftw-core": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "1.1.2",