Skip to content

feat(mqtt): add opt-in Home Assistant MQTT discovery - #5543

Merged
JakobLichterfeld merged 19 commits into
mainfrom
ha_discovery
Aug 14, 2026
Merged

feat(mqtt): add opt-in Home Assistant MQTT discovery#5543
JakobLichterfeld merged 19 commits into
mainfrom
ha_discovery

Conversation

@brianmay

Copy link
Copy Markdown
Collaborator

Add an MQTT_HOME_ASSISTANT_DISCOVERY env var that, when enabled, makes VehicleSubscriber publish HA discovery config payloads (one per entity) to homeassistant//teslamate_<car_id>/<object_id>/config on startup, mirroring the manual configuration documented in website/docs/integrations/home_assistant.md.

@netlify

netlify Bot commented Jul 23, 2026

Copy link
Copy Markdown

Deploy Preview for teslamate ready!

Name Link
🔨 Latest commit 75da857
🔍 Latest deploy log https://app.netlify.com/projects/teslamate/deploys/6a7f2688d82fcc0008bc02ff
😎 Deploy Preview https://deploy-preview-5543--teslamate.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@brianmay

Copy link
Copy Markdown
Collaborator Author

This has been irritating me for a while.

Was created by AI. Not tested or reviewed yet :-)

Not tried to add new config to nix module yet either. Don't want to conflict with #5337. But nix config might be required in order for me to test it properly.

@brianmay

Copy link
Copy Markdown
Collaborator Author

Opt-in seems to be sensible, to not auto-break existing configurations.

@JakobLichterfeld JakobLichterfeld added the enhancement New feature or request label Jul 24, 2026
@JakobLichterfeld JakobLichterfeld added this to the v4.1.0 milestone Jul 24, 2026
@JakobLichterfeld JakobLichterfeld added elixir Pull requests that update Elixir code area:teslamate Related to TeslaMate core labels Jul 24, 2026
@JakobLichterfeld

JakobLichterfeld commented Jul 24, 2026

Copy link
Copy Markdown
Member

Nice addition!

Not tried to add new config to nix module yet either. Don't want to conflict with #5337. But nix config might be required in order for me to test it properly.

I fixed a small issue, successfully tested in production with UNIX socket connection, and merged it you can rebase to main.

Not tested or reviewed yet :-)

I can review in the coming days.

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Brian, this is a long-requested feature and the implementation looks solid — opt-in by default, the entity definitions mirror our documented manual setup nicely, and the test coverage is great. 👍

A few things I'd like to see addressed before merging:

  1. HomeAssistant.clear/3 is never called outside its own test. Its docstring promises entities are removed "when discovery is disabled or a vehicle is removed", but nothing wires it up — users who turn discovery off will keep the retained config payloads (and thus the entities in HA) forever. Either hook it up (e.g. clear once on init when discovery is disabled) or document how to clean up manually.

  2. Migration from the manual YAML config: the generated unique_ids (teslamate_1_battery_level, …) are identical to the ones in our documented mqtt_sensors.yaml, so running both at the same time will produce duplicate-ID errors in HA. Also, the docs claim "the entity IDs match those produced by the manual mqtt_sensors.yaml", but with object_id: "speed" HA should generate sensor.speed, while the manual config uses sensor.tesla_speed — that would break existing dashboards/automations on migration. Could you verify the resulting entity IDs, and add a note to the docs that the manual configuration must be removed first?

  3. Markdown table in environment_variables.md got mangled: the separator row gained an extra | --- | column and the MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX row has a trailing empty cell — this won't render correctly.

Smaller points, non-blocking:

  • healthy is in @do_not_retain, so the discovered binary sensor stays unknown after an HA restart until the value changes. It could also serve as an availability topic for all entities — fine as a follow-up.
  • MQTT_NAMESPACE is validated via validate_namespace!, the new MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX isn't — an empty value produces topics with a leading /.
  • charge_energy_added uses state_class: total; since the value resets per charging session, please double-check whether total_increasing is the better fit for the HA energy dashboard.
  • The env var docs say MQTT_HOME_ASSISTANT_DISCOVERY should be "set together with MQTT_HOME_ASSISTANT_DISCOVERY_URL", but the URL is optional — maybe reword.
  • home_assistant_test.exs uses TeslaMate.DataCase but never touches the database — plain ExUnit.Case would do.

🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@JakobLichterfeld

Copy link
Copy Markdown
Member

One more thing I noticed while cross-checking against the full topic list in website/docs/integrations/mqtt.md:

The entity list mirrors the manual mqtt_sensors.yaml, which is itself only a subset of what TeslaMate publishes. Non-deprecated topics currently without a discovery entity:

  • charging_state — probably the most useful one, I'd like to see it included right away
  • charge_current_request / charge_current_request_max
  • center_display_state, service_mode
  • download_perc / install_perc (software update progress)
  • the eight individual door/window sensors (driver_front_door_open, …) — only the aggregate doors_open/windows_open are covered
  • tpms_soft_warning_fl/fr/rl/rr
  • sun_roof_installed / sun_roof_percent_open / sun_roof_state (model-dependent)

(latitude/longitude are fine via the location device tracker, and the deprecated active_route_* topics are correctly derived from the active_route JSON instead.)

Since the discovery configs are retained and published per entity, extending coverage later is cheap — so apart from charging_state this doesn't have to block the PR. But the docs should state which topics get an entity so users know what to expect.

🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@brianmay

Copy link
Copy Markdown
Collaborator Author

Thanks!

Will have a look at this ASAP.

The one remaining thing I can think of right now will be updating the nix module. Probably add the config values, but give them good default values so they can be optional.

@brianmay

Copy link
Copy Markdown
Collaborator Author

I intend to look into this as my next priority. Right after I fix the mess I created in my personal project ;-)

@brianmay

Copy link
Copy Markdown
Collaborator Author

Rebased against latest main branch; one conflict resolve.

@brianmay

brianmay commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

I plan to address each concern in a separate commit. Hopefully this will make it easier to review. Changes can be squashed before merging.

AI summary of the concerns, turned into TODO items for easy tracking. I hope I didn't miss anything (will edit as required):

  • Fix the mangled table in environment_variables.md (blocking 3)
  • Reword the URL wording + validate MQTT_HOME_ASSISTANT_DISCOVERY_PREFIX like MQTT_NAMESPACE
  • Wire up HomeAssistant.clear/3 (blocking 1) + test
  • Migration-safe entity IDs + docs note to remove the manual YAML (blocking 2)
  • charge_energy_addedtotal_increasing
  • home_assistant_test.exs → plain ExUnit.Case
  • Add charging_state entity (from second review)
  • Docs listing which topics get an entity
  • Check: discovery topics (teslamate_<car_id>) and unique_ids don't include MQTT_NAMESPACE.

brianmay added a commit that referenced this pull request Aug 11, 2026
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.
brianmay added a commit that referenced this pull request Aug 11, 2026
MQTT_HOME_ASSISTANT_DISCOVERY_URL is optional; clarify that setting MQTT_HOME_ASSISTANT_DISCOVERY does not require it. Addresses review comment on #5543.
brianmay added a commit that referenced this pull request Aug 11, 2026
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.
brianmay added a commit that referenced this pull request Aug 11, 2026
MQTT_HOME_ASSISTANT_DISCOVERY_URL is optional; clarify that setting MQTT_HOME_ASSISTANT_DISCOVERY does not require it. Addresses review comment on #5543.
brianmay added a commit that referenced this pull request Aug 11, 2026
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.
brianmay added a commit that referenced this pull request Aug 11, 2026
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.
@brianmay

Copy link
Copy Markdown
Collaborator Author

Calling clear when vehicle is removed might be tricky. Wondering if it is worth it. AI says:

You've understood it correctly — there is genuinely no vehicle-removal event to hook into. Here's the situation:

Current architecture

  • Vehicles and Mqtt.PubSub are both static supervisors — their children are fixed at init. The vehicle list is fetched from the Tesla API once at startup (vehicles.ex:81), and Vehicles.restart()/kill() (which would re-list) are only used in tests.
  • So TeslaMate only notices a removed car on app restart, and then just silently fails to start a subscriber for it. Nobody ever runs HomeAssistant.clear for that car.
  • The removed car's car_id isn't tracked anywhere after the fact, and car_ids are auto-increment (unbounded), so you can't enumerate the stale discovery topics from scratch.

