diff --git a/config/config.exs b/config/config.exs index 08063cd..ffafd34 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,6 +18,8 @@ config :localiserd, Localiser.Web.Endpoint, config :localiserd, :jwt_secret, System.get_env("JWT_SECRET") || "localiser_dev_jwt_secret_change_in_prod!!" +config :localiser, enrollment_timeout_ms: 10_000 + config :mdns_lite, hosts: ["localiser"], ttl: 120, diff --git a/lib/localiser/domain/schema/sensor.ex b/lib/localiser/domain/schema/sensor.ex index edc93ba..2c67468 100644 --- a/lib/localiser/domain/schema/sensor.ex +++ b/lib/localiser/domain/schema/sensor.ex @@ -7,6 +7,7 @@ defmodule Localiser.Domain.Schema.Sensor do schema "sensors" do field :sensor_id, :string + field :confirmed, :boolean, default: false field :x, :float field :y, :float field :floor_x, :float, virtual: true @@ -21,7 +22,7 @@ defmodule Localiser.Domain.Schema.Sensor do @doc false def changeset(sensor, attrs) do sensor - |> cast(attrs, [:sensor_id, :room_id, :x, :y]) + |> cast(attrs, [:sensor_id, :confirmed, :room_id, :x, :y]) |> validate_required([:sensor_id]) |> unique_constraint(:sensor_id) |> assoc_constraint(:room) diff --git a/lib/localiser/domain/sensors.ex b/lib/localiser/domain/sensors.ex index f37ef0a..faf404b 100644 --- a/lib/localiser/domain/sensors.ex +++ b/lib/localiser/domain/sensors.ex @@ -55,6 +55,8 @@ defmodule Localiser.Domain.Sensors do end def delete_sensor(%Sensor{} = sensor) do + topic = "localiser/sensor/#{sensor.sensor_id}/cmd" + Localiser.MQTT.Connection.publish(topic, Jason.encode!(%{command: "factory_reset"})) Repo.delete(sensor) end @@ -89,14 +91,45 @@ defmodule Localiser.Domain.Sensors do # 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 bumps updated_at if the sensor_id already exists. + # 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}) + |> Sensor.changeset(%{sensor_id: sensor_id, confirmed: true}) |> Repo.insert( - on_conflict: [set: [updated_at: DateTime.utc_now()]], + on_conflict: [set: [confirmed: true, updated_at: DateTime.utc_now()]], conflict_target: :sensor_id, returning: true ) diff --git a/lib/localiser/web/channels/sensors_channel.ex b/lib/localiser/web/channels/sensors_channel.ex index 719d33b..bf95146 100644 --- a/lib/localiser/web/channels/sensors_channel.ex +++ b/lib/localiser/web/channels/sensors_channel.ex @@ -28,6 +28,16 @@ defmodule Localiser.Web.Channels.SensorsChannel do {:noreply, socket} end + def handle_info({:sensor_expected, sensor}, socket) do + push(socket, "sensor_expected", render_sensor(sensor)) + {:noreply, socket} + end + + def handle_info({:sensor_enrollment_timeout, sensor_id}, socket) do + push(socket, "sensor_enrollment_timeout", %{sensor_id: sensor_id}) + {:noreply, socket} + end + def handle_info(_msg, socket), do: {:noreply, socket} defp render_sensor(sensor) do diff --git a/lib/localiser/web/controllers/sensor_controller.ex b/lib/localiser/web/controllers/sensor_controller.ex index a5034a0..723c65f 100644 --- a/lib/localiser/web/controllers/sensor_controller.ex +++ b/lib/localiser/web/controllers/sensor_controller.ex @@ -68,6 +68,15 @@ defmodule Localiser.Web.Controllers.SensorController do unauthorized: {"Unauthorized", "application/json", Schemas.Error} ] + operation :enroll, + summary: "Notify server a sensor is about to be enrolled", + request_body: {"Enroll params", "application/json", Schemas.SensorEnrollParams, required: true}, + responses: [ + ok: {"Sensor (unconfirmed)", "application/json", Schemas.Sensor}, + bad_request: {"Missing sensor_id", "application/json", Schemas.Error}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + operation :calibration_start, summary: "Begin RSSI calibration", parameters: [id: [in: :path, type: :integer, required: true]], @@ -86,6 +95,24 @@ defmodule Localiser.Web.Controllers.SensorController do unauthorized: {"Unauthorized", "application/json", Schemas.Error} ] + def enroll(conn, %{"sensor_id" => sensor_id}) do + case Sensors.expect_sensor(sensor_id) do + {:ok, sensor} -> + json(conn, render_sensor(sensor)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def enroll(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "sensor_id is required"}) + end + def index(conn, _params) do json(conn, Enum.map(Sensors.list_sensors(), &render_sensor/1)) end @@ -176,6 +203,7 @@ defmodule Localiser.Web.Controllers.SensorController do %{ id: sensor.id, sensor_id: sensor.sensor_id, + confirmed: sensor.confirmed, room_id: sensor.room_id, floor_x: sensor.floor_x, floor_y: sensor.floor_y, diff --git a/lib/localiser/web/router.ex b/lib/localiser/web/router.ex index 30a7707..e1eb8d2 100644 --- a/lib/localiser/web/router.ex +++ b/lib/localiser/web/router.ex @@ -74,6 +74,7 @@ defmodule Localiser.Web.Router do resources "/tags", TagController, except: [:new, :edit] # Sensors + post "/sensors", SensorController, :enroll get "/sensors", SensorController, :index get "/sensors/unplaced", SensorController, :unplaced get "/sensors/:id", SensorController, :show diff --git a/lib/localiser/web/schemas.ex b/lib/localiser/web/schemas.ex index ad44506..2fbb815 100644 --- a/lib/localiser/web/schemas.ex +++ b/lib/localiser/web/schemas.ex @@ -52,12 +52,13 @@ defmodule Localiser.Web.Schemas do properties: %{ id: %Schema{type: :integer}, sensor_id: %Schema{type: :string}, + confirmed: %Schema{type: :boolean}, room_id: %Schema{type: :integer, nullable: true}, floor_x: %Schema{type: :number, format: :float, nullable: true}, floor_y: %Schema{type: :number, format: :float, nullable: true}, rssi_ref: %Schema{type: :number, format: :float, nullable: true} }, - required: [:id, :sensor_id] + required: [:id, :sensor_id, :confirmed] }) end @@ -236,6 +237,15 @@ defmodule Localiser.Web.Schemas do }) end + defmodule SensorEnrollParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "SensorEnrollParams", type: :object, + properties: %{sensor_id: %Schema{type: :string}}, + required: [:sensor_id] + }) + end + defmodule SensorUpdateParams do require OpenApiSpex OpenApiSpex.schema(%{ diff --git a/priv/repo/migrations/20260515115837_add_confirmed_to_sensors.exs b/priv/repo/migrations/20260515115837_add_confirmed_to_sensors.exs new file mode 100644 index 0000000..5c66fa8 --- /dev/null +++ b/priv/repo/migrations/20260515115837_add_confirmed_to_sensors.exs @@ -0,0 +1,9 @@ +defmodule Localiser.Repo.Migrations.AddConfirmedToSensors do + use Ecto.Migration + + def change do + alter table(:sensors) do + add :confirmed, :boolean, default: false, null: false + end + end +end