Skip to content

Commit 2437427

Browse files
author
Bolyki György
committed
feat: add runtime liveness reporting
1 parent 7054517 commit 2437427

20 files changed

Lines changed: 1320 additions & 26 deletions

File tree

lib/tesla_api/stream.ex

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule TeslaApi.Stream do
1111
vehicle_id: nil,
1212
timer: nil,
1313
receiver: &IO.inspect/1,
14+
liveness_receiver: nil,
1415
last_data: nil,
1516
timeouts: 0,
1617
disconnects: 0
@@ -27,6 +28,7 @@ defmodule TeslaApi.Stream do
2728
def start_link(args) do
2829
state = %State{
2930
receiver: Keyword.get(args, :receiver, &Logger.debug(inspect(&1))),
31+
liveness_receiver: Keyword.get(args, :liveness_receiver, fn _event -> :ok end),
3032
vehicle_id: Keyword.fetch!(args, :vehicle_id),
3133
auth: Keyword.fetch!(args, :auth)
3234
}
@@ -56,13 +58,15 @@ defmodule TeslaApi.Stream do
5658

5759
@impl true
5860
def handle_cast(:disconnect, %State{vehicle_id: vid} = state) do
61+
notify_liveness(state, :disconnected)
5962
send(self(), :exit)
6063
{:reply, frame!(%{msg_type: "data:unsubscribe", tag: "#{vid}"}), state}
6164
end
6265

6366
@impl true
64-
def handle_connect(_conn, state) do
67+
def handle_connect(_conn, %State{} = state) do
6568
Logger.debug("Connection established")
69+
notify_liveness(state, :connected)
6670
send(self(), :subscribe)
6771
{:ok, state}
6872
end
@@ -203,13 +207,16 @@ defmodule TeslaApi.Stream do
203207

204208
case reason do
205209
{:local, :normal} ->
210+
notify_liveness(state, :reconnecting)
211+
206212
Logger.debug(
207213
"Connection was closed (a:#{n}|t:#{state.timeouts}|d:#{state.disconnects}). Reconnecting …"
208214
)
209215

210216
{:reconnect, state}
211217

212218
{:remote, :closed} ->
219+
notify_liveness(state, :reconnecting)
213220
Logger.warning("WebSocket disconnected. Reconnecting …")
214221

215222
n
@@ -219,6 +226,7 @@ defmodule TeslaApi.Stream do
219226
{:reconnect, %{state | last_data: nil}}
220227

221228
%WebSockex.ConnError{} = e ->
229+
notify_liveness(state, :reconnecting)
222230
Logger.warning("Disconnected! #{Exception.message(e)} | #{n}")
223231

224232
n
@@ -228,6 +236,7 @@ defmodule TeslaApi.Stream do
228236
{:reconnect, state}
229237

230238
%WebSockex.RequestError{} = e ->
239+
notify_liveness(state, :reconnecting)
231240
Logger.warning("Disconnected! #{Exception.message(e)} | #{n}")
232241

233242
n
@@ -241,7 +250,9 @@ defmodule TeslaApi.Stream do
241250
@impl true
242251
def terminate(:normal, _state), do: :ok
243252

244-
def terminate(reason, _state) do
253+
def terminate(reason, %State{} = state) do
254+
notify_liveness(state, :terminated)
255+
245256
# https://github.com/Azolo/websockex/issues/51
246257
with {exception, stacktrace} <- reason, true <- is_exception(exception) do
247258
Logger.error(fn -> Exception.format(:error, exception, stacktrace) end)
@@ -264,6 +275,9 @@ defmodule TeslaApi.Stream do
264275
:math.pow(base, n) |> min(max) |> max(min) |> round() |> :timer.seconds()
265276
end
266277

278+
defp notify_liveness(%State{liveness_receiver: nil}, _event), do: :ok
279+
defp notify_liveness(%State{liveness_receiver: receiver}, event), do: receiver.(event)
280+
267281
defp cancel_timer(nil), do: :ok
268282
defp cancel_timer(ref) when is_reference(ref), do: Process.cancel_timer(ref)
269283
end

lib/teslamate/api.ex

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,26 @@ defmodule TeslaMate.Api do
4949
end
5050
end
5151

