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 Pythiae publish the Plus board
cd0e1e5 · Gabriel Morell · 2026-09-05 18:22
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"><topic>.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