The one usable signal: the DB. Log.list_cars() returns every car ever seen — removed cars are never deleted (history is kept). So at startup you could clear discovery for all DB car_ids, then the current subscribers republish fresh configs for the cars that still exist. That handles removal with no cross-restart state:

  • PubSub.init: if discovery enabled, clear configs for every car in the DB → removed cars' entities disappear.
  • Existing subscribers publish fresh configs on their first summary → current cars reappear.

Caveats: a brief window where entities vanish then return on restart, and cars with logging disabled (settings.enabled == false, filtered out of Vehicles) would lose their entities since no subscriber republishes them.

Pragmatic alternative: the review explicitly accepted clear-on-init, so we could just soften the clear/3 docstring to not promise vehicle removal and document manual cleanup instead (that's what #5555 effectively did).

Which do you want — the DB-driven clear-on-startup, or trim the docstring + document manual cleanup for the removal case?

That first solution feels kind of yuck, but haven't thought about it in great detail yet.

@brianmay

Copy link
Copy Markdown
Collaborator Author

Presumably what we need is to process every vehicle in Log.list_cars() that is not returned by TeslaMate.Api.list_vehicles().

Might require some restructuring of code. Opinions appreciated :-)

@JakobLichterfeld

JakobLichterfeld commented Aug 12, 2026

Copy link
Copy Markdown
Member

Thanks for your continued work on this!

I'd advise against diffing against TeslaMate.Api.list_vehicles() — it's unreliable exactly when you'd need it: at startup it can return :not_signed_in, rate-limit errors, etc., which is why Vehicles.init already falls back to Log.list_cars() in those cases. A "DB minus API" diff would then consider every car removed. Plumbing the API result through to make it safe is the restructuring you suspected, and I don't think it's worth it.

I'd frame the invariant differently: discovery configs should exist exactly for the cars TeslaMate publishes state topics for — and that set is simply the running vehicle processes, i.e. Vehicles.list(). That also covers cars with logging disabled (settings.enabled == false): they're still in the API response, but get no MQTT data, so their entities should be cleared as well — the API diff would incorrectly keep them.

Concretely: in Mqtt.PubSub.init (when discovery is enabled), take Log.list_cars() minus the car IDs from the Vehicles.list() call that's already there, and run HomeAssistant.clear/3 for the difference. No API dependency, no restructuring, and no vanish-and-reappear window since active cars are never cleared. The remaining gaps — a prefix change leaves old topics behind, and a car removed mid-run is only cleaned up on the next restart — I'd just document.

One related thing I noticed while looking at this: the discovery topics (teslamate_<car_id>) and unique_ids don't include MQTT_NAMESPACE. Two instances sharing one broker — the very use case namespaces exist for — would collide on the same config topics, and one instance's clears would wipe the other's entities (car IDs start at 1 everywhere). Probably worth scoping the node/unique_id by namespace before this ships, even though that's a separate concern.

🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

brianmay added a commit that referenced this pull request Aug 12, 2026
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.
@brianmay

Copy link
Copy Markdown
Collaborator Author

Hopefully I have addressed most of the concerns now.

As per #5543 (comment) there are additional topics I can add if required. I added charging_state only for now.

I see a test failure - which I suspect is not related to these changes, and a dialyzer failure. Will investigate.

@brianmay

Copy link
Copy Markdown
Collaborator Author

Now fixed the nix module too.

@JakobLichterfeld JakobLichterfeld left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive turnaround, thanks Brian! I went through all nine new commits — the startup cleanup is exactly the approach we discussed, the entity ID alignment checks out against the manual YAML (I verified the unique_ids match, including the dropped _km suffixes), the prefix validation mirrors MQTT_NAMESPACE (empty falls back to the default, wildcards raise at boot), the namespace scoping is correctly applied to node, unique_id, and device identifier, and charging_state (nice touch adding the manual YAML equivalent too), the nix module (defaults make the options properly optional), and the doc updates all look good.

One real issue, from the interplay of two of the new commits: the startup cleanup in PubSub.clear_removed_vehicles/2 passes only Keyword.take(opts, [:discovery_prefix]) to HomeAssistant.clear/3 — it predates the namespace-scoping commit and wasn't updated (the disable-path in VehicleSubscriber.clear_discovery/1 does pass the namespace correctly). On an instance with MQTT_NAMESPACE set this means:

  1. its own stale configs (under teslamate_<ns>_<car_id>) are never cleared, and
  2. worse, it publishes empty retained payloads to the un-namespaced teslamate_<car_id>/... topics — potentially wiping the entities of a sibling instance on the same broker, which is exactly the interference the namespace commit prevents.

