Compare commits

..

10 Commits

20 changed files with 921 additions and 124 deletions
+3
View File
@@ -25,4 +25,7 @@ localiserd-*.tar
# sqlite database files
priv/db/*
# firmware files
priv/firmware/*
.elixir_ls/
+37
View File
@@ -0,0 +1,37 @@
defmodule Localiser.Domain.Firmware do
@moduledoc false
def firmware_dir do
dir = Application.app_dir(:localiserd, "priv/firmware")
File.mkdir_p!(dir)
dir
end
def store(version, %Plug.Upload{path: tmp_path}) do
dest = Path.join(firmware_dir(), "#{version}.bin")
case File.cp(tmp_path, dest) do
:ok -> {:ok, version}
{:error, reason} -> {:error, reason}
end
end
def list do
dir = firmware_dir()
dir
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".bin"))
|> Enum.map(fn filename ->
version = String.replace_suffix(filename, ".bin", "")
path = Path.join(dir, filename)
stat = File.stat!(path, time: :posix)
%{version: version, size: stat.size, uploaded_at: stat.mtime}
end)
|> Enum.sort_by(& &1.uploaded_at, :desc)
end
def get(version) do
path = Path.join(firmware_dir(), "#{version}.bin")
if File.exists?(path), do: {:ok, path}, else: :not_found
end
end
+2 -1
View File
@@ -8,6 +8,7 @@ defmodule Localiser.Domain.Schema.Sensor do
schema "sensors" do
field :sensor_id, :string
field :confirmed, :boolean, default: false
field :firmware_version, :string
field :x, :float
field :y, :float
field :floor_x, :float, virtual: true
@@ -22,7 +23,7 @@ defmodule Localiser.Domain.Schema.Sensor do
@doc false
def changeset(sensor, attrs) do
sensor
|> cast(attrs, [:sensor_id, :confirmed, :room_id, :x, :y])
|> cast(attrs, [:sensor_id, :confirmed, :firmware_version, :room_id, :x, :y])
|> validate_required([:sensor_id])
|> unique_constraint(:sensor_id)
|> assoc_constraint(:room)
+63 -9
View File
@@ -59,6 +59,52 @@ defmodule Localiser.Domain.Sensors do
{: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".
@@ -76,13 +122,11 @@ defmodule Localiser.Domain.Sensors do
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()
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
@@ -143,12 +187,22 @@ defmodule Localiser.Domain.Sensors do
# 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
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(%{sensor_id: sensor_id, confirmed: true})
|> Sensor.changeset(attrs)
|> Repo.insert(
on_conflict: [set: [confirmed: true, updated_at: DateTime.utc_now()]],
on_conflict: on_conflict,
conflict_target: :sensor_id,
returning: true
)
+86
View File
@@ -0,0 +1,86 @@
defmodule Localiser.Localisation.Calibration do
@moduledoc false
# Returns [{rssi, is_outlier}] using Tukey IQR fences on the full sample list.
def classify_outliers([]), do: []
def classify_outliers(samples) do
{lo, hi} = iqr_fences(samples)
Enum.map(samples, fn rssi -> {rssi, rssi < lo or rssi > hi} end)
end
# Live hint: is this single reading an outlier relative to the samples collected so far?
# Returns false when fewer than 5 existing samples (not enough signal).
def outlier?(_reading, existing) when length(existing) < 5, do: false
def outlier?(reading, existing) do
{lo, hi} = iqr_fences(existing)
reading < lo or reading > hi
end
# OLS regression for RSSI = A - 10n * log10(d).
# stages :: [%{distance: float, mean_rssi: float}]
# Requires at least 2 stages with distinct distances.
# Returns {:ok, {rssi_ref :: integer, path_loss_exp :: float}} or {:error, :insufficient_data}.
def least_squares(stages) when length(stages) < 2, do: {:error, :insufficient_data}
def least_squares(stages) do
points = Enum.map(stages, fn %{distance: d, mean_rssi: rssi} ->
{:math.log10(d), rssi}
end)
xs = Enum.map(points, &elem(&1, 0))
ys = Enum.map(points, &elem(&1, 1))
x_bar = mean(xs)
y_bar = mean(ys)
cov_xy = xs |> Enum.zip(ys) |> Enum.reduce(0.0, fn {x, y}, acc ->
acc + (x - x_bar) * (y - y_bar)
end)
var_x = Enum.reduce(xs, 0.0, fn x, acc -> acc + (x - x_bar) * (x - x_bar) end)
if var_x == 0.0 do
{:error, :insufficient_data}
else
beta = cov_xy / var_x
a = y_bar - beta * x_bar
path_loss_exp = -beta / 10.0
rssi_ref = round(a)
{:ok, {rssi_ref, path_loss_exp}}
end
end
# --- Private ---
defp iqr_fences(samples) do
sorted = Enum.sort(samples)
n = length(sorted)
q1 = percentile(sorted, n, 0.25)
q3 = percentile(sorted, n, 0.75)
iqr = q3 - q1
{q1 - 1.5 * iqr, q3 + 1.5 * iqr}
end
defp percentile(sorted, n, p) do
idx = p * (n - 1)
lo = floor(idx)
hi = ceil(idx)
if lo == hi do
Enum.at(sorted, lo) * 1.0
else
frac = idx - lo
Enum.at(sorted, lo) * (1 - frac) + Enum.at(sorted, hi) * frac
end
end
defp mean(list) do
Enum.sum(list) / length(list)
end
end
+61 -6
View File
@@ -8,7 +8,7 @@ defmodule Localiser.Localisation.Filter.Particle do
@default_velocity_noise 0.3 # σ m/s per update (constant velocity model)
@default_max_speed 2.0 # m per update cap (constant velocity model)
@default_likelihood_model :laplacian
@default_sensor_sigma 3.0 # σ (Gaussian) or b (Laplacian) in metres
@default_sensor_sigma 1.0 # σ (Gaussian) or b (Laplacian) in metres
@default_weight_floor 1.0e-6
@default_injection_fraction 0.03
@default_resample_threshold 0.5
@@ -41,14 +41,16 @@ defmodule Localiser.Localisation.Filter.Particle do
}
floor_bounds = derive_bounds(rooms)
room_bounds = derive_room_bounds(rooms)
n = params.n_particles
particles = for _ <- 1..n, do: sample_uniform(floor_bounds)
particles = for _ <- 1..n, do: sample_in_rooms(room_bounds, floor_bounds)
weights = List.duplicate(1.0 / n, n)
state = Map.merge(params, %{
particles: particles,
weights: weights,
floor_bounds: floor_bounds,
room_bounds: room_bounds,
last_update_ms: nil
})
@@ -105,8 +107,9 @@ defmodule Localiser.Localisation.Filter.Particle do
case state.movement_model do
:random_walk ->
Enum.map(particles, fn {x, y} ->
reflect_bounds(
constrain_to_rooms(
{x + randn(sigma_eff), y + randn(sigma_eff)},
state.room_bounds,
state.floor_bounds
)
end)
@@ -127,8 +130,9 @@ defmodule Localiser.Localisation.Filter.Particle do
{vx2, vy2}
end
{nx, ny} = reflect_bounds(
{nx, ny} = constrain_to_rooms(
{x + vx2 + randn(sigma_eff), y + vy2 + randn(sigma_eff)},
state.room_bounds,
state.floor_bounds
)
@@ -164,7 +168,7 @@ defmodule Localiser.Localisation.Filter.Particle do
{new_p, new_w} =
Enum.reduce(0..(state.n_particles - 1), {[], []}, fn i, {ps, ws} ->
if MapSet.member?(low_idxs, i) do
{[sample_uniform(state.floor_bounds) | ps], [mean_w | ws]}
{[sample_in_rooms(state.room_bounds, state.floor_bounds) | ps], [mean_w | ws]}
else
{[elem(particles_arr, i) | ps], [elem(weights_arr, i) | ws]}
end
@@ -225,7 +229,7 @@ defmodule Localiser.Localisation.Filter.Particle do
jittered =
Enum.map(new_particles, fn p ->
{x, y} = particle_pos(p)
reflect_bounds({x + randn(jitter_sigma), y + randn(jitter_sigma)}, bounds)
constrain_to_rooms({x + randn(jitter_sigma), y + randn(jitter_sigma)}, state.room_bounds, bounds)
end)
uniform_w = List.duplicate(1.0 / state.n_particles, state.n_particles)
@@ -327,6 +331,57 @@ defmodule Localiser.Localisation.Filter.Particle do
defp ensure_velocity({x, y}), do: {x, y, 0.0, 0.0}
defp ensure_velocity({x, y, vx, vy}), do: {x, y, vx, vy}
defp constrain_to_rooms({x, y}, [], fallback_bounds) do
reflect_bounds({x, y}, fallback_bounds)
end
defp constrain_to_rooms({x, y}, room_bounds, _fallback) do
if Enum.any?(room_bounds, fn {x0, y0, x1, y1} ->
x >= x0 and x <= x1 and y >= y0 and y <= y1
end) do
{x, y}
else
{x0, y0, x1, y1} =
Enum.min_by(room_bounds, fn {x0, y0, x1, y1} ->
dx = max(0.0, max(x0 - x, x - x1))
dy = max(0.0, max(y0 - y, y - y1))
dx * dx + dy * dy
end)
{clamp(x, x0, x1), clamp(y, y0, y1)}
end
end
defp clamp(v, lo, hi), do: max(lo, min(hi, v))
defp sample_in_rooms([], fallback_bounds), do: sample_uniform(fallback_bounds)
defp sample_in_rooms(room_bounds, _fallback) do
areas = Enum.map(room_bounds, fn {x0, y0, x1, y1} -> (x1 - x0) * (y1 - y0) end)
total = Enum.sum(areas)
r = :rand.uniform() * total
{x0, y0, x1, y1} =
Enum.zip(room_bounds, areas)
|> Enum.reduce_while(r, fn {bounds, area}, acc ->
if acc <= area, do: {:halt, bounds}, else: {:cont, acc - area}
end)
|> then(fn
{_, _, _, _} = b -> b
_ -> List.last(room_bounds)
end)
sample_uniform({x0, y0, x1, y1})
end
defp derive_room_bounds(rooms) do
Enum.map(rooms, fn r ->
ox = r.x || 0.0
oy = r.y || 0.0
{ox, oy, ox + (r.width || 0.0), oy + (r.height || 0.0)}
end)
end
defp derive_bounds([]) do
@fallback_bounds
end
+210 -60
View File
@@ -5,12 +5,22 @@ defmodule Localiser.Localisation.Sensor.Server do
alias Localiser.Domain.Sensors
alias Localiser.Domain.Schema.{Sensor, SensorCalibration}
alias Localiser.Localisation.Calibration
alias Localiser.MQTT.Connection, as: MQTTConnection
@default_rssi_ref -59
@default_path_loss_exp 2.0
@default_samples 30
# mode: :ok | {:calibrating, buffer :: [integer()], target :: pos_integer()}
# mode:
# :ok
# {: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]
def start_link({sensor, room}) do
@@ -21,32 +31,53 @@ defmodule Localiser.Localisation.Sensor.Server do
{:via, Registry, {Localiser.Registry, {:sensor, sensor_id}}}
end
# Returns %{sensor_id, floor_x, floor_y, distance, rssi, tx_power} for a raw RSSI reading.
# tx_power is the beacon-advertised expected RSSI at 1 m (nil if not available).
def measure(sensor_id, rssi, tx_power \\ nil) do
GenServer.call(via(sensor_id), {:measure, rssi, tx_power})
end
# Returns true if the sensor is currently collecting calibration samples.
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
# Feeds a raw RSSI value into the calibration buffer.
def calibration_reading(sensor_id, rssi) do
GenServer.cast(via(sensor_id), {:calibration_reading, rssi})
# Returns a JSON-serialisable snapshot of the current calibration state for channel join.
def calibration_state(sensor_id) do
GenServer.call(via(sensor_id), :calibration_state)
end
# Starts calibration mode. sample_target: number of RSSI samples to collect.
def begin_calibration(sensor_id, sample_target \\ 50) do
GenServer.cast(via(sensor_id), {:begin_calibration, sample_target})
# 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
# Aborts an in-progress calibration without saving.
# 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.
def finish_calibration(sensor_id) do
GenServer.call(via(sensor_id), :finish_calibration)
end
# Aborts calibration from any state, discarding all stages.
def abort_calibration(sensor_id) 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")
@@ -83,38 +114,178 @@ defmodule Localiser.Localisation.Sensor.Server do
end
@impl true
def handle_call(:calibrating?, _from, state) do
{:reply, match?({:calibrating, _, _}, state.mode), state}
def handle_call(:in_calibration_mode?, _from, state) do
{:reply, state.mode != :ok, state}
end
@impl true
def handle_cast({:begin_calibration, target}, state) do
def handle_call(:calibration_state, _from, state) do
snapshot = case state.mode do
:ok ->
%{status: "idle"}
{: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, tag_id, distance, samples, completed} ->
%{
status: "stage_active",
tag_id: tag_id,
distance: distance,
samples_needed: samples_needed(),
stage_progress: %{current: length(samples), total: samples_needed()},
completed_stages: Enum.map(completed, &render_stage/1)
}
end
{:reply, snapshot, state}
end
@impl true
def handle_call(:begin_calibration_mode, _from, state) do
MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_start"}))
{:noreply, %{state | mode: {:calibrating, [], target}}}
broadcast_calibration(state.sensor_id, {:calibration_mode_entered, state.sensor_id, samples_needed()})
{:reply, :ok, %{state | mode: {:calibration_mode, nil, []}}}
end
@impl true
def handle_cast(:abort_calibration, %{mode: {:calibrating, _, _}} = state) do
MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_stop"}))
{:noreply, %{state | mode: :ok}}
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_cast(:abort_calibration, state), do: {:noreply, state}
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_cast({:calibration_reading, rssi}, %{mode: {:calibrating, buffer, target}} = state) do
buffer = [rssi | buffer]
def handle_call({:start_stage, _distance}, _from, %{mode: {:calibrating_stage, _, _, _, _}} = state) do
{:reply, {:error, :already_active}, state}
end
if length(buffer) >= target do
finalize_calibration(buffer, state)
else
{:noreply, %{state | mode: {:calibrating, buffer, target}}}
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, 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, tag_id, distance, [], completed}}}
end
@impl true
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}} ->
sensor_struct = %Sensor{id: state.sensor_db_id, sensor_id: state.sensor_id}
case Sensors.add_calibration(sensor_struct, %{
rssi_ref: rssi_ref,
path_loss_exp: path_loss_exp,
calibrated_at: DateTime.utc_now()
}) do
{:ok, _} ->
Logger.info("[Sensor.Server] Calibration finished for #{state.sensor_id}: rssi_ref=#{rssi_ref} n=#{path_loss_exp}")
MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_stop"}))
broadcast_calibration(state.sensor_id, {:calibration_finished, state.sensor_id, rssi_ref, path_loss_exp})
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:calibration_complete, state.sensor_id, rssi_ref, path_loss_exp})
result = %{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp}
{:reply, {:ok, result}, %{state | rssi_ref: rssi_ref, path_loss_exp: path_loss_exp, mode: :ok}}
{:error, reason} ->
Logger.error("[Sensor.Server] Failed to save calibration for #{state.sensor_id}: #{inspect(reason)}")
{:reply, {:error, :save_failed}, state}
end
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_cast({:calibration_reading, _rssi}, state), do: {:noreply, state}
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
{:reply, {:error, :stage_active}, state}
end
def handle_call(:finish_calibration, _from, state) do
{:reply, {:error, :not_in_calibration_mode}, state}
end
@impl true
def handle_cast(:abort_calibration, %{mode: :ok} = state), do: {:noreply, state}
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, tag_id, rssi}, %{mode: {:calibration_mode, nil, _}} = state) do
Logger.error("Calibration reading in scan mode: tag_id=#{tag_id} rssi=#{rssi}")
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]
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)
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, tag_id, new_completed}}}
else
{:noreply, %{state | mode: {:calibrating_stage, tag_id, distance, new_samples, completed}}}
end
end
# Reading from a different tag while stage active - drop.
def handle_cast({:calibration_reading, _other_tag, _rssi}, state), do: {:noreply, state}
# Position updated (sensor dragged in layout).
@impl true
def handle_info({:sensor_enrolled, %Sensor{sensor_id: sid} = sensor, room}, %{sensor_id: sid} = state) do
floor_x = (room.x || 0.0) + (sensor.x || 0.0)
@@ -122,45 +293,24 @@ defmodule Localiser.Localisation.Sensor.Server do
{:noreply, %{state | floor_x: floor_x, floor_y: floor_y}}
end
# Ignore PubSub messages not relevant to this server.
def handle_info(_msg, state), do: {:noreply, state}
# Private
defp finalize_calibration(buffer, state) do
rssi_ref = median(buffer)
sensor_struct = %Sensor{id: state.sensor_db_id, sensor_id: state.sensor_id}
case Sensors.add_calibration(sensor_struct, %{
rssi_ref: rssi_ref,
path_loss_exp: state.path_loss_exp,
calibrated_at: DateTime.utc_now()
}) do
{:ok, _calibration} ->
Logger.info("[Sensor.Server] Calibration complete for #{state.sensor_id}: rssi_ref=#{rssi_ref}")
MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_stop"}))
Phoenix.PubSub.broadcast(Localiser.PubSub, "sensors", {:calibration_complete, state.sensor_id})
{:noreply, %{state | rssi_ref: rssi_ref, mode: :ok}}
{:error, reason} ->
Logger.error("[Sensor.Server] Failed to save calibration for #{state.sensor_id}: #{inspect(reason)}")
{:noreply, %{state | mode: :ok}}
end
defp broadcast_calibration(sensor_id, message) do
Phoenix.PubSub.broadcast(Localiser.PubSub, "calibration:#{sensor_id}", message)
end
defp median(list) do
sorted = Enum.sort(list)
len = length(sorted)
mid = div(len, 2)
if rem(len, 2) == 0 do
round((Enum.at(sorted, mid - 1) + Enum.at(sorted, mid)) / 2)
else
Enum.at(sorted, mid)
end
defp render_stage(%{distance: d, mean_rssi: r, readings: readings}) do
%{distance: d, mean_rssi: r, readings: Enum.map(readings, fn {rssi, outlier} -> %{rssi: rssi, outlier: outlier} end)}
end
# d = 10 ^ ((rssi_ref - rssi) / (10 * n))
defp samples_needed do
Application.get_env(:localiser, :calibration_samples, @default_samples)
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))
end
+19 -2
View File
@@ -93,12 +93,23 @@ defmodule Localiser.Localisation.Tag.Filter do
end
end
# Room geometry changed: reload the rooms cache so containment checks stay accurate.
@impl true
def handle_info({event, _}, state) when event in [:room_created, :room_updated] do
def handle_info({:room_created, _room}, state) do
{:noreply, %{state | rooms: load_rooms()}}
end
def handle_info({:room_updated, updated_room}, state) do
new_rooms = load_rooms()
old_room = Enum.find(state.rooms, &(&1.id == updated_room.id))
if geometry_changed?(old_room, updated_room) do
{:ok, new_filter_state} = state.filter_module.init([], rooms: new_rooms)
{:noreply, %{state | rooms: new_rooms, filter_state: new_filter_state}}
else
{:noreply, %{state | rooms: new_rooms}}
end
end
def handle_info({:room_deleted, _room_id, _floor_id}, state) do
{:noreply, %{state | rooms: load_rooms()}}
end
@@ -131,6 +142,12 @@ defmodule Localiser.Localisation.Tag.Filter do
|> Enum.flat_map(& &1.rooms)
end
defp geometry_changed?(nil, _), do: true
defp geometry_changed?(old, new) do
old.x != new.x or old.y != new.y or
old.width != new.width or old.height != new.height
end
defp find_room(rooms, x, y) do
Enum.find(rooms, fn room ->
ox = room.x || 0.0
+13 -3
View File
@@ -30,7 +30,7 @@ defmodule Localiser.MQTT.Router do
handle_rssi(sensor_id, payload)
{:announce, sensor_id} ->
handle_announce(sensor_id)
handle_announce(sensor_id, payload)
{:error, :invalid_topic} ->
Logger.debug("[MQTT.Router] Received message with invalid topic: #{topic}")
@@ -51,6 +51,10 @@ defmodule Localiser.MQTT.Router do
end
defp handle_rssi(sensor_id, payload) do
if sensor_id == "anchor_73f460" do
Logger.error("Sensor send payload: #{payload}")
end
case Jason.decode(payload) do
{:ok, %{"id" => id, "rssi" => rssi, "type" => type} = decoded} ->
reading = %{
@@ -70,8 +74,14 @@ defmodule Localiser.MQTT.Router do
end
end
defp handle_announce(sensor_id) do
case Sensors.upsert_announced(sensor_id) do
defp handle_announce(sensor_id, payload) do
version =
case Jason.decode(payload) do
{:ok, %{"version" => v}} when is_binary(v) -> v
_ -> nil
end
case Sensors.upsert_announced(sensor_id, version) do
{:ok, _sensor} ->
Logger.info("[MQTT.Router] Sensor announced: #{sensor_id}")
+14 -13
View File
@@ -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)]
@@ -0,0 +1,63 @@
defmodule Localiser.Web.Channels.CalibrationChannel do
use Phoenix.Channel
alias Localiser.Domain.Sensors
alias Localiser.Localisation.Sensor.Server, as: SensorServer
@impl true
def join("calibration:" <> sensor_id, _params, socket) do
case Sensors.get_sensor_by_sensor_id(sensor_id) do
nil ->
{:error, %{reason: "sensor not found"}}
_sensor ->
Phoenix.PubSub.subscribe(Localiser.PubSub, "calibration:#{sensor_id}")
state = SensorServer.calibration_state(sensor_id)
{:ok, state, assign(socket, :sensor_id, sensor_id)}
end
end
@impl true
def handle_info({:calibration_mode_entered, _sensor_id, samples_needed}, socket) do
push(socket, "mode_entered", %{samples_needed: samples_needed})
{: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}
end
def handle_info({:calibration_reading, _sensor_id, rssi, is_outlier, %{stage: {cur, total}}}, socket) do
push(socket, "reading", %{rssi: rssi, outlier: is_outlier, stage_progress: %{current: cur, total: total}})
{:noreply, socket}
end
def handle_info({:stage_complete, _sensor_id, distance, readings, mean_rssi}, socket) do
rendered = Enum.map(readings, fn {rssi, outlier} -> %{rssi: rssi, outlier: outlier} end)
push(socket, "stage_complete", %{distance: distance, readings: rendered, mean_rssi: mean_rssi})
{:noreply, socket}
end
def handle_info({:calibration_finished, _sensor_id, rssi_ref, path_loss_exp}, socket) do
push(socket, "finished", %{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:noreply, socket}
end
def handle_info({:calibration_cancelled, _sensor_id}, socket) do
push(socket, "cancelled", %{})
{:noreply, socket}
end
def handle_info(_msg, socket), do: {:noreply, socket}
end
@@ -23,8 +23,8 @@ defmodule Localiser.Web.Channels.SensorsChannel do
{:noreply, socket}
end
def handle_info({:calibration_complete, sensor_id}, socket) do
push(socket, "calibration_complete", %{sensor_id: sensor_id})
def handle_info({:calibration_complete, sensor_id, rssi_ref, path_loss_exp}, socket) do
push(socket, "calibration_complete", %{sensor_id: sensor_id, rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:noreply, socket}
end
@@ -0,0 +1,82 @@
defmodule Localiser.Web.Controllers.FirmwareController do
use Phoenix.Controller, formats: [:json]
alias Localiser.Domain.{Firmware, Sensors}
# Admin: upload a new firmware version.
# Expects multipart/form-data with fields: version (string), firmware (file).
def upload(conn, %{"version" => version, "firmware" => %Plug.Upload{} = upload}) do
case Firmware.store(version, upload) do
{:ok, _version} ->
json(conn, %{status: "ok", version: version})
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Upload failed: #{inspect(reason)}"})
end
end
def upload(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "version and firmware file are required"})
end
# Admin: list available firmware versions.
def index(conn, _params) do
json(conn, Firmware.list())
end
# Public: serve firmware binary.
def download(conn, %{"version" => version}) do
case Firmware.get(version) do
{:ok, path} ->
conn
|> put_resp_content_type("application/octet-stream")
|> put_resp_header("content-disposition", ~s(attachment; filename="#{version}.bin"))
|> send_file(200, path)
:not_found ->
conn
|> put_status(:not_found)
|> json(%{error: "Firmware version not found"})
end
end
# Admin: broadcast OTA update to the entire fleet.
# POST /api/firmware/:version/ota
def ota_fleet(conn, %{"version" => version}) do
case Firmware.get(version) do
{:ok, _path} ->
url = firmware_url(conn, version)
Sensors.send_ota_broadcast(url, version)
json(conn, %{status: "ok", url: url, version: version})
:not_found ->
conn
|> put_status(:not_found)
|> json(%{error: "Firmware version not found"})
end
end
# Admin: upload a new firmware version and push OTA update to fleet immediately.
# Expects multipart/form-data with fields: version (string), firmware (file).
def ota_fleet_instant(conn, %{"version" => version, "firmware" => %Plug.Upload{} = upload}) do
case Firmware.store(version, upload) do
{:ok, _version} ->
url = firmware_url(conn, version)
Sensors.send_ota_broadcast(url, version)
json(conn, %{status: "ok", version: version})
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Upload failed: #{inspect(reason)}"})
end
end
defp firmware_url(conn, version) do
"#{conn.scheme}://#{conn.host}:#{conn.port}/api/firmware/#{version}"
end
end
@@ -2,7 +2,7 @@ defmodule Localiser.Web.Controllers.SensorController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Sensors
alias Localiser.Domain.{Sensors, Firmware}
alias Localiser.Localisation.Sensor.Server, as: SensorServer
alias Localiser.Web.Schemas
@@ -77,6 +77,20 @@ defmodule Localiser.Web.Controllers.SensorController do
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :ota,
summary: "Send OTA update command to a single sensor",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"OTA params", "application/json", %OpenApiSpex.Schema{
type: :object,
required: [:version],
properties: %{version: %OpenApiSpex.Schema{type: :string}}
}, required: true},
responses: [
ok: {"Command sent", "application/json", Schemas.CommandSent},
not_found: {"Firmware version not found", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :factory_reset,
summary: "Send factory reset command to sensor",
parameters: [id: [in: :path, type: :integer, required: true]],
@@ -94,21 +108,49 @@ defmodule Localiser.Web.Controllers.SensorController do
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_start,
summary: "Begin RSSI calibration",
operation :calibration_begin,
summary: "Enter calibration mode (between-stages)",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Calibration params", "application/json", Schemas.CalibrationStartParams, required: true},
responses: [
ok: {"Calibration started", "application/json", Schemas.CalibrationStatus},
bad_request: {"Missing reference_distance", "application/json", Schemas.Error},
ok: {"Calibration mode entered", "application/json", Schemas.CalibrationBeginResponse},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_stop,
summary: "Abort active calibration",
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]],
request_body: {"Stage params", "application/json", Schemas.CalibrationStageParams, required: true},
responses: [
ok: {"Stage started", "application/json", Schemas.CalibrationStageResponse},
bad_request: {"Missing or invalid distance", "application/json", Schemas.Error},
unprocessable_entity: {"Stage already active", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_finish,
summary: "Run regression over completed stages and save calibration",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Calibration aborted", "application/json", Schemas.CalibrationStatus},
ok: {"Calibration saved", "application/json", Schemas.CalibrationFinishResponse},
unprocessable_entity: {"Insufficient stages or stage active", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_cancel,
summary: "Abort calibration, discard all stages",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Calibration cancelled", "application/json", Schemas.CalibrationStatus},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
@@ -198,12 +240,39 @@ defmodule Localiser.Web.Controllers.SensorController do
end
end
def ota(conn, %{"id" => id, "version" => version}) do
sensor = Sensors.get_sensor!(id)
case Firmware.get(version) do
{:ok, _path} ->
url = "#{conn.scheme}://#{conn.host}:#{conn.port}/api/firmware/#{version}"
{:ok, _} = Sensors.send_ota_update(sensor, url, version)
json(conn, %{status: "ok", url: url, version: version})
:not_found ->
conn
|> put_status(:not_found)
|> json(%{error: "Firmware version not found"})
end
end
def ota(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "version is required"})
end
def factory_reset(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
{:ok, _} = Sensors.factory_reset(sensor)
json(conn, %{status: "ok"})
end
def get_version(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
{:ok, version} = Sensors.get_version(sensor)
json(conn, %{status: "ok", version: version})
end
def reconfigure(conn, %{"id" => id} = params) do
sensor = Sensors.get_sensor!(id)
config = Map.take(params, ["ssid", "password", "mqtt_broker", "mqtt_port"])
@@ -211,24 +280,107 @@ defmodule Localiser.Web.Controllers.SensorController do
json(conn, %{status: "ok"})
end
def calibration_start(conn, %{"id" => id, "reference_distance" => ref_dist}) do
def calibration_begin(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.begin_calibration(sensor.sensor_id, ref_dist)
json(conn, %{status: "calibrating"})
:ok = SensorServer.begin_calibration_mode(sensor.sensor_id)
json(conn, %{status: "calibration_mode", samples_needed: calibration_samples_needed()})
end
def calibration_start(conn, _params) do
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: "reference_distance is required"})
|> json(%{error: "tag_id is required"})
end
def calibration_stop(conn, %{"id" => id}) do
def calibration_stage_start(conn, %{"id" => id, "distance" => distance})
when is_number(distance) and distance > 0 do
sensor = Sensors.get_sensor!(id)
case SensorServer.start_stage(sensor.sensor_id, distance * 1.0) do
{:ok, samples_needed} ->
json(conn, %{status: "stage_active", distance: distance, samples_needed: samples_needed})
{:error, :already_active} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "a stage is already active"})
{:error, :not_in_calibration_mode} ->
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
def calibration_stage_start(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "distance is required and must be a positive number"})
end
def calibration_finish(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
case SensorServer.finish_calibration(sensor.sensor_id) do
{:ok, %{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp}} ->
json(conn, %{status: "idle", rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:error, :insufficient_stages} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "at least 2 completed stages required"})
{:error, :stage_active} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "cannot finish while a stage is active"})
{:error, :not_in_calibration_mode} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "sensor is not in calibration mode"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: inspect(reason)})
end
end
def calibration_cancel(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.abort_calibration(sensor.sensor_id)
json(conn, %{status: "idle"})
end
defp calibration_samples_needed do
Application.get_env(:localiser, :calibration_samples, 30)
end
defp render_sensor(sensor) do
%{
id: sensor.id,
@@ -136,6 +136,7 @@ defmodule Localiser.Web.Controllers.TagController do
id: tag.id,
tag_id: tag.tag_id,
name: tag.name,
color: tag.color,
room_id: live && live.room_id,
last_seen: live && live.last_seen
}
+3 -2
View File
@@ -7,9 +7,10 @@ defmodule Localiser.Web.Endpoint do
plug Plug.RequestId
plug Plug.Parsers,
parsers: [:json],
parsers: [:json, :multipart],
pass: ["application/json"],
json_decoder: Jason
json_decoder: Jason,
length: 4_000_000
plug OpenApiSpex.Plug.PutApiSpec, module: Localiser.Web.ApiSpec
+22 -2
View File
@@ -48,12 +48,28 @@ defmodule Localiser.Web.Router do
get "/onboarding", OnboardingController, :status
end
# Firmware download - public (ESP32 devices fetch without auth)
scope "/api", Localiser.Web.Controllers do
pipe_through :api
get "/firmware/:version", FirmwareController, :download
end
# User self-service (show own profile)
scope "/api", Localiser.Web.Controllers do
pipe_through :authenticated
get "/users/me", UserController, :me
end
# Firmware management - admin only
scope "/api", Localiser.Web.Controllers do
pipe_through :admin
get "/firmware", FirmwareController, :index
post "/firmware", FirmwareController, :upload
post "/firmware/:version/ota", FirmwareController, :ota_fleet
post "/firmware/:version/ota/instant", FirmwareController, :ota_fleet_instant
post "/sensors/:id/ota", SensorController, :ota
end
# User admin CRUD
scope "/api", Localiser.Web.Controllers do
pipe_through :admin
@@ -85,7 +101,11 @@ defmodule Localiser.Web.Router do
delete "/sensors/:id/place", SensorController, :unplace
post "/sensors/:id/factory_reset", SensorController, :factory_reset
post "/sensors/:id/reconfigure", SensorController, :reconfigure
post "/sensors/:id/calibration/start", SensorController, :calibration_start
post "/sensors/:id/calibration/stop", SensorController, :calibration_stop
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
get "/sensors/:id/version", SensorController, :get_version
end
end
+63 -9
View File
@@ -70,10 +70,11 @@ defmodule Localiser.Web.Schemas do
id: %Schema{type: :integer},
tag_id: %Schema{type: :string},
name: %Schema{type: :string},
color: %Schema{type: :string, nullable: true},
room_id: %Schema{type: :integer, nullable: true},
last_seen: %Schema{type: :string, format: :"date-time", nullable: true}
},
required: [:id, :tag_id, :name, :room_id, :last_seen]
required: [:id, :tag_id, :name, :color, :room_id, :last_seen]
})
end
@@ -139,11 +140,49 @@ defmodule Localiser.Web.Schemas do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStatus", type: :object,
properties: %{status: %Schema{type: :string, enum: ["calibrating", "idle"]}},
properties: %{status: %Schema{type: :string, enum: ["idle"]}},
required: [:status]
})
end
defmodule CalibrationBeginResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationBeginResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["calibration_mode"]},
samples_needed: %Schema{type: :integer}
},
required: [:status, :samples_needed]
})
end
defmodule CalibrationStageResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStageResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["stage_active"]},
distance: %Schema{type: :number, format: :float},
samples_needed: %Schema{type: :integer}
},
required: [:status, :distance, :samples_needed]
})
end
defmodule CalibrationFinishResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationFinishResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["idle"]},
rssi_ref: %Schema{type: :integer},
path_loss_exp: %Schema{type: :number, format: :float}
},
required: [:status, :rssi_ref, :path_loss_exp]
})
end
# ── Request body schemas ────────────────────────────────────────────────────
defmodule SetupParams do
@@ -291,17 +330,28 @@ defmodule Localiser.Web.Schemas do
})
end
defmodule CalibrationStartParams do
defmodule CalibrationTagParams do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStartParams", type: :object,
title: "CalibrationTagParams", type: :object,
properties: %{
reference_distance: %Schema{
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(%{
title: "CalibrationStageParams", type: :object,
properties: %{
distance: %Schema{
type: :number, format: :float,
description: "Known tag-to-sensor distance in metres"
description: "Known tag-to-sensor distance in metres (must be > 0)"
}
},
required: [:reference_distance]
required: [:distance]
})
end
@@ -325,7 +375,8 @@ defmodule Localiser.Web.Schemas do
title: "TagParams", type: :object,
properties: %{
tag_id: %Schema{type: :string, description: "BLE MAC address or device ID"},
name: %Schema{type: :string}
name: %Schema{type: :string},
color: %Schema{type: :string}
},
required: [:tag_id, :name]
})
@@ -335,7 +386,10 @@ defmodule Localiser.Web.Schemas do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "TagUpdateParams", type: :object,
properties: %{name: %Schema{type: :string}}
properties: %{
name: %Schema{type: :string},
color: %Schema{type: :string}
}
})
end
end
+1
View File
@@ -4,6 +4,7 @@ defmodule Localiser.Web.UserSocket do
channel "particles:*", Localiser.Web.Channels.ParticlesChannel
channel "rooms:occupancy", Localiser.Web.Channels.RoomChannel
channel "sensors", Localiser.Web.Channels.SensorsChannel
channel "calibration:*", Localiser.Web.Channels.CalibrationChannel
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
@@ -0,0 +1,9 @@
defmodule Localiser.Repo.Migrations.AddFirmwareVersionToSensors do
use Ecto.Migration
def change do
alter table(:sensors) do
add :firmware_version, :string
end
end
end