Let a Pythiae publish the Plus board

cd0e1e5 · Gabriel Morell · 2026-09-05 18:22

17 files +127 -15
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/configuration/pythiae.ex
+14 −1
@@ -50,6 +50,10 @@ defmodule RoomSanctum.Configuration.Pythiae do
50 50 # both. A Plani is a vision whose anchor moves, so the two answer the same
51 51 # question from different places and merging them would show each twice.
52 52 field :curr_plani, :id
53 + # Publish the Plus reading too, on the Ankyra's `.plus` topic. Alongside
54 + # the Basic board rather than instead of it: a client that does not know
55 + # about the second topic is unaffected by this being on.
56 + field :plus, :boolean, default: false
53 57 embeds_many :consts, Const
54 58 embeds_one :tweaks, Tweaks
55 59
@@ -59,7 +63,16 @@ defmodule RoomSanctum.Configuration.Pythiae do
59 63 @doc false
60 64 def changeset(pythiae, attrs) do
61 65 pythiae
62 − |> cast(attrs, [:visions, :ankyra, :user_id, :curr_vision, :curr_foci, :curr_plani, :name])
66 + |> cast(attrs, [
67 + :visions,
68 + :ankyra,
69 + :user_id,
70 + :curr_vision,
71 + :curr_foci,
72 + :curr_plani,
73 + :name,
74 + :plus
75 + ])
63 76 |> cast_embed(:consts, with: &Const.changeset/2)
64 77 |> foreign_key_constraint(:user_id)
65 78 |> foreign_key_constraint(:curr_vision)
modified apps/room_sanctum/lib/room_sanctum/workers/ankyra_worker.ex
+31 −0
@@ -65,6 +65,20 @@ defmodule RoomSanctum.Worker.Ankyra do
65 65 |> GenServer.cast({:publish_img, data})
66 66 end
67 67
68 + @doc """
69 + The Plus board, on a topic of its own.
70 +
71 + Beside `publish/2` rather than replacing it: the two are the same board read
72 + two ways, and every client that exists reads the Basic one. A second topic
73 + costs a subscriber that does not want it nothing, where a second shape on the
74 + same topic would cost it the board.
75 + """
76 + def publish_plus(name, data) do
77 + "ankyra#{name}"
78 + |> via_tuple()
79 + |> GenServer.cast({:publish_plus, data})
80 + end
81 +
68 82 @doc """
69 83 Where a client last said it was, or nil.
70 84
@@ -139,6 +153,23 @@ defmodule RoomSanctum.Worker.Ankyra do
139 153 {:noreply, state}
140 154 end
141 155
156 + def handle_cast({:publish_plus, data}, state) do
157 + case AMQP.Application.get_channel(:default) do
158 + {:ok, chan} ->
159 + AMQP.Basic.publish(
160 + chan,
161 + "amq.topic",
162 + state.ankyra.topic <> ".plus",
163 + data |> Poison.encode!()
164 + )
165 +
166 + {:error, error} ->
167 + Logger.warning("ankyra #{state.id}: could not publish plus: #{inspect(error)}")
168 + end
169 +
170 + {:noreply, state}
171 + end
172 +
142 173 def handle_cast({:publish_img, data}, state) do
143 174 IO.puts("and here")
144 175
modified apps/room_sanctum/lib/room_sanctum/workers/pythiae_worker.ex
+47 −14
@@ -86,9 +86,7 @@ defmodule RoomSanctum.Worker.Pythiae do
86 86 # IO.puts("change detected")
87 87 # IO.inspect({DateTime.utc_now(), comparison})
88 88
89 − for a <- state.pythiae.ankyra do
90 − RoomSanctum.Worker.Ankyra.publish(a, current.data |> condense(current.queries))
91 − end
89 + publish_board(state, current)
92 90
93 91 {:noreply, state |> Map.put(:vision, current) |> Map.put(:lastpub, DateTime.utc_now())}
94 92 else
@@ -99,9 +97,7 @@ defmodule RoomSanctum.Worker.Pythiae do
99 97 def handle_cast(:query_current_now, state) do
100 98 current = showing(state.pythiae)
101 99
102 − for a <- state.pythiae.ankyra do
103 − RoomSanctum.Worker.Ankyra.publish(a, current.data |> condense(current.queries))
104 − end
100 + publish_board(state, current)
105 101
106 102 {:noreply, state |> Map.put(:vision, current)}
107 103 end
@@ -144,16 +140,38 @@ defmodule RoomSanctum.Worker.Pythiae do
144 140 RoomSanctum.Worker.Vision.get_state(pythiae.curr_vision)
145 141 end
146 142
147 − defp condense(data, queries) do
143 + # The board, to every Ankyra this Pythiae publishes to.
144 + #
145 + # Twice, where the Pythiae asks for it: the Basic reading on the Ankyra's
146 + # topic and the Plus one on `<topic>.plus`. Both are whole boards -- Plus
147 + # falls back to Basic's answer for every type it does not write -- so a
148 + # client reads one or the other and never has to stitch them together.
149 + #
150 + # Condensed once for all of them rather than once per Ankyra, which is a
151 + # vision's worth of work repeated for every client that was listening.
152 + defp publish_board(state, current) do
153 + basic = condense(current.data, current.queries)
154 + plus = if plus?(state.pythiae), do: condense(current.data, current.queries, :plus)
155 +
156 + for a <- state.pythiae.ankyra do
157 + RoomSanctum.Worker.Ankyra.publish(a, basic)
158 + if plus, do: RoomSanctum.Worker.Ankyra.publish_plus(a, plus)
159 + end
160 + end
161 +
162 + defp plus?(%{plus: true}), do: true
163 + defp plus?(_), do: false
164 +
165 + defp condense(data, queries, reading \\ :basic) do
148 166 # Create a map of query id to query info for quick lookup
149 167 query_map = queries |> Enum.map(fn q -> {q.id, q} end) |> Enum.into(%{})
150 −
168 +
151 169 data
152 170 |> Enum.map(fn {{id, type}, datum} ->
153 171 query = Map.get(query_map, id)
154 −
155 − case condense_one({id, type}, datum, query) do
156 − {:ok, condensed} -> {"#{type}-#{id}", condensed}
172 +
173 + case condense_one({id, type}, datum, query, reading) do
174 + {:ok, condensed} -> {"#{key_type(type, reading)}-#{id}", condensed}
157 175 :error -> nil
158 176 end
159 177 end)
@@ -161,6 +179,16 @@ defmodule RoomSanctum.Worker.Pythiae do
161 179 |> Enum.into(%{})
162 180 end
163 181
182 + # What the entry is keyed as. The Plus reading of a GTFS query is a different
183 + # shape from the Basic one and says so in the key, so a payload is
184 + # self-describing whichever topic it arrived on -- a client is never left
185 + # inferring the shape from where it came from.
186 + #
187 + # Only GTFS: every other type is Basic's answer under Plus too, and giving it
188 + # a new key would say a difference that is not there.
189 + defp key_type(:gtfs, :plus), do: "gtfs_plus"
190 + defp key_type(type, _reading), do: type
191 +
164 192 # One entry, or nothing.
165 193 #
166 194 # A board is a dozen unrelated queries, and one of them raising used to take
@@ -168,12 +196,14 @@ defmodule RoomSanctum.Worker.Pythiae do
168 196 # no board at all, on every tick, with nothing saying why. The client already
169 197 # drops what it cannot read and draws the rest; this is the same call made at
170 198 # the other end, where there is a log to say it happened.
171 − defp condense_one({id, type}, datum, query) do
199 + defp condense_one({id, type}, datum, query, reading) do
200 + condenser = condenser(reading)
201 +
172 202 condensed =
173 203 if query do
174 − RoomSanctum.Condenser.BasicMQTT.condense({id, type}, datum, query)
204 + condenser.condense({id, type}, datum, query)
175 205 else
176 − RoomSanctum.Condenser.BasicMQTT.condense_data({id, type}, datum)
206 + condenser.condense_data({id, type}, datum)
177 207 end
178 208
179 209 {:ok, condensed}
@@ -182,4 +212,7 @@ defmodule RoomSanctum.Worker.Pythiae do
182 212 Logger.warning("pythiae: dropping #{type}-#{id}: #{Exception.message(error)}")
183 213 :error
184 214 end
215 +
216 + defp condenser(:plus), do: RoomSanctum.Condenser.PlusMQTT
217 + defp condenser(_), do: RoomSanctum.Condenser.BasicMQTT
185 218 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/form_component.html.heex
+12 −0
@@ -25,6 +25,18 @@
25 25 <div class="form-control">
26 26 <.input field={@form[:curr_foci]} type="select" label="Current Foci" options={@cfg_foci_sel} />
27 27 </div>
28 + <div class="form-control">
29 + <.input
30 + field={@form[:plus]}
31 + type="checkbox"
32 + label="Also publish the Plus board"
33 + />
34 + <p class="text-sm opacity-70">
35 + The same board with each arrival kept whole -- occupancy, delay, track,
36 + cancellations -- on <span class="font-mono">&lt;topic&gt;.plus</span>,
37 + alongside the ordinary one. Clients that do not read it are unaffected.
38 + </p>
39 + </div>
28 40 <div class="form-control">
29 41 <label class="block text-md font-bold leading-6 pt-4 text-primary-content">
30 42 Consts
added apps/room_sanctum/priv/repo/migrations/20260905120000_add_plus_to_pythiae.exs
+23 −0
@@ -0,0 +1,23 @@
1 +defmodule RoomSanctum.Repo.Migrations.AddPlusToPythiae do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Whether this Pythiae also publishes the Plus reading of its board.
6 +
7 + Plus keeps each arrival whole rather than flattening a route's departures
8 + into parallel lists of times, so it can say what the feed said about one
9 + departure -- how full, how late, which track. It goes to the Ankyra's
10 + `.plus` topic alongside the Basic board rather than in place of it: every
11 + client that reads the board today keeps reading exactly what it read before,
12 + and one that wants the longer answer subscribes to the other topic.
13 +
14 + Off by default, because it is a second publish of the same board on every
15 + tick and nothing should start paying for it without being asked.
16 + """
17 +
18 + def change do
19 + alter table(:cfg_pythiae) do
20 + add :plus, :boolean, default: false, null: false
21 + end
22 + end
23 +end

Parents: ed1ce38