Should be a one-liner (Keyword.take(opts, [:discovery_prefix, :namespace])) plus a namespace case in pubsub_test.exs.

Non-blocking points:

  • The Task.start cleanup runs concurrently with the supervisor children starting up, so it may fire before the MQTT connection is established — failures are only logged and cleanup then happens on the next restart. Since it's idempotent and retried every start that's fine, but a short code comment noting this would help future readers.
  • The drain_discovery_configs test helper is now copied into three test files (vehicle_subscriber_test, vehicle_sync_test, and a variant in home_assistant_test) — maybe worth extracting into a shared helper while squashing. Each call also idles for 200 ms, which adds a bit of suite runtime.
  • The state_class: totaltotal_increasing change also touches the pre-existing manual YAML in the docs. Semantically correct, but worth a mention in the release notes since it changes HA long-term statistics behavior for manual-config users who re-sync their YAML.
  • Noticed in passing: validate_namespace! rejects / but not the MQTT wildcards +/#, while the new validate_discovery_prefix! does. A namespace containing a wildcard already breaks the regular state topics on main, so this is pre-existing and out of scope — but if you're touching runtime.exs anyway, aligning the two would be a cheap hardening.

I also checked the performance impact with our low-end targets (e.g. Raspberry Pi 3B+) in mind, since the boot-time clears run for everyone, including users who never enable discovery: steady state adds only a single boolean check per summary; the one-time discovery burst (~72 small JSON payloads) goes through the same publisher path as the ~60 values of every regular summary publish, so it's on the order of one extra summary; and the per-boot clears run in handle_continue / a background task, so supervision tree startup isn't blocked and boot time is unchanged. If the broker is unreachable, Tortoise fails fast and reduce_while halts — no timeout pile-up. So: no concerns.

Once the namespace fix is in, this looks ready to me. Regarding the remaining topics from the coverage list: fine to leave as a follow-up, the per-entity configs make extending cheap.

🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

@JakobLichterfeld

Copy link
Copy Markdown
Member

To save a round-trip I pushed the remaining review points myself:

  • The blocker: clear_removed_vehicles/2 now passes the namespace to HomeAssistant.clear/3 (computed once before the loop), so a namespaced instance clears its own discovery topics instead of the un-namespaced ones of a sibling instance sharing the broker. Added a namespace case to pubsub_test.exs asserting the scoped node is cleared and the un-namespaced topic stays untouched.
  • Non-blocking nits: extracted the shared drain_discovery_configs test helper into TestHelper (the find_config helpers in home_assistant_test stay local, they're single-use), added a short comment explaining the startup cleanup timing vs. the MQTT connection, and aligned validate_namespace! with validate_discovery_prefix! so MQTT wildcards in the namespace now raise at boot.
  • Changelog: added the feature entry to [unreleased], plus a note section at the top for manual-config users: re-syncing the manual YAML picks up state_class: total_increasing for charge_energy_added, which changes the Home Assistant long-term statistics behavior.

All affected test files pass locally and mix format is clean — from my side this is now ready. Thanks again for the great work, Brian!


🤖 Review drafted with Claude Code (Fable 5 high) — sponsored by Claude for Open Source

brianmay and others added 19 commits August 14, 2026 16:29
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.
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.
MQTT_HOME_ASSISTANT_DISCOVERY_URL is optional; clarify that setting MQTT_HOME_ASSISTANT_DISCOVERY does not require it. Addresses review comment on #5543.
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.
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.
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.
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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
@JakobLichterfeld
JakobLichterfeld merged commit 345ee52 into main Aug 14, 2026
27 checks passed
@JakobLichterfeld
JakobLichterfeld deleted the ha_discovery branch August 14, 2026 14:55
JakobLichterfeld added a commit that referenced this pull request Aug 14, 2026
…5611)

* fix(mqtt): don't require the namespace option in VehicleSubscriber

Since #5543, Mqtt.init drops nil options before starting PubSub, so the :namespace key is absent when MQTT_NAMESPACE is unset and the previous Keyword.fetch! crashed every such instance at boot (KeyError crash loop).
Use Keyword.get and add a regression test starting a subscriber without the key.

Fixes #5608

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update changelog

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:teslamate Related to TeslaMate core elixir Pull requests that update Elixir code enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants