init: inital commit

This commit is contained in:
2026-04-16 15:46:00 +02:00
commit 34ddbe669e
40 changed files with 1556 additions and 0 deletions
@@ -0,0 +1,32 @@
defmodule Localiser.Localisation.Filter.Behaviour do
@moduledoc """
Behaviour for localisation filter implementations.
Filters receive a batch of resolved sensor measurements (RSSI already converted
to distance) and produce an estimated `{x, y}` position in floor coordinate space.
"""
@type sensor_measurement :: %{
sensor_id: String.t(),
floor_x: float(),
floor_y: float(),
distance: float()
}
@type position :: {float(), float()}
@type confidence :: float()
@doc "Initialise the filter. `sensors` is a list of all enrolled sensor structs."
@callback init(sensors :: list(), opts :: keyword()) :: {:ok, state :: term()}
@doc """
Feed a batch of measurements into the filter and return the updated position
estimate and new filter state.
"""
@callback update(state :: term(), measurements :: [sensor_measurement()]) ::
{:ok, position(), new_state :: term()}
@doc "Return the current best position estimate and a confidence score in [0.0, 1.0]."
@callback estimate(state :: term()) :: {position(), confidence()}
end
@@ -0,0 +1,21 @@
defmodule Localiser.Localisation.Filter.DiscreteRecursiveBayes do
@behaviour Localiser.Localisation.Filter.Behaviour
@impl true
def init(_sensors, _opts) do
# TODO: discretise floor into a grid, initialise uniform prior over cells
{:ok, %{}}
end
@impl true
def update(state, _measurements) do
# TODO: motion model (convolution), measurement update (pointwise multiply + normalise)
{:ok, {0.0, 0.0}, state}
end
@impl true
def estimate(_state) do
# TODO: return MAP cell centre; confidence = max cell probability
{{0.0, 0.0}, 0.0}
end
end
@@ -0,0 +1,21 @@
defmodule Localiser.Localisation.Filter.Kalman do
@behaviour Localiser.Localisation.Filter.Behaviour
@impl true
def init(_sensors, _opts) do
# TODO: initialise state vector [x, y, vx, vy], covariance P, Q, R matrices
{:ok, %{}}
end
@impl true
def update(state, _measurements) do
# TODO: predict step (F * x), update step (Kalman gain, residual)
{:ok, {0.0, 0.0}, state}
end
@impl true
def estimate(_state) do
# TODO: return state vector position and trace of P as inverse-confidence proxy
{{0.0, 0.0}, 0.0}
end
end
@@ -0,0 +1,21 @@
defmodule Localiser.Localisation.Filter.Particle do
@behaviour Localiser.Localisation.Filter.Behaviour
@impl true
def init(_sensors, _opts) do
# TODO: initialise particle set, weights, noise parameters
{:ok, %{}}
end
@impl true
def update(state, _measurements) do
# TODO: predict step, weight update by likelihood, resample
{:ok, {0.0, 0.0}, state}
end
@impl true
def estimate(_state) do
# TODO: weighted mean of particle set
{{0.0, 0.0}, 0.0}
end
end
@@ -0,0 +1,32 @@
defmodule Localiser.Localisation.Filter.Supervisor do
use DynamicSupervisor
alias Localiser.Domain.Tags
alias Localiser.Localisation.Tag.Filter, as: TagFilter
def start_link(_args) do
case DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__) do
{:ok, pid} ->
Tags.list_tags() |> Enum.each(&start_tag_filter/1)
{:ok, pid}
error ->
error
end
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_tag_filter(tag) do
child_spec = %{
id: {TagFilter, tag.tag_id},
start: {TagFilter, :start_link, [tag]},
restart: :transient
}
DynamicSupervisor.start_child(__MODULE__, child_spec)
end
end
@@ -0,0 +1,24 @@
defmodule Localiser.Localisation.Floor.Server do
use Supervisor
alias Localiser.Localisation.Room
alias Localiser.Localisation.Sensor
def start_link(floor) do
Supervisor.start_link(__MODULE__, floor, name: via(floor.id))
end
def via(floor_id) do
{:via, Registry, {Localiser.Registry, {:floor_server, floor_id}}}
end
@impl true
def init(floor) do
children = [
{Room.Supervisor, floor},
{Sensor.Supervisor, floor},
{Sensor.Manager, floor}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
@@ -0,0 +1,32 @@
defmodule Localiser.Localisation.Floor.Supervisor do
use DynamicSupervisor
alias Localiser.Domain.Floors
alias Localiser.Localisation.Floor.Server
def start_link(_args) do
case DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__) do
{:ok, pid} ->
Floors.list_floors() |> Enum.each(&start_floor_server/1)
{:ok, pid}
error ->
error
end
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_floor_server(floor) do
child_spec = %{
id: {Server, floor.id},
start: {Server, :start_link, [floor]},
restart: :transient
}
DynamicSupervisor.start_child(__MODULE__, child_spec)
end
end
+75
View File
@@ -0,0 +1,75 @@
defmodule Localiser.Localisation.Room.Server do
use GenServer
@pubsub Localiser.PubSub
defstruct [:id, :name, :offset_x, :offset_y, :width, :height, occupants: MapSet.new()]
def start_link(room) do
GenServer.start_link(__MODULE__, room, name: via(room.id))
end
def via(room_id) do
{:via, Registry, {Localiser.Registry, {:room, room_id}}}
end
def tag_entered(room_id, tag_id) do
GenServer.cast(via(room_id), {:tag_entered, tag_id})
end
def tag_left(room_id, tag_id) do
GenServer.cast(via(room_id), {:tag_left, tag_id})
end
def get_occupants(room_id) do
GenServer.call(via(room_id), :get_occupants)
end
@impl true
def init(room) do
state = %__MODULE__{
id: room.id,
name: room.name,
offset_x: room.offset_x || 0.0,
offset_y: room.offset_y || 0.0,
width: room.width || 0.0,
height: room.height || 0.0
}
{:ok, state}
end
@impl true
def handle_cast({:tag_entered, tag_id}, state) do
new_occupants = MapSet.put(state.occupants, tag_id)
if new_occupants != state.occupants do
broadcast(state.id, new_occupants)
end
{:noreply, %{state | occupants: new_occupants}}
end
def handle_cast({:tag_left, tag_id}, state) do
new_occupants = MapSet.delete(state.occupants, tag_id)
if new_occupants != state.occupants do
broadcast(state.id, new_occupants)
end
{:noreply, %{state | occupants: new_occupants}}
end
@impl true
def handle_call(:get_occupants, _from, state) do
{:reply, state.occupants, state}
end
defp broadcast(room_id, occupants) do
Phoenix.PubSub.broadcast(
@pubsub,
"room:#{room_id}",
{:room_occupancy_changed, room_id, occupants}
)
end
end
@@ -0,0 +1,41 @@
defmodule Localiser.Localisation.Room.Supervisor do
use DynamicSupervisor
alias Localiser.Domain.Rooms
alias Localiser.Localisation.Room.Server
def start_link(floor) do
case DynamicSupervisor.start_link(__MODULE__, :ok, name: via(floor.id)) do
{:ok, pid} ->
seed_rooms(floor.id)
{:ok, pid}
error ->
error
end
end
def via(floor_id) do
{:via, Registry, {Localiser.Registry, {:room_supervisor, floor_id}}}
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_room_server(floor_id, room) do
child_spec = %{
id: {Server, room.id},
start: {Server, :start_link, [room]},
restart: :transient
}
DynamicSupervisor.start_child(via(floor_id), child_spec)
end
defp seed_rooms(floor_id) do
Rooms.list_rooms_for_floor(floor_id)
|> Enum.each(&start_room_server(floor_id, &1))
end
end
@@ -0,0 +1,54 @@
defmodule Localiser.Localisation.Sensor.Manager do
@moduledoc """
GenServer that subscribes to the "sensors" PubSub topic and manages
Sensor.Server lifecycle for a specific floor.
- {:sensor_enrolled, sensor, room} → start Sensor.Server if not already running
- {:sensor_unenrolled, sensor_id} → terminate the Sensor.Server for this floor
"""
use GenServer
alias Localiser.Localisation.Sensor
def start_link(floor) do
GenServer.start_link(__MODULE__, floor.id, name: via(floor.id))
end
def via(floor_id) do
{:via, Registry, {Localiser.Registry, {:sensor_manager, floor_id}}}
end
@impl true
def init(floor_id) do
Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors")
{:ok, %{floor_id: floor_id}}
end
# Sensor placed (or moved) onto this floor's layout.
@impl true
def handle_info({:sensor_enrolled, sensor, %{floor_id: floor_id} = room}, %{floor_id: floor_id} = state) do
case Registry.lookup(Localiser.Registry, {:sensor, sensor.sensor_id}) do
[] -> Sensor.Supervisor.start_sensor_server(floor_id, sensor, room)
_ -> :ok # Sensor.Server is already running and handles position via its own PubSub sub
end
{:noreply, state}
end
# Sensor removed from layout — stop the server for this floor if it's running.
def handle_info({:sensor_unenrolled, sensor_id}, %{floor_id: floor_id} = state) do
case Registry.lookup(Localiser.Registry, {:sensor, sensor_id}) do
[{pid, _}] ->
DynamicSupervisor.terminate_child(Sensor.Supervisor.via(floor_id), pid)
[] ->
:ok
end
{:noreply, state}
end
# Ignore all other PubSub broadcasts (sensor announced, calibration_complete, etc.)
def handle_info(_msg, state), do: {:noreply, state}
end
+170
View File
@@ -0,0 +1,170 @@
defmodule Localiser.Localisation.Sensor.Server do
use GenServer
require Logger
alias Localiser.Domain.Sensors
alias Localiser.Domain.Schema.{Sensor, SensorCalibration}
alias Localiser.MQTT.Connection, as: MQTTConnection
@default_rssi_ref -59
@default_path_loss_exp 2.0
# mode: :ok | {:calibrating, buffer :: [integer()], target :: pos_integer()}
defstruct [:sensor_id, :sensor_db_id, :floor_x, :floor_y, :rssi_ref, :path_loss_exp, mode: :ok]
def start_link({sensor, room}) do
GenServer.start_link(__MODULE__, {sensor, room}, name: via(sensor.sensor_id))
end
def via(sensor_id) do
{:via, Registry, {Localiser.Registry, {:sensor, sensor_id}}}
end
# Returns %{sensor_id, floor_x, floor_y, distance} for a raw RSSI reading.
def measure(sensor_id, rssi) do
GenServer.call(via(sensor_id), {:measure, rssi})
end
# Returns true if the sensor is currently collecting calibration samples.
def calibrating?(sensor_id) do
GenServer.call(via(sensor_id), :calibrating?)
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})
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})
end
# Aborts an in-progress calibration without saving.
def abort_calibration(sensor_id) do
GenServer.cast(via(sensor_id), :abort_calibration)
end
@impl true
def init({sensor, room}) do
Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors")
calibration = Sensors.latest_calibration(sensor)
{rssi_ref, path_loss_exp} = calibration_params(calibration)
state = %__MODULE__{
sensor_id: sensor.sensor_id,
sensor_db_id: sensor.id,
floor_x: (room.offset_x || 0.0) + (sensor.x || 0.0),
floor_y: (room.offset_y || 0.0) + (sensor.y || 0.0),
rssi_ref: rssi_ref,
path_loss_exp: path_loss_exp
}
{:ok, state}
end
@impl true
def handle_call({:measure, rssi}, _from, state) do
distance = rssi_to_distance(rssi, state.rssi_ref, state.path_loss_exp)
measurement = %{
sensor_id: state.sensor_id,
floor_x: state.floor_x,
floor_y: state.floor_y,
distance: distance
}
{:reply, measurement, state}
end
@impl true
def handle_call(:calibrating?, _from, state) do
{:reply, match?({:calibrating, _, _}, state.mode), state}
end
@impl true
def handle_cast({:begin_calibration, target}, state) do
MQTTConnection.publish("localiser/sensor/#{state.sensor_id}/cmd", ~s({"action":"calibrate_start"}))
{:noreply, %{state | mode: {:calibrating, [], target}}}
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}}
end
def handle_cast(:abort_calibration, state), do: {:noreply, state}
@impl true
def handle_cast({:calibration_reading, rssi}, %{mode: {:calibrating, buffer, target}} = state) do
buffer = [rssi | buffer]
if length(buffer) >= target do
finalize_calibration(buffer, state)
else
{:noreply, %{state | mode: {:calibrating, buffer, target}}}
end
end
def handle_cast({:calibration_reading, _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.offset_x || 0.0) + (sensor.x || 0.0)
floor_y = (room.offset_y || 0.0) + (sensor.y || 0.0)
{: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
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
end
# d = 10 ^ ((rssi_ref - rssi) / (10 * n))
defp rssi_to_distance(rssi, rssi_ref, path_loss_exp) do
:math.pow(10.0, (rssi_ref - rssi) / (10.0 * path_loss_exp))
end
defp calibration_params(nil), do: {@default_rssi_ref, @default_path_loss_exp}
defp calibration_params(%SensorCalibration{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp}) do
{rssi_ref, path_loss_exp}
end
end
@@ -0,0 +1,44 @@
defmodule Localiser.Localisation.Sensor.Supervisor do
use DynamicSupervisor
alias Localiser.Domain.{Rooms, Sensors}
alias Localiser.Localisation.Sensor.Server
def start_link(floor) do
case DynamicSupervisor.start_link(__MODULE__, :ok, name: via(floor.id)) do
{:ok, pid} ->
seed_sensors(floor.id)
{:ok, pid}
error ->
error
end
end
def via(floor_id) do
{:via, Registry, {Localiser.Registry, {:sensor_supervisor, floor_id}}}
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def start_sensor_server(floor_id, sensor, room) do
child_spec = %{
id: {Server, sensor.id},
start: {Server, :start_link, [{sensor, room}]},
restart: :transient
}
DynamicSupervisor.start_child(via(floor_id), child_spec)
end
defp seed_sensors(floor_id) do
Rooms.list_rooms_for_floor(floor_id)
|> Enum.each(fn room ->
Sensors.list_sensors_for_room(room.id)
|> Enum.each(&start_sensor_server(floor_id, &1, room))
end)
end
end
+93
View File
@@ -0,0 +1,93 @@
defmodule Localiser.Localisation.Tag.Filter do
use GenServer
alias Localiser.Domain.Floors
alias Localiser.Localisation.Room.Server, as: RoomServer
@default_filter Localiser.Localisation.Filter.Particle
defstruct [
:tag_id,
:filter_module,
:filter_state,
:rooms,
current_room_id: nil
]
def start_link(tag) do
GenServer.start_link(__MODULE__, tag, name: via(tag.tag_id))
end
def via(tag_id) do
{:via, Registry, {Localiser.Registry, {:filter, tag_id}}}
end
# Deliver a batch of resolved measurements to this tag's filter. Called by RSSI.Buffer.
# measurements :: [%{sensor_id, floor_x, floor_y, distance}]
def ingest(tag_id, measurements) do
GenServer.cast(via(tag_id), {:ingest, measurements})
end
# Hot-swap the filter module. Reinitialises filter state with new module.
def swap_filter(tag_id, new_module, opts \\ []) do
GenServer.call(via(tag_id), {:swap_filter, new_module, opts})
end
@impl true
def init(tag) do
filter_module = Application.get_env(:localiserd, :default_filter, @default_filter)
rooms = load_rooms()
{:ok, filter_state} = filter_module.init([], [])
state = %__MODULE__{
tag_id: tag.tag_id,
filter_module: filter_module,
filter_state: filter_state,
rooms: rooms
}
{:ok, state}
end
@impl true
def handle_cast({:ingest, []}, state), do: {:noreply, state}
def handle_cast({:ingest, measurements}, state) do
{:ok, {x, y}, new_filter_state} = state.filter_module.update(state.filter_state, measurements)
new_room = find_room(state.rooms, x, y)
new_room_id = if new_room, do: new_room.id, else: nil
if new_room_id != state.current_room_id do
if state.current_room_id, do: RoomServer.tag_left(state.current_room_id, state.tag_id)
if new_room_id, do: RoomServer.tag_entered(new_room_id, state.tag_id)
end
{:noreply, %{state | filter_state: new_filter_state, current_room_id: new_room_id}}
end
@impl true
def handle_call({:swap_filter, new_module, opts}, _from, state) do
case new_module.init([], opts) do
{:ok, new_filter_state} ->
{:reply, :ok, %{state | filter_module: new_module, filter_state: new_filter_state}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
defp load_rooms do
Floors.list_floors_with_rooms()
|> Enum.flat_map(& &1.rooms)
end
defp find_room(rooms, x, y) do
Enum.find(rooms, fn room ->
ox = room.offset_x || 0.0
oy = room.offset_y || 0.0
x >= ox and x < ox + (room.width || 0.0) and
y >= oy and y < oy + (room.height || 0.0)
end)
end
end