Files
localiserd/lib/localiser/domain/sensors.ex
T

251 lines
7.2 KiB
Elixir

defmodule Localiser.Domain.Sensors do
import Ecto.Query
alias Localiser.Repo
alias Localiser.Domain.Schema.Sensor
alias Localiser.Domain.Schema.SensorCalibration
def list_sensors do
Sensor
|> Repo.all()
|> Repo.preload(:room)
|> Enum.map(&populate_floor_coords/1)
end
def list_unplaced do
Sensor |> where([s], is_nil(s.room_id)) |> Repo.all()
end
def any_placed?, do: Repo.exists?(from s in Sensor, where: not is_nil(s.room_id))
def list_sensors_for_room(room_id) do
Sensor
|> where([s], s.room_id == ^room_id)
|> Repo.all()
end
def list_sensors_for_floor(floor_id) do
Sensor
|> join(:inner, [s], r in assoc(s, :room))
|> where([_s, r], r.floor_id == ^floor_id)
|> Repo.all()
end
def get_sensor!(id) do
Sensor
|> Repo.get!(id)
|> Repo.preload(:room)
|> populate_floor_coords()
end
def get_sensor_by_sensor_id(sensor_id) do
Repo.get_by(Sensor, sensor_id: sensor_id)
end
def create_sensor(attrs) do
%Sensor{}
|> Sensor.changeset(attrs)
|> Repo.insert()
end
def update_sensor(%Sensor{} = sensor, attrs) do
sensor
|> Sensor.changeset(attrs)
|> Repo.update()
end
def factory_reset(%Sensor{} = sensor) do
Localiser.MQTT.Connection.publish(cmd_topic(sensor), Jason.encode!(%{action: "factory_reset"}))
{:ok, sensor}
end
@version_timeout_ms 5_000
def get_version(%Sensor{} = sensor) do
:ok = Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors")
case Localiser.MQTT.Connection.publish(cmd_topic(sensor), Jason.encode!(%{action: "version"})) do
:ok ->
result =
receive do
{:sensor_announced, %Sensor{sensor_id: sid, firmware_version: v}}
when sid == sensor.sensor_id and not is_nil(v) ->
{:ok, v}
after
@version_timeout_ms -> {:error, :timeout}
end
Phoenix.PubSub.unsubscribe(Localiser.PubSub, "sensors")
result
error ->
Phoenix.PubSub.unsubscribe(Localiser.PubSub, "sensors")
error
end
end
def send_ota_update(%Sensor{} = sensor, url, version) do
payload = %{
action: "ota",
url: url,
version: version
}
Localiser.MQTT.Connection.publish(cmd_topic(sensor), Jason.encode!(payload))
{:ok, sensor}
end
def send_ota_broadcast(url, version) do
payload = %{
action: "ota",
url: url,
version: version
}
Localiser.MQTT.Connection.publish("localiser/ota", Jason.encode!(payload))
end
# Pushes new WiFi and/or MQTT broker settings to the sensor. The sensor will
# apply the changes and restart. All keys are optional — omit any you don't
# want to change. Valid keys: "ssid", "password", "mqtt_host", "mqtt_port".
def reconfigure_settings(%Sensor{} = sensor, config) do
payload = Map.merge(
%{"action" => "reconfigure_settings"},
Map.new(config, fn
{"mqtt_broker", v} -> {"mqtt_host", v}
entry -> entry
end)
)
Localiser.MQTT.Connection.publish(cmd_topic(sensor), Jason.encode!(payload))
{:ok, sensor}
end
def delete_sensor(%Sensor{} = sensor) do
factory_reset(sensor)
with {:ok, deleted} <- Repo.delete(sensor) do
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_unenrolled, deleted.sensor_id})
{:ok, deleted}
end
end
def add_calibration(%Sensor{} = sensor, attrs) do
attrs = Map.put(attrs, :sensor_id, sensor.id)
%SensorCalibration{}
|> SensorCalibration.changeset(attrs)
|> Repo.insert()
end
def list_calibrations(%Sensor{} = sensor) do
SensorCalibration
|> where([c], c.sensor_id == ^sensor.id)
|> order_by([c], desc: c.calibrated_at)
|> Repo.all()
end
def latest_calibration(%Sensor{} = sensor) do
SensorCalibration
|> where([c], c.sensor_id == ^sensor.id)
|> order_by([c], desc: c.calibrated_at)
|> limit(1)
|> Repo.one()
end
# Sensor lifecycle / enrollment helpers
@enrollment_timeout_ms Application.compile_env(:localiserd, :enrollment_timeout_ms, 10_000)
# Called by the phone app to indicate a sensor is about to come online.
# Creates an unconfirmed sensor record and starts a timeout Task.
def expect_sensor(sensor_id) do
result =
%Sensor{}
|> Sensor.changeset(%{sensor_id: sensor_id, confirmed: false})
|> Repo.insert(
on_conflict: [set: [confirmed: false, updated_at: DateTime.utc_now()]],
conflict_target: :sensor_id,
returning: true
)
case result do
{:ok, sensor} ->
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_expected, sensor})
Task.start(fn ->
Process.sleep(@enrollment_timeout_ms)
fresh = Repo.get_by(Sensor, sensor_id: sensor_id)
if fresh && !fresh.confirmed do
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_enrollment_timeout, sensor_id})
end
end)
{:ok, sensor}
error ->
error
end
end
# Called when an ESP board self-announces on MQTT. Inserts a new unplaced sensor
# record, or marks it confirmed if the sensor_id already exists.
def upsert_announced(sensor_id, firmware_version \\ nil) do
attrs = %{sensor_id: sensor_id, confirmed: true}
attrs = if firmware_version, do: Map.put(attrs, :firmware_version, firmware_version), else: attrs
on_conflict =
if firmware_version do
[set: [confirmed: true, firmware_version: firmware_version, updated_at: DateTime.utc_now()]]
else
[set: [confirmed: true, updated_at: DateTime.utc_now()]]
end
result =
%Sensor{}
|> Sensor.changeset(attrs)
|> Repo.insert(
on_conflict: on_conflict,
conflict_target: :sensor_id,
returning: true
)
case result do
{:ok, sensor} ->
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_announced, sensor})
{:ok, sensor}
error ->
error
end
end
# Places (or re-places) a sensor at a specific position within a room.
# Broadcasts {:sensor_enrolled, sensor, room} for Sensor.Supervisor / Sensor.Server.
def place_sensor(%Sensor{} = sensor, room_id, {x, y}) do
with {:ok, sensor} <- update_sensor(sensor, %{room_id: room_id, x: x, y: y}) do
room = Repo.preload(sensor, :room).room
sensor = populate_floor_coords(%{sensor | room: room})
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_enrolled, sensor, room})
{:ok, sensor}
end
end
# Removes a sensor from the room layout without deleting the DB record.
# Broadcasts {:sensor_unenrolled, sensor_id} for Sensor.Supervisor.
def remove_from_layout(%Sensor{} = sensor) do
with {:ok, sensor} <- update_sensor(sensor, %{room_id: nil, x: nil, y: nil}) do
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:sensor_unenrolled, sensor.sensor_id})
{:ok, sensor}
end
end
defp cmd_topic(sensor), do: "localiser/sensor/#{sensor.sensor_id}/cmd"
defp populate_floor_coords(%Sensor{room: nil} = sensor), do: sensor
defp populate_floor_coords(%Sensor{room: room} = sensor) do
%{sensor |
floor_x: (room.x || 0.0) + (sensor.x || 0.0),
floor_y: (room.y || 0.0) + (sensor.y || 0.0)
}
end
end