Skip to content

Commit a63f2a9

Browse files
committed
Retry failed Home Assistant discovery publishes
1 parent f0841e8 commit a63f2a9

2 files changed

Lines changed: 179 additions & 14 deletions

File tree

lib/teslamate/mqtt/pubsub/vehicle_subscriber.ex

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
99
alias TeslaMate.Vehicles.Vehicle.Summary
1010
alias TeslaMate.Vehicles
1111

12+
@discovery_retry_initial_delay :timer.seconds(5)
13+
@discovery_retry_max_delay :timer.minutes(5)
14+
1215
defstruct [
1316
:car_id,
1417
:last_values,
@@ -17,7 +20,12 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
1720
:discovery,
1821
:discovery_base_url,
1922
:discovery_prefix,
20-
:discovery_device
23+
:discovery_device,
24+
:discovery_pending_summary,
25+
:discovery_pending_device,
26+
:discovery_retry_delay,
27+
:discovery_retry_timer,
28+
:discovery_retry_token
2129
]
2230

2331
alias __MODULE__, as: State
@@ -126,33 +134,104 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
126134
{:noreply, %{state | last_values: last_values}}
127135
end
128136

137+
def handle_info(
138+
{:retry_discovery, token},
139+
%State{
140+
discovery: true,
141+
discovery_retry_token: token,
142+
discovery_pending_summary: %Summary{} = summary,
143+
discovery_pending_device: device
144+
} = state
145+
) do
146+
state = %{
147+
state
148+
| discovery_pending_summary: nil,
149+
discovery_pending_device: nil,
150+
discovery_retry_timer: nil,
151+
discovery_retry_token: nil
152+
}
153+
154+
{:noreply, publish_or_schedule_discovery(summary, device, state)}
155+
end
156+
157+
def handle_info({:retry_discovery, _stale_token}, %State{} = state), do: {:noreply, state}
158+
129159
defp maybe_publish_discovery(%Summary{} = summary, %State{discovery: true} = state) do
130160
opts = discovery_opts(state)
131161
device = HomeAssistant.device(summary, opts)
132162

133-
if device == state.discovery_device do
134-
state
135-
else
136-
case publish_discovery(summary, opts, state) do
137-
:ok -> %{state | discovery_device: device}
138-
{:error, _reason} -> state
139-
end
163+
cond do
164+
device == state.discovery_device ->
165+
reset_discovery_retry(state)
166+
167+
is_reference(state.discovery_retry_timer) ->
168+
%{
169+
state
170+
| discovery_pending_summary: summary,
171+
discovery_pending_device: device
172+
}
173+
174+
true ->
175+
publish_or_schedule_discovery(summary, device, state)
140176
end
141177
end
142178

143179
defp maybe_publish_discovery(%Summary{}, %State{} = state), do: state
144180

145-
defp publish_discovery(%Summary{} = summary, opts, %State{deps: deps}) do
146-
case HomeAssistant.publish(summary, opts, deps.publisher) do
181+
defp publish_or_schedule_discovery(%Summary{} = summary, device, %State{} = state) do
182+
case publish_discovery(summary, discovery_opts(state), state) do
147183
:ok ->
148-
:ok
184+
state
185+
|> reset_discovery_retry()
186+
|> Map.put(:discovery_device, device)
149187

150-
{:error, reason} = error ->
151-
Logger.warning("MQTT HA discovery publishing failed: #{inspect(reason)}")
152-
error
188+
{:error, reason} ->
189+
schedule_discovery_retry(summary, device, reason, state)
153190
end
154191
end
155192

193+
defp publish_discovery(%Summary{} = summary, opts, %State{deps: deps}) do
194+
HomeAssistant.publish(summary, opts, deps.publisher)
195+
end
196+
197+
defp schedule_discovery_retry(%Summary{} = summary, device, reason, %State{} = state) do
198+
delay = next_discovery_retry_delay(state.discovery_retry_delay)
199+
token = make_ref()
200+
timer = Process.send_after(self(), {:retry_discovery, token}, delay)
201+
202+
Logger.warning(
203+
"MQTT HA discovery publishing failed: #{inspect(reason)}; retrying in #{div(delay, 1_000)}s"
204+
)
205+
206+
%{
207+
state
208+
| discovery_pending_summary: summary,
209+
discovery_pending_device: device,
210+
discovery_retry_delay: delay,
211+
discovery_retry_timer: timer,
212+
discovery_retry_token: token
213+
}
214+
end
215+
216+
defp next_discovery_retry_delay(nil), do: @discovery_retry_initial_delay
217+
218+
defp next_discovery_retry_delay(delay) do
219+
min(delay * 2, @discovery_retry_max_delay)
220+
end
221+
222+
defp reset_discovery_retry(%State{discovery_retry_timer: timer} = state) do
223+
if is_reference(timer), do: Process.cancel_timer(timer)
224+
225+
%{
226+
state
227+
| discovery_pending_summary: nil,
228+
discovery_pending_device: nil,
229+
discovery_retry_delay: nil,
230+
discovery_retry_timer: nil,
231+
discovery_retry_token: nil
232+
}
233+
end
234+
156235
defp discovery_opts(%State{} = state) do
157236
[
158237
car_id: state.car_id,

test/teslamate/mqtt/pubsub/vehicle_subscriber_test.exs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
5252
)
5353
end
5454

