From 06daad31c7aeeb6d04f64769969cb828f2e76921 Mon Sep 17 00:00:00 2001 From: Max Ekman Date: Wed, 22 Apr 2026 14:38:20 +0200 Subject: [PATCH 1/2] feat: show firmware update progress on e-ink display Custom NervesHubLink.Client hooks into fwup progress callbacks and renders a progress bar across the top of the screen. Shows a full-screen 'Rebooting...' message before the device restarts. Throttled to re-render every 5% to avoid overwhelming e-ink. --- config/target.exs | 1 + lib/name_badge/application.ex | 3 +- lib/name_badge/firmware_client.ex | 30 +++++++++++++++ lib/name_badge/firmware_progress.ex | 25 +++++++++++++ lib/name_badge/layout.ex | 58 +++++++++++++++++++++++++---- lib/name_badge/screen.ex | 55 ++++++++++++++++++++++++++- 6 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 lib/name_badge/firmware_client.ex create mode 100644 lib/name_badge/firmware_progress.ex diff --git a/config/target.exs b/config/target.exs index a7a113f..956e2ef 100644 --- a/config/target.exs +++ b/config/target.exs @@ -94,6 +94,7 @@ product_secret = System.get_env("NH_PRODUCT_SECRET") if product_key && product_secret do config :nerves_hub_link, host: "manage.nervescloud.com", + client: NameBadge.FirmwareClient, shared_secret: [ product_key: product_key, product_secret: product_secret diff --git a/lib/name_badge/application.ex b/lib/name_badge/application.ex index dcdddca..07ff8ac 100644 --- a/lib/name_badge/application.ex +++ b/lib/name_badge/application.ex @@ -17,7 +17,8 @@ defmodule NameBadge.Application do # Starts a worker by calling: NameBadge.Worker.start_link(arg) # {NameBadge.Worker, arg}, {Registry, name: NameBadge.Registry, keys: :duplicate}, - NameBadge.Socket + NameBadge.Socket, + NameBadge.FirmwareProgress ] ++ target_children(@target) # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/name_badge/firmware_client.ex b/lib/name_badge/firmware_client.ex new file mode 100644 index 0000000..cb20667 --- /dev/null +++ b/lib/name_badge/firmware_client.ex @@ -0,0 +1,30 @@ +if Mix.target() != :host do + defmodule NameBadge.FirmwareClient do + @moduledoc """ + Custom NervesHubLink.Client that pushes firmware download progress + and reboot state into FirmwareProgress. Delays reboot by 3 seconds + so the screen can render a "Rebooting..." message first. + """ + + use NervesHubLink.Client + + @impl NervesHubLink.Client + def handle_fwup_message({:progress, percent}) do + NameBadge.FirmwareProgress.set_state({:downloading, percent}) + end + + def handle_fwup_message({:ok, 0, _}) do + NameBadge.FirmwareProgress.set_state(:rebooting) + end + + def handle_fwup_message(msg), do: super(msg) + + # Called in a spawned process by NervesHubLink.Client.initiate_reboot/0 + # Sleep gives the screen time to render "Rebooting..." before restart + @impl NervesHubLink.Client + def reboot do + Process.sleep(3_000) + Nerves.Runtime.reboot() + end + end +end diff --git a/lib/name_badge/firmware_progress.ex b/lib/name_badge/firmware_progress.ex new file mode 100644 index 0000000..8b4ada4 --- /dev/null +++ b/lib/name_badge/firmware_progress.ex @@ -0,0 +1,25 @@ +defmodule NameBadge.FirmwareProgress do + @moduledoc """ + Tracks firmware update state and notifies subscribers via Registry. + + States: `:idle`, `{:downloading, 0..100}`, `:rebooting`. + Layout reads `state/0` synchronously during render. Screen processes + subscribe to `:firmware_progress` for re-render triggers. + """ + + use Agent + + def start_link(_), do: Agent.start_link(fn -> :idle end, name: __MODULE__) + + def state, do: Agent.get(__MODULE__, & &1) + + def set_state(new_state) do + Agent.update(__MODULE__, fn _ -> new_state end) + + Registry.dispatch(NameBadge.Registry, :firmware_progress, fn pids -> + for {pid, _} <- pids, do: send(pid, {:firmware_progress, new_state}) + end) + end + + def subscribe, do: Registry.register(NameBadge.Registry, :firmware_progress, nil) +end diff --git a/lib/name_badge/layout.ex b/lib/name_badge/layout.ex index 3d8d363..8e41cde 100644 --- a/lib/name_badge/layout.ex +++ b/lib/name_badge/layout.ex @@ -15,17 +15,24 @@ defmodule NameBadge.Layout do end def app_layout(content, opts \\ []) do - buttons = Keyword.get(opts, :button_hints, %{}) + case NameBadge.FirmwareProgress.state() do + :rebooting -> + root_layout(firmware_progress_markup(), opts) - app_layout = - """ - #{icons_markup()} - #{buttons_markup(buttons)} + _ -> + buttons = Keyword.get(opts, :button_hints, %{}) - #{content} - """ + app_layout = + """ + #{firmware_progress_markup()} + #{icons_markup()} + #{buttons_markup(buttons)} - root_layout(app_layout, opts) + #{content} + """ + + root_layout(app_layout, opts) + end end defp icons_markup() do @@ -67,6 +74,41 @@ defmodule NameBadge.Layout do """ end + defp firmware_progress_markup() do + case NameBadge.FirmwareProgress.state() do + {:downloading, percent} -> + bar_width = round(percent * 400 / 100) + + """ + #place(top + left, dx: -32pt, dy: -32pt, + stack(dir: ttb, spacing: 0pt, + rect(width: #{bar_width}pt, height: 4pt, fill: black), + rect(width: 400pt, height: 0.5pt, fill: luma(200)), + ) + ) + """ + + :rebooting -> + """ + #place(top + left, dx: -32pt, dy: -32pt, + rect(width: 400pt, height: 4pt, fill: black) + ) + #place(center + horizon, + rect(fill: white, inset: 16pt, stroke: 1pt + black, radius: 4pt)[ + #align(center)[ + #text(size: 24pt, font: "Silkscreen", weight: 400, tracking: -2pt)[Rebooting...] + #v(4pt) + #text(size: 14pt)[Do not remove power] + ] + ] + ) + """ + + _ -> + "" + end + end + defp buttons_markup(button_hints) do hint_text = [a: "A", b: "B", ab: "AB"] diff --git a/lib/name_badge/screen.ex b/lib/name_badge/screen.ex index c8f1a7d..49e9bf6 100644 --- a/lib/name_badge/screen.ex +++ b/lib/name_badge/screen.ex @@ -16,7 +16,8 @@ defmodule NameBadge.Screen do first_render?: true, action: nil, mount_args: nil, - last_render: %{} + last_render: %{}, + fw_progress: nil ] def start_link(args) do @@ -59,6 +60,7 @@ defmodule NameBadge.Screen do {:ok, screen} = screen.module.mount(screen.mount_args, screen) ButtonMonitor.subscribe(:button_1) ButtonMonitor.subscribe(:button_2) + NameBadge.FirmwareProgress.subscribe() process_screen(screen) end @@ -102,6 +104,56 @@ defmodule NameBadge.Screen do process_screen(screen) end + def handle_info({:firmware_progress, {:downloading, percent}}, screen) do + last_percent = + case screen.fw_progress do + {:downloading, p} -> p + _ -> -5 + end + + if percent - last_percent >= 5 or percent == 100 do + fw_tick = Map.get(screen.assigns, :_fw_tick, 0) + 1 + + screen = %{ + screen + | fw_progress: {:downloading, percent}, + assigns: Map.put(screen.assigns, :_fw_tick, fw_tick) + } + + process_screen(screen) + else + {:noreply, screen} + end + end + + def handle_info({:firmware_progress, :rebooting}, screen) do + fw_tick = Map.get(screen.assigns, :_fw_tick, 0) + 1 + + screen = %{ + screen + | fw_progress: :rebooting, + assigns: Map.put(screen.assigns, :_fw_tick, fw_tick) + } + + process_screen(screen) + end + + def handle_info({:firmware_progress, :idle}, screen) do + fw_tick = Map.get(screen.assigns, :_fw_tick, 0) + 1 + + screen = %{ + screen + | fw_progress: nil, + assigns: Map.put(screen.assigns, :_fw_tick, fw_tick) + } + + process_screen(screen) + end + + def handle_info({:firmware_progress, _other}, screen) do + {:noreply, screen} + end + def handle_info(message, screen) do {:noreply, screen} = screen.module.handle_info(message, screen) @@ -149,6 +201,7 @@ defmodule NameBadge.Screen do defp maybe_render(%__MODULE__{} = screen) do cond do screen.first_render? -> {:noreply, screen, {:continue, {:render, []}}} + screen.fw_progress == :rebooting -> {:noreply, screen, {:continue, {:render, []}}} Map.equal?(screen.last_render, screen.assigns) -> {:noreply, screen} true -> {:noreply, screen, {:continue, {:render, [refresh_type: :partial]}}} end From 78fce5233c04279dde5f9b0014b838b5004d3fd0 Mon Sep 17 00:00:00 2001 From: Max Ekman Date: Wed, 22 Apr 2026 14:38:20 +0200 Subject: [PATCH 2/2] chore: formatting --- lib/name_badge/battery.ex | 9 +++++---- lib/name_badge/mocks/battery_mock.ex | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/name_badge/battery.ex b/lib/name_badge/battery.ex index 6ad18d3..9a856a1 100644 --- a/lib/name_badge/battery.ex +++ b/lib/name_badge/battery.ex @@ -18,10 +18,11 @@ defmodule NameBadge.Battery do min_voltage = 3.0 max_voltage = 4.2 - percentage = ((v - min_voltage) / (max_voltage - min_voltage) * 100) - |> max(0) - |> min(100) - |> round() + percentage = + ((v - min_voltage) / (max_voltage - min_voltage) * 100) + |> max(0) + |> min(100) + |> round() percentage end diff --git a/lib/name_badge/mocks/battery_mock.ex b/lib/name_badge/mocks/battery_mock.ex index 741ff5c..fa74f9a 100644 --- a/lib/name_badge/mocks/battery_mock.ex +++ b/lib/name_badge/mocks/battery_mock.ex @@ -21,10 +21,11 @@ defmodule NameBadge.BatteryMock do min_voltage = 3.0 max_voltage = 4.2 - percentage = ((v - min_voltage) / (max_voltage - min_voltage) * 100) - |> max(0) - |> min(100) - |> round() + percentage = + ((v - min_voltage) / (max_voltage - min_voltage) * 100) + |> max(0) + |> min(100) + |> round() percentage end