diff --git a/.gitignore b/.gitignore index 3460148..5a11e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,7 @@ localiserd-*.tar # sqlite database files priv/db/* +# firmware files +priv/firmware/* + .elixir_ls/ \ No newline at end of file diff --git a/lib/localiser/domain/firmware.ex b/lib/localiser/domain/firmware.ex new file mode 100644 index 0000000..8cc4888 --- /dev/null +++ b/lib/localiser/domain/firmware.ex @@ -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 diff --git a/lib/localiser/domain/sensors.ex b/lib/localiser/domain/sensors.ex index 00a6fde..9cb18c6 100644 --- a/lib/localiser/domain/sensors.ex +++ b/lib/localiser/domain/sensors.ex @@ -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". diff --git a/lib/localiser/web/controllers/firmware_controller.ex b/lib/localiser/web/controllers/firmware_controller.ex new file mode 100644 index 0000000..66c37c3 --- /dev/null +++ b/lib/localiser/web/controllers/firmware_controller.ex @@ -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 diff --git a/lib/localiser/web/controllers/sensor_controller.ex b/lib/localiser/web/controllers/sensor_controller.ex index 9e33e5e..2126730 100644 --- a/lib/localiser/web/controllers/sensor_controller.ex +++ b/lib/localiser/web/controllers/sensor_controller.ex @@ -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) diff --git a/lib/localiser/web/endpoint.ex b/lib/localiser/web/endpoint.ex index 7708b2f..a4c0c63 100644 --- a/lib/localiser/web/endpoint.ex +++ b/lib/localiser/web/endpoint.ex @@ -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 diff --git a/lib/localiser/web/router.ex b/lib/localiser/web/router.ex index c26a1bb..87c41a5 100644 --- a/lib/localiser/web/router.ex +++ b/lib/localiser/web/router.ex @@ -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