feat: support firmware uploads, OTA pushes
This commit is contained in:
@@ -25,4 +25,7 @@ localiserd-*.tar
|
||||
# sqlite database files
|
||||
priv/db/*
|
||||
|
||||
# firmware files
|
||||
priv/firmware/*
|
||||
|
||||
.elixir_ls/
|
||||
@@ -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
|
||||
@@ -59,6 +59,27 @@ defmodule Localiser.Domain.Sensors do
|
||||
{:ok, sensor}
|
||||
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".
|
||||
|
||||
@@ -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]],
|
||||
@@ -198,6 +212,27 @@ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user