Credo: dispatch tables as data and function heads

f1dab56 · Gabe Morell · 2026-11-04 20:49

22 files +685 -850
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+225 −235
@@ -3,21 +3,20 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
3 3 import MapMerge
4 4
5 5 # GTFS route_type codes by name. PlusMQTT keeps its own copy of the table.
6 − defp gtfs_mode(route_type) do
7 − case route_type do
8 − "0" -> "LightRail"
9 − "1" -> "Subway"
10 − "2" -> "Rail"
11 − "3" -> "Bus"
12 − "4" -> "Ferry"
13 − "5" -> "CableCar"
14 − "6" -> "Gondola"
15 − "7" -> "Funicular"
16 − "11" -> "Trolleybus"
17 − "12" -> "Monorail"
18 − _ -> "Unknown"
19 − end
20 − end
6 + @gtfs_modes %{
7 + "0" => "LightRail",
8 + "1" => "Subway",
9 + "2" => "Rail",
10 + "3" => "Bus",
11 + "4" => "Ferry",
12 + "5" => "CableCar",
13 + "6" => "Gondola",
14 + "7" => "Funicular",
15 + "11" => "Trolleybus",
16 + "12" => "Monorail"
17 + }
18 +
19 + defp gtfs_mode(route_type), do: Map.get(@gtfs_modes, route_type, "Unknown")
21 20
22 21 defp wrap(item) do
23 22 [item]
@@ -84,240 +83,231 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
84 83 # IO.inspect({type, data})
85 84 # end
86 85
87 − case type do
88 − :gtfs ->
89 − data
90 − |> Enum.map(fn f ->
91 − %{
92 − time: f.arrival_time,
93 − time_live: f.arrival_time_live_ts,
94 − destination: f.trip.trip_headsign,
95 − direction: f.trip.direction.direction,
96 − route: f.trip.route_id,
97 − presentation: route_presentation(f.trip.route, f.trip.route_id),
98 − mode: f.trip.route.route_type |> gtfs_mode,
99 − tz: f.tz,
100 − bearing: Map.get(f, :bearing)
101 − }
102 − end)
103 − |> Enum.reduce(%{}, fn %{
104 − time: time,
105 − time_live: time_live,
106 − destination: dest,
107 − direction: dir,
108 − route: route,
109 − presentation: presentation,
110 − mode: mode,
111 − tz: tz,
112 − bearing: bearing
113 − },
114 − acc ->
115 − update_in(acc, [{route, dest, dir}], fn
116 − nil ->
117 − %{
118 − route: route,
119 − dest: dest,
120 − dir: dir,
121 − mode: mode,
122 − times: [time],
123 − times_live: [livetime(time_live, tz)],
124 − bearing: bearing
125 − }
126 − |> Map.merge(presentation)
86 + condense_type(type, data)
87 + end
127 88
128 − refs ->
129 − %{
130 − refs
131 − | times: [time | refs.times],
132 − times_live: [livetime(time_live, tz) | refs.times_live],
133 − # A blended Plani groups a route across every stop it calls
134 − # at inside the radius, so these times can come from two
135 − # places at once. Agreeing keeps the bearing; disagreeing
136 − # drops it, because "north east" would then be true of only
137 − # some of the departures under it.
138 − bearing: if(refs.bearing == bearing, do: bearing, else: nil)
139 − }
140 − end)
141 − end)
142 − |> Enum.map(fn {_k, v} ->
143 − case Enum.any?(v.times_live, fn x -> x != nil end) do
144 − true ->
145 − v
146 − |> Map.put(:times, v.times |> Enum.reverse())
147 − |> Map.put(:times_live, v.times_live |> Enum.reverse())
148 −
149 − false ->
150 − v |> Map.put(:times, v.times |> Enum.reverse()) |> Map.delete(:times_live)
151 − end
152 − end)
153 − # Only a Plani has a bearing to give, and only for a stop far enough
154 − # away to have one. Dropped rather than published as null, so a
155 − # vision's payload is byte for byte what it always was.
156 − |> Enum.map(fn route ->
157 − if route.bearing, do: route, else: Map.delete(route, :bearing)
158 − end)
159 −
160 − :gbfs ->
161 − # A bike names its type by an id of the feed's own choosing -- Bay
162 − # Wheels' e-bike is "2" -- so the types are resolved once for the list
163 − # rather than every bike being labelled with a number.
164 − vehicle_types = gbfs_vehicle_types(data)
165 −
166 − data
167 − |> Enum.map(fn
168 − # An area query answers with loose bikes rather than a dock, and a
169 − # bike has none of a station's fields -- no name, no capacity,
170 − # nothing to dock. Told apart by what came back rather than by the
171 − # query, since the condenser is only ever handed the answer.
172 − %{bike_id: bike_id} = b ->
173 − %{
174 − kind: :free_bike,
175 − name: vehicle_label(vehicle_types, b) || bike_id,
176 − # What kind of thing it is, so a client can draw a car as a car.
177 − # The name already says "Car", but a name is read and an icon is
178 − # glanced at, and these cards are built to be glanced at.
179 − form_factor: vehicle_form_factor(vehicle_types, b),
180 − id: bike_id,
181 − lat: b.lat,
182 − lon: b.lon,
183 − range_m: b.current_range_meters,
184 − fuel_pct: b.current_fuel_percent,
185 − reserved: b.is_reserved,
186 − disabled: b.is_disabled
187 − }
188 − |> with_dir(b)
189 −
190 − f ->
191 − %{
192 − name: f.name,
193 − id: f.station_id,
194 − avail: f.num_bikes_available,
195 − avail_elec: f.num_ebikes_available,
196 − # Same reasoning: a feed that does not break its count down by
197 − # motor leaves these null, and subtracting one raises.
198 − avail_std: (f.num_bikes_available || 0) - (f.num_ebikes_available || 0),
199 − docks_avail: f.num_docks_available,
200 − docks_disabled: f.num_docks_disabled,
201 − capacity: f.capacity,
202 − # A left join, so a dock that no e-bike is sitting in comes
203 − # back nil rather than empty -- and mapping over that raised,
204 − # which killed the condense for the *whole* board rather than
205 − # this one dock. A Plani feels it hardest: it breaks a source
206 − # out into an entry per dock, so a radius holding one ordinary
207 − # unelectrified dock published nothing at all.
208 − ebikes_info:
209 − (f.ebikes_info || [])
210 − |> Enum.map(fn eb ->
211 − %{
212 − name: eb.displayed_number,
213 − battery_pct: eb.battery_charge_percentage,
214 − range_mi_cons: eb.range_estimate.conservative_range_miles,
215 − range_me_est: eb.range_estimate.estimated_range_miles
216 − }
217 − end)
218 − }
219 − |> with_dir(f)
220 − end)
221 −
222 − :tidal ->
223 − data
224 − |> Enum.group_by(fn x -> x.type end)
225 − |> Enum.map(fn {extreme, data} ->
226 − case data do
227 − [first, second] ->
228 − k1 = "first_#{extreme |> String.downcase()}" |> String.to_atom()
229 − k2 = "second_#{extreme |> String.downcase()}" |> String.to_atom()
230 − kv1 = "first_#{extreme |> String.downcase()}v" |> String.to_atom()
231 − kv2 = "second_#{extreme |> String.downcase()}v" |> String.to_atom()
232 − %{k1 => first.t |> time, k2 => second.t |> time, kv1 => first.v, kv2 => second.v}
233 −
234 − [solo] ->
235 − k1 = "first_#{extreme |> String.downcase()}" |> String.to_atom()
236 − kv1 = "first_#{extreme |> String.downcase()}v" |> String.to_atom()
237 − %{k1 => solo.t |> time, kv1 => solo.v}
238 − end
239 − end)
240 − |> Enum.reduce(&Map.merge/2)
241 − |> wrap
242 −
243 − :weather ->
244 − data
245 − |> Enum.map(fn f ->
89 + # Published as they come.
90 + @passthrough [
91 + :cronos,
92 + :gitlab,
93 + :github,
94 + :drought,
95 + :pollen,
96 + :icarus,
97 + :mailbox,
98 + :treasury,
99 + :bourse,
100 + :packages,
101 + :const
102 + ]
103 +
104 + defp condense_type(:gtfs, data) do
105 + data
106 + |> Enum.map(fn f ->
107 + %{
108 + time: f.arrival_time,
109 + time_live: f.arrival_time_live_ts,
110 + destination: f.trip.trip_headsign,
111 + direction: f.trip.direction.direction,
112 + route: f.trip.route_id,
113 + presentation: route_presentation(f.trip.route, f.trip.route_id),
114 + mode: f.trip.route.route_type |> gtfs_mode,
115 + tz: f.tz,
116 + bearing: Map.get(f, :bearing)
117 + }
118 + end)
119 + |> Enum.reduce(%{}, fn %{
120 + time: time,
121 + time_live: time_live,
122 + destination: dest,
123 + direction: dir,
124 + route: route,
125 + presentation: presentation,
126 + mode: mode,
127 + tz: tz,
128 + bearing: bearing
129 + },
130 + acc ->
131 + update_in(acc, [{route, dest, dir}], fn
132 + nil ->
246 133 %{
247 − name: f.name,
248 − weather: List.first(f.weather).main,
249 − temp: f.main.temp,
250 − feel: f.main.feels_like,
251 − hum: f.main.humidity,
252 − pressure: f.main.pressure,
253 − wind: f.wind,
254 − visibility: f |> Map.get(:visibility),
255 − units: f.units
134 + route: route,
135 + dest: dest,
136 + dir: dir,
137 + mode: mode,
138 + times: [time],
139 + times_live: [livetime(time_live, tz)],
140 + bearing: bearing
256 141 }
257 − end)
258 −
259 − :aqi ->
260 − data
261 − |> Enum.map(fn f ->
262 − pairs = HourlyObsData.compile_pairs(f)
263 − pairs ||| %{name: f.reporting_areas |> List.first()}
264 − end)
265 −
266 − :ephem ->
267 − data
268 − |> Enum.map(fn f ->
269 − case Map.get(f, :period) do
270 − nil -> {:name, f.name}
271 − _val -> {f.period, f.result}
272 − end
273 − end)
274 − |> Enum.into(%{})
275 − |> wrap
276 −
277 − :calendar ->
278 − data
279 − |> Enum.map(fn f ->
142 + |> Map.merge(presentation)
143 +
144 + refs ->
280 145 %{
281 − date_start: f.date_start |> DateTime.to_date(),
282 − description: f.blob["description"]
146 + refs
147 + | times: [time | refs.times],
148 + times_live: [livetime(time_live, tz) | refs.times_live],
149 + # A blended Plani groups a route across every stop it calls
150 + # at inside the radius, so these times can come from two
151 + # places at once. Agreeing keeps the bearing; disagreeing
152 + # drops it, because "north east" would then be true of only
153 + # some of the departures under it.
154 + bearing: if(refs.bearing == bearing, do: bearing, else: nil)
283 155 }
284 − end)
285 −
286 − :cronos ->
287 − data
288 −
289 − :gitlab ->
290 − data
291 −
292 − :github ->
293 − data
294 −
295 − :drought ->
296 − data
156 + end)
157 + end)
158 + |> Enum.map(fn {_k, v} ->
159 + case Enum.any?(v.times_live, fn x -> x != nil end) do
160 + true ->
161 + v
162 + |> Map.put(:times, v.times |> Enum.reverse())
163 + |> Map.put(:times_live, v.times_live |> Enum.reverse())
164 +
165 + false ->
166 + v |> Map.put(:times, v.times |> Enum.reverse()) |> Map.delete(:times_live)
167 + end
168 + end)
169 + # Only a Plani has a bearing to give, and only for a stop far enough
170 + # away to have one. Dropped rather than published as null, so a
171 + # vision's payload is byte for byte what it always was.
172 + |> Enum.map(fn route ->
173 + if route.bearing, do: route, else: Map.delete(route, :bearing)
174 + end)
175 + end
297 176
298 − :pollen ->
299 − data
177 + defp condense_type(:gbfs, data) do
178 + # A bike names its type by an id of the feed's own choosing -- Bay
179 + # Wheels' e-bike is "2" -- so the types are resolved once for the list
180 + # rather than every bike being labelled with a number.
181 + vehicle_types = gbfs_vehicle_types(data)
300 182
301 − :icarus ->
302 − data
183 + data
184 + |> Enum.map(fn
185 + # An area query answers with loose bikes rather than a dock, and a
186 + # bike has none of a station's fields -- no name, no capacity,
187 + # nothing to dock. Told apart by what came back rather than by the
188 + # query, since the condenser is only ever handed the answer.
189 + %{bike_id: bike_id} = b ->
190 + %{
191 + kind: :free_bike,
192 + name: vehicle_label(vehicle_types, b) || bike_id,
193 + # What kind of thing it is, so a client can draw a car as a car.
194 + # The name already says "Car", but a name is read and an icon is
195 + # glanced at, and these cards are built to be glanced at.
196 + form_factor: vehicle_form_factor(vehicle_types, b),
197 + id: bike_id,
198 + lat: b.lat,
199 + lon: b.lon,
200 + range_m: b.current_range_meters,
201 + fuel_pct: b.current_fuel_percent,
202 + reserved: b.is_reserved,
203 + disabled: b.is_disabled
204 + }
205 + |> with_dir(b)
206 +
207 + f ->
208 + %{
209 + name: f.name,
210 + id: f.station_id,
211 + avail: f.num_bikes_available,
212 + avail_elec: f.num_ebikes_available,
213 + # Same reasoning: a feed that does not break its count down by
214 + # motor leaves these null, and subtracting one raises.
215 + avail_std: (f.num_bikes_available || 0) - (f.num_ebikes_available || 0),
216 + docks_avail: f.num_docks_available,
217 + docks_disabled: f.num_docks_disabled,
218 + capacity: f.capacity,
219 + # A left join, so a dock that no e-bike is sitting in comes
220 + # back nil rather than empty -- and mapping over that raised,
221 + # which killed the condense for the *whole* board rather than
222 + # this one dock. A Plani feels it hardest: it breaks a source
223 + # out into an entry per dock, so a radius holding one ordinary
224 + # unelectrified dock published nothing at all.
225 + ebikes_info:
226 + (f.ebikes_info || [])
227 + |> Enum.map(fn eb ->
228 + %{
229 + name: eb.displayed_number,
230 + battery_pct: eb.battery_charge_percentage,
231 + range_mi_cons: eb.range_estimate.conservative_range_miles,
232 + range_me_est: eb.range_estimate.estimated_range_miles
233 + }
234 + end)
235 + }
236 + |> with_dir(f)
237 + end)
238 + end
303 239
304 − :mailbox ->
305 − data
240 + defp condense_type(:tidal, data) do
241 + data
242 + |> Enum.group_by(fn x -> x.type end)
243 + |> Enum.map(fn {extreme, data} ->
244 + case data do
245 + [first, second] ->
246 + k1 = "first_#{extreme |> String.downcase()}" |> String.to_atom()
247 + k2 = "second_#{extreme |> String.downcase()}" |> String.to_atom()
248 + kv1 = "first_#{extreme |> String.downcase()}v" |> String.to_atom()
249 + kv2 = "second_#{extreme |> String.downcase()}v" |> String.to_atom()
250 + %{k1 => first.t |> time, k2 => second.t |> time, kv1 => first.v, kv2 => second.v}
251 +
252 + [solo] ->
253 + k1 = "first_#{extreme |> String.downcase()}" |> String.to_atom()
254 + kv1 = "first_#{extreme |> String.downcase()}v" |> String.to_atom()
255 + %{k1 => solo.t |> time, kv1 => solo.v}
256 + end
257 + end)
258 + |> Enum.reduce(&Map.merge/2)
259 + |> wrap
260 + end
306 261
307 − :treasury ->
308 − data
262 + defp condense_type(:weather, data) do
263 + data
264 + |> Enum.map(fn f ->
265 + %{
266 + name: f.name,
267 + weather: List.first(f.weather).main,
268 + temp: f.main.temp,
269 + feel: f.main.feels_like,
270 + hum: f.main.humidity,
271 + pressure: f.main.pressure,
272 + wind: f.wind,
273 + visibility: f |> Map.get(:visibility),
274 + units: f.units
275 + }
276 + end)
277 + end
309 278
310 − :bourse ->
311 − data
279 + defp condense_type(:aqi, data) do
280 + data
281 + |> Enum.map(fn f ->
282 + pairs = HourlyObsData.compile_pairs(f)
283 + pairs ||| %{name: f.reporting_areas |> List.first()}
284 + end)
285 + end
312 286
313 − :packages ->
314 − data
287 + defp condense_type(:ephem, data) do
288 + data
289 + |> Enum.map(fn f ->
290 + case Map.get(f, :period) do
291 + nil -> {:name, f.name}
292 + _val -> {f.period, f.result}
293 + end
294 + end)
295 + |> Enum.into(%{})
296 + |> wrap
297 + end
315 298
316 − :const ->
317 − data
318 − end
299 + defp condense_type(:calendar, data) do
300 + data
301 + |> Enum.map(fn f ->
302 + %{
303 + date_start: f.date_start |> DateTime.to_date(),
304 + description: f.blob["description"]
305 + }
306 + end)
319 307 end
320 308
309 + defp condense_type(type, data) when type in @passthrough, do: data
310 +
321 311 @doc """
322 312 Condenses data and wraps it with query information
323 313 """
modified apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+14 −15
@@ -146,21 +146,20 @@ defmodule RoomSanctum.Condenser.PlusMQTT do
146 146
147 147 # Same table as Basic's, kept here rather than reaching into
148 148 # a private function of another condenser.
149 − defp gtfs_mode(route_type) do
150 − case route_type do
151 − "0" -> "LightRail"
152 − "1" -> "Subway"
153 − "2" -> "Rail"
154 − "3" -> "Bus"
155 − "4" -> "Ferry"
156 − "5" -> "CableCar"
157 − "6" -> "Gondola"
158 − "7" -> "Funicular"
159 − "11" -> "Trolleybus"
160 − "12" -> "Monorail"
161 − _ -> "Unknown"
162 − end
163 − end
149 + @gtfs_modes %{
150 + "0" => "LightRail",
151 + "1" => "Subway",
152 + "2" => "Rail",
153 + "3" => "Bus",
154 + "4" => "Ferry",
155 + "5" => "CableCar",
156 + "6" => "Gondola",
157 + "7" => "Funicular",
158 + "11" => "Trolleybus",
159 + "12" => "Monorail"
160 + }
161 +
162 + defp gtfs_mode(route_type), do: Map.get(@gtfs_modes, route_type, "Unknown")
164 163
165 164 defp livetime(nil, _tz), do: nil
166 165
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+56 −44
@@ -610,20 +610,22 @@ defmodule RoomSanctum.Storage do
610 610 # spec's list is left alone rather than guessed at.
611 611 defp mode_label(nil), do: nil
612 612
613 + @mode_labels %{
614 + "0" => "Tram",
615 + "1" => "Subway",
616 + "2" => "Rail",
617 + "3" => "Bus",
618 + "4" => "Ferry",
619 + "5" => "Cable tram",
620 + "6" => "Aerial lift",
621 + "7" => "Funicular",
622 + "11" => "Trolleybus",
623 + "12" => "Monorail"
624 + }
625 +
613 626 defp mode_label(route_type) do
614 − case to_string(route_type) do
615 − "0" -> "Tram"
616 − "1" -> "Subway"
617 − "2" -> "Rail"
618 − "3" -> "Bus"
619 − "4" -> "Ferry"
620 − "5" -> "Cable tram"
621 − "6" -> "Aerial lift"
622 − "7" -> "Funicular"
623 − "11" -> "Trolleybus"
624 − "12" -> "Monorail"
625 − other -> other
626 − end
627 + route_type = to_string(route_type)
628 + Map.get(@mode_labels, route_type, route_type)
627 629 end
628 630
629 631 @doc """
@@ -1458,38 +1460,48 @@ defmodule RoomSanctum.Storage do
1458 1460 )
1459 1461 end
1460 1462
1461 − from([st, trip: t] in queryable,
1462 − where:
1463 − (exists(
1464 − from(c in scheduled, where: c.service_id == parent_as(:trip).service_id, select: 1)
1465 − ) or
1466 − exists(
1467 − from(cd in exception.(1),
1468 − where: cd.service_id == parent_as(:trip).service_id,
1469 − select: 1
1470 − )
1471 − ) or
1472 − (not exists(
1473 − from(c in "gtfs_calendars",
1474 − where: c.source_id == ^source_id and c.service_id == parent_as(:trip).service_id,
1475 − select: 1
1476 − )
1477 − ) and
1478 − not exists(
1479 − from(cd in "gtfs_calendar_dates",
1480 − where:
1481 − cd.source_id == ^source_id and cd.service_id == parent_as(:trip).service_id,
1482 − select: 1
1483 − )
1484 − )) or
1485 − (not exists(from(c in scheduled, select: 1)) and
1486 − not exists(from(cd in exception.(1), select: 1)))) and
1487 − not exists(
1488 − from(cd in exception.(2),
1489 − where: cd.service_id == parent_as(:trip).service_id,
1490 − select: 1
1491 − )
1463 + listed = dynamic([], ^service_in(scheduled) or ^service_in(exception.(1)))
1464 + unmentioned = service_unmentioned(source_id)
1465 + stale = nothing_running(scheduled, exception.(1))
1466 + removed = service_in(exception.(2))
1467 +
1468 + where(queryable, ^dynamic([], (^listed or ^unmentioned or ^stale) and not (^removed)))
1469 + end
1470 +
1471 + # The trip's service is one of the rows of this calendar query.
1472 + defp service_in(calendar_query) do
1473 + dynamic(
1474 + [],
1475 + exists(
1476 + from(c in calendar_query, where: c.service_id == parent_as(:trip).service_id, select: 1)
1477 + )
1478 + )
1479 + end
1480 +
1481 + # Neither file says anything about the trip's service.
1482 + defp service_unmentioned(source_id) do
1483 + dynamic(
1484 + [],
1485 + not exists(
1486 + from(c in "gtfs_calendars",
1487 + where: c.source_id == ^source_id and c.service_id == parent_as(:trip).service_id,
1488 + select: 1
1489 + )
1490 + ) and
1491 + not exists(
1492 + from(cd in "gtfs_calendar_dates",
1493 + where: cd.source_id == ^source_id and cd.service_id == parent_as(:trip).service_id,
1494 + select: 1
1492 1495 )
1496 + )
1497 + )
1498 + end
1499 +
1500 + # Nothing at all runs today: the source's service data has gone stale.
1501 + defp nothing_running(scheduled, added) do
1502 + dynamic(
1503 + [],
1504 + not exists(from(c in scheduled, select: 1)) and not exists(from(cd in added, select: 1))
1493 1505 )
1494 1506 end
1495 1507
modified apps/room_sanctum/lib/room_sanctum/storage/gbfs/v1/vehicle_types.ex
+20 −19
@@ -20,28 +20,29 @@ defmodule RoomSanctum.Storage.GBFS.V1.VehicleTypes do
20 20 about a type is its form factor and how it is propelled, which is exactly the
21 21 distinction a rider cares about.
22 22 """
23 − def label(%{form_factor: form_factor, propulsion_type: propulsion}) do
24 − case {form_factor, propulsion} do
25 − {nil, _} -> nil
26 − # The combinations a rider has a word for. GBFS spells out the machine
27 − # ("bicycle", "electric_assist"); people say e-bike.
28 − {"bicycle", "human"} -> "Bike"
29 − {"bicycle", _electric} -> "E-bike"
30 − {"cargo_bicycle", "human"} -> "Cargo bike"
31 − {"cargo_bicycle", _electric} -> "Electric cargo bike"
32 − {"scooter" <> _, _} -> "Scooter"
33 − {"moped", _} -> "Moped"
34 − {"car", _} -> "Car"
35 − # Anything the spec adds later reads as well as its own words allow,
36 − # rather than not at all.
37 − {factor, nil} -> humanise(factor)
38 − {factor, "human"} -> humanise(factor)
39 − {factor, propulsion} -> "#{humanise(propulsion)} #{String.downcase(humanise(factor))}"
40 − end
41 − end
23 + def label(%{form_factor: form_factor, propulsion_type: propulsion}),
24 + do: name_for(form_factor, propulsion)
42 25
43 26 def label(_type), do: nil
44 27
28 + defp name_for(nil, _propulsion), do: nil
29 + # The combinations a rider has a word for. GBFS spells out the machine
30 + # ("bicycle", "electric_assist"); people say e-bike.
31 + defp name_for("bicycle", "human"), do: "Bike"
32 + defp name_for("bicycle", _electric), do: "E-bike"
33 + defp name_for("cargo_bicycle", "human"), do: "Cargo bike"
34 + defp name_for("cargo_bicycle", _electric), do: "Electric cargo bike"
35 + defp name_for("scooter" <> _, _propulsion), do: "Scooter"
36 + defp name_for("moped", _propulsion), do: "Moped"
37 + defp name_for("car", _propulsion), do: "Car"
38 + # Anything the spec adds later reads as well as its own words allow,
39 + # rather than not at all.
40 + defp name_for(factor, nil), do: humanise(factor)
41 + defp name_for(factor, "human"), do: humanise(factor)
42 +
43 + defp name_for(factor, propulsion),
44 + do: "#{humanise(propulsion)} #{String.downcase(humanise(factor))}"
45 +
45 46 # "electric_assist" -> "Electric assist", "bicycle" -> "Bicycle".
46 47 defp humanise(value) do
47 48 value
modified apps/room_sanctum/lib/room_sanctum/workers/keryx_item_cache.ex
+48 −75
@@ -98,88 +98,61 @@ defmodule RoomSanctum.Worker.KeryxItemCache do
98 98 end
99 99 end
100 100
101 − # Extract item ID (duplicated from keryx_worker for independence)
102 − defp extract_item_id(item, type) when is_map(item) do
103 − id_value =
104 − case type do
105 − :gtfs ->
106 − Map.get(item, :trip_id) || Map.get(item, "trip_id")
107 −
108 − :gbfs ->
109 − # Use the name field for GBFS instead of station_id
110 − Map.get(item, :name) || Map.get(item, "name") || Map.get(item, :station_id) ||
111 − Map.get(item, "station_id")
112 −
113 − :weather ->
114 − Map.get(item, :time) || Map.get(item, "time") || Map.get(item, :dt) ||
115 − Map.get(item, "dt")
116 −
117 − :tidal ->
118 − Map.get(item, :t) || Map.get(item, "t") || Map.get(item, :time) || Map.get(item, "time")
119 −
120 − :aqi ->
121 − Map.get(item, :parameter) || Map.get(item, "parameter")
122 −
123 − :calendar ->
124 − Map.get(item, :uid) || Map.get(item, "uid") || Map.get(item, :id) || Map.get(item, "id")
125 −
126 − :ephem ->
127 − Map.get(item, :name) || Map.get(item, "name")
128 −
129 − :cronos ->
130 − Map.get(item, :id) || Map.get(item, "id") || Map.get(item, :name) ||
131 − Map.get(item, "name")
132 −
133 − :gitlab ->
134 − Map.get(item, :id) || Map.get(item, "id")
135 −
136 − :github ->
137 − Map.get(item, :id) || Map.get(item, "id")
138 −
139 − :drought ->
140 − Map.get(item, "fips") || Map.get(item, "state") || Map.get(item, "mapDate") ||
141 − Map.get(item, :mapDate)
142 −
143 − :pollen ->
144 − case Map.get(item, "date") || Map.get(item, :date) do
145 − %{"year" => y, "month" => m, "day" => d} -> "#{y}-#{m}-#{d}"
146 − %{year: y, month: m, day: d} -> "#{y}-#{m}-#{d}"
147 − other -> inspect(other)
148 − end
101 + # Where each type keeps the field an item's id is read from, in order of
102 + # preference. A type not listed falls back to @default_id_keys.
103 + @id_keys %{
104 + gtfs: [:trip_id, "trip_id"],
105 + # Use the name field for GBFS instead of station_id
106 + gbfs: [:name, "name", :station_id, "station_id"],
107 + weather: [:time, "time", :dt, "dt"],
108 + tidal: [:t, "t", :time, "time"],
109 + aqi: [:parameter, "parameter"],
110 + calendar: [:uid, "uid", :id, "id"],
111 + ephem: [:name, "name"],
112 + cronos: [:id, "id", :name, "name"],
113 + gitlab: [:id, "id"],
114 + github: [:id, "id"],
115 + drought: ["fips", "state", "mapDate", :mapDate],
116 + bourse: [:symbol, "symbol"],
117 + treasury: [:pair, "pair"],
118 + mailbox: [:uid, "uid"],
119 + packages: [:tracking_number, "tracking_number", :id, "id"]
120 + }
121 +
122 + @default_id_keys [:id, "id", :uid, "uid"]
149 123
150 − :bourse ->
151 − Map.get(item, :symbol) || Map.get(item, "symbol")
152 −
153 − :treasury ->
154 − Map.get(item, :pair) || Map.get(item, "pair")
155 −
156 − :mailbox ->
157 − Map.get(item, :uid) || Map.get(item, "uid")
158 −
159 − :icarus ->
160 − # Area mode keys on the airframe; a flight watch keys on the instance,
161 − # so a notification does not re-fire when the hex is latched or the
162 − # aircraft briefly drops out of coverage.
163 − case Map.get(item, "kind") do
164 − "flight" ->
165 − "#{Map.get(item, "callsign")}/#{Map.get(item, "sched_arrival")}"
124 + # Extract item ID (duplicated from keryx_worker for independence)
125 + defp extract_item_id(item, type) when is_map(item), do: stubbify(item_id_value(item, type))
166 126
167 − _ ->
168 − Map.get(item, "hex") || Map.get(item, :hex)
169 − end
127 + defp extract_item_id(_item, _type), do: generate_stub()
170 128
171 − :packages ->
172 − Map.get(item, :tracking_number) || Map.get(item, "tracking_number") ||
173 − Map.get(item, :id) || Map.get(item, "id")
129 + defp item_id_value(item, :pollen) do
130 + case Map.get(item, "date") || Map.get(item, :date) do
131 + %{"year" => y, "month" => m, "day" => d} -> "#{y}-#{m}-#{d}"
132 + %{year: y, month: m, day: d} -> "#{y}-#{m}-#{d}"
133 + other -> inspect(other)
134 + end
135 + end
174 136
175 − _ ->
176 − Map.get(item, :id) || Map.get(item, "id") || Map.get(item, :uid) || Map.get(item, "uid")
177 − end
137 + # Area mode keys on the airframe; a flight watch keys on the instance,
138 + # so a notification does not re-fire when the hex is latched or the
139 + # aircraft briefly drops out of coverage.
140 + defp item_id_value(item, :icarus) do
141 + case Map.get(item, "kind") do
142 + "flight" -> "#{Map.get(item, "callsign")}/#{Map.get(item, "sched_arrival")}"
143 + _ -> Map.get(item, "hex") || Map.get(item, :hex)
144 + end
145 + end
178 146
179 − stubbify(id_value)
147 + defp item_id_value(item, type) do
148 + first_value(item, Map.get(@id_keys, type, @default_id_keys))
180 149 end
181 150
182 − defp extract_item_id(_item, _type), do: generate_stub()
151 + # The first of the keys that holds a value wins -- an `a || b || ...` chain
152 + # over them.
153 + defp first_value(item, keys) do
154 + Enum.reduce(keys, nil, fn key, found -> found || Map.get(item, key) end)
155 + end
183 156
184 157 defp stubbify(nil), do: generate_stub()
185 158
modified apps/room_sanctum/lib/room_sanctum/workers/keryx_worker.ex
+82 −131
@@ -201,110 +201,79 @@ defmodule RoomSanctum.Worker.Keryx do
201 201 end
202 202
203 203 # Execute a single query
204 − defp execute_query(q) do
205 − case q.source.type do
206 − :gtfs ->
207 − RoomGtfs.Worker.query_stop(q.source.id, q.query)
204 + # Workers that may not be running: one that is not has nothing to read.
205 + @readers %{
206 + drought: RoomDrought.Worker,
207 + pollen: RoomPollen.Worker,
208 + icarus: RoomIcarus.Worker,
209 + mailbox: RoomHermes.Mail.ImapWorker,
210 + treasury: RoomTreasury.Worker,
211 + bourse: RoomBourse.Worker
212 + }
208 213
209 − :gbfs ->
210 − RoomGbfs.Worker.query_stop(q.source.id, q.query)
214 + defp execute_query(%{source: %{type: :gtfs}} = q),
215 + do: RoomGtfs.Worker.query_stop(q.source.id, q.query)
211 216
212 − :tidal ->
213 − RoomTidal.Worker.query_tides(q.source.id, q.query)
217 + defp execute_query(%{source: %{type: :gbfs}} = q),
218 + do: RoomGbfs.Worker.query_stop(q.source.id, q.query)
214 219
215 − :weather ->
216 − RoomWeather.Worker.query_weather(
217 − q.source.id,
218 − q.query
219 − )
220 + defp execute_query(%{source: %{type: :tidal}} = q),
221 + do: RoomTidal.Worker.query_tides(q.source.id, q.query)
220 222
221 − :aqi ->
222 − RoomAirQuality.Worker.query_place(
223 − q.source.id,
224 − q.query
225 − )
223 + defp execute_query(%{source: %{type: :weather}} = q),
224 + do: RoomWeather.Worker.query_weather(q.source.id, q.query)
226 225
227 − :ephem ->
228 − RoomEphem.Worker.query_ephem(
229 − q.source.id,
230 − q.query
231 − )
226 + defp execute_query(%{source: %{type: :aqi}} = q),
227 + do: RoomAirQuality.Worker.query_place(q.source.id, q.query)
232 228
233 − :calendar ->
234 − RoomCalendar.Worker.query_calendar(
235 − q.source.id,
236 − q.query
237 − )
229 + defp execute_query(%{source: %{type: :ephem}} = q),
230 + do: RoomEphem.Worker.query_ephem(q.source.id, q.query)
238 231
239 − :cronos ->
240 − RoomCronos.Worker.query_cronos(q.id, q.query)
232 + defp execute_query(%{source: %{type: :calendar}} = q),
233 + do: RoomCalendar.Worker.query_calendar(q.source.id, q.query)
241 234
242 − :gitlab ->
243 − case RoomGitlab.Worker.pid(q.source.id) do
244 − nil ->
245 − []
235 + defp execute_query(%{source: %{type: :cronos}} = q),
236 + do: RoomCronos.Worker.query_cronos(q.id, q.query)
246 237
247 − _val ->
248 − (Process.alive?(RoomGitlab.Worker.pid(q.source.id)) &&
249 − RoomGitlab.Worker.read_jobs(q.source.id, q.query)) || []
250 − end
251 −
252 − :github ->
253 − case RoomGithub.Worker.pid(q.source.id) do
254 − nil ->
255 − []
256 −
257 − _val ->
258 − level = Map.get(q.query, :level) || Map.get(q.query, "level") || "runs"
259 −
260 − if Process.alive?(RoomGithub.Worker.pid(q.source.id)) do
261 − case level do
262 − "jobs" -> RoomGithub.Worker.read_jobs(q.source.id, q.query)
263 − _ -> RoomGithub.Worker.read_runs(q.source.id, q.query)
264 − end
265 − else
266 − []
267 − end
268 − end
238 + defp execute_query(%{source: %{type: :gitlab}} = q) do
239 + case RoomGitlab.Worker.pid(q.source.id) do
240 + nil ->
241 + []
269 242
270 − :packages ->
271 − RoomPackages.Worker.read(q.source.id, q.query)
243 + _val ->
244 + (Process.alive?(RoomGitlab.Worker.pid(q.source.id)) &&
245 + RoomGitlab.Worker.read_jobs(q.source.id, q.query)) || []
246 + end
247 + end
272 248
273 − :drought ->
274 − case RoomDrought.Worker.pid(q.source.id) do
275 − nil -> []
276 − _pid -> RoomDrought.Worker.read(q.source.id, q.query) || []
277 − end
249 + defp execute_query(%{source: %{type: :github}} = q) do
250 + case RoomGithub.Worker.pid(q.source.id) do
251 + nil ->
252 + []
278 253
279 − :pollen ->
280 − case RoomPollen.Worker.pid(q.source.id) do
281 − nil -> []
282 − _pid -> RoomPollen.Worker.read(q.source.id, q.query) || []
283 − end
254 + _val ->
255 + level = Map.get(q.query, :level) || Map.get(q.query, "level") || "runs"
284 256
285 − :icarus ->
286 − case RoomIcarus.Worker.pid(q.source.id) do
287 − nil -> []
288 − _pid -> RoomIcarus.Worker.read(q.source.id, q.query) || []
257 + if Process.alive?(RoomGithub.Worker.pid(q.source.id)) do
258 + case level do
259 + "jobs" -> RoomGithub.Worker.read_jobs(q.source.id, q.query)
260 + _ -> RoomGithub.Worker.read_runs(q.source.id, q.query)
261 + end
262 + else
263 + []
289 264 end
265 + end
266 + end
290 267
291 − :mailbox ->
292 − case RoomHermes.Mail.ImapWorker.pid(q.source.id) do
293 − nil -> []
294 − _pid -> RoomHermes.Mail.ImapWorker.read(q.source.id, q.query) || []
295 − end
268 + defp execute_query(%{source: %{type: :packages}} = q),
269 + do: RoomPackages.Worker.read(q.source.id, q.query)
296 270
297 − :treasury ->
298 − case RoomTreasury.Worker.pid(q.source.id) do
299 − nil -> []
300 − _pid -> RoomTreasury.Worker.read(q.source.id, q.query) || []
301 − end
271 + defp execute_query(%{source: %{type: type}} = q) when is_map_key(@readers, type) do
272 + worker = Map.fetch!(@readers, type)
302 273
303 − :bourse ->
304 − case RoomBourse.Worker.pid(q.source.id) do
305 − nil -> []
306 − _pid -> RoomBourse.Worker.read(q.source.id, q.query) || []
307 − end
274 + case worker.pid(q.source.id) do
275 + nil -> []
276 + _pid -> worker.read(q.source.id, q.query) || []
308 277 end
309 278 end
310 279
@@ -496,56 +465,38 @@ defmodule RoomSanctum.Worker.Keryx do
496 465 end
497 466 end
498 467
468 + # Where each type keeps the field an item's id is read from, in order of
469 + # preference. A type not listed falls back to the common id fields,
470 + # @default_id_keys.
471 + @id_keys %{
472 + gtfs: [:trip_id, "trip_id"],
473 + # Use the name field for GBFS instead of station_id
474 + gbfs: [:name, "name", :station_id, "station_id"],
475 + weather: [:time, "time", :dt, "dt"],
476 + tidal: [:t, "t", :time, "time"],
477 + aqi: [:parameter, "parameter"],
478 + calendar: [:uid, "uid", :id, "id"],
479 + ephem: [:name, "name"],
480 + cronos: [:id, "id", :name, "name"],
481 + gitlab: [:id, "id"],
482 + packages: [:tracking_number, "tracking_number", :id, "id"]
483 + }
484 +
485 + @default_id_keys [:id, "id", :uid, "uid"]
486 +
499 487 # Extract and stubbify an identifier from an item based on its type
500 488 defp extract_item_id(item, type) when is_map(item) do
501 − # Try to find a suitable ID field based on the data type
502 − id_value =
503 − case type do
504 − :gtfs ->
505 − Map.get(item, :trip_id) || Map.get(item, "trip_id")
506 −
507 − :gbfs ->
508 − # Use the name field for GBFS instead of station_id
509 − Map.get(item, :name) || Map.get(item, "name") || Map.get(item, :station_id) ||
510 − Map.get(item, "station_id")
511 −
512 − :weather ->
513 − Map.get(item, :time) || Map.get(item, "time") || Map.get(item, :dt) ||
514 − Map.get(item, "dt")
515 −
516 − :tidal ->
517 − Map.get(item, :t) || Map.get(item, "t") || Map.get(item, :time) || Map.get(item, "time")
518 −
519 − :aqi ->
520 − Map.get(item, :parameter) || Map.get(item, "parameter")
521 −
522 − :calendar ->
523 − Map.get(item, :uid) || Map.get(item, "uid") || Map.get(item, :id) || Map.get(item, "id")
524 −
525 − :ephem ->
526 − Map.get(item, :name) || Map.get(item, "name")
527 −
528 − :cronos ->
529 − Map.get(item, :id) || Map.get(item, "id") || Map.get(item, :name) ||
530 − Map.get(item, "name")
531 −
532 − :gitlab ->
533 − Map.get(item, :id) || Map.get(item, "id")
534 −
535 − :packages ->
536 − Map.get(item, :tracking_number) || Map.get(item, "tracking_number") ||
537 − Map.get(item, :id) || Map.get(item, "id")
538 −
539 − _ ->
540 − # Fallback: try common ID fields
541 − Map.get(item, :id) || Map.get(item, "id") || Map.get(item, :uid) || Map.get(item, "uid")
542 − end
543 −
544 − stubbify(id_value)
489 + stubbify(first_value(item, Map.get(@id_keys, type, @default_id_keys)))
545 490 end
546 491
547 492 defp extract_item_id(_item, _type), do: generate_stub()
548 493
494 + # The first of the keys that holds a value wins -- an `a || b || ...` chain
495 + # over them.
496 + defp first_value(item, keys) do
497 + Enum.reduce(keys, nil, fn key, found -> found || Map.get(item, key) end)
498 + end
499 +
549 500 # Convert a value into a URL-safe stub
550 501 defp stubbify(nil), do: generate_stub()
551 502
modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+109 −104
@@ -296,110 +296,7 @@ defmodule RoomSanctum.Worker.Plani do
296 296 defp ask(source_id, %Geo.Point{} = anchor, plani) do
297 297 source = Configuration.get_source!(:bare, source_id)
298 298
299 − case source.type do
300 − :gtfs ->
301 − stops = Storage.nearby_stops(source_id, anchor, plani.limit, plani.radius)
302 −
303 − by_stop =
304 − Enum.map(stops, fn stop ->
305 − arrivals =
306 − source_id
307 − |> arrivals_at(stop.stop_id, source.config)
308 − |> Enum.sort_by(& &1.arrival_time)
309 −
310 − # Which way the stop lies, carried on each arrival: the condenser
311 − # groups arrivals into routes and only it knows which end up
312 − # together, so the bearing has to travel with them.
313 − bearing = compass(anchor, Map.get(stop, :stop_lat), Map.get(stop, :stop_lon))
314 −
315 − {stop, Enum.map(arrivals, &Map.put(&1, :bearing, bearing))}
316 − end)
317 − |> nearest_per_route(plani)
318 −
319 − entries =
320 − if plani.break_out do
321 − # One entry per stop, named after it, the way a vision's queries
322 − # are. A stop with nothing due is left out rather than published
323 − # empty -- the same call the clients already make.
324 − for {stop, arrivals} <- by_stop, arrivals != [] do
325 − key = "#{source_id}:#{stop.stop_id}"
326 −
327 − descriptor =
328 − described(source, key, stop.stop_name, %{
329 − source_id: source_id,
330 − stop: stop.stop_id
331 − })
332 −
333 − {{key, :gtfs}, arrivals, descriptor}
334 − end
335 − else
336 − blended =
337 − by_stop
338 − |> Enum.flat_map(fn {_stop, arrivals} -> arrivals end)
339 − |> Enum.sort_by(& &1.arrival_time)
340 − |> Enum.take(16)
341 −
342 − [{{source_id, :gtfs}, blended, described(source)}]
343 − end
344 −
345 − {entries, places(stops, source, :stop)}
346 −
347 − :gbfs ->
348 − # Both, because a source does not say which kind it is. A docked
349 − # system answers with docks and a dockless one with loose bikes, and
350 − # asking only for the latter left a city full of docks looking empty.
351 − # Both come back closest-first, so the cap keeps the nearest rather
352 − # than whichever the feed happened to list first. Capped separately:
353 − # they are different things, and one starving the other would make a
354 − # docked system with loose bikes in it look like it had no docks.
355 − bikes =
356 − Storage.free_bikes_near(source_id, anchor, plani.radius)
357 − |> cap(plani.bike_limit)
358 − |> Enum.map(&toward(&1, anchor))
359 −
360 − docks =
361 − Storage.stations_near(source_id, anchor, plani.radius)
362 − |> cap(plani.bike_limit)
363 − |> Enum.map(&toward(&1, anchor))
364 −
365 − entries =
366 − if plani.break_out do
367 − # A dock is a place, so it gets its own entry. Loose bikes are not
368 − # -- they are a count of what is lying about nearby -- so they stay
369 − # together under the source.
370 − per_dock =
371 − for dock <- docks do
372 − key = "#{source_id}:#{dock.station_id}"
373 − {{key, :gbfs}, [dock], described(source, key, dock.name, %{source_id: source_id})}
374 − end
375 −
376 − if bikes == [],
377 − do: per_dock,
378 − else: [{{source_id, :gbfs}, bikes, described(source)} | per_dock]
379 − else
380 − [{{source_id, :gbfs}, bikes ++ docks, described(source)}]
381 − end
382 −
383 − {entries, places(bikes, source, :bike) ++ places(docks, source, :dock)}
384 −
385 − :aqi ->
386 − readings = Storage.nearby_aqi_stations(source_id, anchor, plani.limit)
387 − {[{{source_id, :aqi}, readings, described(source)}], places(readings, source, :monitor)}
388 −
389 − type when type in @asked_at_a_point ->
390 − # Answered *at* the anchor rather than near it. These name a foci in a
391 − # vision -- the weather at home, sunrise at home -- and the only thing
392 − # a Plani changes is where "at" is. The query is the anchor itself,
393 − # which `Configuration.place_for!/1` prefers over a foci id.
394 − results = ask_at(type, source_id, %{place: anchor})
395 − {[{{source_id, type}, results, described(source)}], []}
396 −
397 − other ->
398 − # Sources with nothing located in them. Left out rather than guessed
399 − # at; the page says what it could not draw.
400 − Logger.debug("plani: no spatial answer for a #{other} source")
401 − nil
402 − end
299 + ask_source(source.type, source_id, source, anchor, plani)
403 300 rescue
404 301 # `catch` takes throws and exits and lets a raise straight through, so one
405 302 # source with no config, or a stop id that has gone, took the whole worker
@@ -414,6 +311,114 @@ defmodule RoomSanctum.Worker.Plani do
414 311
415 312 defp ask(_source_id, _anchor, _plani), do: nil
416 313
314 + defp ask_source(:gtfs, source_id, source, anchor, plani) do
315 + stops = Storage.nearby_stops(source_id, anchor, plani.limit, plani.radius)
316 +
317 + by_stop =
318 + Enum.map(stops, fn stop ->
319 + arrivals =
320 + source_id
321 + |> arrivals_at(stop.stop_id, source.config)
322 + |> Enum.sort_by(& &1.arrival_time)
323 +
324 + # Which way the stop lies, carried on each arrival: the condenser
325 + # groups arrivals into routes and only it knows which end up
326 + # together, so the bearing has to travel with them.
327 + bearing = compass(anchor, Map.get(stop, :stop_lat), Map.get(stop, :stop_lon))
328 +
329 + {stop, Enum.map(arrivals, &Map.put(&1, :bearing, bearing))}
330 + end)
331 + |> nearest_per_route(plani)
332 +
333 + entries =
334 + if plani.break_out do
335 + # One entry per stop, named after it, the way a vision's queries
336 + # are. A stop with nothing due is left out rather than published
337 + # empty -- the same call the clients already make.
338 + for {stop, arrivals} <- by_stop, arrivals != [] do
339 + key = "#{source_id}:#{stop.stop_id}"
340 +
341 + descriptor =
342 + described(source, key, stop.stop_name, %{
343 + source_id: source_id,
344 + stop: stop.stop_id
345 + })
346 +
347 + {{key, :gtfs}, arrivals, descriptor}
348 + end
349 + else
350 + blended =
351 + by_stop
352 + |> Enum.flat_map(fn {_stop, arrivals} -> arrivals end)
353 + |> Enum.sort_by(& &1.arrival_time)
354 + |> Enum.take(16)
355 +
356 + [{{source_id, :gtfs}, blended, described(source)}]
357 + end
358 +
359 + {entries, places(stops, source, :stop)}
360 + end
361 +
362 + defp ask_source(:gbfs, source_id, source, anchor, plani) do
363 + # Both, because a source does not say which kind it is. A docked
364 + # system answers with docks and a dockless one with loose bikes, and
365 + # asking only for the latter left a city full of docks looking empty.
366 + # Both come back closest-first, so the cap keeps the nearest rather
367 + # than whichever the feed happened to list first. Capped separately:
368 + # they are different things, and one starving the other would make a
369 + # docked system with loose bikes in it look like it had no docks.
370 + bikes =
371 + Storage.free_bikes_near(source_id, anchor, plani.radius)
372 + |> cap(plani.bike_limit)
373 + |> Enum.map(&toward(&1, anchor))
374 +
375 + docks =
376 + Storage.stations_near(source_id, anchor, plani.radius)
377 + |> cap(plani.bike_limit)
378 + |> Enum.map(&toward(&1, anchor))
379 +
380 + entries =
381 + if plani.break_out do
382 + # A dock is a place, so it gets its own entry. Loose bikes are not
383 + # -- they are a count of what is lying about nearby -- so they stay
384 + # together under the source.
385 + per_dock =
386 + for dock <- docks do
387 + key = "#{source_id}:#{dock.station_id}"
388 + {{key, :gbfs}, [dock], described(source, key, dock.name, %{source_id: source_id})}
389 + end
390 +
391 + if bikes == [],
392 + do: per_dock,
393 + else: [{{source_id, :gbfs}, bikes, described(source)} | per_dock]
394 + else
395 + [{{source_id, :gbfs}, bikes ++ docks, described(source)}]
396 + end
397 +
398 + {entries, places(bikes, source, :bike) ++ places(docks, source, :dock)}
399 + end
400 +
401 + defp ask_source(:aqi, source_id, source, anchor, plani) do
402 + readings = Storage.nearby_aqi_stations(source_id, anchor, plani.limit)
403 + {[{{source_id, :aqi}, readings, described(source)}], places(readings, source, :monitor)}
404 + end
405 +
406 + defp ask_source(type, source_id, source, anchor, _plani) when type in @asked_at_a_point do
407 + # Answered *at* the anchor rather than near it. These name a foci in a
408 + # vision -- the weather at home, sunrise at home -- and the only thing
409 + # a Plani changes is where "at" is. The query is the anchor itself,
410 + # which `Configuration.place_for!/1` prefers over a foci id.
411 + results = ask_at(type, source_id, %{place: anchor})
412 + {[{{source_id, type}, results, described(source)}], []}
413 + end
414 +
415 + defp ask_source(other, _source_id, _source, _anchor, _plani) do
416 + # Sources with nothing located in them. Left out rather than guessed
417 + # at; the page says what it could not draw.
418 + Logger.debug("plani: no spatial answer for a #{other} source")
419 + nil
420 + end
421 +
417 422 # What the condensers expect alongside the data: a vision hands them a query,
418 423 # so a Plani hands them something query-shaped. The empty `query` matters --
419 424 # the GTFS condenser reads a stop out of it to look up stop-specific alerts,
modified apps/room_sanctum/lib/room_sanctum/workers/vision_worker.ex
+58 −77
@@ -308,98 +308,79 @@ defmodule RoomSanctum.Worker.Vision do
308 308 :skip
309 309 end
310 310
311 − defp query_one(q) do
312 − case q.source.type do
313 − :gtfs ->
314 − RoomGtfs.Worker.query_stop(q.source.id, q.query)
311 + # Workers that may not be running: one that is not has nothing to read.
312 + @readers %{
313 + drought: RoomDrought.Worker,
314 + pollen: RoomPollen.Worker,
315 + icarus: RoomIcarus.Worker,
316 + mailbox: RoomHermes.Mail.ImapWorker,
317 + treasury: RoomTreasury.Worker,
318 + bourse: RoomBourse.Worker
319 + }
315 320
316 − :gbfs ->
317 − RoomGbfs.Worker.query_stop(q.source.id, q.query)
321 + defp query_one(%{source: %{type: :gtfs}} = q),
322 + do: RoomGtfs.Worker.query_stop(q.source.id, q.query)
318 323
319 − :tidal ->
320 − RoomTidal.Worker.query_tides(q.source.id, q.query)
324 + defp query_one(%{source: %{type: :gbfs}} = q),
325 + do: RoomGbfs.Worker.query_stop(q.source.id, q.query)
321 326
322 − :weather ->
323 − RoomWeather.Worker.query_weather(q.source.id, q.query)
327 + defp query_one(%{source: %{type: :tidal}} = q),
328 + do: RoomTidal.Worker.query_tides(q.source.id, q.query)
324 329
325 − :aqi ->
326 − RoomAirQuality.Worker.query_place(q.source.id, q.query)
330 + defp query_one(%{source: %{type: :weather}} = q),
331 + do: RoomWeather.Worker.query_weather(q.source.id, q.query)
327 332
328 − :ephem ->
329 − RoomEphem.Worker.query_ephem(q.source.id, q.query)
333 + defp query_one(%{source: %{type: :aqi}} = q),
334 + do: RoomAirQuality.Worker.query_place(q.source.id, q.query)
330 335
331 − :calendar ->
332 − RoomCalendar.Worker.query_calendar(q.source.id, q.query)
336 + defp query_one(%{source: %{type: :ephem}} = q),
337 + do: RoomEphem.Worker.query_ephem(q.source.id, q.query)
333 338
334 − :cronos ->
335 − RoomCronos.Worker.query_cronos(q.id, q.query)
339 + defp query_one(%{source: %{type: :calendar}} = q),
340 + do: RoomCalendar.Worker.query_calendar(q.source.id, q.query)
336 341
337 − :gitlab ->
338 − case RoomGitlab.Worker.pid(q.source.id) do
339 − nil ->
340 − []
342 + defp query_one(%{source: %{type: :cronos}} = q),
343 + do: RoomCronos.Worker.query_cronos(q.id, q.query)
341 344
342 − _val ->
343 − (Process.alive?(RoomGitlab.Worker.pid(q.source.id)) &&
344 − RoomGitlab.Worker.read_jobs(q.source.id, q.query)) || []
345 − end
346 −
347 − :github ->
348 − case RoomGithub.Worker.pid(q.source.id) do
349 − nil ->
350 − []
351 −
352 − _val ->
353 − level = Map.get(q.query, :level) || Map.get(q.query, "level") || "runs"
354 −
355 − if Process.alive?(RoomGithub.Worker.pid(q.source.id)) do
356 − case level do
357 − "jobs" -> RoomGithub.Worker.read_jobs(q.source.id, q.query)
358 − _ -> RoomGithub.Worker.read_runs(q.source.id, q.query)
359 − end
360 − else
361 − []
362 − end
363 − end
364 −
365 − :packages ->
366 − RoomPackages.Worker.read(q.source.id, q.query)
345 + defp query_one(%{source: %{type: :gitlab}} = q) do
346 + case RoomGitlab.Worker.pid(q.source.id) do
347 + nil ->
348 + []
367 349
368 − :drought ->
369 − case RoomDrought.Worker.pid(q.source.id) do
370 − nil -> []
371 − _pid -> RoomDrought.Worker.read(q.source.id, q.query) || []
372 − end
373 −
374 − :pollen ->
375 − case RoomPollen.Worker.pid(q.source.id) do
376 − nil -> []
377 − _pid -> RoomPollen.Worker.read(q.source.id, q.query) || []
378 − end
350 + _val ->
351 + (Process.alive?(RoomGitlab.Worker.pid(q.source.id)) &&
352 + RoomGitlab.Worker.read_jobs(q.source.id, q.query)) || []
353 + end
354 + end
379 355
380 − :icarus ->
381 − case RoomIcarus.Worker.pid(q.source.id) do
382 − nil -> []
383 − _pid -> RoomIcarus.Worker.read(q.source.id, q.query) || []
356 + defp query_one(%{source: %{type: :github}} = q) do
357 + case RoomGithub.Worker.pid(q.source.id) do
358 + nil ->
359 + []
360 +
361 + _val ->
362 + level = Map.get(q.query, :level) || Map.get(q.query, "level") || "runs"
363 +
364 + if Process.alive?(RoomGithub.Worker.pid(q.source.id)) do
365 + case level do
366 + "jobs" -> RoomGithub.Worker.read_jobs(q.source.id, q.query)
367 + _ -> RoomGithub.Worker.read_runs(q.source.id, q.query)
368 + end
369 + else
370 + []
384 371 end
372 + end
373 + end
385 374
386 − :mailbox ->
387 − case RoomHermes.Mail.ImapWorker.pid(q.source.id) do
388 − nil -> []
389 − _pid -> RoomHermes.Mail.ImapWorker.read(q.source.id, q.query) || []
390 − end
375 + defp query_one(%{source: %{type: :packages}} = q),
376 + do: RoomPackages.Worker.read(q.source.id, q.query)
391 377
392 − :treasury ->
393 − case RoomTreasury.Worker.pid(q.source.id) do
394 − nil -> []
395 − _pid -> RoomTreasury.Worker.read(q.source.id, q.query) || []
396 − end
378 + defp query_one(%{source: %{type: type}} = q) when is_map_key(@readers, type) do
379 + worker = Map.fetch!(@readers, type)
397 380
398 − :bourse ->
399 − case RoomBourse.Worker.pid(q.source.id) do
400 − nil -> []
401 − _pid -> RoomBourse.Worker.read(q.source.id, q.query) || []
402 − end
381 + case worker.pid(q.source.id) do
382 + nil -> []
383 + _pid -> worker.read(q.source.id, q.query) || []
403 384 end
404 385 end
405 386 end
modified apps/room_sanctum/lib/room_sanctum_web/icon_helpers.ex
+51 −128
@@ -1,130 +1,53 @@
1 1 defmodule RoomSanctumWeb.IconHelpers do
2 − def icon(source_type) do
3 − case source_type do
4 − :calendar ->
5 − "fa-calendar-alt"
6 −
7 − :rideshare ->
8 − "fa-taxi"
9 −
10 − :hass ->
11 − "fa-home"
12 −
13 − :gtfs ->
14 − "fa-bus-alt"
15 −
16 − :gbfs ->
17 − "fa-bicycle"
18 −
19 − :tidal ->
20 − "fa-water"
21 −
22 − :ephem ->
23 − "fa-moon"
24 −
25 − :weather ->
26 − "fa-cloud-sun"
27 −
28 − :aqi ->
29 − "fa-lungs"
30 −
31 − :cronos ->
32 − "fa-clock"
33 −
34 − :gitlab ->
35 − "fa-code-branch"
36 −
37 − :github ->
38 − "fa-github"
39 −
40 − :drought ->
41 − "fa-sun-plant-wilt"
42 −
43 − :pollen ->
44 − "fa-seedling"
45 −
46 − :icarus ->
47 − "fa-plane-up"
48 −
49 − :mailbox ->
50 − "fa-inbox"
51 −
52 − :treasury ->
53 − "fa-money-bill-transfer"
54 −
55 − :bourse ->
56 − "fa-chart-line"
57 −
58 − :packages ->
59 − "fa-envelopes-bulk"
60 −
61 − #
62 − :const ->
63 − "fa-triangle-exclamation"
64 − end
65 − end
66 −
67 − def icon_code(source_type) do
68 − case source_type do
69 − :calendar ->
70 − "f073"
71 −
72 − :rideshare ->
73 − "f1ba"
74 −
75 − :hass ->
76 − "f015"
77 −
78 − :gtfs ->
79 − "f55e"
80 −
81 − :gbfs ->
82 − "f206"
83 −
84 − :tidal ->
85 − "f773"
86 −
87 − :ephem ->
88 − "f186"
89 −
90 − :weather ->
91 − "f6c4"
92 −
93 − :aqi ->
94 − "f604"
95 −
96 − :cronos ->
97 − "f017"
98 −
99 − :gitlab ->
100 − "f126"
101 −
102 − :github ->
103 − "f09b"
104 −
105 − :drought ->
106 − "e5c8"
107 −
108 − :pollen ->
109 − "f4d8"
110 −
111 − :icarus ->
112 − "e22d"
113 −
114 − :mailbox ->
115 − "f01c"
116 −
117 − :treasury ->
118 − "e528"
119 −
120 − :bourse ->
121 − "f201"
122 −
123 − :const ->
124 − "f071"
125 −
126 − :packages ->
127 − "f674"
128 − end
129 − end
2 + # Font Awesome class and codepoint for each source type. An unknown type
3 + # raises, as the case these replace did.
4 + @icons %{
5 + calendar: "fa-calendar-alt",
6 + rideshare: "fa-taxi",
7 + hass: "fa-home",
8 + gtfs: "fa-bus-alt",
9 + gbfs: "fa-bicycle",
10 + tidal: "fa-water",
11 + ephem: "fa-moon",
12 + weather: "fa-cloud-sun",
13 + aqi: "fa-lungs",
14 + cronos: "fa-clock",
15 + gitlab: "fa-code-branch",
16 + github: "fa-github",
17 + drought: "fa-sun-plant-wilt",
18 + pollen: "fa-seedling",
19 + icarus: "fa-plane-up",
20 + mailbox: "fa-inbox",
21 + treasury: "fa-money-bill-transfer",
22 + bourse: "fa-chart-line",
23 + packages: "fa-envelopes-bulk",
24 + const: "fa-triangle-exclamation"
25 + }
26 +
27 + @icon_codes %{
28 + calendar: "f073",
29 + rideshare: "f1ba",
30 + hass: "f015",
31 + gtfs: "f55e",
32 + gbfs: "f206",
33 + tidal: "f773",
34 + ephem: "f186",
35 + weather: "f6c4",
36 + aqi: "f604",
37 + cronos: "f017",
38 + gitlab: "f126",
39 + github: "f09b",
40 + drought: "e5c8",
41 + pollen: "f4d8",
42 + icarus: "e22d",
43 + mailbox: "f01c",
44 + treasury: "e528",
45 + bourse: "f201",
46 + const: "f071",
47 + packages: "f674"
48 + }
49 +
50 + def icon(source_type), do: Map.fetch!(@icons, source_type)
51 +
52 + def icon_code(source_type), do: Map.fetch!(@icon_codes, source_type)
130 53 end
modified apps/room_zeus/lib/supervision_worker.ex
+22 −22
@@ -5,29 +5,29 @@ defmodule RoomZeus.DynSupervisor do
5 5 DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
6 6 end
7 7
8 − def start_child(type, instance_id) do
9 − spec =
10 − case type do
11 − :gtfs -> {RoomGtfs.Worker, [name: instance_id]}
12 − :gbfs -> {RoomGbfs.Worker, [name: instance_id]}
13 − :aqi -> {RoomAirQuality.Worker, [name: instance_id]}
14 − :tidal -> {RoomTidal.Worker, [name: instance_id]}
15 − :weather -> {RoomWeather.Worker, [name: instance_id]}
16 − :calendar -> {RoomCalendar.Worker, [name: instance_id]}
17 − :gitlab -> {RoomGitlab.Worker, [name: instance_id]}
18 − :github -> {RoomGithub.Worker, [name: instance_id]}
19 − :packages -> {RoomPackages.Worker, [name: instance_id]}
20 − :drought -> {RoomDrought.Worker, [name: instance_id]}
21 − :pollen -> {RoomPollen.Worker, [name: instance_id]}
22 − :icarus -> {RoomIcarus.Worker, [name: instance_id]}
23 − :mailbox -> {RoomHermes.Mail.ImapWorker, [name: instance_id]}
24 − :treasury -> {RoomTreasury.Worker, [name: instance_id]}
25 − :bourse -> {RoomBourse.Worker, [name: instance_id]}
26 − _ -> nil
27 − end
8 + # The worker behind each source type; a type not listed starts nothing.
9 + @workers %{
10 + gtfs: RoomGtfs.Worker,
11 + gbfs: RoomGbfs.Worker,
12 + aqi: RoomAirQuality.Worker,
13 + tidal: RoomTidal.Worker,
14 + weather: RoomWeather.Worker,
15 + calendar: RoomCalendar.Worker,
16 + gitlab: RoomGitlab.Worker,
17 + github: RoomGithub.Worker,
18 + packages: RoomPackages.Worker,
19 + drought: RoomDrought.Worker,
20 + pollen: RoomPollen.Worker,
21 + icarus: RoomIcarus.Worker,
22 + mailbox: RoomHermes.Mail.ImapWorker,
23 + treasury: RoomTreasury.Worker,
24 + bourse: RoomBourse.Worker
25 + }
28 26
29 − if spec != nil do
30 − DynamicSupervisor.start_child(__MODULE__, spec)
27 + def start_child(type, instance_id) do
28 + case Map.fetch(@workers, type) do
29 + {:ok, worker} -> DynamicSupervisor.start_child(__MODULE__, {worker, [name: instance_id]})
30 + :error -> nil
31 31 end
32 32 end
33 33

Parents: 6e7ed93