Read a Plani broken out by stop, rather than dropping it

d13c936 · Gabriel Morell · 2026-10-05 23:47

6 files +177 -39
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