Add option use custom namespace for MQTT topics

This commit is contained in:
Adrian Kumpf
2020-01-17 14:13:04 +01:00
parent 52c26fb74a
commit 120e82cb18
7 changed files with 88 additions and 28 deletions

View File

@@ -9,6 +9,16 @@ defmodule Util do
def validate_locale!("de"), do: "de"
def validate_locale!(lang), do: raise("Unsupported locale: #{inspect(lang)}")
def validate_namespace!(nil), do: nil
def validate_namespace!(""), do: nil
def validate_namespace!(ns) when is_binary(ns) do
case String.contains?(ns, "/") do
true -> raise "MQTT_NAMESPACE must not contain '/'"
false -> ns
end
end
def parse_check_origin!("true"), do: true
def parse_check_origin!("false"), do: false
def parse_check_origin!(hosts) when is_binary(hosts), do: String.split(hosts, ",")
@@ -40,7 +50,8 @@ if System.get_env("DISABLE_MQTT") != "true" do
username: System.get_env("MQTT_USERNAME"),
password: System.get_env("MQTT_PASSWORD"),
tls: System.get_env("MQTT_TLS"),
accept_invalid_certs: System.get_env("MQTT_TLS_ACCEPT_INVALID_CERTS")
accept_invalid_certs: System.get_env("MQTT_TLS_ACCEPT_INVALID_CERTS"),
namespace: System.get_env("MQTT_NAMESPACE") |> Util.validate_namespace!()
end
config :logger,

View File

@@ -19,5 +19,6 @@ TeslaMate accepts the following environment variables for runtime configuration:
| **MQTT_PASSWORD** | Password _(optional)_ | |
| **MQTT_TLS** | Enables TLS if `true` _(optional)_ | false |
| **MQTT_TLS_ACCEPT_INVALID_CERTS** | Accepts invalid certificates if `true` _(optional)_ | false |
| **MQTT_NAMESPACE** | Inserts a custom namespace into the MQTT topic _(optional)_. For example, with `MQTT_NAMESPACE=account_0`: `teslamate/account_0/cars/$car_id/state`. | |
| **LOCALE** | The default locale for the web interface and addresses. Currently available: `en` (default) and `de` | en |
| **TZ** | Used to establish the local time zone, e.g. to use the local time in logs. See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). | |

View File

@@ -14,9 +14,9 @@ defmodule TeslaMate.Mqtt do
client_id = generate_client_id()
children = [
{Tortoise.Connection, config() ++ [client_id: client_id]},
{Tortoise.Connection, connection_config() ++ [client_id: client_id]},
{Publisher, client_id: client_id},
PubSub
{PubSub, namespace: namespace()}
]
Supervisor.init(children, strategy: :one_for_one)
@@ -26,14 +26,14 @@ defmodule TeslaMate.Mqtt do
alias Tortoise.Transport
defp config do
auth = Application.get_env(:teslamate, :mqtt)
host = Keyword.get(auth, :host)
defp connection_config do
opts = Application.get_env(:teslamate, :mqtt)
host = Keyword.get(opts, :host)
server =
if Keyword.get(auth, :tls) == "true" do
if Keyword.get(opts, :tls) == "true" do
verify =
if Keyword.get(auth, :accept_invalid_certs) == "true" do
if Keyword.get(opts, :accept_invalid_certs) == "true" do
:verify_none
else
:verify_peer
@@ -45,14 +45,18 @@ defmodule TeslaMate.Mqtt do
end
[
user_name: Keyword.get(auth, :username),
password: Keyword.get(auth, :password),
user_name: Keyword.get(opts, :username),
password: Keyword.get(opts, :password),
server: server,
handler: {Handler, []},
subscriptions: []
]
end
defp namespace do
Application.get_env(:teslamate, :mqtt) |> Keyword.get(:namespace)
end
defp generate_client_id do
"TESLAMATE_" <> (:rand.uniform() |> to_string() |> Base.encode16() |> String.slice(0..10))
end

View File

@@ -11,10 +11,10 @@ defmodule TeslaMate.Mqtt.PubSub do
end
@impl true
def init(_opts) do
def init(opts) do
children =
Vehicles.list()
|> Enum.map(&{VehicleSubscriber, car_id: &1.car.id})
|> Enum.map(&{VehicleSubscriber, Keyword.merge(opts, car_id: &1.car.id)})
Supervisor.init(children, strategy: :one_for_one)
end

