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.
Report a location on demand, and stop one dock sinking a board
b14afb0 · Gabriel Morell · 2026-09-03 18:47
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