neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Hand an Ankyra's connection over as a link
e1827fb · Gabriel Morell · 2026-09-02 19:21
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.ex
+85
−2
| 9 | 9 | Phoenix.PubSub.subscribe(RoomSanctum.PubSub, RoomSanctum.Worker.Ankyra.topic(id)) |
| 10 | 10 | end |
| 11 | 11 | |
| 12 | − {:ok, assign(socket, :consumer_count, nil)} | |
| 12 | + {:ok, | |
| 13 | + socket | |
| 14 | + |> assign(:consumer_count, nil) | |
| 15 | + |> assign(:broker, default_broker())} | |
| 13 | 16 | end |
| 14 | 17 | |
| 15 | 18 | @impl true |
| 17 | 20 | {:noreply, |
| 18 | 21 | socket |
| 19 | 22 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 20 | − |> assign(:ankyra, Accounts.get_rabbit_user!(id))} | |
| 23 | + |> assign(:ankyra, Accounts.get_rabbit_user!(id)) | |
| 24 | + |> then(&assign_pairing(&1, &1.assigns.broker))} | |
| 21 | 25 | end |
| 22 | 26 | |
| 23 | 27 | @impl true |
| 53 | 57 | {:noreply, assign(socket, :ankyra, updated)} |
| 54 | 58 | end |
| 55 | 59 | |
| 60 | + # The broker's address is not the app's: the phone reaches RabbitMQ directly, | |
| 61 | + # and where that is depends on how the cluster is exposed. Configured where | |
| 62 | + # it is known, corrected here where it is not, without a deploy. | |
| 63 | + def handle_event("set-broker", %{"host" => host, "port" => port} = params, socket) do | |
| 64 | + broker = %{ | |
| 65 | + host: String.trim(host), | |
| 66 | + port: parse_port(port), | |
| 67 | + tls: params["tls"] == "on" | |
| 68 | + } | |
| 69 | + | |
| 70 | + {:noreply, assign_pairing(socket, broker)} | |
| 71 | + end | |
| 72 | + | |
| 73 | + defp assign_pairing(socket, broker) do | |
| 74 | + uri = pairing_uri(socket.assigns.ankyra, broker) | |
| 75 | + | |
| 76 | + socket | |
| 77 | + |> assign(:broker, broker) | |
| 78 | + |> assign(:pairing_uri, uri) | |
| 79 | + |> assign(:pairing_qr, qr_svg(uri)) | |
| 80 | + end | |
| 81 | + | |
| 82 | + @doc """ | |
| 83 | + Everything a client needs to subscribe, as one link. | |
| 84 | + | |
| 85 | + A custom scheme rather than a payload the app has to be pointed at: the | |
| 86 | + phone's own camera resolves it, so pairing is scan-and-open with no scanner | |
| 87 | + in the client and no five fields retyped by hand. | |
| 88 | + """ | |
| 89 | + def pairing_uri(ankyra, broker) do | |
| 90 | + query = | |
| 91 | + URI.encode_query(%{ | |
| 92 | + "host" => broker.host, | |
| 93 | + "port" => broker.port, | |
| 94 | + "user" => ankyra.username, | |
| 95 | + "pass" => ankyra.password, | |
| 96 | + "topic" => ankyra.topic, | |
| 97 | + "tls" => if(broker.tls, do: "1", else: "0") | |
| 98 | + }) | |
| 99 | + | |
| 100 | + "apolloscrib://ankyra?" <> query | |
| 101 | + end | |
| 102 | + | |
| 103 | + defp qr_svg(uri) do | |
| 104 | + uri | |
| 105 | + |> EQRCode.encode() | |
| 106 | + |> EQRCode.svg(background_color: "#ffffff", color: "#000000", viewbox: true) | |
| 107 | + |> strip_xml_prolog() | |
| 108 | + end | |
| 109 | + | |
| 110 | + # eqrcode writes a standalone SVG document, prolog and all. Inline in HTML | |
| 111 | + # that prolog is not markup, and browsers render it as text above the code. | |
| 112 | + defp strip_xml_prolog(svg) do | |
| 113 | + String.replace(svg, ~r/\A\s*<\?xml[^>]*\?>\s*/, "") | |
| 114 | + end | |
| 115 | + | |
| 116 | + defp default_broker do | |
| 117 | + configured = Application.get_env(:room_sanctum, :mqtt, []) | |
| 118 | + | |
| 119 | + %{ | |
| 120 | + # The app's own hostname is the best guess available and is right | |
| 121 | + # whenever the broker sits behind the same name. | |
| 122 | + host: Keyword.get(configured, :host) || endpoint_host(), | |
| 123 | + port: Keyword.get(configured, :port, 1883), | |
| 124 | + tls: Keyword.get(configured, :tls, false) | |
| 125 | + } | |
| 126 | + end | |
| 127 | + | |
| 128 | + defp endpoint_host do | |
| 129 | + RoomSanctumWeb.Endpoint.config(:url)[:host] || "localhost" | |
| 130 | + end | |
| 131 | + | |
| 132 | + defp parse_port(port) do | |
| 133 | + case Integer.parse(to_string(port)) do | |
| 134 | + {n, _} when n > 0 and n < 65_536 -> n | |
| 135 | + _ -> 1883 | |
| 136 | + end | |
| 137 | + end | |
| 138 | + | |
| 56 | 139 | defp page_title(:show), do: "Ankyra Detail" |
| 57 | 140 | defp page_title(:edit), do: "Modify Ankyra" |
| 58 | 141 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.html.heex
+55
−0
| 52 | 52 | </div> |
| 53 | 53 | </div> |
| 54 | 54 | </div> |
| 55 | + <div class="card card-compact w-full bg-base-100 shadow-xl m-2"> | |
| 56 | + <div class="card-body"> | |
| 57 | + <h3 class="card-title">Pair a Client</h3> | |
| 58 | + <p class="text-sm opacity-70"> | |
| 59 | + Scan with the phone's camera. The link carries this Ankyra's credentials | |
| 60 | + and topic, so nothing is retyped — Apollo's Crib for Android opens it and | |
| 61 | + connects. The broker's address is not the app's, so check it here. | |
| 62 | + </p> | |
| 63 | + <div class="flex flex-wrap items-start gap-4"> | |
| 64 | + <div class="bg-white p-3 rounded-xl shrink-0 w-64 [&>svg]:w-full [&>svg]:h-auto"> | |
| 65 | + <%= Phoenix.HTML.raw(@pairing_qr) %> | |
| 66 | + </div> | |
| 67 | + <div class="flex-1 min-w-[18rem]"> | |
| 68 | + <form phx-change="set-broker" class="flex flex-wrap gap-2 items-end"> | |
| 69 | + <label class="form-control"> | |
| 70 | + <span class="label-text">Broker host</span> | |
| 71 | + <input | |
| 72 | + type="text" | |
| 73 | + name="host" | |
| 74 | + value={@broker.host} | |
| 75 | + class="input input-bordered input-sm font-mono" | |
| 76 | + autocomplete="off" | |
| 77 | + /> | |
| 78 | + </label> | |
| 79 | + <label class="form-control"> | |
| 80 | + <span class="label-text">Port</span> | |
| 81 | + <input | |
| 82 | + type="number" | |
| 83 | + name="port" | |
| 84 | + value={@broker.port} | |
| 85 | + class="input input-bordered input-sm w-24 font-mono" | |
| 86 | + /> | |
| 87 | + </label> | |
| 88 | + <label class="label cursor-pointer gap-2"> | |
| 89 | + <span class="label-text">TLS</span> | |
| 90 | + <input type="checkbox" name="tls" class="toggle toggle-accent toggle-sm" checked={@broker.tls} /> | |
| 91 | + </label> | |
| 92 | + </form> | |
| 93 | + <pre class="mt-3 text-xs whitespace-pre-wrap break-all opacity-70"><%= @pairing_uri %></pre> | |
| 94 | + <%= if @ankyra.auto_register_clients do %> | |
| 95 | + <p class="text-sm opacity-70 mt-2"> | |
| 96 | + The client registers its own id on first connect. | |
| 97 | + </p> | |
| 98 | + <% else %> | |
| 99 | + <p class="text-sm text-warning mt-2"> | |
| 100 | + <i class="fas fa-fw fa-triangle-exclamation"></i> | |
| 101 | + Auto-register is off — add the client's id below, or its first | |
| 102 | + connection will be refused. | |
| 103 | + </p> | |
| 104 | + <% end %> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | + | |
| 55 | 110 | <div class="card card-compact w-full bg-base-100 shadow-xl m-2"> |
| 56 | 111 | <div class="card-body"> |
| 57 | 112 | <div class="flex items-center justify-between"> |
modified
apps/room_sanctum/mix.exs
+3
−0
| 68 | 68 | {:telemetry_poller, "~> 1.0"}, |
| 69 | 69 | {:gettext, "~> 0.18"}, |
| 70 | 70 | {:jason, "~> 1.2"}, |
| 71 | + # Pairing an Ankyra with a phone: the show page draws its connection as a | |
| 72 | + # QR rather than five fields to retype. Pure Elixir, SVG out, no ports. | |
| 73 | + {:eqrcode, "~> 0.2"}, | |
| 71 | 74 | {:plug_cowboy, "~> 2.5"}, |
| 72 | 75 | {:timex, "~> 3.7.8"}, |
| 73 | 76 | {:poison, "~> 5.0", override: true}, |
added
apps/room_sanctum/test/room_sanctum_web/live/ankyra_pairing_test.exs
+61
−0
| 1 | +defmodule RoomSanctumWeb.AnkyraPairingTest do | |
| 2 | + @moduledoc """ | |
| 3 | + An Ankyra's connection, handed to a phone as a link rather than as five | |
| 4 | + fields to retype. | |
| 5 | + | |
| 6 | + The page itself is not mounted here: `users_rabbit` is created by the hermes | |
| 7 | + repo's migrations and is not in the sanctum test database, so there is no | |
| 8 | + Ankyra to render. What is worth testing is the link, and that is a function. | |
| 9 | + """ | |
| 10 | + use ExUnit.Case, async: true | |
| 11 | + | |
| 12 | + alias RoomSanctum.Accounts.RabbitUser | |
| 13 | + alias RoomSanctumWeb.AnkyraLive.Show | |
| 14 | + | |
| 15 | + defp ankyra do | |
| 16 | + %RabbitUser{ | |
| 17 | + username: "dff93bf2-12dd-4680-b4b0-77254b95fe48", | |
| 18 | + password: "hunter2", | |
| 19 | + topic: "Iodized-Nutritious-Xenoposeidon" | |
| 20 | + } | |
| 21 | + end | |
| 22 | + | |
| 23 | + defp broker(overrides \\ %{}) do | |
| 24 | + Map.merge(%{host: "ac.neiam.org", port: 1883, tls: false}, overrides) | |
| 25 | + end | |
| 26 | + | |
| 27 | + describe "the pairing link" do | |
| 28 | + test "carries everything a client needs to subscribe" do | |
| 29 | + uri = Show.pairing_uri(ankyra(), broker()) | |
| 30 | + %URI{scheme: scheme, host: host, query: query} = URI.parse(uri) | |
| 31 | + params = URI.decode_query(query) | |
| 32 | + | |
| 33 | + assert scheme == "apolloscrib" | |
| 34 | + assert host == "ankyra" | |
| 35 | + assert params["host"] == "ac.neiam.org" | |
| 36 | + assert params["port"] == "1883" | |
| 37 | + assert params["user"] == "dff93bf2-12dd-4680-b4b0-77254b95fe48" | |
| 38 | + assert params["pass"] == "hunter2" | |
| 39 | + assert params["topic"] == "Iodized-Nutritious-Xenoposeidon" | |
| 40 | + assert params["tls"] == "0" | |
| 41 | + end | |
| 42 | + | |
| 43 | + test "says so when the broker wants TLS" do | |
| 44 | + uri = Show.pairing_uri(ankyra(), broker(%{port: 8883, tls: true})) | |
| 45 | + params = uri |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() | |
| 46 | + | |
| 47 | + assert params["port"] == "8883" | |
| 48 | + assert params["tls"] == "1" | |
| 49 | + end | |
| 50 | + | |
| 51 | + test "a topic with characters a URL minds survives the round trip" do | |
| 52 | + # Rabbit topics are generated names, but nothing stops one being edited | |
| 53 | + # into something with a space or an ampersand in it. | |
| 54 | + awkward = %{ankyra() | topic: "Front Door & Back"} | |
| 55 | + uri = Show.pairing_uri(awkward, broker()) | |
| 56 | + params = uri |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() | |
| 57 | + | |
| 58 | + assert params["topic"] == "Front Door & Back" | |
| 59 | + end | |
| 60 | + end | |
| 61 | +end |
modified
clients/android/README.md
+12
−4
| 21 | 21 | |
| 22 | 22 | ## Setting it up |
| 23 | 23 | |
| 24 | −1. In Apollo's Crib, go to `cfg/ankyra` and note the rabbit user's **username**, | |
| 25 | − **password** and **topic**. Leave auto-registration on, or add the client id | |
| 26 | − the app shows you to that Ankyra's client ids -- the broker will refuse the | |
| 27 | − connection otherwise, and Ankyra counts its consumers by that id. | |
| 24 | +1. In Apollo's Crib, open the Ankyra at `cfg/ankyra/<id>` and scan the **Pair a | |
| 25 | + Client** QR with the phone's camera. It carries the credentials, the topic | |
| 26 | + and the broker address as an `apolloscrib://ankyra` link, which this app | |
| 27 | + handles -- nothing is retyped and there is no scanner in here to break. | |
| 28 | + Check the broker host on that page first: it is where the phone reaches | |
| 29 | + RabbitMQ, which is not necessarily where the app is. | |
| 30 | + | |
| 31 | + Failing that, copy the username, password and topic across by hand. | |
| 32 | + | |
| 33 | + Either way, leave auto-registration on, or add the client id the app shows | |
| 34 | + you to that Ankyra's client ids -- the broker will refuse the connection | |
| 35 | + otherwise, and Ankyra counts its consumers by that id. | |
| 28 | 36 | 2. Point a Pythiae at the vision you want and give it that Ankyra. |
| 29 | 37 | 3. Install this app, fill in the broker host, port (1883 for the RabbitMQ MQTT |
| 30 | 38 | plugin) and those credentials, and connect. The screen shows the queries as |
modified
clients/android/app/build.gradle.kts
+6
−0
Click to load diff…
modified
clients/android/app/src/main/AndroidManifest.xml
+10
−0
Click to load diff…
added
clients/android/app/src/main/java/io/neiam/apolloscrib/data/Pairing.kt
+52
−0
Click to load diff…
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/data/VisionStore.kt
+14
−0
Click to load diff…
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+44
−8
Click to load diff…
added
clients/android/app/src/test/java/io/neiam/apolloscrib/data/PairingTest.kt
+49
−0
Click to load diff…
modified
clients/android/gradle/libs.versions.toml
+2
−0
Click to load diff…
modified
mix.lock
+1
−0
Click to load diff…
Parents: 75067f6