Report a location on demand, and stop one dock sinking a board

b14afb0 · Gabriel Morell · 2026-09-03 18:47

25 files +192 -19
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+10 −2
@@ -172,12 +172,20 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
172 172 id: f.station_id,
173 173 avail: f.num_bikes_available,
174 174 avail_elec: f.num_ebikes_available,
175 − avail_std: f.num_bikes_available - f.num_ebikes_available,
175 + # Same reasoning: a feed that does not break its count down by
176 + # motor leaves these null, and subtracting one raises.
177 + avail_std: (f.num_bikes_available || 0) - (f.num_ebikes_available || 0),
176 178 docks_avail: f.num_docks_available,
177 179 docks_disabled: f.num_docks_disabled,
178 180 capacity: f.capacity,
179 181 ebikes_info:
180 − f.ebikes_info
182 + # A left join, so a dock that no e-bike is sitting in comes
183 + # back nil rather than empty -- and mapping over that raised,
184 + # which killed the condense for the *whole* board rather than
185 + # this one dock. A Plani feels it hardest: it breaks a source
186 + # out into an entry per dock, so a radius holding one ordinary
187 + # unelectrified dock published nothing at all.
188 + (f.ebikes_info || [])
181 189 |> Enum.map(fn eb ->
182 190 %{
183 191 name: eb.displayed_number,
modified apps/room_sanctum/lib/room_sanctum/workers/pythiae_worker.ex
+25 −5
@@ -152,14 +152,34 @@ defmodule RoomSanctum.Worker.Pythiae do
152 152 |> Enum.map(fn {{id, type}, datum} ->
153 153 query = Map.get(query_map, id)
154 154
155 − condensed = if query do
155 + case condense_one({id, type}, datum, query) do
156 + {:ok, condensed} -> {"#{type}-#{id}", condensed}
157 + :error -> nil
158 + end
159 + end)
160 + |> Enum.reject(&is_nil/1)
161 + |> Enum.into(%{})
162 + end
163 +
164 + # One entry, or nothing.
165 + #
166 + # A board is a dozen unrelated queries, and one of them raising used to take
167 + # the publish down with it -- so a single dock with an unexpected shape meant
168 + # no board at all, on every tick, with nothing saying why. The client already
169 + # drops what it cannot read and draws the rest; this is the same call made at
170 + # the other end, where there is a log to say it happened.
171 + defp condense_one({id, type}, datum, query) do
172 + condensed =
173 + if query do
156 174 RoomSanctum.Condenser.BasicMQTT.condense({id, type}, datum, query)
157 175 else
158 176 RoomSanctum.Condenser.BasicMQTT.condense_data({id, type}, datum)
159 177 end
160 −
161 − {"#{type}-#{id}", condensed}
162 − end)
163 − |> Enum.into(%{})
178 +
179 + {:ok, condensed}
180 + rescue
181 + error ->
182 + Logger.warning("pythiae: dropping #{type}-#{id}: #{Exception.message(error)}")
183 + :error
164 184 end
165 185 end
added apps/room_sanctum/test/room_sanctum/gbfs_condense_test.exs
+40 −0
@@ -0,0 +1,40 @@
1 +defmodule RoomSanctum.GbfsCondenseTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias RoomSanctum.Condenser.BasicMQTT
5 +
6 + # Both queries that produce these rows left join the e-bikes table, so the
7 + # field is nil for every dock that has no e-bike sitting in it -- which is
8 + # most of them, in a system that does not run any.
9 + @dock %{
10 + name: "Packard Ave",
11 + station_id: "6cec3afa-554c-466f-8323-5c619fe0ddc6",
12 + num_bikes_available: 4,
13 + num_ebikes_available: 0,
14 + num_docks_available: 9,
15 + num_docks_disabled: 0,
16 + capacity: 13,
17 + ebikes_info: nil
18 + }
19 +
20 + test "a dock with no e-bikes row condenses rather than raising" do
21 + [station] = BasicMQTT.condense_data({7, :gbfs}, [@dock])
22 +
23 + assert station.name == "Packard Ave"
24 + assert station.avail == 4
25 + assert station.ebikes_info == []
26 + end
27 +
28 + test "a feed that does not count e-bikes separately still gets a standard count" do
29 + dock = %{@dock | num_ebikes_available: nil}
30 +
31 + assert [%{avail_std: 4}] = BasicMQTT.condense_data({7, :gbfs}, [dock])
32 + end
33 +
34 + test "a broken out dock keys by source and station" do
35 + # What a Plani publishes: the station id is a UUID, so the key has dashes
36 + # well past the one separating the type. See WireTest on the client.
37 + assert "gbfs-7:6cec3afa-554c-466f-8323-5c619fe0ddc6" ==
38 + "gbfs-7:#{@dock.station_id}"
39 + end
40 +end
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+17 −0
@@ -118,6 +118,12 @@ class AnkyraService : Service() {
118 118 sendUplink("${settings.topic}.publish.board", "{}")
119 119 }
120 120
121 + /** Say where this client is, now. See [LocationReporter.reportNow]. */
122 + fun reportLocationNow(onResult: (Boolean) -> Unit) {
123 + val reporter = location
124 + if (reporter == null) onResult(false) else reporter.reportNow(onResult)
125 + }
126 +
121 127 private fun onPayload(payload: String) {
122 128 Log.d(TAG, "payload: ${payload.length} bytes")
123 129 store.save(payload)
@@ -250,6 +256,17 @@ class AnkyraService : Service() {
250 256 running?.requestBoard()
251 257 }
252 258
259 + /**
260 + * Publish a position on demand.
261 + *
262 + * Reports false when nothing is listening, which is the same answer
263 + * the button wants for a service that is not running: it did not go.
264 + */
265 + fun reportLocationNow(context: Context, onResult: (Boolean) -> Unit) {
266 + val service = running
267 + if (service == null) onResult(false) else service.reportLocationNow(onResult)
268 + }
269 +
253 270 fun stop(context: Context) {
254 271 context.startService(
255 272 Intent(context, AnkyraService::class.java).setAction(ACTION_STOP)
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/LocationReporter.kt
+58 −12
@@ -65,14 +65,7 @@ class LocationReporter(
65 65 if (listening) return
66 66 val manager = manager ?: return
67 67
68 − val provider = when {
69 − Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
70 − manager.allProviders.contains(LocationManager.FUSED_PROVIDER) ->
71 − LocationManager.FUSED_PROVIDER
72 − manager.allProviders.contains(LocationManager.NETWORK_PROVIDER) ->
73 − LocationManager.NETWORK_PROVIDER
74 − else -> LocationManager.GPS_PROVIDER
75 − }
68 + val provider = bestProvider(manager)
76 69
77 70 runCatching {
78 71 manager.requestLocationUpdates(
@@ -90,6 +83,15 @@ class LocationReporter(
90 83 }.onFailure { Log.w(TAG, "could not start location updates", it) }
91 84 }
92 85
86 + private fun bestProvider(manager: LocationManager): String = when {
87 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
88 + manager.allProviders.contains(LocationManager.FUSED_PROVIDER) ->
89 + LocationManager.FUSED_PROVIDER
90 + manager.allProviders.contains(LocationManager.NETWORK_PROVIDER) ->
91 + LocationManager.NETWORK_PROVIDER
92 + else -> LocationManager.GPS_PROVIDER
93 + }
94 +
93 95 private fun stop() {
94 96 if (!listening) return
95 97 runCatching { manager?.removeUpdates(listener) }
@@ -97,6 +99,46 @@ class LocationReporter(
97 99 Log.d(TAG, "stopped reporting location")
98 100 }
99 101
102 + /**
103 + * Report where this client is, now, whatever the floors say.
104 + *
105 + * Asked for rather than waited for: the interval and the distance between
106 + * updates are there to keep a phone in a pocket quiet, and a person
107 + * looking at the screen has better information about whether this moment
108 + * matters than either of them does.
109 + */
110 + fun reportNow(onResult: (Boolean) -> Unit = {}) {
111 + if (!canReport()) return onResult(false)
112 + val manager = manager ?: return onResult(false)
113 +
114 + val provider = bestProvider(manager)
115 +
116 + runCatching {
117 + // A current fix rather than the last known one: "report now" that
118 + // sends a position from an hour ago is worse than useless, since
119 + // it looks like it worked.
120 + manager.getCurrentLocation(
121 + provider,
122 + null,
123 + context.mainExecutor
124 + ) { location ->
125 + if (location != null) {
126 + report(location)
127 + onResult(true)
128 + } else {
129 + // No fix in the time it was willing to wait -- indoors,
130 + // usually. The last one known is better than nothing.
131 + val last = runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
132 + last?.let(::report)
133 + onResult(last != null)
134 + }
135 + }
136 + }.onFailure {
137 + Log.w(TAG, "could not take a fix", it)
138 + onResult(false)
139 + }
140 + }
141 +
100 142 private fun report(location: Location) {
101 143 // Checked again at the moment of sending: the setting can be turned
102 144 // off between a fix being requested and arriving, and that fix should
@@ -109,10 +151,14 @@ class LocationReporter(
109 151 private const val TAG = "LocationReporter"
110 152
111 153 /**
112 − * Two minutes and a hundred metres, whichever the provider reaches
113 − * first. A foci that travels is answering "which stop am I near",
114 − * which does not change at walking pace within a block -- and the
115 − * cost of asking more often is the phone's battery.
154 + * Two minutes *and* a hundred metres: Android treats both as floors,
155 + * so a fix arrives only once each has been passed.
156 + *
157 + * Which means a phone standing still reports nothing at all, and that
158 + * is the intent -- a foci that travels is answering "which stop am I
159 + * near", which does not change while you are sitting down, and the
160 + * cost of asking more often is the battery. [reportNow] is the way to
161 + * say where you are without waiting for either.
116 162 */
117 163 private const val MIN_INTERVAL_MS = 2 * 60 * 1000L
118 164 private const val MIN_DISTANCE_M = 100f
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+42 −0
@@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
32 32 import androidx.compose.runtime.mutableStateOf
33 33 import androidx.compose.runtime.remember
34 34 import androidx.compose.runtime.setValue
35 +import androidx.compose.runtime.LaunchedEffect
35 36 import androidx.compose.ui.Alignment
36 37 import androidx.compose.ui.Modifier
37 38 import androidx.compose.ui.text.input.KeyboardType
@@ -42,6 +43,7 @@ import androidx.compose.foundation.text.KeyboardOptions
42 43 import androidx.compose.ui.platform.LocalContext
43 44 import io.neiam.apolloscrib.data.Pairing
44 45 import io.neiam.apolloscrib.feed.FeedProvider
46 +import kotlinx.coroutines.delay
45 47 import io.neiam.apolloscrib.widget.BoardWidget
46 48 import io.neiam.apolloscrib.data.Settings
47 49 import io.neiam.apolloscrib.ui.theme.ALL_THEMES
@@ -274,6 +276,46 @@ private fun ConnectionScreen(
274 276 }
275 277 }
276 278 )
279 +
280 + // Only worth offering once reporting is on -- and worth offering
281 + // at all because the automatic report waits for two minutes *and*
282 + // a hundred metres, so a phone standing still never sends one.
283 + if (reportLocation) {
284 + // "" is untried, which shows nothing; the button says enough
285 + // on its own until it has been pressed.
286 + var result by remember { mutableStateOf("") }
287 +
288 + // A result that stayed on screen would be read as the current
289 + // state of something, and it is not -- it is what happened
290 + // when the button was last pressed.
291 + LaunchedEffect(result) {
292 + if (result.isNotEmpty()) {
293 + delay(4000)
294 + result = ""
295 + }
296 + }
297 +
298 + Row(
299 + verticalAlignment = Alignment.CenterVertically,
300 + horizontalArrangement = Arrangement.spacedBy(12.dp)
301 + ) {
302 + OutlinedButton(onClick = {
303 + result = ""
304 + AnkyraService.reportLocationNow(context) { sent ->
305 + result = if (sent) "Sent" else "No fix to send"
306 + }
307 + }) {
308 + Text("Report now")
309 + }
310 + if (result.isNotEmpty()) {
311 + Text(
312 + result,
313 + style = MaterialTheme.typography.bodySmall,
314 + color = LocalAppTheme.current.dim
315 + )
316 + }
317 + }
318 + }
277 319 }
278 320
279 321 Text("Client id: ${settings.clientId}", style = MaterialTheme.typography.bodySmall)

Parents: 54a26e2