saw-livetrack 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StrausbergAutomationWorks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: saw-livetrack
3
+ Version: 0.1.0
4
+ Summary: Shared code for the Live Track Home Assistant integrations: source-agnostic fix tracking, plus an Amtraker API client
5
+ Author: StrausbergAutomationWorks
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/StrausbergAutomationWorks/LiveTrackCore
8
+ Project-URL: Issues, https://github.com/StrausbergAutomationWorks/LiveTrackCore/issues
9
+ Keywords: home-assistant,train-tracking,geo-location,amtrak,via-rail,brightline,amtraker
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # saw-amtraker-client
20
+
21
+ Shared Amtraker API client for the **Live Track** passenger-rail integrations
22
+ for Home Assistant: Live Track Amtrak, Live Track VIA Rail and Live Track
23
+ Brightline.
24
+
25
+ Pure Python, no Home Assistant imports. Each integration declares it in
26
+ `manifest.json` `requirements`.
27
+
28
+ ## Why this is a separate package
29
+
30
+ HACS permits only **one integration per repository**, so three integrations
31
+ cannot share a folder. Vendoring the same client into three repositories is how
32
+ three subtly different clients get written, so it ships as a dependency instead.
33
+
34
+ ## What it refuses to do, and why
35
+
36
+ Each refusal comes from a measurement against the live feed, not from caution.
37
+
38
+ | Behaviour | Reason |
39
+ |---|---|
40
+ | `course_deg()` always returns `None` | the feed's direction is an eight-value octant string; converting `"SW"` to `225.0` invents precision the feed never carried |
41
+ | `speed_kmh()` returns `None` for Brightline, even when the field is non-zero | measured 0.0 on every instance while trains covered 76-133 km at 44-77 mph. The field is unpopulated, not stationary |
42
+ | `observed_at()` returns `None` for Brightline | its `lastValTS` advances every 30 s while the position changes every 60 s. It is a feed refresh clock, not an observation time |
43
+ | `observed_at()` returns `None` for `Predeparture` trains | that field then carries a scheduled *future* departure |
44
+ | a finished train is handled by type | the API returns a bare `[]` list, not the documented keyed object |
45
+ | HTTP 429 raises `RateLimited` | a non-200 must never be recorded as "no data" |
46
+ | an empty `User-Agent` raises at construction | the server blocks such requests, so failing here is clearer than an empty result later |
47
+
48
+ A terminal snap - the feed resetting a finished train's position to its
49
+ terminus - is detected by **implied speed**, not by a fixed distance. A flat
50
+ distance threshold discards genuine movement: at 125 mph over the observed
51
+ 180 s maximum interval a train legitimately covers 10 km.
52
+
53
+ ## Caching
54
+
55
+ `/v3/trains` takes no parameters and returns every provider, so N consumers
56
+ would otherwise make N identical system-wide requests. One client instance
57
+ serves all three providers from a single cached fetch.
58
+
59
+ ## Data attribution
60
+
61
+ Train data is provided by **[Amtraker](https://amtraker.com)** and is licensed
62
+ under the [Open Data Commons Attribution License (ODC-By) v1.0](https://opendatacommons.org/licenses/by/1-0/).
63
+
64
+ ## Disclaimer
65
+
66
+ Independent project. Not affiliated with, endorsed by or connected to Amtrak,
67
+ VIA Rail Canada or Brightline.
68
+
69
+ ## Licence
70
+
71
+ MIT.
@@ -0,0 +1,53 @@
1
+ # saw-amtraker-client
2
+
3
+ Shared Amtraker API client for the **Live Track** passenger-rail integrations
4
+ for Home Assistant: Live Track Amtrak, Live Track VIA Rail and Live Track
5
+ Brightline.
6
+
7
+ Pure Python, no Home Assistant imports. Each integration declares it in
8
+ `manifest.json` `requirements`.
9
+
10
+ ## Why this is a separate package
11
+
12
+ HACS permits only **one integration per repository**, so three integrations
13
+ cannot share a folder. Vendoring the same client into three repositories is how
14
+ three subtly different clients get written, so it ships as a dependency instead.
15
+
16
+ ## What it refuses to do, and why
17
+
18
+ Each refusal comes from a measurement against the live feed, not from caution.
19
+
20
+ | Behaviour | Reason |
21
+ |---|---|
22
+ | `course_deg()` always returns `None` | the feed's direction is an eight-value octant string; converting `"SW"` to `225.0` invents precision the feed never carried |
23
+ | `speed_kmh()` returns `None` for Brightline, even when the field is non-zero | measured 0.0 on every instance while trains covered 76-133 km at 44-77 mph. The field is unpopulated, not stationary |
24
+ | `observed_at()` returns `None` for Brightline | its `lastValTS` advances every 30 s while the position changes every 60 s. It is a feed refresh clock, not an observation time |
25
+ | `observed_at()` returns `None` for `Predeparture` trains | that field then carries a scheduled *future* departure |
26
+ | a finished train is handled by type | the API returns a bare `[]` list, not the documented keyed object |
27
+ | HTTP 429 raises `RateLimited` | a non-200 must never be recorded as "no data" |
28
+ | an empty `User-Agent` raises at construction | the server blocks such requests, so failing here is clearer than an empty result later |
29
+
30
+ A terminal snap - the feed resetting a finished train's position to its
31
+ terminus - is detected by **implied speed**, not by a fixed distance. A flat
32
+ distance threshold discards genuine movement: at 125 mph over the observed
33
+ 180 s maximum interval a train legitimately covers 10 km.
34
+
35
+ ## Caching
36
+
37
+ `/v3/trains` takes no parameters and returns every provider, so N consumers
38
+ would otherwise make N identical system-wide requests. One client instance
39
+ serves all three providers from a single cached fetch.
40
+
41
+ ## Data attribution
42
+
43
+ Train data is provided by **[Amtraker](https://amtraker.com)** and is licensed
44
+ under the [Open Data Commons Attribution License (ODC-By) v1.0](https://opendatacommons.org/licenses/by/1-0/).
45
+
46
+ ## Disclaimer
47
+
48
+ Independent project. Not affiliated with, endorsed by or connected to Amtrak,
49
+ VIA Rail Canada or Brightline.
50
+
51
+ ## Licence
52
+
53
+ MIT.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "saw-livetrack"
7
+ version = "0.1.0"
8
+ description = "Shared code for the Live Track Home Assistant integrations: source-agnostic fix tracking, plus an Amtraker API client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "StrausbergAutomationWorks" }]
13
+ keywords = ["home-assistant", "train-tracking", "geo-location", "amtrak", "via-rail", "brightline", "amtraker"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/StrausbergAutomationWorks/LiveTrackCore"
24
+ Issues = "https://github.com/StrausbergAutomationWorks/LiveTrackCore/issues"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-dir]
30
+ "" = "src"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,53 @@
1
+ """SAW LiveTrack - shared code for the Live Track Home Assistant integrations.
2
+
3
+ Two submodules, deliberately separate:
4
+
5
+ saw_livetrack.track source-agnostic. Holds the last two DISTINCT fixes
6
+ per object and emits the `previous_*` segment fields
7
+ so a consumer can animate between two OBSERVED
8
+ positions. The aliasing bug it prevents is not
9
+ specific to any feed: ANY integration whose poll
10
+ interval is near its source's publish interval sees
11
+ duplicate fixes, and storing one as the previous fix
12
+ collapses the segment to zero length.
13
+
14
+ saw_livetrack.amtraker the Amtraker API client, for Amtrak, VIA Rail and
15
+ Brightline. Data from Amtraker (amtraker.com),
16
+ ODC-By v1.0.
17
+
18
+ `amtraker` imports from `track`. Never the reverse.
19
+
20
+ Not affiliated with Amtrak, VIA Rail Canada or Brightline.
21
+ """
22
+
23
+ from .track import ( # noqa: F401
24
+ COURSE_MIN_M,
25
+ JITTER_M,
26
+ MAX_PLAUSIBLE_KMH,
27
+ SNAP_KM,
28
+ FixTracker,
29
+ bearing_deg,
30
+ haversine_km,
31
+ )
32
+ from .amtraker import ( # noqa: F401
33
+ BATCH_STAMPED,
34
+ CONTRACT_VERSION,
35
+ FEED_TICK_S,
36
+ POSITION_INTERVAL_S,
37
+ PROVIDERS,
38
+ SHARED_KEY,
39
+ VELOCITY_UNPOPULATED,
40
+ AmtrakerClient,
41
+ AmtrakerError,
42
+ FeedUnavailable,
43
+ RateLimited,
44
+ classify_movement,
45
+ course_deg,
46
+ derived_speed_kmh,
47
+ heading_octant,
48
+ observed_at,
49
+ parse_ts,
50
+ speed_kmh,
51
+ )
52
+
53
+ __version__ = "0.1.0"
@@ -0,0 +1,367 @@
1
+ """Shared Amtraker API client for the Live Track family.
2
+
3
+ Consumed by Live Track Amtrak, Live Track VIA Rail and Live Track Brightline.
4
+ Pure Python: NO homeassistant imports, so it can ship as a PyPI package that
5
+ each integration declares in manifest.json requirements (05_SHARED_LESSONS.md
6
+ section B8.1a).
7
+
8
+ Data: Amtraker (https://amtraker.com), ODC-By v1.0.
9
+
10
+ Every rule enforced here was MEASURED. See 05_SHARED_LESSONS.md section B8.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import json
17
+ import math
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ # Contract version stamped into the shared hass.data key. Bump ONLY when the
22
+ # shape a coordinator publishes changes. B8.1a: a consumer finding a
23
+ # coordinator at a version it does not understand must build its own rather
24
+ # than misuse a foreign object.
25
+ CONTRACT_VERSION = 1
26
+ SHARED_KEY = "saw_amtraker_feed_v%d" % CONTRACT_VERSION
27
+
28
+ BASE = "https://api-v3.amtraker.com/v3"
29
+
30
+ # B8: a User-Agent is MANDATORY. Requests without one are blocked server-side.
31
+ DEFAULT_UA = "SAW-LiveTrack/0.1 (+https://github.com/StrausbergAutomationWorks)"
32
+
33
+ PROVIDERS = ("Amtrak", "Via", "Brightline")
34
+
35
+ # --- Measured artefact thresholds. B8, Brightline SSOT section 4.3 ----------
36
+
37
+ # TERMINAL SNAP: on completing a run the feed resets a train's position to the
38
+ # terminus before dropping it. Measured at 313 km in one 30 s tick, an implied
39
+ # 11,670 mph. trainState stayed "Active" and eventCode stayed unchanged, so
40
+ # NEITHER FIELD FLAGS IT. Displacement is the only available signal.
41
+ #
42
+ # A snap is an impossible SPEED, not a fixed distance. CAUGHT BY A TEST
43
+ # 2026-09-06: a flat 5 km threshold would have discarded GENUINE movement,
44
+ # because at 125 mph over the observed 180 s maximum interval a train
45
+ # legitimately covers 10 km. Fastest scheduled service on this feed is Acela
46
+ # at 150 mph (241 km/h); 322 km/h leaves headroom while still rejecting a
47
+ # 37,560 km/h reset by two orders of magnitude.
48
+
49
+ # Distance fallback for when the two fixes cannot be dated -- which is exactly
50
+ # the BATCH_STAMPED case where the snap was observed. 25 km is ~2.5x the
51
+ # largest legitimate displacement seen and still 12x below the measured 313 km.
52
+
53
+ # Two clocks, measured with ZERO variance (n=225 and n=74). B8.3.
54
+ FEED_TICK_S = 30.0
55
+ POSITION_INTERVAL_S = 60.0
56
+
57
+ # Providers whose `velocity` is never populated. B8.4: measured 0 of 9 for
58
+ # Brightline while trains covered 76-133 km at highway speed.
59
+ VELOCITY_UNPOPULATED = frozenset({"Brightline"})
60
+
61
+ # Providers whose lastValTS is a fleet-wide feed refresh clock rather than a
62
+ # per-train observation time. B8.3 - cannot serve as `last_seen`.
63
+ BATCH_STAMPED = frozenset({"Brightline"})
64
+
65
+
66
+ class AmtrakerError(Exception):
67
+ """Base error for this client."""
68
+
69
+
70
+ class RateLimited(AmtrakerError):
71
+ """HTTP 429. NOT 'no data' and NOT a hard negative -- back off and retry.
72
+
73
+ 01_ENVIRONMENT.md: letting a non-200 count as absence once deleted two
74
+ working sensor sites from a build plan.
75
+ """
76
+
77
+
78
+ class FeedUnavailable(AmtrakerError):
79
+ """Transport or decode failure. Distinct from 'looked and found nothing',
80
+ which is an empty result rather than an exception (C2)."""
81
+
82
+
83
+ # --------------------------------------------------------------------------
84
+ # Timestamps
85
+ # --------------------------------------------------------------------------
86
+
87
+ def parse_ts(value):
88
+ """Parse a feed timestamp to an aware datetime, or None.
89
+
90
+ B8.4: THREE formats occur in this one field, and VIA is inconsistent with
91
+ itself. Measured over 381 instances:
92
+ Amtrak offset, no millis '2026-09-06T20:41:41-07:00'
93
+ Brightline Z + millis '2026-09-07T02:00:08.539Z'
94
+ VIA Z (x48) AND Z+millis (x76)
95
+ Never assume a provider is uniform.
96
+ """
97
+ if not value or not isinstance(value, str):
98
+ return None
99
+ text = value.strip()
100
+ if not text:
101
+ return None
102
+ if text.endswith("Z"):
103
+ text = text[:-1] + "+00:00"
104
+ try:
105
+ parsed = _dt.datetime.fromisoformat(text)
106
+ except ValueError:
107
+ return None
108
+ if parsed.tzinfo is None:
109
+ return None
110
+ return parsed
111
+
112
+
113
+ def observed_at(train):
114
+ """When this train's position was true, or None if unknowable.
115
+
116
+ B8.3: for BATCH_STAMPED providers lastValTS is a FEED REFRESH CLOCK -- it
117
+ advances every 30 s while the position sits still, so it cannot date the
118
+ position. D0 then applies: omit rather than approximate.
119
+
120
+ B8: for Amtrak, lastValTS on a `Predeparture` train carries a SCHEDULED
121
+ FUTURE DEPARTURE, not an observation. Measured negative ages of -1,774 s,
122
+ -274 s and -94 s. Branch on trainState before trusting it.
123
+ """
124
+ if train.get("provider") in BATCH_STAMPED:
125
+ return None
126
+ if train.get("trainState") == "Predeparture":
127
+ return None
128
+ return parse_ts(train.get("lastValTS"))
129
+
130
+
131
+ # --------------------------------------------------------------------------
132
+ # Geometry
133
+ # --------------------------------------------------------------------------
134
+
135
+ # haversine_km, JITTER_M, SNAP_KM and MAX_PLAUSIBLE_KMH now live in
136
+ # track.py, which knows nothing about any particular source.
137
+ from .track import ( # noqa: E402,F401
138
+ JITTER_M,
139
+ MAX_PLAUSIBLE_KMH,
140
+ SNAP_KM,
141
+ haversine_km,
142
+ )
143
+
144
+
145
+ def classify_movement(prev, curr):
146
+ """Classify displacement between two fixes of the SAME train.
147
+
148
+ Returns one of "snap", "still", "moved", or "unknown".
149
+
150
+ B8 / Brightline SSOT 4.3: both artefacts are invisible to trainState and
151
+ eventCode, so a consumer that trusts those fields ships both bugs.
152
+
153
+ A snap is judged by IMPLIED SPEED where the fixes can be dated, and only
154
+ falls back to raw distance where they cannot. A flat distance test throws
155
+ away real movement -- see MAX_PLAUSIBLE_KMH.
156
+ """
157
+ for rec in (prev, curr):
158
+ if not isinstance(rec.get("lat"), (int, float)):
159
+ return "unknown"
160
+ if not isinstance(rec.get("lon"), (int, float)):
161
+ return "unknown"
162
+ km = haversine_km(prev["lat"], prev["lon"], curr["lat"], curr["lon"])
163
+
164
+ t0, t1 = observed_at(prev), observed_at(curr)
165
+ if t0 is not None and t1 is not None:
166
+ dt = (t1 - t0).total_seconds()
167
+ if dt > 0:
168
+ if km / (dt / 3600.0) > MAX_PLAUSIBLE_KMH:
169
+ return "snap"
170
+ elif km * 1000.0 >= JITTER_M:
171
+ # Movement with no forward time cannot be real.
172
+ return "snap"
173
+ elif km > SNAP_KM:
174
+ return "snap"
175
+
176
+ if km * 1000.0 < JITTER_M:
177
+ return "still"
178
+ return "moved"
179
+
180
+
181
+ def speed_kmh(train):
182
+ """Reported speed in km/h, or None when the field is not real.
183
+
184
+ B8.4: `velocity` is documented in MPH and is genuinely populated for
185
+ Amtrak (220 of 248) and VIA (44 of 124). For Brightline it read 0.0 on
186
+ every one of 9 instances while four trains covered 76-133 km at 44-77 mph
187
+ -- the field is UNPOPULATED, not "stationary" (D3b-ii-a).
188
+
189
+ D0: omit rather than approximate. Returning None means the caller omits
190
+ the key; returning 0.0 would assert the train is stopped.
191
+ """
192
+ if train.get("provider") in VELOCITY_UNPOPULATED:
193
+ return None
194
+ value = train.get("velocity")
195
+ if not isinstance(value, (int, float)):
196
+ return None
197
+ if value < 0:
198
+ return None
199
+ return value * 1.609344
200
+
201
+
202
+ def derived_speed_kmh(prev, curr):
203
+ """Speed from two position fixes, or None.
204
+
205
+ WARNING -- READ BEFORE USING. This is an ESTIMATE and it is a 60-second
206
+ AVERAGE, not an instantaneous speed. It is intended as a DISPLAYED value,
207
+ labelled as derived.
208
+
209
+ It must NEVER feed `max_extrapolation_s` or any dead reckoning: D3b-i
210
+ forbids deriving speed from consecutive positions and then extrapolating
211
+ on it, however good the number looks.
212
+
213
+ Returns None for snaps, for stationary jitter, and for missing timestamps,
214
+ because each of those produces a fictional speed.
215
+ """
216
+ kind = classify_movement(prev, curr)
217
+ if kind in ("snap", "unknown"):
218
+ return None
219
+ t0, t1 = observed_at(prev), observed_at(curr)
220
+ if t0 is None or t1 is None:
221
+ return None
222
+ dt = (t1 - t0).total_seconds()
223
+ if dt <= 0:
224
+ return None
225
+ if kind == "still":
226
+ return 0.0
227
+ km = haversine_km(prev["lat"], prev["lon"], curr["lat"], curr["lon"])
228
+ return km / (dt / 3600.0)
229
+
230
+
231
+ def course_deg(train):
232
+ """Always None. Present so nobody adds it later thinking it was missed.
233
+
234
+ B8.2: the feed's `heading` is one of eight OCTANT STRINGS. Converting "SW"
235
+ to 225.0 invents 22.5 degrees of precision the feed never carried, and D0
236
+ says omit rather than approximate. Use heading_octant() instead.
237
+ """
238
+ return None
239
+
240
+
241
+ def heading_octant(train):
242
+ """The raw octant string, or None.
243
+
244
+ D4 display helper. All eight values occur (measured n=198), so it is real
245
+ data rather than a constant -- but it is the DISPLAY form, which inverts
246
+ D5's usual 'integrations emit physics, consumers derive display'.
247
+ """
248
+ value = train.get("heading")
249
+ if not isinstance(value, str):
250
+ return None
251
+ value = value.strip().upper()
252
+ if value in ("N", "NE", "E", "SE", "S", "SW", "W", "NW"):
253
+ return value
254
+ return None
255
+
256
+
257
+ # --------------------------------------------------------------------------
258
+ # Transport
259
+ # --------------------------------------------------------------------------
260
+
261
+ def _request(path, user_agent, timeout, opener=None):
262
+ url = "%s/%s" % (BASE, path)
263
+ req = urllib.request.Request(url, headers={"User-Agent": user_agent})
264
+ try:
265
+ open_fn = opener or urllib.request.urlopen
266
+ with open_fn(req, timeout=timeout) as response:
267
+ raw = response.read()
268
+ except urllib.error.HTTPError as err:
269
+ if err.code == 429:
270
+ raise RateLimited("HTTP 429 from %s" % path) from err
271
+ raise FeedUnavailable("HTTP %s from %s" % (err.code, path)) from err
272
+ except Exception as err: # noqa: BLE001 - transport of any kind
273
+ raise FeedUnavailable("%s from %s" % (type(err).__name__, path)) from err
274
+ try:
275
+ return json.loads(raw.decode("utf-8"))
276
+ except Exception as err: # noqa: BLE001
277
+ raise FeedUnavailable("undecodable body from %s" % path) from err
278
+
279
+
280
+ def _flatten(payload):
281
+ """Normalise a trains payload to a list.
282
+
283
+ B8: a bare number or a finished train returns `[]` -- a LIST, not the
284
+ documented keyed object. Branch on TYPE, not on emptiness.
285
+ """
286
+ if isinstance(payload, list):
287
+ return []
288
+ if not isinstance(payload, dict):
289
+ return []
290
+ out = []
291
+ for value in payload.values():
292
+ if isinstance(value, list):
293
+ out.extend(x for x in value if isinstance(x, dict))
294
+ return out
295
+
296
+
297
+ class AmtrakerClient:
298
+ """Fetches the Amtraker feed once and serves every provider from it.
299
+
300
+ B8.1: /v3/trains takes NO parameters and returns every provider, so N
301
+ consumers make N identical system-wide requests unless the FEED is cached.
302
+ Same shape as 05 section B7 and 03_BACKLOG.md item 104. Documentation does
303
+ not fix it; a shared cache does.
304
+
305
+ Measured payload sizes:
306
+ /v3/trains ~1,150,000 bytes whole fleet
307
+ /v3/stale ~6,500 bytes per-train timeSince
308
+ /v3/trains/<id> ~2,500 bytes one train
309
+ """
310
+
311
+ def __init__(self, user_agent=DEFAULT_UA, ttl_s=FEED_TICK_S, timeout=30,
312
+ opener=None, clock=None):
313
+ if not user_agent or not str(user_agent).strip():
314
+ # B8: the server blocks requests with no User-Agent. Fail loudly
315
+ # here rather than puzzling over empty results later.
316
+ raise ValueError("a non-empty User-Agent is required")
317
+ self.user_agent = user_agent
318
+ self.ttl_s = float(ttl_s)
319
+ self.timeout = timeout
320
+ self._opener = opener
321
+ self._clock = clock or (lambda: _dt.datetime.now(_dt.timezone.utc))
322
+ self._cached = None
323
+ self._cached_at = None
324
+ self.fetch_count = 0
325
+ self.serve_count = 0
326
+
327
+ def _fresh(self):
328
+ if self._cached_at is None:
329
+ return False
330
+ return (self._clock() - self._cached_at).total_seconds() < self.ttl_s
331
+
332
+ def fetch_fleet(self, force=False):
333
+ """All trains, cached for ttl_s. Default TTL is the measured 30 s feed
334
+ tick, so polling faster than the feed changes costs nothing upstream."""
335
+ self.serve_count += 1
336
+ if not force and self._fresh():
337
+ return self._cached
338
+ payload = _request("trains", self.user_agent, self.timeout, self._opener)
339
+ self._cached = _flatten(payload)
340
+ self._cached_at = self._clock()
341
+ self.fetch_count += 1
342
+ return self._cached
343
+
344
+ def trains(self, provider=None, force=False):
345
+ """Trains, optionally filtered to one provider.
346
+
347
+ B8.4: the providers do NOT share a data contract -- velocity is real
348
+ for two and always-zero for the third, last_seen has a source for two
349
+ and none for the third. Filtering is where three integrations diverge.
350
+ """
351
+ if provider is not None and provider not in PROVIDERS:
352
+ raise ValueError("unknown provider %r" % (provider,))
353
+ rows = self.fetch_fleet(force=force)
354
+ if provider is None:
355
+ return list(rows)
356
+ return [t for t in rows if t.get("provider") == provider]
357
+
358
+ def stale(self):
359
+ """/v3/stale: per-train timeSince for every active train, ~180x
360
+ cheaper than the fleet feed. Use it to DETECT update events."""
361
+ return _request("stale", self.user_agent, self.timeout, self._opener)
362
+
363
+ def train(self, train_id):
364
+ """One train, ~2,500 bytes. Accepts 'b5756' or 'b5756-6'."""
365
+ return _flatten(
366
+ _request("trains/%s" % train_id, self.user_agent, self.timeout,
367
+ self._opener))