feat: support firmware uploads, OTA pushes

This commit is contained in:
2026-05-17 14:39:29 +02:00
parent e4e4065c2b
commit 2601484fad
7 changed files with 198 additions and 3 deletions
+3
View File
@@ -25,4 +25,7 @@ localiserd-*.tar
# sqlite database files # sqlite database files
priv/db/* priv/db/*
# firmware files
priv/firmware/*
.elixir_ls/ .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
+21
View File
@@ -59,6 +59,27 @@ defmodule Localiser.Domain.Sensors do
{:ok, sensor} {:ok, sensor}
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 # 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 # 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". # want to change. Valid keys: "ssid", "password", "mqtt_host", "mqtt_port".
@@ -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 Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Sensors alias Localiser.Domain.{Sensors, Firmware}
alias Localiser.Localisation.Sensor.Server, as: SensorServer alias Localiser.Localisation.Sensor.Server, as: SensorServer
alias Localiser.Web.Schemas alias Localiser.Web.Schemas
@@ -77,6 +77,20 @@ defmodule Localiser.Web.Controllers.SensorController do
unauthorized: {"Unauthorized", "application/json", Schemas.Error} 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, operation :factory_reset,
summary: "Send factory reset command to sensor", summary: "Send factory reset command to sensor",
parameters: [id: [in: :path, type: :integer, required: true]], parameters: [id: [in: :path, type: :integer, required: true]],
@@ -198,6 +212,27 @@ defmodule Localiser.Web.Controllers.SensorController do
end end
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 def factory_reset(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id) sensor = Sensors.get_sensor!(id)
{:ok, _} = Sensors.factory_reset(sensor) {:ok, _} = Sensors.factory_reset(sensor)
+3 -2
View File
@@ -7,9 +7,10 @@ defmodule Localiser.Web.Endpoint do
plug Plug.RequestId plug Plug.RequestId
plug Plug.Parsers, plug Plug.Parsers,
parsers: [:json], parsers: [:json, :multipart],
pass: ["application/json"], pass: ["application/json"],
json_decoder: Jason json_decoder: Jason,
length: 4_000_000
plug OpenApiSpex.Plug.PutApiSpec, module: Localiser.Web.ApiSpec plug OpenApiSpex.Plug.PutApiSpec, module: Localiser.Web.ApiSpec
+16
View File
@@ -48,12 +48,28 @@ defmodule Localiser.Web.Router do
get "/onboarding", OnboardingController, :status get "/onboarding", OnboardingController, :status
end 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) # User self-service (show own profile)
scope "/api", Localiser.Web.Controllers do scope "/api", Localiser.Web.Controllers do
pipe_through :authenticated pipe_through :authenticated
get "/users/me", UserController, :me get "/users/me", UserController, :me
end 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 # User admin CRUD
scope "/api", Localiser.Web.Controllers do scope "/api", Localiser.Web.Controllers do
pipe_through :admin pipe_through :admin