Let a Plani have several homes, and pick the nearest

bc42901 · Gabriel Morell · 2026-09-05 18:52

26 files +411 -21
Message
{commit_body(@commit)}

Files changed

modified PLANI.md
+22 −0
@@ -29,6 +29,28 @@ memory there is nothing to fall back *from*, so the home foci answers.
29 29 └──no───▶ home foci's place (from the database)
30 30 ```
31 31
32 +### Which home
33 +
34 +A Plani may have several. `home_foci_id` is the first and the fallback;
35 +`home_foci_ids` names others, and `home_tint` follows every foci wearing a
36 +colour — the same shortcut sources have, and the reason foci now carry a tint
37 +at all.
38 +
39 +With more than one, the anchor settles on **the one nearest to where the client
40 +last was**, not the one configured first. A house and an office are both home;
41 +which is the right answer depends on which you were last near.
42 +
43 +That last position is held in the worker and nowhere else, so it dies with the
44 +process — a Plani that has just restarted goes to its first home, which is
45 +where a Plani with one home has always gone. Keeping it anywhere durable would
46 +be storing a location history, which is the thing this component exists to
47 +avoid.
48 +
49 +`home_after_mins` is how long a client may say nothing first: 5 to 30 minutes
50 +in five minute steps. The Ankyra keeps positions for the longest of those, and
51 +still *shows* only five minutes on its page — keeping a position and displaying
52 +one are different questions.
53 +
32 54 ## What already exists
33 55
34 56 More than it looks, because the pieces were built for other reasons.
modified apps/room_sanctum/lib/room_sanctum/configuration/foci.ex
+14 −1
@@ -5,6 +5,10 @@ defmodule RoomSanctum.Configuration.Foci do
5 5 schema "cfg_focis" do
6 6 field :name, :string
7 7 field :place, Geo.PostGIS.Geometry
8 +
9 + # As on a source: a colour that says these belong together, without making
10 + # a group out of it. A Plani can follow a tint of foci as its homes.
11 + field :tint, :string
8 12 belongs_to :user, RoomSanctum.Accounts.User
9 13
10 14 timestamps()
@@ -13,8 +17,17 @@ defmodule RoomSanctum.Configuration.Foci do
13 17 @doc false
14 18 def changeset(foci, attrs) do
15 19 foci
16 − |> cast(attrs, [:name, :place, :user_id])
20 + |> cast(attrs, [:name, :place, :user_id, :tint])
17 21 |> foreign_key_constraint(:user_id)
18 22 |> validate_required([:name, :place])
23 + |> validate_tint()
24 + end
25 +
26 + # An unknown tint renders as an unstyled dot rather than failing, which is
27 + # the kind of wrong that goes unnoticed. Same rule as a Plani's follow_tint.
28 + defp validate_tint(changeset) do
29 + validate_change(changeset, :tint, fn :tint, tint ->
30 + if tint in [nil, ""] or RoomSanctum.Tints.valid?(tint), do: [], else: [tint: "is not a tint"]
31 + end)
19 32 end
20 33 end
modified apps/room_sanctum/lib/room_sanctum/configuration/plani.ex
+47 −0
@@ -19,7 +19,19 @@ defmodule RoomSanctum.Configuration.Plani do
19 19 belongs_to :user, RoomSanctum.Accounts.User
20 20
21 21 # Where it is when it does not know where it is.
22 + #
23 + # More than one is allowed, because more than one is true: a house and an
24 + # office are both home, and which one answers depends on where you were
25 + # last. `home_foci_id` is the first of them and the answer for a Plani
26 + # that has never heard a position at all.
22 27 field :home_foci_id, :integer
28 + field :home_foci_ids, {:array, :integer}, default: []
29 + field :home_tint, :string
30 +
31 + # How long a client may say nothing before the anchor goes home. Five
32 + # minutes suits a phone in a pocket on a bus; half an hour suits one that
33 + # only reports when it is opened.
34 + field :home_after_mins, :integer, default: 5
23 35
24 36 # Whose position to follow. An Ankyra may carry several clients and only
25 37 # one of them is the one that travels.
@@ -62,6 +74,9 @@ defmodule RoomSanctum.Configuration.Plani do
62 74 :name,
63 75 :user_id,
64 76 :home_foci_id,
77 + :home_foci_ids,
78 + :home_tint,
79 + :home_after_mins,
65 80 :ankyra_id,
66 81 :client_id,
67 82 :sources,
@@ -80,7 +95,11 @@ defmodule RoomSanctum.Configuration.Plani do
80 95 # Looser than `limit`: loose bikes are counted rather than read one by one,
81 96 # so a larger number is still a sensible thing to ask for.
82 97 |> validate_number(:bike_limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 50)
98 + # Offered as a menu of five minute steps, so the number is never anything
99 + # but one of these -- but a form is not the only way in.
100 + |> validate_inclusion(:home_after_mins, [5, 10, 15, 20, 25, 30])
83 101 |> validate_tint()
102 + |> validate_home_tint()
84 103 |> foreign_key_constraint(:user_id)
85 104 |> foreign_key_constraint(:home_foci_id)
86 105 end
@@ -97,6 +116,34 @@ defmodule RoomSanctum.Configuration.Plani do
97 116 end)
98 117 end
99 118
119 + defp validate_home_tint(changeset) do
120 + validate_change(changeset, :home_tint, fn :home_tint, tint ->
121 + if tint in [nil, ""] or RoomSanctum.Tints.valid?(tint),
122 + do: [],
123 + else: [home_tint: "is not a tint"]
124 + end)
125 + end
126 +
127 + @doc """
128 + Every foci this Plani calls home: the first one, any others it names, and
129 + any wearing its home tint.
130 +
131 + In that order and deduplicated, so `home_foci_id` stays the one a Plani with
132 + no position falls back to -- which is what it has always been, and what
133 + keeps every existing Plani behaving exactly as it did.
134 + """
135 + def homes_for(%__MODULE__{} = plani, all_foci) do
136 + tinted =
137 + case plani.home_tint do
138 + blank when blank in [nil, ""] -> []
139 + tint -> all_foci |> Enum.filter(&(&1.tint == tint)) |> Enum.map(& &1.id)
140 + end
141 +
142 + ([plani.home_foci_id] ++ (plani.home_foci_ids || []) ++ tinted)
143 + |> Enum.reject(&is_nil/1)
144 + |> Enum.uniq()
145 + end
146 +
100 147 @doc """
101 148 Every source this Plani asks: the ones it names, and the ones wearing its
102 149 tint.
modified apps/room_sanctum/lib/room_sanctum/workers/ankyra_worker.ex
+21 −12
@@ -10,11 +10,17 @@ 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
13 + # How long a reported position is worth *showing*. Five minutes is a trail
14 + # rather than a history: long enough to see which way somebody is going,
15 + # short enough that it is gone before it is a record.
16 + @positions_shown_s 300
17 +
18 + # How long one is worth *keeping*, which is not the same question. A Plani
19 + # may wait up to half an hour before it gives up on a client and goes home,
20 + # and it cannot ask about a position that has already been dropped. Still a
21 + # trail rather than a record: nothing here outlives the process.
22 + @positions_ttl_s 1800
23 + @positions_kept 64
18 24
19 25 @registry :zeus
20 26 def start_link(opts) do
@@ -87,11 +93,11 @@ defmodule RoomSanctum.Worker.Ankyra do
87 93 trail. Nil when that client has not reported inside the window, which is the
88 94 signal to fall back to a home foci rather than an error.
89 95 """
90 − def position(name, client_id) do
96 + def position(name, client_id, max_age_s \\ @positions_shown_s) do
91 97 try do
92 98 "ankyra#{name}"
93 99 |> via_tuple()
94 − |> GenServer.call({:position, client_id}, 5_000)
100 + |> GenServer.call({:position, client_id, max_age_s}, 5_000)
95 101 rescue
96 102 # A registry that is not up raises from the lookup rather than exiting.
97 103 ArgumentError -> nil
@@ -110,10 +116,10 @@ defmodule RoomSanctum.Worker.Ankyra do
110 116
111 117 def topic(id), do: "ankyra:#{id}"
112 118
113 − def handle_call({:position, client_id}, _from, state) do
119 + def handle_call({:position, client_id, max_age_s}, _from, state) do
114 120 latest =
115 121 state.positions
116 − |> prune()
122 + |> prune(max_age_s)
117 123 |> Enum.find(fn p -> is_nil(client_id) or p.client_id == client_id end)
118 124
119 125 {:reply, latest, state}
@@ -372,16 +378,19 @@ defmodule RoomSanctum.Worker.Ankyra do
372 378 end
373 379 end
374 380
375 − defp prune(positions) do
376 − cutoff = DateTime.add(DateTime.utc_now(), -@positions_ttl_s, :second)
381 + defp prune(positions, max_age_s \\ @positions_ttl_s) do
382 + cutoff = DateTime.add(DateTime.utc_now(), -max_age_s, :second)
377 383 Enum.filter(positions, fn p -> DateTime.compare(p.at, cutoff) == :gt end)
378 384 end
379 385
386 + # The page draws a five minute trail whatever the worker is holding for the
387 + # Plani's benefit -- keeping a position and showing it are different
388 + # questions, and the answer to the second did not change.
380 389 defp broadcast_positions(id, positions) do
381 390 Phoenix.PubSub.broadcast(
382 391 RoomSanctum.PubSub,
383 392 topic(id),
384 − {:ankyra_positions, positions}
393 + {:ankyra_positions, prune(positions, @positions_shown_s)}
385 394 )
386 395 end
387 396
modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+67 −8
@@ -60,7 +60,11 @@ defmodule RoomSanctum.Worker.Plani do
60 60 places: [],
61 61 notes: %{},
62 62 anchor: nil,
63 − anchored_to: :home
63 + anchored_to: :home,
64 + # The last position a client reported, for choosing which home to
65 + # settle on. Lost with the process, deliberately: a Plani that has just
66 + # restarted falls back to its first home, which is where it started.
67 + last_seen: nil
64 68 }}
65 69 end
66 70
@@ -119,7 +123,7 @@ defmodule RoomSanctum.Worker.Plani do
119 123 def handle_cast(:query, %{plani: nil} = state), do: {:noreply, state}
120 124
121 125 def handle_cast(:query, state) do
122 − {anchor, anchored_to} = anchor(state.plani)
126 + {anchor, anchored_to, last_seen} = anchor(state.plani, state.last_seen)
123 127
124 128 # Resolved on every tick rather than held: a source tinted since the last
125 129 # one should join without the Plani being edited, which is the whole point
@@ -165,7 +169,8 @@ defmodule RoomSanctum.Worker.Plani do
165 169 places: places,
166 170 notes: notes,
167 171 anchor: anchor,
168 − anchored_to: anchored_to
172 + anchored_to: anchored_to,
173 + last_seen: last_seen
169 174 }}
170 175 end
171 176
@@ -187,22 +192,76 @@ defmodule RoomSanctum.Worker.Plani do
187 192
188 193 def handle_info(_msg, state), do: {:noreply, state}
189 194
190 − # Where to ask from: the client if it has said recently, the home foci if not.
191 − defp anchor(plani) do
195 + # Where to ask from: the client if it has said recently, a home foci if not.
196 + #
197 + # Takes and returns the last position this worker saw, because "which home"
198 + # is answered by where you were, not by which was configured first. A house
199 + # and an office are both home; the useful one is the one you were last near.
200 + # Held here and nowhere else -- it does not outlive the process, which is the
201 + # same promise the position itself is under.
202 + defp anchor(plani, last_seen) do
203 + window = (plani.home_after_mins || 5) * 60
204 +
192 205 reported =
193 206 if plani.ankyra_id do
194 − RoomSanctum.Worker.Ankyra.position(to_string(plani.ankyra_id), plani.client_id)
207 + RoomSanctum.Worker.Ankyra.position(to_string(plani.ankyra_id), plani.client_id, window)
195 208 end
196 209
197 210 case reported do
198 211 %{lat: lat, lon: lon} ->
199 − {%Geo.Point{coordinates: {lon, lat}, srid: 4326}, :client}
212 + point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
213 + {point, :client, point}
200 214
201 215 _ ->
202 − {Configuration.get_foci!(plani.home_foci_id).place, :home}
216 + {home(plani, last_seen), :home, last_seen}
217 + end
218 + end
219 +
220 + # The home to settle on: the nearest to where the client last was.
221 + #
222 + # With one home this is that home, which is every Plani that has not been
223 + # told otherwise. With several and nothing ever heard from a client, it is
224 + # `home_foci_id` -- the first, and what a Plani has always fallen back to.
225 + defp home(plani, last_seen) do
226 + all = Configuration.list_focis({:user, plani.user_id})
227 + ids = RoomSanctum.Configuration.Plani.homes_for(plani, all)
228 +
229 + all
230 + |> Enum.filter(&(&1.id in ids))
231 + |> nearest_home(last_seen, plani.home_foci_id)
232 + end
233 +
234 + @doc false
235 + # Public only so a test can reach it; the database read is the caller's.
236 + def nearest_home(foci, last_seen, fallback_id)
237 +
238 + def nearest_home([], _last_seen, _fallback_id), do: nil
239 +
240 + def nearest_home([only], _last_seen, _fallback_id), do: only.place
241 +
242 + def nearest_home(foci, %Geo.Point{} = seen, fallback_id) do
243 + # A foci with no place cannot be measured against and cannot be an answer.
244 + case Enum.filter(foci, &match?(%Geo.Point{}, &1.place)) do
245 + [] -> nearest_home(foci, nil, fallback_id)
246 + placed -> placed |> Enum.min_by(&distance(&1.place, seen)) |> Map.get(:place)
203 247 end
204 248 end
205 249
250 + def nearest_home(foci, _last_seen, fallback_id) do
251 + # Never heard from anybody: the first home, which is where a Plani with
252 + # one home has always gone.
253 + Enum.find_value(foci, hd(foci).place, fn f -> if f.id == fallback_id, do: f.place end)
254 + end
255 +
256 + # Close enough to order by. Squared degrees, scaled for longitude at this
257 + # latitude -- no square root because nothing here needs the number itself,
258 + # only which of two is smaller.
259 + defp distance(%Geo.Point{coordinates: {lon1, lat1}}, %Geo.Point{coordinates: {lon2, lat2}}) do
260 + d_lat = lat1 - lat2
261 + d_lon = (lon1 - lon2) * :math.cos(rad((lat1 + lat2) / 2))
262 + d_lat * d_lat + d_lon * d_lon
263 + end
264 +
206 265 # What is near the anchor, for one source. The shape of the answer matches
207 266 # what the equivalent query would have returned, so the condensers need to
208 267 # know nothing about Plani.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_foci_live/form_component.html.heex
+31 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/form_component.ex
+70 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.ex
+13 −0

Click to load diff…

added apps/room_sanctum/priv/repo/migrations/20260905120000_add_tint_to_foci.exs
+18 −0

Click to load diff…

added apps/room_sanctum/priv/repo/migrations/20260905121000_add_homes_to_plani.exs
+23 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/plani_homes_test.exs
+85 −0

Click to load diff…

Parents: cd0e1e5