Show where an Ankyra's clients say they are

721d2fc · Gabriel Morell · 2026-09-03 16:34

18 files +185 -8
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_sanctum/lib/room_sanctum/workers/ankyra_worker.ex
+84 −8
@@ -10,6 +10,12 @@ defmodule RoomSanctum.Worker.Ankyra do
10 10 # Asking is cheap and answering is not: a vision's queries all run again.
11 11 @request_floor_ms 1_000
12 12
13 + # How long a reported position is worth showing, and how many to keep. Five
14 + # minutes is a trail rather than a history: long enough to see which way
15 + # somebody is going, short enough that it is gone before it is a record.
16 + @positions_ttl_s 300
17 + @positions_kept 20
18 +
13 19 @registry :zeus
14 20 def start_link(opts) do
15 21 GenServer.start_link(__MODULE__, opts, name: via_tuple("ankyra" <> opts[:id]))
@@ -34,7 +40,8 @@ defmodule RoomSanctum.Worker.Ankyra do
34 40 initial_delay: 10
35 41 )
36 42
37 − {:ok, %{id: opts[:id], ankyra: nil, requests: nil, last_request: nil}}
43 + {:ok,
44 + %{id: opts[:id], ankyra: nil, requests: nil, last_request: nil, positions: []}}
38 45 end
39 46
40 47 defp via_tuple(name), do: {:via, Registry, {@registry, name}}
@@ -68,15 +75,21 @@ defmodule RoomSanctum.Worker.Ankyra do
68 75
69 76 def handle_cast(:meta_check, %{ankyra: nil} = state), do: {:noreply, state}
70 77
71 − def handle_cast(:meta_check, state) do
72 − parent = self()
73 − username = state.ankyra.username
78 + # Positions age out whether or not anything is arriving, so the pruning
79 + # rides the check that already runs every couple of seconds. Without it a
80 + # client that stopped moving would sit on the page indefinitely.
81 + def handle_cast(:meta_check, %{positions: [_ | _]} = state) do
82 + pruned = prune(state.positions)
74 83
75 − spawn(fn -> send(parent, {:status_result, connected_clients(username)}) end)
84 + if pruned != state.positions do
85 + broadcast_positions(state.id, pruned)
86 + end
76 87
77 − {:noreply, state}
88 + do_meta_check(%{state | positions: pruned})
78 89 end
79 90
91 + def handle_cast(:meta_check, state), do: do_meta_check(state)
92 +
80 93 def handle_cast(:refresh_db_cfg, state) do
81 94 p = Accounts.get_rabbit_user!(state[:id])
82 95 {:noreply, state |> Map.put(:ankyra, p) |> listen_for_requests()}
@@ -112,8 +125,11 @@ defmodule RoomSanctum.Worker.Ankyra do
112 125 def handle_info({:basic_cancel, _meta}, state), do: {:noreply, state |> Map.put(:requests, nil)}
113 126 def handle_info({:basic_cancel_ok, _meta}, state), do: {:noreply, state}
114 127
115 − def handle_info({:basic_deliver, _payload, %{routing_key: key}}, state) do
116 − {:noreply, serve_request(state, key)}
128 + def handle_info({:basic_deliver, payload, %{routing_key: key}}, state) do
129 + cond do
130 + String.contains?(key, ".up.") -> {:noreply, remember_position(state, payload)}
131 + true -> {:noreply, serve_request(state, key)}
132 + end
117 133 end
118 134
119 135 def handle_info({:status_result, count}, state) do
@@ -199,6 +215,7 @@ defmodule RoomSanctum.Worker.Ankyra do
199 215 {:ok, chan} <- AMQP.Channel.open(conn),
200 216 {:ok, %{queue: queue}} <- AMQP.Queue.declare(chan, "", exclusive: true, auto_delete: true),
201 217 :ok <- AMQP.Queue.bind(chan, queue, "amq.topic", routing_key: request_key(state)),
218 + :ok <- AMQP.Queue.bind(chan, queue, "amq.topic", routing_key: uplink_key(state)),
202 219 {:ok, tag} <- AMQP.Basic.consume(chan, queue, self(), no_ack: true) do
203 220 Logger.info("ankyra #{state.id} listening for requests on #{request_key(state)}")
204 221 state |> Map.put(:requests, tag) |> Map.put(:requests_chan, chan)
@@ -211,6 +228,8 @@ defmodule RoomSanctum.Worker.Ankyra do
211 228
212 229 defp request_key(state), do: state.ankyra.topic <> ".publish.#"
213 230
231 + defp uplink_key(state), do: state.ankyra.topic <> ".up.#"
232 +
214 233 # The consumer's own lifecycle. Nothing to do but let them through.
215 234
216 235 # Serve a request, if one is due.
@@ -246,6 +265,63 @@ defmodule RoomSanctum.Worker.Ankyra do
246 265 end
247 266 end
248 267
268 + defp do_meta_check(state) do
269 + parent = self()
270 + username = state.ankyra.username
271 +
272 + spawn(fn -> send(parent, {:status_result, connected_clients(username)}) end)
273 +
274 + {:noreply, state}
275 + end
276 +
277 + # Where a client said it was, kept for as long as it is worth showing.
278 + #
279 + # In memory and nowhere else: this is a trail on a page somebody is looking
280 + # at, not a record of where anybody has been. It goes when the worker does,
281 + # and each fix goes five minutes after it arrived.
282 + defp remember_position(state, payload) do
283 + case decode_position(payload) do
284 + nil ->
285 + state
286 +
287 + position ->
288 + positions = prune([position | state.positions]) |> Enum.take(@positions_kept)
289 + broadcast_positions(state.id, positions)
290 + %{state | positions: positions}
291 + end
292 + end
293 +
294 + defp decode_position(payload) do
295 + with {:ok, %{"lat" => lat, "lon" => lon} = fix} <- Poison.decode(payload),
296 + true <- is_number(lat) and is_number(lon) do
297 + %{
298 + lat: lat,
299 + lon: lon,
300 + accuracy_m: Map.get(fix, "accuracy_m"),
301 + speed_mps: Map.get(fix, "speed_mps"),
302 + client_id: Map.get(fix, "client_id"),
303 + # When it reached us, not when the phone says it was taken: a clock
304 + # that is wrong should not put a fix in the future or age it out early.
305 + at: DateTime.utc_now()
306 + }
307 + else
308 + _ -> nil
309 + end
310 + end
311 +
312 + defp prune(positions) do
313 + cutoff = DateTime.add(DateTime.utc_now(), -@positions_ttl_s, :second)
314 + Enum.filter(positions, fn p -> DateTime.compare(p.at, cutoff) == :gt end)
315 + end
316 +
317 + defp broadcast_positions(id, positions) do
318 + Phoenix.PubSub.broadcast(
319 + RoomSanctum.PubSub,
320 + topic(id),
321 + {:ankyra_positions, positions}
322 + )
323 + end
324 +
249 325 defp too_soon?(nil, _now), do: false
250 326
251 327 defp too_soon?(last, now), do: DateTime.diff(now, last, :millisecond) < @request_floor_ms
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.ex
+20 −0
@@ -156,6 +156,26 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
156 156 end
157 157 end
158 158
159 + @doc """
160 + How stale a fix is, as a fraction: 1.0 just arrived, 0.0 about to go.
161 +
162 + Drives the opacity, so a trail fades rather than blinking out. The floor
163 + keeps the oldest one readable right up until it is dropped.
164 + """
165 + def freshness(%{at: at}, ttl_s \\ 300) do
166 + age = DateTime.diff(DateTime.utc_now(), at, :second)
167 + ((ttl_s - age) / ttl_s) |> max(0.0) |> min(1.0) |> then(&(0.25 + 0.75 * &1))
168 + end
169 +
170 + @doc "How long ago a fix arrived, in words."
171 + def age_in_words(%{at: at}) do
172 + case DateTime.diff(DateTime.utc_now(), at, :second) do
173 + s when s < 5 -> "just now"
174 + s when s < 60 -> "#{s}s ago"
175 + s -> "#{div(s, 60)}m ago"
176 + end
177 + end
178 +
159 179 defp page_title(:show), do: "Ankyra Detail"
160 180 defp page_title(:edit), do: "Modify Ankyra"
161 181 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.html.heex
+38 −0
@@ -52,6 +52,44 @@
52 52 </div>
53 53 </div>
54 54 </div>
55 + <%= if @positions != [] do %>
56 + <div class="card card-compact w-full bg-base-100 shadow-xl m-2">
57 + <div class="card-body">
58 + <h3 class="card-title">
59 + <i class="fas fa-fw fa-location-dot"></i> Where its clients are
60 + </h3>
61 + <p class="text-sm opacity-70">
62 + Reported by clients that were asked to, and kept for five minutes.
63 + Held in memory only — nothing here is written down, and it empties
64 + itself as the fixes age out.
65 + </p>
66 + <div class="flex flex-col gap-1 mt-2">
67 + <%= for position <- @positions do %>
68 + <div
69 + class="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 font-mono text-sm"
70 + style={"opacity: #{RoomSanctumWeb.AnkyraLive.Show.freshness(position)}"}
71 + >
72 + <span class="w-32 shrink-0 opacity-70">
73 + <%= RoomSanctumWeb.AnkyraLive.Show.age_in_words(position) %>
74 + </span>
75 + <span><%= Float.round(position.lat, 5) %>, <%= Float.round(position.lon, 5) %></span>
76 + <%= if position.accuracy_m do %>
77 + <span class="opacity-70">±<%= round(position.accuracy_m) %>m</span>
78 + <% end %>
79 + <%= if position.speed_mps do %>
80 + <span class="opacity-70">
81 + <i class="fas fa-fw fa-gauge-high"></i>
82 + <%= Float.round(position.speed_mps * 3.6, 1) %> km/h
83 + </span>
84 + <% end %>
85 + <span class="ml-auto opacity-50"><%= position.client_id %></span>
86 + </div>
87 + <% end %>
88 + </div>
89 + </div>
90 + </div>
91 + <% end %>
92 +
55 93 <div class="card card-compact w-full bg-base-100 shadow-xl m-2">
56 94 <div class="card-body">
57 95 <h3 class="card-title">Pair a Client</h3>
added apps/room_sanctum/test/room_sanctum_web/live/ankyra_positions_test.exs
+43 −0
@@ -0,0 +1,43 @@
1 +defmodule RoomSanctumWeb.AnkyraPositionsTest do
2 + @moduledoc """
3 + How a reported position is shown as it ages.
4 +
5 + The page itself is not mounted: `users_rabbit` comes from the hermes repo's
6 + migrations and is not in this test database. What is worth pinning down is
7 + the fade, which is arithmetic.
8 + """
9 + use ExUnit.Case, async: true
10 +
11 + alias RoomSanctumWeb.AnkyraLive.Show
12 +
13 + defp seconds_ago(n), do: %{at: DateTime.add(DateTime.utc_now(), -n, :second)}
14 +
15 + describe "freshness" do
16 + test "a fix that just arrived is fully opaque" do
17 + assert_in_delta Show.freshness(seconds_ago(0)), 1.0, 0.01
18 + end
19 +
20 + test "fades towards, but never to, invisible" do
21 + half = Show.freshness(seconds_ago(150))
22 + old = Show.freshness(seconds_ago(299))
23 +
24 + assert half < 1.0
25 + assert old < half
26 + # The last one before it is dropped is still readable; a row that fades
27 + # to nothing looks like a rendering fault rather than an old fix.
28 + assert old >= 0.25
29 + end
30 +
31 + test "a fix past its life does not go negative" do
32 + assert Show.freshness(seconds_ago(10_000)) == 0.25
33 + end
34 + end
35 +
36 + describe "age in words" do
37 + test "reads as an age, not a timestamp" do
38 + assert Show.age_in_words(seconds_ago(0)) == "just now"
39 + assert Show.age_in_words(seconds_ago(30)) == "30s ago"
40 + assert Show.age_in_words(seconds_ago(125)) == "2m ago"
41 + end
42 + end
43 +end

Parents: 2effc9d