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.
Let a Plani have several homes, and pick the nearest
bc42901 · Gabriel Morell · 2026-09-05 18:52
Message
{commit_body(@commit)}
Files changed
modified
PLANI.md
+22
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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