52-
def stream(name \\ @name, vid, receiver) do
52+
def stream(vid, receiver) when is_function(receiver, 1) do
53+
stream(@name, vid, receiver, [])
54+
end
55+
56+
def stream(vid, receiver, opts) when is_function(receiver, 1) and is_list(opts) do
57+
stream(@name, vid, receiver, opts)
58+
end
59+
60+
def stream(name, vid, receiver) when is_function(receiver, 1) do
61+
stream(name, vid, receiver, [])
62+
end
63+
64+
def stream(name, vid, receiver, opts) when is_function(receiver, 1) and is_list(opts) do
5365
with {:ok, %Auth{} = auth} <- fetch_auth(name) do
54-
TeslaApi.Stream.start_link(auth: auth, vehicle_id: vid, receiver: receiver)
66+
TeslaApi.Stream.start_link(
67+
auth: auth,
68+
vehicle_id: vid,
69+
receiver: receiver,
70+
liveness_receiver: Keyword.get(opts, :liveness_receiver, fn _event -> :ok end)
71+
)
5572
end
5673
end
5774

lib/teslamate/application.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ defmodule TeslaMate.Application do
2828
TeslaMate.Api,
2929
TeslaMate.Updater,
3030
{Phoenix.PubSub, name: TeslaMate.PubSub},
31+
{TeslaMate.RuntimeHealth, mqtt_enabled: mqtt_config != nil},
3132
TeslaMateWeb.Endpoint,
3233
TeslaMate.Terrain,
3334
TeslaMate.Vehicles,
@@ -44,6 +45,7 @@ defmodule TeslaMate.Application do
4445
TeslaMate.Api,
4546
TeslaMate.Updater,
4647
{Phoenix.PubSub, name: TeslaMate.PubSub},
48+
{TeslaMate.RuntimeHealth, mqtt_enabled: false},
4749
TeslaMateWeb.Endpoint,
4850
{TeslaMate.Terrain, disabled: true},
4951
{TeslaMate.Repair, limit: 250},

lib/teslamate/import/fake_api.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ defmodule TeslaMate.Import.FakeApi do
3333
{:ok, nil}
3434
end
3535

36+
def stream(_name, _vid, _receiver, _opts) do
37+
{:ok, nil}
38+
end
39+
3640
# Callbacks
3741

3842
@impl true

lib/teslamate/mqtt.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ defmodule TeslaMate.Mqtt do
5353
user_name: opts[:username],
5454
password: opts[:password],
5555
server: server,
56-
handler: {Handler, []},
56+
handler: {Handler, [runtime_health: TeslaMate.RuntimeHealth]},
5757
subscriptions: []
5858
]
5959
end

lib/teslamate/mqtt/handler.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,39 @@ defmodule TeslaMate.Mqtt.Handler do
22
use Tortoise311.Handler
33

44
require Logger
5+
import Core.Dependency, only: [call: 3]
6+
7+
alias TeslaMate.RuntimeHealth
58

69
@impl true
710
def connection(:up, state) do
811
Logger.info("MQTT connection has been established")
12+
:ok = call(runtime_health(state), :record_mqtt_connection, [:up])
913
{:ok, state}
1014
end
1115

1216
def connection(:down, state) do
1317
Logger.warning("MQTT connection has been dropped")
18+
:ok = call(runtime_health(state), :record_mqtt_connection, [:down])
1419
{:ok, state}
1520
end
1621

1722
def connection(:terminating, state) do
1823
Logger.warning("MQTT connection is terminating")
24+
:ok = call(runtime_health(state), :record_mqtt_connection, [:terminating])
1925
{:ok, state}
2026
end
2127

2228
@impl true
23-
def terminate(reason, _state) do
29+
def terminate(reason, state) do
2430
Logger.warning("MQTT Client has been terminated with reason: #{inspect(reason)}")
31+
:ok = call(runtime_health(state), :record_mqtt_connection, [:terminated])
2532
:ok
2633
end
34+
35+
defp runtime_health(state) when is_list(state) do
36+
Keyword.get(state, :runtime_health, RuntimeHealth)
37+
end
38+
39+
defp runtime_health(_state), do: RuntimeHealth
2740
end

lib/teslamate/mqtt/pubsub/vehicle_subscriber.ex

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
55
import Core.Dependency, only: [call: 3]
66

77
alias TeslaMate.Mqtt.Publisher
8+
alias TeslaMate.RuntimeHealth
89
alias TeslaMate.Vehicles.Vehicle.Summary
910
alias TeslaMate.Vehicles
1011

11-
defstruct [:car_id, :last_values, :deps, :namespace]
12+
defstruct [:car_id, :last_values, :deps, :namespace, mqtt_generation: 0]
1213
alias __MODULE__, as: State
1314

1415
def child_spec(arg) do
@@ -45,13 +46,31 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
4546

4647
deps = %{
4748
vehicles: Keyword.get(opts, :deps_vehicles, Vehicles),
48-
publisher: Keyword.get(opts, :deps_publisher, Publisher)
49+
publisher: Keyword.get(opts, :deps_publisher, Publisher),
50+
runtime_health: Keyword.get(opts, :deps_runtime_health, RuntimeHealth)
4951
}
5052

