Skip to content

Commit 345ee52

Browse files
brianmayJakobLichterfeldclaude
authored
feat(mqtt): add opt-in Home Assistant MQTT discovery (#5543)
* feat(mqtt): add opt-in Home Assistant MQTT discovery Add an MQTT_HOME_ASSISTANT_DISCOVERY env var that, when enabled, makes VehicleSubscriber publish HA discovery config payloads (one per entity) to homeassistant/<component>/teslamate_<car_id>/<object_id>/config on startup, mirroring the manual configuration documented in website/docs/integrations/home_assistant.md. * docs: fix malformed environment variables table Fix the header separator row, which gained an extra column, and the MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX row, which had a trailing empty cell. Addresses review comment on #5543. * docs: reword MQTT_HOME_ASSISTANT_DISCOVERY_URL wording MQTT_HOME_ASSISTANT_DISCOVERY_URL is optional; clarify that setting MQTT_HOME_ASSISTANT_DISCOVERY does not require it. Addresses review comment on #5543. * feat(mqtt): validate MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX Validate the discovery prefix like MQTT_NAMESPACE: empty values fall back to the default so topics never get a leading '/', and MQTT wildcards are rejected. Addresses review comment on #5543. * feat(mqtt): clear Home Assistant discovery configs on disable Call HomeAssistant.clear/3 so retained discovery configs (and the entities in Home Assistant) are removed when discovery is disabled. Addresses review comment on #5543. * feat(mqtt): clear Home Assistant discovery configs for removed vehicles on startup * feat(mqtt): make discovery entity IDs match the manual mqtt_sensors.yaml Prefix the discovery object_id with tesla_ so Home Assistant generates the same entity IDs as the documented manual configuration (e.g. sensor.tesla_speed instead of sensor.speed), avoiding broken dashboards and automations on migration. Drop the _km suffix from the battery range sensors to match the manual unique_ids. Document that the manual mqtt_sensors.yaml must be removed before enabling discovery to avoid duplicate unique_id errors. Addresses review comment on #5543. * fix(mqtt): use state_class total_increasing for charge_energy_added discovery entity * feat(mqtt): add charging_state discovery entity * test(mqtt): use plain ExUnit.Case in home_assistant_test * feat(mqtt): scope discovery topics and unique_ids by MQTT namespace * feat(vehicles): add Summary type definition * docs(mqtt): simplify discovered entities note * feat(nix): add Home Assistant MQTT discovery options * fix(mqtt): scope startup discovery cleanup by MQTT namespace Pass the namespace to HomeAssistant.clear/3 in clear_removed_vehicles/2 so a namespaced instance clears its own discovery topics instead of the un-namespaced ones of a sibling instance sharing the broker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): extract shared drain_discovery_configs helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(mqtt): explain startup discovery cleanup timing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mqtt): reject MQTT wildcards in MQTT_NAMESPACE Align with validate_discovery_prefix!: a namespace containing + or # would produce unpublishable state and discovery topics, so fail fast at boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update changelog --------- Co-authored-by: Jakob Lichterfeld <jakob-lichterfeld@gmx.de> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2c99541 commit 345ee52

16 files changed

Lines changed: 1312 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
Existing charging processes are recomputed once during the upgrade migration: previously empty or zero `charge_energy_used` values (short or mixed AC sessions) gain values, the first start after the upgrade can take a few minutes longer on databases with years of history and slow HW, and charge costs are deliberately not changed retroactively.
66

7+
### Note for manual Home Assistant configurations
8+
9+
The documented manual [mqtt_sensors.yaml](https://docs.teslamate.org/docs/integrations/home_assistant#mqtt_sensorsyaml-mqtt-section-of-configurationyaml) now uses `state_class: total_increasing` for the `charge_energy_added` sensor (#5543). If you re-sync your manual YAML, Home Assistant will treat the per-charge resets as meter cycles, which changes the long-term statistics behavior (e.g. in the Energy dashboard).
10+
711
### New features
812

913
- feat: add service mode to webview and reduce log when car is Unlocked at service mode (#5289 - @NirKli)
@@ -14,6 +18,7 @@ Existing charging processes are recomputed once during the upgrade migration: pr
1418
- feat: link the software update icon to the notateslaapp release notes (#5490 - @NirKli)
1519
- feat: add fullscreen mode to vehicle summary map (#5495 - @hakong)
1620
- feat(web): expose VIN in car summary ( #5556 - @Helvio88, @magrathean-uk)
21+
- feat(mqtt): add opt-in Home Assistant MQTT discovery (#5543 - @brianmay, @JakobLichterfeld)
1722

1823
### Improvements and bug fixes
1924

config/runtime.exs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,20 @@ defmodule Util do
1212
def validate_namespace!(""), do: nil
1313

1414
def validate_namespace!(ns) when is_binary(ns) do
15-
case String.contains?(ns, "/") do
16-
true -> raise "MQTT_NAMESPACE must not contain '/'"
17-
false -> ns
15+
cond do
16+
String.contains?(ns, "/") -> raise "MQTT_NAMESPACE must not contain '/'"
17+
String.contains?(ns, ["+", "#"]) -> raise "MQTT_NAMESPACE must not contain MQTT wildcards"
18+
true -> ns
19+
end
20+
end
21+
22+
def validate_discovery_prefix!(nil), do: nil
23+
def validate_discovery_prefix!(""), do: nil
24+
25+
def validate_discovery_prefix!(prefix) when is_binary(prefix) do
26+
case String.contains?(prefix, ["+", "#"]) do
27+
true -> raise "MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX must not contain MQTT wildcards"
28+
false -> prefix
1829
end
1930
end
2031

@@ -181,7 +192,11 @@ if System.get_env("DISABLE_MQTT") != "true" or config_env() == :test do
181192
tls: System.get_env("MQTT_TLS") == "true",
182193
accept_invalid_certs: System.get_env("MQTT_TLS_ACCEPT_INVALID_CERTS") == "true",
183194
namespace: System.get_env("MQTT_NAMESPACE") |> Util.validate_namespace!(),
184-
ipv6: System.get_env("MQTT_IPV6") == "true"
195+
ipv6: System.get_env("MQTT_IPV6") == "true",
196+
discovery: System.get_env("MQTT_HOME_ASSISTANT_DISCOVERY") == "true",
197+
discovery_base_url: System.get_env("MQTT_HOME_ASSISTANT_DISCOVERY_URL"),
198+
discovery_prefix:
199+
System.get_env("MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX") |> Util.validate_discovery_prefix!()
185200
end
186201

187202
if config_env() != :test do

lib/teslamate/mqtt.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ defmodule TeslaMate.Mqtt do
1616
children = [
1717
{Tortoise311.Connection, connection_config(opts) ++ [client_id: client_id]},
1818
{Publisher, client_id: client_id},
19-
{PubSub, namespace: opts[:namespace]}
19+
{PubSub,
20+
[
21+
namespace: opts[:namespace],
22+
discovery: opts[:discovery],
23+
discovery_base_url: opts[:discovery_base_url],
24+
discovery_prefix: opts[:discovery_prefix]
25+
]
26+
|> Enum.reject(fn {_key, value} -> is_nil(value) end)}
2027
]
2128

2229
Supervisor.init(children, strategy: :one_for_one)

lib/teslamate/mqtt/pubsub.ex

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
defmodule TeslaMate.Mqtt.PubSub do
22
use Supervisor
33

4+
require Logger
5+
46
alias __MODULE__.VehicleSubscriber
7+
alias __MODULE__.HomeAssistant
8+
alias TeslaMate.Log
9+
alias TeslaMate.Mqtt.Publisher
510
alias TeslaMate.Vehicles
11+
alias TeslaMate.Vehicles.Vehicle.Summary
612

713
# API
814

@@ -12,10 +18,48 @@ defmodule TeslaMate.Mqtt.PubSub do
1218

1319
@impl true
1420
def init(opts) do
21+
subscriber_opts =
22+
opts
23+
|> Keyword.take([:namespace, :discovery, :discovery_base_url, :discovery_prefix])
24+
25+
vehicles = Vehicles.list()
26+
27+
if Keyword.get(opts, :discovery, false) do
28+
# Runs concurrently with the supervised children starting up, so it may
29+
# fire before the MQTT connection is established. Failures are only
30+
# logged; since the cleanup is idempotent and repeated on every start,
31+
# a missed run is corrected on the next restart.
32+
Task.start(fn -> clear_removed_vehicles(vehicles, opts) end)
33+
end
34+
1535
children =
16-
Vehicles.list()
17-
|> Enum.map(&{VehicleSubscriber, Keyword.merge(opts, car_id: &1.car.id)})
36+
vehicles
37+
|> Enum.map(&{VehicleSubscriber, Keyword.merge(subscriber_opts, car_id: &1.car.id)})
1838

1939
Supervisor.init(children, strategy: :one_for_one)
2040
end
41+
42+
@doc """
43+
Clears Home Assistant discovery configs for cars that are no longer
44+
tracked by a vehicle process, e.g. because they were removed from the
45+
Tesla account or because logging was disabled, so their entities are
46+
removed from Home Assistant.
47+
"""
48+
@spec clear_removed_vehicles([Summary.t()], keyword()) :: :ok
49+
def clear_removed_vehicles(vehicles, opts) do
50+
publisher = Keyword.get(opts, :deps_publisher, Publisher)
51+
active_ids = Enum.map(vehicles, & &1.car.id)
52+
clear_opts = Keyword.take(opts, [:namespace, :discovery_prefix])
53+
54+
Log.list_cars()
55+
|> Enum.reject(&(&1.id in active_ids))
56+
|> Enum.each(fn car ->
57+
case HomeAssistant.clear(car.id, clear_opts, publisher) do
58+
:ok -> :ok
59+
{:error, reason} -> Logger.warning("MQTT HA discovery cleanup failed: #{inspect(reason)}")
60+
end
61+
end)
62+
63+
:ok
64+
end
2165
end

0 commit comments

Comments
 (0)