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 # 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) Repo.delete(sensor) end def enroll_sensor(%Sensor{} = sensor, room_id) do sensor |> Sensor.changeset(%{room_id: room_id}) |> Repo.update() 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(:localiser, :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) do result = %Sensor{} |> Sensor.changeset(%{sensor_id: sensor_id, confirmed: true}) |> Repo.insert( on_conflict: [set: [confirmed: true, updated_at: DateTime.utc_now()]], 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