feat: support firmware uploads, OTA pushes
This commit is contained in:
@@ -25,4 +25,7 @@ localiserd-*.tar
|
|||||||
# sqlite database files
|
# sqlite database files
|
||||||
priv/db/*
|
priv/db/*
|
||||||
|
|
||||||
|
# firmware files
|
||||||
|
priv/firmware/*
|
||||||
|
|
||||||
.elixir_ls/
|
.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}
|
{: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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user