5153
:ok = call(deps.vehicles, :subscribe_to_summary, [car_id])
54+
:ok = call(deps.runtime_health, :subscribe_mqtt, [])
5255
:ok = clear_retained(car_id, namespace, deps.publisher)
5356

54-
{:ok, %State{car_id: car_id, namespace: namespace, deps: deps}}
57+
mqtt_snapshot = call(deps.runtime_health, :mqtt_snapshot, [])
58+
59+
mqtt_generation =
60+
if mqtt_snapshot.status == :ok do
61+
send(self(), {:mqtt_reconnected, mqtt_snapshot.generation})
62+
nil
63+
else
64+
mqtt_snapshot.generation
65+
end
66+
67+
{:ok,
68+
%State{
69+
car_id: car_id,
70+
namespace: namespace,
71+
deps: deps,
72+
mqtt_generation: mqtt_generation
73+
}}
5574
end
5675

5776
@publish_if_nil ~w(charge_energy_added charger_actual_current charger_phases
@@ -60,14 +79,36 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
6079

6180
@impl true
6281
def handle_info(%Summary{} = summary, %State{} = state) do
82+
state = reconcile_mqtt_generation(state)
83+
publish_summary(summary, state)
84+
end
85+
86+
def handle_info({:mqtt_reconnected, generation}, %State{} = state) do
87+
mqtt_snapshot = current_mqtt_snapshot(state)
88+
89+
if mqtt_snapshot.status == :ok and mqtt_snapshot.generation == generation and
90+
generation != state.mqtt_generation do
91+
state = %{state | last_values: nil, mqtt_generation: generation}
92+
93+
case current_summary(state) do
94+
%Summary{} = summary -> publish_summary(summary, state)
95+
_other -> {:noreply, state}
96+
end
97+
else
98+
{:noreply, state}
99+
end
100+
end
101+
102+
defp publish_summary(%Summary{} = summary, %State{} = state) do
63103
values =
64104
%{}
65105
|> add_simple_values(summary)
66106
|> add_car_latitude_longitude(summary)
67107
|> add_geofence(summary)
68108
|> add_active_route(summary)
69109

70-
last_values = publish_values(values, state)
110+
{last_values, result} = publish_values(values, state)
111+
:ok = call(state.deps.runtime_health, :record_mqtt_publish, [state.car_id, result])
71112
{:noreply, %{state | last_values: last_values}}
72113
end
73114

@@ -86,17 +127,21 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
86127
on_timeout: :kill_task,
87128
ordered: false
88129
)
89-
|> Enum.reduce(last_values, fn
90-
{:ok, {{key, value}, :ok}}, acc ->
91-
Map.put(acc, key, value)
130+
|> Enum.reduce({last_values, 0}, fn
131+
{:ok, {{key, value}, :ok}}, {acc, failures} ->
132+
{Map.put(acc, key, value), failures}
92133

93-
{:ok, {_entry, reason}}, acc ->
134+
{:ok, {_entry, reason}}, {acc, failures} ->
94135
Logger.warning("MQTT publishing failed: #{inspect(reason)}")
95-
acc
136+
{acc, failures + 1}
96137

97-
{:exit, reason}, acc ->
138+
{:exit, reason}, {acc, failures} ->
98139
Logger.warning("MQTT publishing failed: #{inspect(reason)}")
99-
acc
140+
{acc, failures + 1}
141+
end)
142+
|> then(fn
143+
{values, 0} -> {values, :ok}
144+
{values, _failures} -> {values, {:error, :publish_failed}}
100145
end)
101146
end
102147

@@ -214,4 +259,29 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
214259

215260
defp to_str(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
216261
defp to_str(value), do: to_string(value)
262+
263+
defp current_summary(%State{car_id: car_id, deps: deps}) do
264+
call(deps.vehicles, :summary, [car_id])
265+
catch
266+
:exit, reason ->
267+
Logger.warning("Could not republish MQTT snapshot: #{inspect(reason)}")
268+
nil
269+
end
270+
271+
defp reconcile_mqtt_generation(%State{} = state) do
272+
case current_mqtt_snapshot(state) do
273+
%{status: :ok, generation: generation}
274+
when not is_nil(generation) and generation != state.mqtt_generation ->
275+
%{state | last_values: nil, mqtt_generation: generation}
276+
277+
_snapshot ->
278+
state
279+
end
280+
end
281+
282+
defp current_mqtt_snapshot(%State{deps: deps}) do
283+
call(deps.runtime_health, :mqtt_snapshot, [])
284+
catch
285+
:exit, _reason -> %{status: :unavailable, generation: nil}
286+
end
217287
end

0 commit comments

Comments
 (0)