From b0d9a7dbf8ac2d8717a18cc1a906d89bf4f0952f Mon Sep 17 00:00:00 2001 From: dvdrw Date: Thu, 21 May 2026 21:46:45 +0200 Subject: [PATCH] feat: accept calibration readings only from a chosen tag --- lib/localiser/localisation/sensor/server.ex | 136 +++++++++++------- lib/localiser/rssi_buffer.ex | 27 ++-- .../web/channels/calibration_channel.ex | 10 ++ .../web/controllers/sensor_controller.ex | 40 ++++++ lib/localiser/web/router.ex | 1 + lib/localiser/web/schemas.ex | 11 ++ 6 files changed, 164 insertions(+), 61 deletions(-) diff --git a/lib/localiser/localisation/sensor/server.ex b/lib/localiser/localisation/sensor/server.ex index 9ac8d8e..5a0045d 100644 --- a/lib/localiser/localisation/sensor/server.ex +++ b/lib/localiser/localisation/sensor/server.ex @@ -14,8 +14,11 @@ defmodule Localiser.Localisation.Sensor.Server do # mode: # :ok - # {:calibration_mode, completed_stages} - # {:calibrating_stage, distance, samples, completed_stages} + # {:calibration_mode, tag_id :: String.t() | nil, completed_stages} + # nil -> scanning: all incoming (tag_id, rssi) pairs are advertised on the channel + # tag -> tag committed, awaiting stage start + # {:calibrating_stage, tag_id, distance, samples, completed_stages} + # only readings from tag_id are accumulated; others are dropped # # completed_stages :: [%{distance: float, mean_rssi: float, readings: [{rssi, is_outlier}]}] defstruct [:sensor_id, :sensor_db_id, :floor_x, :floor_y, :rssi_ref, :path_loss_exp, mode: :ok] @@ -32,9 +35,9 @@ defmodule Localiser.Localisation.Sensor.Server do GenServer.call(via(sensor_id), {:measure, rssi, tx_power}) end - # Returns true only when a stage is actively collecting (used by RSSI.Buffer). - def calibrating?(sensor_id) do - GenServer.call(via(sensor_id), :calibrating?) + # Returns true when in any calibration state (used by RSSI.Buffer to redirect readings). + def in_calibration_mode?(sensor_id) do + GenServer.call(via(sensor_id), :in_calibration_mode?) end # Returns a JSON-serialisable snapshot of the current calibration state for channel join. @@ -42,23 +45,25 @@ defmodule Localiser.Localisation.Sensor.Server do GenServer.call(via(sensor_id), :calibration_state) end - # Feeds a raw RSSI value into the active stage buffer. Ignored between stages. - def calibration_reading(sensor_id, rssi) do - GenServer.cast(via(sensor_id), {:calibration_reading, rssi}) - end - - # Puts the sensor into calibration mode (between-stages). Returns :ok. + # Puts the sensor into scanning mode. RSSI readings for all tags are advertised on the + # calibration channel so the user can identify and select a tag. def begin_calibration_mode(sensor_id) do GenServer.call(via(sensor_id), :begin_calibration_mode) end - # Starts collecting samples for a given distance. Returns {:ok, samples_needed} or {:error, reason}. + # Commits to collecting samples only from tag_id in subsequent stages. + # Can be called again to change the tag (resets completed stages). + def set_calibration_tag(sensor_id, tag_id) do + GenServer.call(via(sensor_id), {:set_calibration_tag, tag_id}) + end + + # Starts collecting samples for a given distance. Requires a tag to be committed first. + # Returns {:ok, samples_needed} or {:error, reason}. def start_stage(sensor_id, distance) do GenServer.call(via(sensor_id), {:start_stage, distance}) end # Runs OLS regression over completed stages and saves the result. Requires >= 2 stages. - # Returns {:ok, %{rssi_ref: integer, path_loss_exp: float}} or {:error, reason}. def finish_calibration(sensor_id) do GenServer.call(via(sensor_id), :finish_calibration) end @@ -68,6 +73,11 @@ defmodule Localiser.Localisation.Sensor.Server do GenServer.cast(via(sensor_id), :abort_calibration) end + # Called by RSSI.Buffer for every reading when the sensor is in any calibration state. + def calibration_reading(sensor_id, tag_id, rssi) do + GenServer.cast(via(sensor_id), {:calibration_reading, tag_id, rssi}) + end + @impl true def init({sensor, room}) do Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors") @@ -104,8 +114,8 @@ defmodule Localiser.Localisation.Sensor.Server do end @impl true - def handle_call(:calibrating?, _from, state) do - {:reply, match?({:calibrating_stage, _, _, _}, state.mode), state} + def handle_call(:in_calibration_mode?, _from, state) do + {:reply, state.mode != :ok, state} end @impl true @@ -114,19 +124,29 @@ defmodule Localiser.Localisation.Sensor.Server do :ok -> %{status: "idle"} - {:calibration_mode, completed} -> + {:calibration_mode, nil, _completed} -> + %{ + status: "scanning", + tag_id: nil, + samples_needed: samples_needed(), + completed_stages: [] + } + + {:calibration_mode, tag_id, completed} -> %{ status: "calibration_mode", + tag_id: tag_id, samples_needed: samples_needed(), completed_stages: Enum.map(completed, &render_stage/1) } - {:calibrating_stage, distance, samples, completed} -> + {:calibrating_stage, tag_id, distance, samples, completed} -> %{ status: "stage_active", + tag_id: tag_id, distance: distance, samples_needed: samples_needed(), - stage_progress: {length(samples), samples_needed()}, + stage_progress: %{current: length(samples), total: samples_needed()}, completed_stages: Enum.map(completed, &render_stage/1) } end @@ -138,26 +158,44 @@ defmodule Localiser.Localisation.Sensor.Server do def handle_call(:begin_calibration_mode, _from, state) do MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_start"})) broadcast_calibration(state.sensor_id, {:calibration_mode_entered, state.sensor_id, samples_needed()}) - {:reply, :ok, %{state | mode: {:calibration_mode, []}}} + {:reply, :ok, %{state | mode: {:calibration_mode, nil, []}}} end @impl true - def handle_call({:start_stage, _distance}, _from, %{mode: {:calibrating_stage, _, _, _}} = state) do + def handle_call({:set_calibration_tag, tag_id}, _from, %{mode: {:calibration_mode, _old_tag, _completed}} = state) do + broadcast_calibration(state.sensor_id, {:calibration_tag_set, state.sensor_id, tag_id}) + {:reply, :ok, %{state | mode: {:calibration_mode, tag_id, []}}} + end + + def handle_call({:set_calibration_tag, _tag_id}, _from, %{mode: {:calibrating_stage, _, _, _, _}} = state) do + {:reply, {:error, :stage_active}, state} + end + + def handle_call({:set_calibration_tag, _tag_id}, _from, state) do + {:reply, {:error, :not_in_calibration_mode}, state} + end + + @impl true + def handle_call({:start_stage, _distance}, _from, %{mode: {:calibrating_stage, _, _, _, _}} = state) do {:reply, {:error, :already_active}, state} end + def handle_call({:start_stage, _distance}, _from, %{mode: {:calibration_mode, nil, _}} = state) do + {:reply, {:error, :no_tag_selected}, state} + end + def handle_call({:start_stage, _distance}, _from, %{mode: :ok} = state) do {:reply, {:error, :not_in_calibration_mode}, state} end - def handle_call({:start_stage, distance}, _from, %{mode: {:calibration_mode, completed}} = state) do + def handle_call({:start_stage, distance}, _from, %{mode: {:calibration_mode, tag_id, completed}} = state) do n = samples_needed() broadcast_calibration(state.sensor_id, {:stage_started, state.sensor_id, distance, length(completed)}) - {:reply, {:ok, n}, %{state | mode: {:calibrating_stage, distance, [], completed}}} + {:reply, {:ok, n}, %{state | mode: {:calibrating_stage, tag_id, distance, [], completed}}} end @impl true - def handle_call(:finish_calibration, _from, %{mode: {:calibration_mode, completed}} = state) + def handle_call(:finish_calibration, _from, %{mode: {:calibration_mode, _tag_id, completed}} = state) when length(completed) >= 2 do case Calibration.least_squares(completed) do {:ok, {rssi_ref, path_loss_exp}} -> @@ -186,11 +224,11 @@ defmodule Localiser.Localisation.Sensor.Server do end end - def handle_call(:finish_calibration, _from, %{mode: {:calibration_mode, _}} = state) do + def handle_call(:finish_calibration, _from, %{mode: {:calibration_mode, _, _}} = state) do {:reply, {:error, :insufficient_stages}, state} end - def handle_call(:finish_calibration, _from, %{mode: {:calibrating_stage, _, _, _}} = state) do + def handle_call(:finish_calibration, _from, %{mode: {:calibrating_stage, _, _, _, _}} = state) do {:reply, {:error, :stage_active}, state} end @@ -199,49 +237,53 @@ defmodule Localiser.Localisation.Sensor.Server do end @impl true - def handle_cast(:abort_calibration, state) do - case state.mode do - :ok -> - {:noreply, state} + def handle_cast(:abort_calibration, %{mode: :ok} = state), do: {:noreply, state} - _ -> - MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_stop"})) - broadcast_calibration(state.sensor_id, {:calibration_cancelled, state.sensor_id}) - {:noreply, %{state | mode: :ok}} - end + def handle_cast(:abort_calibration, state) do + MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_stop"})) + broadcast_calibration(state.sensor_id, {:calibration_cancelled, state.sensor_id}) + {:noreply, %{state | mode: :ok}} end + # Scan mode: tag not yet committed - advertise all (tag_id, rssi) pairs for UI selection. @impl true - def handle_cast({:calibration_reading, rssi}, %{mode: {:calibrating_stage, distance, samples, completed}} = state) do + def handle_cast({:calibration_reading, tag_id, rssi}, %{mode: {:calibration_mode, nil, _}} = state) do + broadcast_calibration(state.sensor_id, {:calibration_scan_reading, state.sensor_id, tag_id, rssi}) + {:noreply, state} + end + + # Tag committed but no stage active - drop readings silently. + def handle_cast({:calibration_reading, _tag_id, _rssi}, %{mode: {:calibration_mode, _tag, _}} = state) do + {:noreply, state} + end + + # Stage active: only accumulate readings from the committed tag. + def handle_cast({:calibration_reading, tag_id, rssi}, %{mode: {:calibrating_stage, tag_id, distance, samples, completed}} = state) do n = samples_needed() is_outlier = Calibration.outlier?(rssi, samples) new_samples = [rssi | samples] - progress = {length(new_samples), n} - broadcast_calibration(state.sensor_id, {:calibration_reading, state.sensor_id, rssi, is_outlier, %{stage: progress}}) + broadcast_calibration(state.sensor_id, {:calibration_reading, state.sensor_id, rssi, is_outlier, %{stage: {length(new_samples), n}}}) if length(new_samples) >= n do classified = Calibration.classify_outliers(new_samples) clean = for {r, false} <- classified, do: r - mean_rssi = if clean == [] do - mean(new_samples) - else - mean(clean) - end + mean_rssi = if clean == [], do: mean(new_samples), else: mean(clean) stage = %{distance: distance, mean_rssi: mean_rssi, readings: classified} new_completed = [stage | Enum.reject(completed, &(&1.distance == distance))] broadcast_calibration(state.sensor_id, {:stage_complete, state.sensor_id, distance, classified, mean_rssi}) - {:noreply, %{state | mode: {:calibration_mode, new_completed}}} + {:noreply, %{state | mode: {:calibration_mode, tag_id, new_completed}}} else - {:noreply, %{state | mode: {:calibrating_stage, distance, new_samples, completed}}} + {:noreply, %{state | mode: {:calibrating_stage, tag_id, distance, new_samples, completed}}} end end - def handle_cast({:calibration_reading, _rssi}, state), do: {:noreply, state} + # Reading from a different tag while stage active - drop. + def handle_cast({:calibration_reading, _other_tag, _rssi}, state), do: {:noreply, state} @impl true def handle_info({:sensor_enrolled, %Sensor{sensor_id: sid} = sensor, room}, %{sensor_id: sid} = state) do @@ -266,9 +308,7 @@ defmodule Localiser.Localisation.Sensor.Server do Application.get_env(:localiser, :calibration_samples, @default_samples) end - defp mean(list) do - Enum.sum(list) / length(list) - end + defp mean(list), do: Enum.sum(list) / length(list) defp rssi_to_distance(rssi, rssi_ref, path_loss_exp) do :math.pow(10.0, (rssi_ref - rssi) / (10.0 * path_loss_exp)) diff --git a/lib/localiser/rssi_buffer.ex b/lib/localiser/rssi_buffer.ex index 563de20..2c8bf68 100644 --- a/lib/localiser/rssi_buffer.ex +++ b/lib/localiser/rssi_buffer.ex @@ -35,26 +35,27 @@ defmodule Localiser.RSSI.Buffer do defp flush_batches(batches) do Enum.each(batches, fn {tag_id, readings} -> - case Registry.lookup(Localiser.Registry, {:filter, tag_id}) do - [{_pid, _}] -> - measurements = Enum.flat_map(readings, &resolve_measurement/1) - if measurements != [], do: TagFilter.ingest(tag_id, measurements) + # Calibration routing runs regardless of whether the tag has a filter registered, + # so scan-mode readings from unknown tags still reach the sensor server. + normal = Enum.flat_map(readings, &route_reading(tag_id, &1)) - [] -> - :ok + if normal != [] do + case Registry.lookup(Localiser.Registry, {:filter, tag_id}) do + [{_pid, _}] -> TagFilter.ingest(tag_id, normal) + [] -> :ok + end end end) end - # Resolves a raw RSSI reading to a measurement with sensor location and distance. - # If the sensor is in calibration mode, feeds the reading to Sensor.Server instead - # and returns [] so the sample is excluded from Tag.Filter measurements. - # If the sensor server isn't running, returns []. - defp resolve_measurement(%{sensor_id: sensor_id, rssi: rssi, tx_power: tx_power}) do + # Routes one reading. If the sensor is in any calibration state, forwards to Sensor.Server + # (with tag_id so scan-mode can advertise it) and returns [] to exclude from Tag.Filter. + # Otherwise returns a normal measurement map. + defp route_reading(tag_id, %{sensor_id: sensor_id, rssi: rssi, tx_power: tx_power}) do case Registry.lookup(Localiser.Registry, {:sensor, sensor_id}) do [{_pid, _}] -> - if SensorServer.calibrating?(sensor_id) do - SensorServer.calibration_reading(sensor_id, rssi) + if SensorServer.in_calibration_mode?(sensor_id) do + SensorServer.calibration_reading(sensor_id, tag_id, rssi) [] else [SensorServer.measure(sensor_id, rssi, tx_power)] diff --git a/lib/localiser/web/channels/calibration_channel.ex b/lib/localiser/web/channels/calibration_channel.ex index d5ad2e5..202c28d 100644 --- a/lib/localiser/web/channels/calibration_channel.ex +++ b/lib/localiser/web/channels/calibration_channel.ex @@ -23,6 +23,16 @@ defmodule Localiser.Web.Channels.CalibrationChannel do {:noreply, socket} end + def handle_info({:calibration_scan_reading, _sensor_id, tag_id, rssi}, socket) do + push(socket, "scan_reading", %{tag_id: tag_id, rssi: rssi}) + {:noreply, socket} + end + + def handle_info({:calibration_tag_set, _sensor_id, tag_id}, socket) do + push(socket, "tag_set", %{tag_id: tag_id}) + {:noreply, socket} + end + def handle_info({:stage_started, _sensor_id, distance, completed_count}, socket) do push(socket, "stage_started", %{distance: distance, completed_stages: completed_count}) {:noreply, socket} diff --git a/lib/localiser/web/controllers/sensor_controller.ex b/lib/localiser/web/controllers/sensor_controller.ex index 19c2d65..7c1d4df 100644 --- a/lib/localiser/web/controllers/sensor_controller.ex +++ b/lib/localiser/web/controllers/sensor_controller.ex @@ -116,6 +116,16 @@ defmodule Localiser.Web.Controllers.SensorController do unauthorized: {"Unauthorized", "application/json", Schemas.Error} ] + operation :calibration_set_tag, + summary: "Commit to a specific tag for calibration stages", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Tag params", "application/json", Schemas.CalibrationTagParams, required: true}, + responses: [ + ok: {"Tag committed", "application/json", Schemas.CalibrationBeginResponse}, + unprocessable_entity: {"Stage active or not in calibration mode", "application/json", Schemas.Error}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + operation :calibration_stage_start, summary: "Start collecting samples for a specific distance", parameters: [id: [in: :path, type: :integer, required: true]], @@ -276,6 +286,31 @@ defmodule Localiser.Web.Controllers.SensorController do json(conn, %{status: "calibration_mode", samples_needed: calibration_samples_needed()}) end + def calibration_set_tag(conn, %{"id" => id, "tag_id" => tag_id}) when is_binary(tag_id) do + sensor = Sensors.get_sensor!(id) + + case SensorServer.set_calibration_tag(sensor.sensor_id, tag_id) do + :ok -> + json(conn, %{status: "calibration_mode", samples_needed: calibration_samples_needed()}) + + {:error, :stage_active} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "cannot change tag while a stage is active"}) + + {:error, :not_in_calibration_mode} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "sensor is not in calibration mode"}) + end + end + + def calibration_set_tag(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "tag_id is required"}) + end + def calibration_stage_start(conn, %{"id" => id, "distance" => distance}) when is_number(distance) and distance > 0 do sensor = Sensors.get_sensor!(id) @@ -293,6 +328,11 @@ defmodule Localiser.Web.Controllers.SensorController do conn |> put_status(:unprocessable_entity) |> json(%{error: "sensor is not in calibration mode"}) + + {:error, :no_tag_selected} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "no tag selected — call /calibration/tag first"}) end end diff --git a/lib/localiser/web/router.ex b/lib/localiser/web/router.ex index a89a3bc..ea7ae2c 100644 --- a/lib/localiser/web/router.ex +++ b/lib/localiser/web/router.ex @@ -102,6 +102,7 @@ defmodule Localiser.Web.Router do post "/sensors/:id/factory_reset", SensorController, :factory_reset post "/sensors/:id/reconfigure", SensorController, :reconfigure post "/sensors/:id/calibration/begin", SensorController, :calibration_begin + post "/sensors/:id/calibration/tag", SensorController, :calibration_set_tag post "/sensors/:id/calibration/stage", SensorController, :calibration_stage_start post "/sensors/:id/calibration/finish", SensorController, :calibration_finish delete "/sensors/:id/calibration", SensorController, :calibration_cancel diff --git a/lib/localiser/web/schemas.ex b/lib/localiser/web/schemas.ex index c95df95..2ae585d 100644 --- a/lib/localiser/web/schemas.ex +++ b/lib/localiser/web/schemas.ex @@ -330,6 +330,17 @@ defmodule Localiser.Web.Schemas do }) end + defmodule CalibrationTagParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "CalibrationTagParams", type: :object, + properties: %{ + tag_id: %Schema{type: :string, description: "Tag ID to lock in for calibration stages"} + }, + required: [:tag_id] + }) + end + defmodule CalibrationStageParams do require OpenApiSpex OpenApiSpex.schema(%{