Hand an Ankyra's connection over as a link

e1827fb · Gabriel Morell · 2026-09-02 19:21

40 files +394 -14
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.ex
+85 −2
@@ -9,7 +9,10 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
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,7 +20,8 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
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,6 +57,85 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
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,6 +52,61 @@
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,6 +68,9 @@ defmodule RoomSanctum.MixProject do
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
@@ -0,0 +1,61 @@
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,10 +21,18 @@ stops, each choosing its own query when Smartspacer opens the setup screen.
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