View File

@@ -8,7 +8,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
alias TeslaMate.Vehicles.Vehicle.Summary
alias TeslaMate.Vehicles
defstruct [:car_id, :last_summary, :deps]
defstruct [:car_id, :last_summary, :deps, :namespace]
alias __MODULE__, as: State
def child_spec(arg) do
@@ -25,6 +25,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
@impl true
def init(opts) do
car_id = Keyword.fetch!(opts, :car_id)
namespace = Keyword.fetch!(opts, :namespace)
deps = %{
vehicles: Keyword.get(opts, :deps_vehicles, Vehicles),
@@ -33,7 +34,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
:ok = call(deps.vehicles, :subscribe_to_summary, [car_id])
{:ok, %State{car_id: car_id, deps: deps}}
{:ok, %State{car_id: car_id, namespace: namespace, deps: deps}}
end
@impl true
@@ -65,12 +66,13 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriber do
{:noreply, %State{state | last_summary: summary}}
end
defp publish({key, value}, %State{car_id: car_id, deps: deps}) do
call(deps.publisher, :publish, [
"teslamate/cars/#{car_id}/#{key}",
to_str(value),
[retain: true, qos: 1]
])
defp publish({key, value}, %State{car_id: car_id, namespace: namespace, deps: deps}) do
topic =
["teslamate", namespace, "cars", car_id, key]
|> Enum.reject(&is_nil(&1))
|> Enum.join("/")
call(deps.publisher, :publish, [topic, to_str(value), [retain: true, qos: 1]])
end
defp to_str(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)

View File

@@ -4,7 +4,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
alias TeslaMate.Mqtt.PubSub.VehicleSubscriber
alias TeslaMate.Vehicles.Vehicle.Summary
defp start_subscriber(name, car_id) do
defp start_subscriber(name, car_id, namespace \\ nil) do
publisher_name = :"mqtt_publisher_#{name}"
vehicles_name = :"vehicles_#{name}"
@@ -16,6 +16,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
[
name: name,
car_id: car_id,
namespace: namespace,
deps_publisher: {MqttPublisherMock, publisher_name},
deps_vehicles: {VehiclesMock, vehicles_name}
]}
@@ -88,6 +89,7 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
summary = %Summary{
plugged_in: false,
battery_level: 60.0,
usable_battery_level: 59,
charge_energy_added: 25,
charge_limit_soc: 90,
charge_port_door_open: false,
@@ -124,4 +126,42 @@ defmodule TeslaMate.Mqtt.PubSub.VehicleSubscriberTest do
refute_receive _
end
test "allows namespaces", %{test: name} do
{:ok, pid} = start_subscriber(name, 0, "account_0")
assert_receive {VehiclesMock, {:subscribe_to_summary, 0}}
summary = %Summary{
display_name: "Foo",
state: :online
}
send(pid, summary)
assert_receive {MqttPublisherMock,
{:publish, "teslamate/account_0/cars/0/display_name", "Foo",
[retain: true, qos: 1]}}
assert_receive {MqttPublisherMock,
{:publish, "teslamate/account_0/cars/0/state", "online",
[retain: true, qos: 1]}}
# Always published
for key <- [
:charge_energy_added,
:charger_actual_current,
:charger_phases,
:charger_power,
:charger_voltage,
:scheduled_charging_start_time,
:time_to_full_charge,
:shift_state
] do
topic = "teslamate/account_0/cars/0/#{key}"
assert_receive {MqttPublisherMock, {:publish, ^topic, "", [retain: true, qos: 1]}}
end
refute_receive _
end
end

View File

@@ -198,16 +198,18 @@ defmodule TeslaMateWeb.CarLive.SummaryTest do
[view] = children(parent_view)
render_click(view, :suspend_logging)
assert html = render(view)
assert html =~ table_row("Status", "falling asleep")
TestHelper.eventually(fn ->
assert html = render(view)
assert html =~ table_row("Status", "falling asleep")
assert "cancel sleep attempt" ==
html |> Floki.find("a[phx-click=resume_logging]") |> Floki.text()
assert "cancel sleep attempt" ==
html |> Floki.find("a[phx-click=resume_logging]") |> Floki.text()
render_click(view, :resume_logging)
render_click(view, :resume_logging)
assert html = render(view)
assert html =~ table_row("Status", "online")
assert html = render(view)
assert html =~ table_row("Status", "online")
end)
end
end