55+
defp assert_discovery_retry(pid, expected_delay) do
56+
state = :sys.get_state(pid)
57+
58+
assert state.discovery_retry_delay == expected_delay
59+
assert is_reference(state.discovery_retry_timer)
60+
assert is_reference(state.discovery_retry_token)
61+
assert is_integer(Process.read_timer(state.discovery_retry_timer))
62+
63+
state
64+
end
65+
66+
defp fire_discovery_retry(pid) do
67+
state = :sys.get_state(pid)
68+
69+
assert is_reference(state.discovery_retry_timer)
70+
assert is_reference(state.discovery_retry_token)
71+
assert is_integer(Process.cancel_timer(state.discovery_retry_timer))
72+
73+
send(pid, {:retry_discovery, state.discovery_retry_token})
74+
end
75+
5576
test "starts before retained topic cleanup completes", %{test: name} do
5677
vehicles_name = :"vehicles_#{name}"
5778
{:ok, _pid} = start_supervised({VehiclesMock, name: vehicles_name, pid: self()})
@@ -511,6 +532,71 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
511532
assert Jason.decode!(payload)["device"]["sw_version"] == "2026.26.1"
512533
end
513534

535+
@tag :capture_log
536+
test "backs off failed discovery publishes and retries the latest metadata", %{test: name} do
537+
topic = "homeassistant/sensor/teslamate_0/display_name/config"
538+
error = {:error, :disconnected}
539+
responses = %{topic => List.duplicate(error, 7) ++ [:ok, error]}
540+
541+
log =
542+
ExUnit.CaptureLog.capture_log(fn ->
543+
{:ok, pid} = start_subscriber(name, 0, nil, responses, discovery: true)
544+
545+
assert_receive {VehiclesMock, {:subscribe_to_summary, 0}}
546+
547+
summary = %Summary{healthy: true, display_name: "Foo", model: "3", state: :online}
548+
send(pid, summary)
549+
550+
assert_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
551+
552+
state = assert_discovery_retry(pid, :timer.seconds(5))
553+
assert state.discovery_pending_summary == summary
554+
assert state.discovery_pending_device.model == "Model 3"
555+
556+
send(pid, %Summary{summary | version: "2026.1"})
557+
latest = %Summary{summary | version: "2026.2"}
558+
send(pid, latest)
559+
560+
state = :sys.get_state(pid)
561+
assert state.discovery_pending_summary == latest
562+
assert state.discovery_pending_device.sw_version == "2026.2"
563+
assert state.discovery_retry_delay == :timer.seconds(5)
564+
565+
refute_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
566+
567+
for expected_delay <- [10, 20, 40, 80, 160, 300] do
568+
fire_discovery_retry(pid)
569+
assert_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
570+
assert_discovery_retry(pid, :timer.seconds(expected_delay))
571+
end
572+
573+
fire_discovery_retry(pid)
574+
assert_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
575+
576+
state = :sys.get_state(pid)
577+
assert state.discovery_device.sw_version == "2026.2"
578+
assert state.discovery_pending_summary == nil
579+
assert state.discovery_pending_device == nil
580+
assert state.discovery_retry_delay == nil
581+
assert state.discovery_retry_timer == nil
582+
assert state.discovery_retry_token == nil
583+
584+
drain_discovery_configs()
585+
586+
send(pid, %Summary{latest | speed: 42})
587+
:sys.get_state(pid)
588+
refute_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
589+
590+
send(pid, %Summary{latest | version: "2026.3"})
591+
assert_receive {MqttPublisherMock, {:publish, ^topic, _payload, _opts}}
592+
assert_discovery_retry(pid, :timer.seconds(5))
593+
end)
594+
595+
assert length(Regex.scan(~r/MQTT HA discovery publishing failed/, log)) == 8
596+
assert log =~ "retrying in 5s"
597+
assert log =~ "retrying in 300s"
598+
end
599+
514600
test "clears discovery configs when discovery is disabled", %{test: name} do
515601
{:ok, pid} = start_subscriber(name, 0)
516602

0 commit comments

Comments
 (0)