neiam
/apollos-dbus
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Read a Plani broken out by stop, rather than dropping it
d13c936 · Gabriel Morell · 2026-10-05 23:47
Message
{commit_body(@commit)}
Files changed
modified
README.md
+10
−2
@@ -72,7 +72,7 @@ reads.
| 72 | 72 | |
| 73 | 73 | | | Type | | |
| 74 | 74 | |---|---|---| |
| 75 | −| `Board` | `s` | Every query, keyed `<type>-<id>`: `{name, type, query_id, source, updated, stale, data, meta, plus, points}` | | |
| 75 | +| `Board` | `s` | Every query, keyed `<type>-<id>`: `{name, type, id, query_id, source, updated, stale, data, meta, plus, points}` | | |
| 76 | 76 | | `Points` | `s` | Every query in a few words, flattened: `[{key, name, type, text, color?, stale}]` | |
| 77 | 77 | | `Queries` | `as` | The keys there are objects for | |
| 78 | 78 | | `Sources` | `s` | `[{name, connected, error?}]` | |
@@ -85,11 +85,19 @@ Also `org.freedesktop.DBus.ObjectManager`, for `GetManagedObjects`.
| 85 | 85 | |
| 86 | 86 | ### `/io/neiam/ApollosCrib/query/<type>_<id>` — `io.neiam.ApollosCrib1.Query` |
| 87 | 87 | |
| 88 | +A key is `<type>-<id>`, and the id is not always a query's: a Plani with | |
| 89 | +a card per stop keys each `<source>:<stop>`, `gtfs-1:2577`, in the feed's | |
| 90 | +own stop ids. In the path the type's dash is `_` and any other character | |
| 91 | +outside `[A-Za-z0-9]` is `_` and its hex, so `gtfs-12` is `gtfs_12` and | |
| 92 | +`gtfs-1:2577` is `gtfs_1_3a2577`. Reading `Board` or `Queries` saves | |
| 93 | +working that out. | |
| 94 | + | |
| 88 | 95 | | | Type | | |
| 89 | 96 | |---|---|---| |
| 90 | 97 | | `Key` | `s` | `gtfs-12` | |
| 91 | 98 | | `Type` | `s` | `gtfs`, `weather`, ... | |
| 92 | −| `QueryId` | `u` | | | |
| 99 | +| `Id` | `s` | Everything after the type: `12`, `1:2577` | | |
| 100 | +| `QueryId` | `u` | The id as a query id; 0 where it is not one | | |
| 93 | 101 | | `Name` | `s` | The query's name, or its key if sent bare | |
| 94 | 102 | | `Source` | `s` | `ankyra:<name>` / `keryx:<name>` | |
| 95 | 103 | | `Updated` | `x` | Seconds since the epoch | |
modified
src/bus.rs
+38
−11
@@ -26,14 +26,31 @@ use crate::source::Command;
| 26 | 26 | pub const NAME: &str = "io.neiam.ApollosCrib"; |
| 27 | 27 | pub const ROOT: &str = "/io/neiam/ApollosCrib"; |
| 28 | 28 | |
| 29 | −/// A query's object path. Keys are `<type>-<id>`; a path allows only | |
| 30 | −/// `[A-Za-z0-9_]` in an element. | |
| 29 | +/// A query's object path. | |
| 30 | +/// | |
| 31 | +/// A path element allows only `[A-Za-z0-9_]`, and a key is `<type>-<id>` | |
| 32 | +/// with an id that may be anything a feed calls a stop. The type's dash | |
| 33 | +/// becomes `_`, so the common `gtfs-12` reads as `gtfs_12`; every other | |
| 34 | +/// character outside the alphabet becomes `_` and its UTF-8 bytes in hex, | |
| 35 | +/// so `gtfs-4:place-sstat` is `gtfs_4_3aplace_2dsstat` and two stops told | |
| 36 | +/// apart only by punctuation stay two objects. | |
| 31 | 37 | pub fn query_path(key: &str) -> String { |
| 32 | − let element: String = key | |
| 33 | − .chars() | |
| 34 | − .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) | |
| 35 | − .collect(); | |
| 36 | − format!("{ROOT}/query/{element}") | |
| 38 | + let (kind, id) = key.split_once('-').unwrap_or((key, "")); | |
| 39 | + let escape = |part: &str| -> String { | |
| 40 | + let mut out = String::new(); | |
| 41 | + for c in part.chars() { | |
| 42 | + if c.is_ascii_alphanumeric() { | |
| 43 | + out.push(c); | |
| 44 | + } else { | |
| 45 | + let mut buf = [0u8; 4]; | |
| 46 | + for b in c.encode_utf8(&mut buf).bytes() { | |
| 47 | + out.push_str(&format!("_{b:02x}")); | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + out | |
| 52 | + }; | |
| 53 | + format!("{ROOT}/query/{}_{}", escape(kind), escape(id)) | |
| 37 | 54 | } |
| 38 | 55 | |
| 39 | 56 | /// Where a key came from, so a refresh of it goes to the right place. |
@@ -55,7 +72,7 @@ pub struct Root {
| 55 | 72 | |
| 56 | 73 | #[interface(name = "io.neiam.ApollosCrib1")] |
| 57 | 74 | impl Root { |
| 58 | − /// Every query, keyed `<type>-<id>`: `{name, type, query_id, source, | |
| 75 | + /// Every query, keyed `<type>-<id>`: `{name, type, id, query_id, source, | |
| 59 | 76 | /// updated, stale, data, meta, plus, points}`. Everything at once, for a |
| 60 | 77 | /// widget that would rather bind one property than one path per query. |
| 61 | 78 | #[zbus(property)] |
@@ -106,7 +123,7 @@ impl Root {
| 106 | 123 | .commands |
| 107 | 124 | .get(&origin.source) |
| 108 | 125 | .ok_or_else(|| fdo::Error::Failed(format!("{} is not configured", origin.source)))?; |
| 109 | − let q = keryx.then_some(origin.query_id); | |
| 126 | + let q = keryx.then_some(origin.query_id).filter(|q| *q != 0); | |
| 110 | 127 | tx.send(Command::Refresh(q)) |
| 111 | 128 | .await |
| 112 | 129 | .map_err(|e| fdo::Error::Failed(e.to_string())) |
@@ -121,6 +138,7 @@ impl Root {
| 121 | 138 | pub struct Query { |
| 122 | 139 | pub key: String, |
| 123 | 140 | pub kind: String, |
| 141 | + pub id: String, | |
| 124 | 142 | pub query_id: u32, |
| 125 | 143 | pub name: String, |
| 126 | 144 | pub source: String, |
@@ -145,6 +163,14 @@ impl Query {
| 145 | 163 | self.kind.clone() |
| 146 | 164 | } |
| 147 | 165 | |
| 166 | + /// Everything in the key after the type: `12`, or for a Plani broken out | |
| 167 | + /// by stop, `4:place-sstat`. | |
| 168 | + #[zbus(property)] | |
| 169 | + fn id(&self) -> String { | |
| 170 | + self.id.clone() | |
| 171 | + } | |
| 172 | + | |
| 173 | + /// The id as a query id; 0 where it is not one. | |
| 148 | 174 | #[zbus(property)] |
| 149 | 175 | fn query_id(&self) -> u32 { |
| 150 | 176 | self.query_id |
@@ -220,8 +246,9 @@ mod tests {
| 220 | 246 | fn a_key_becomes_a_legal_path() { |
| 221 | 247 | assert_eq!(query_path("gtfs-12"), "/io/neiam/ApollosCrib/query/gtfs_12"); |
| 222 | 248 | assert_eq!( |
| 223 | − query_path("some.type-3"), | |
| 224 | − "/io/neiam/ApollosCrib/query/some_type_3" | |
| 249 | + query_path("gtfs-4:place-sstat"), | |
| 250 | + "/io/neiam/ApollosCrib/query/gtfs_4_3aplace_2dsstat" | |
| 225 | 251 | ); |
| 252 | + assert_ne!(query_path("gtfs-4:a-b"), query_path("gtfs-4:a_b")); | |
| 226 | 253 | } |
| 227 | 254 | } |
modified
src/points.rs
+61
−10
@@ -163,21 +163,23 @@ fn aqi(entries: Vec<AqiCondensed>) -> Vec<Point> {
| 163 | 163 | entries |
| 164 | 164 | .iter() |
| 165 | 165 | .filter_map(|a| { |
| 166 | − // Whatever the monitor measured; nothing is assumed present. | |
| 167 | − let mut readings: Vec<String> = a | |
| 166 | + // Whatever the monitor measured; nothing is assumed present. Only | |
| 167 | + // numbers: `combined` is the publisher's own rendering of these, | |
| 168 | + // and a monitor with nothing to report sends nulls. | |
| 169 | + let mut readings: Vec<(String, String)> = a | |
| 168 | 170 | .measurements |
| 169 | 171 | .iter() |
| 170 | 172 | .filter_map(|(k, v)| match v { |
| 171 | − Value::Number(n) => Some(format!("{k} {n}")), | |
| 172 | − Value::String(s) => Some(format!("{k} {s}")), | |
| 173 | + Value::Number(n) => Some((pollutant(k), n.to_string())), | |
| 173 | 174 | _ => None, |
| 174 | 175 | }) |
| 175 | 176 | .collect(); |
| 176 | − readings.sort(); | |
| 177 | 177 | if readings.is_empty() { |
| 178 | 178 | return None; |
| 179 | 179 | } |
| 180 | − let text = readings.join(" · "); | |
| 180 | + readings.sort(); | |
| 181 | + let text: Vec<String> = readings.iter().map(|(k, v)| format!("{k} {v}")).collect(); | |
| 182 | + let text = text.join(" · "); | |
| 181 | 183 | Some(Point::plain(match &a.name { |
| 182 | 184 | Some(n) => format!("{n}: {text}"), |
| 183 | 185 | None => text, |
@@ -186,14 +188,37 @@ fn aqi(entries: Vec<AqiCondensed>) -> Vec<Point> {
| 186 | 188 | .collect() |
| 187 | 189 | } |
| 188 | 190 | |
| 191 | +/// AirNow's parameter names as people write them. | |
| 192 | +fn pollutant(key: &str) -> String { | |
| 193 | + match key { | |
| 194 | + "pm25" => "PM2.5".into(), | |
| 195 | + "pm10" => "PM10".into(), | |
| 196 | + "ozone" => "O₃".into(), | |
| 197 | + "no2" => "NO₂".into(), | |
| 198 | + "so2" => "SO₂".into(), | |
| 199 | + "co" => "CO".into(), | |
| 200 | + other => other.to_uppercase(), | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 189 | 204 | fn ephem(entries: Vec<EphemerisCondensed>) -> Vec<Point> { |
| 190 | 205 | entries |
| 191 | 206 | .iter() |
| 192 | 207 | .map(|e| { |
| 193 | − // In the order the day happens, not the order the map keeps. | |
| 194 | − let mut periods: Vec<(&String, &String)> = e.periods.iter().collect(); | |
| 195 | − periods.sort_by(|a, b| a.1.cmp(b.1)); | |
| 196 | − let text: Vec<String> = periods.iter().map(|(k, v)| format!("{k} {v}")).collect(); | |
| 208 | + // Times in the order the day happens, then whatever is not a | |
| 209 | + // time -- the moon's phase -- said in words. | |
| 210 | + let is_time = |v: &str| v.len() >= 5 && v.as_bytes()[2] == b':'; | |
| 211 | + let mut times: Vec<(&String, &String)> = | |
| 212 | + e.periods.iter().filter(|(_, v)| is_time(v)).collect(); | |
| 213 | + times.sort_by(|a, b| a.1.cmp(b.1)); | |
| 214 | + let mut others: Vec<(&String, &String)> = | |
| 215 | + e.periods.iter().filter(|(_, v)| !is_time(v)).collect(); | |
| 216 | + others.sort(); | |
| 217 | + let text: Vec<String> = times | |
| 218 | + .iter() | |
| 219 | + .map(|(k, v)| format!("{k} {}", clock(v))) | |
| 220 | + .chain(others.iter().map(|(_, v)| v.replace('_', " "))) | |
| 221 | + .collect(); | |
| 197 | 222 | Point::plain(format!("{}: {}", e.name, text.join(" · "))) |
| 198 | 223 | }) |
| 199 | 224 | .collect() |
@@ -257,6 +282,32 @@ mod tests {
| 257 | 282 | assert_eq!(summarise("weather", &data)[0].text, "Home: 12°C light rain"); |
| 258 | 283 | } |
| 259 | 284 | |
| 285 | + #[test] | |
| 286 | + fn air_quality_is_its_numbers_not_the_publishers_string() { | |
| 287 | + // Verbatim shapes from a Boston Metro query: one monitor per entry, | |
| 288 | + // `combined` already rendered, and a monitor that reported nothing. | |
| 289 | + let data = json!([ | |
| 290 | + {"combined": "O2: 27, PM2.5: 12, NO2: 3, ", "name": "Boston Metro", | |
| 291 | + "no2": 3, "ozone": 27, "pm25": 12}, | |
| 292 | + {"combined": "PM2.5: , ", "name": null, "pm25": null} | |
| 293 | + ]); | |
| 294 | + let p: Vec<String> = summarise("aqi", &data) | |
| 295 | + .into_iter() | |
| 296 | + .map(|p| p.text) | |
| 297 | + .collect(); | |
| 298 | + assert_eq!(p, vec!["Boston Metro: NO₂ 3 · O₃ 27 · PM2.5 12"]); | |
| 299 | + } | |
| 300 | + | |
| 301 | + #[test] | |
| 302 | + fn the_sky_in_the_order_it_happens() { | |
| 303 | + let data = json!([{"name": "Here", "sunrise": "06:46:09.140", "moonset": "15:50:42", | |
| 304 | + "sunset": "18:19:09.288", "moonrise": "01:14:35", "phase": "waning_crescent"}]); | |
| 305 | + assert_eq!( | |
| 306 | + summarise("ephem", &data)[0].text, | |
| 307 | + "Here: moonrise 01:14 · sunrise 06:46 · moonset 15:50 · sunset 18:19 · waning crescent" | |
| 308 | + ); | |
| 309 | + } | |
| 310 | + | |
| 260 | 311 | #[test] |
| 261 | 312 | fn what_does_not_parse_says_nothing() { |
| 262 | 313 | assert!(summarise("gtfs", &json!({"not": "a list"})).is_empty()); |
modified
src/store.rs
+2
−0
@@ -169,6 +169,7 @@ impl State {
| 169 | 169 | Some(Query { |
| 170 | 170 | key: key.to_string(), |
| 171 | 171 | kind: e.reading.kind.clone(), |
| 172 | + id: e.reading.id.clone(), | |
| 172 | 173 | query_id: e.reading.query_id, |
| 173 | 174 | name: State::name_of(e), |
| 174 | 175 | source: e.source.clone(), |
@@ -191,6 +192,7 @@ impl State {
| 191 | 192 | json!({ |
| 192 | 193 | "name": State::name_of(e), |
| 193 | 194 | "type": e.reading.kind, |
| 195 | + "id": e.reading.id, | |
| 194 | 196 | "query_id": e.reading.query_id, |
| 195 | 197 | "source": e.source, |
| 196 | 198 | "updated": e.updated, |
modified
src/wire.rs
+66
−16
@@ -3,7 +3,7 @@
| 3 | 3 | //! Kept apart from the connection so it can be tested without one. Two |
| 4 | 4 | //! shapes reach here: |
| 5 | 5 | //! |
| 6 | −//! * An Ankyra board: one JSON object per tick, keyed `"<type>-<queryId>"`, | |
| 6 | +//! * An Ankyra board: one JSON object per tick, keyed `"<type>-<id>"`, | |
| 7 | 7 | //! each value `{data, query: {name, meta}}` -- or, from a publisher that |
| 8 | 8 | //! could not resolve the query, the bare data. |
| 9 | 9 | //! * A Keryx `.meta` message: one query's `{data, query}`, its type and id in |
@@ -20,6 +20,11 @@ pub struct Reading {
| 20 | 20 | /// `gtfs-12`: the key the board uses, and what everything else keys on. |
| 21 | 21 | pub key: String, |
| 22 | 22 | pub kind: String, |
| 23 | + /// Everything after the type: a query's id, `12`, or for a Plani broken | |
| 24 | + /// out a card per stop, the source and the stop, `4:place-sstat`. | |
| 25 | + #[serde(default)] | |
| 26 | + pub id: String, | |
| 27 | + /// The id as a query id, where it is one; 0 where it is not. | |
| 23 | 28 | pub query_id: u32, |
| 24 | 29 | pub name: Option<String>, |
| 25 | 30 | pub meta: Value, |
@@ -27,12 +32,13 @@ pub struct Reading {
| 27 | 32 | } |
| 28 | 33 | |
| 29 | 34 | impl Reading { |
| 30 | − fn new(kind: &str, query_id: u32, value: Value) -> Reading { | |
| 35 | + fn new(kind: &str, id: &str, value: Value) -> Reading { | |
| 31 | 36 | let (data, name, meta) = unwrap(value); |
| 32 | 37 | Reading { |
| 33 | − key: format!("{kind}-{query_id}"), | |
| 38 | + key: format!("{kind}-{id}"), | |
| 34 | 39 | kind: kind.to_string(), |
| 35 | − query_id, | |
| 40 | + id: id.to_string(), | |
| 41 | + query_id: id.parse().unwrap_or(0), | |
| 36 | 42 | name, |
| 37 | 43 | meta, |
| 38 | 44 | data, |
@@ -57,11 +63,15 @@ fn unwrap(value: Value) -> (Value, Option<String>, Value) {
| 57 | 63 | } |
| 58 | 64 | } |
| 59 | 65 | |
| 60 | −/// `gtfs-12` into its type and id. The type is everything before the last | |
| 61 | −/// dash, so a type with a dash in it survives. | |
| 62 | −pub fn split_key(key: &str) -> Option<(&str, u32)> { | |
| 63 | − let (kind, id) = key.rsplit_once('-')?; | |
| 64 | − Some((kind, id.parse().ok()?)) | |
| 66 | +/// `gtfs-12` into its type and id, on the first dash. | |
| 67 | +/// | |
| 68 | +/// The id is not always a number: a Plani broken out into a card per stop | |
| 69 | +/// keys each as `gtfs-<source>:<stop>`, and a stop id is the feed's own -- | |
| 70 | +/// `place-sstat` has a dash of its own. Types have none, so the first dash | |
| 71 | +/// is the one, as the Android client reads it too. | |
| 72 | +pub fn split_key(key: &str) -> Option<(&str, &str)> { | |
| 73 | + let (kind, id) = key.split_once('-')?; | |
| 74 | + (!kind.is_empty() && !id.is_empty()).then_some((kind, id)) | |
| 65 | 75 | } |
| 66 | 76 | |
| 67 | 77 | /// A routing key as it reaches an MQTT client has its dots as slashes. Put |
@@ -86,8 +96,12 @@ pub fn board(payload: &[u8]) -> Result<Vec<Reading>, serde_json::Error> {
| 86 | 96 | .collect()) |
| 87 | 97 | } |
| 88 | 98 | |
| 89 | −/// The Plus board: the same keys, each the extended reading. Kept whole -- | |
| 90 | −/// what to make of it is a consumer's business. | |
| 99 | +/// The Plus board: the same queries, each the extended reading. Kept whole | |
| 100 | +/// -- what to make of it is a consumer's business. | |
| 101 | +/// | |
| 102 | +/// A GTFS entry's Plus reading is a different shape and says so in its key, | |
| 103 | +/// `gtfs_plus-12`; it is filed under the board's `gtfs-12`, which is the | |
| 104 | +/// query it extends. | |
| 91 | 105 | pub fn plus(payload: &[u8]) -> Result<Vec<(String, Value)>, serde_json::Error> { |
| 92 | 106 | let value: Value = serde_json::from_slice(payload)?; |
| 93 | 107 | let Value::Object(map) = value else { |
@@ -95,7 +109,11 @@ pub fn plus(payload: &[u8]) -> Result<Vec<(String, Value)>, serde_json::Error> {
| 95 | 109 | }; |
| 96 | 110 | Ok(map |
| 97 | 111 | .into_iter() |
| 98 | − .filter(|(k, _)| split_key(k).is_some()) | |
| 112 | + .filter_map(|(k, v)| { | |
| 113 | + let (kind, id) = split_key(&k)?; | |
| 114 | + let kind = if kind == "gtfs_plus" { "gtfs" } else { kind }; | |
| 115 | + Some((format!("{kind}-{id}"), v)) | |
| 116 | + }) | |
| 99 | 117 | .collect()) |
| 100 | 118 | } |
| 101 | 119 |
@@ -110,7 +128,8 @@ pub fn keryx_meta(keryx_id: u32, topic: &str, payload: &[u8]) -> Option<Reading>
| 110 | 128 | return None; |
| 111 | 129 | } |
| 112 | 130 | let value: Value = serde_json::from_slice(payload).ok()?; |
| 113 | − Some(Reading::new(kind, qid.parse().ok()?, value)) | |
| 131 | + qid.parse::<u32>().ok()?; | |
| 132 | + Some(Reading::new(kind, qid, value)) | |
| 114 | 133 | } |
| 115 | 134 | |
| 116 | 135 | #[cfg(test)] |
@@ -157,9 +176,40 @@ mod tests {
| 157 | 176 | } |
| 158 | 177 | |
| 159 | 178 | #[test] |
| 160 | − fn a_key_splits_on_its_last_dash() { | |
| 161 | − assert_eq!(split_key("gtfs-12"), Some(("gtfs", 12))); | |
| 162 | − assert_eq!(split_key("some-type-3"), Some(("some-type", 3))); | |
| 179 | + fn a_key_splits_on_its_first_dash() { | |
| 180 | + assert_eq!(split_key("gtfs-12"), Some(("gtfs", "12"))); | |
| 181 | + assert_eq!( | |
| 182 | + split_key("gtfs-4:place-sstat"), | |
| 183 | + Some(("gtfs", "4:place-sstat")) | |
| 184 | + ); | |
| 163 | 185 | assert_eq!(split_key("gtfs"), None); |
| 186 | + assert_eq!(split_key("gtfs-"), None); | |
| 187 | + } | |
| 188 | + | |
| 189 | + #[test] | |
| 190 | + fn a_plani_broken_out_by_stop_is_read_whole() { | |
| 191 | + // What a Plani with a card per stop publishes: source and stop in | |
| 192 | + // the key, and the feed's stop id with a dash of its own. | |
| 193 | + let payload = br##"{ | |
| 194 | + "gtfs-4:place-sstat": {"data": [], "query": {"name": "South Station", "meta": {}}}, | |
| 195 | + "gbfs-9:a3b1": {"data": [], "query": {"name": "Dock", "meta": {}}} | |
| 196 | + }"##; | |
| 197 | + let mut r = board(payload).unwrap(); | |
| 198 | + r.sort_by(|a, b| a.key.cmp(&b.key)); | |
| 199 | + | |
| 200 | + assert_eq!(r.len(), 2); | |
| 201 | + assert_eq!(r[1].key, "gtfs-4:place-sstat"); | |
| 202 | + assert_eq!(r[1].kind, "gtfs"); | |
| 203 | + assert_eq!(r[1].id, "4:place-sstat"); | |
| 204 | + assert_eq!(r[1].query_id, 0, "not a query id, so none"); | |
| 205 | + assert_eq!(r[1].name.as_deref(), Some("South Station")); | |
| 206 | + } | |
| 207 | + | |
| 208 | + #[test] | |
| 209 | + fn gtfs_plus_is_filed_under_the_query_it_extends() { | |
| 210 | + let entries = plus(br#"{"gtfs_plus-12": [], "gbfs-3": []}"#).unwrap(); | |
| 211 | + let mut keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect(); | |
| 212 | + keys.sort(); | |
| 213 | + assert_eq!(keys, vec!["gbfs-3", "gtfs-12"]); | |
| 164 | 214 | } |
| 165 | 215 | } |
Parents: 5fae34f