twobells 0.1.0__py3-none-any.whl

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.
Files changed (166) hide show
  1. twobells/__init__.py +1 -0
  2. twobells/__main__.py +8 -0
  3. twobells/board/__init__.py +1 -0
  4. twobells/board/app.py +581 -0
  5. twobells/board/displays/__init__.py +1 -0
  6. twobells/board/displays/display.py +94 -0
  7. twobells/board/displays/screen.py +150 -0
  8. twobells/board/displays/waveshare.py +239 -0
  9. twobells/board/dotfont.py +187 -0
  10. twobells/board/fonts/Inter-ExtraBold.ttf +0 -0
  11. twobells/board/fonts/Inter-Regular.ttf +0 -0
  12. twobells/board/fonts/LICENSE +92 -0
  13. twobells/board/fonts/SHA256SUMS +3 -0
  14. twobells/board/panel.py +84 -0
  15. twobells/board/positions.py +149 -0
  16. twobells/board/render.py +852 -0
  17. twobells/board/route.py +131 -0
  18. twobells/board/scroll.py +137 -0
  19. twobells/board/sign.py +592 -0
  20. twobells/board/state.py +105 -0
  21. twobells/board/themes.py +63 -0
  22. twobells/board/view.py +268 -0
  23. twobells/cli/__init__.py +1 -0
  24. twobells/cli/common.py +94 -0
  25. twobells/cli/dev.py +266 -0
  26. twobells/cli/main.py +196 -0
  27. twobells/cli/run.py +283 -0
  28. twobells/cli/serve.py +439 -0
  29. twobells/cli/setup.py +114 -0
  30. twobells/cli/timetable.py +55 -0
  31. twobells/core/__init__.py +1 -0
  32. twobells/core/budget.py +34 -0
  33. twobells/core/errors.py +29 -0
  34. twobells/core/files.py +80 -0
  35. twobells/core/model.py +164 -0
  36. twobells/core/net.py +265 -0
  37. twobells/core/osgb.py +118 -0
  38. twobells/core/polling.py +80 -0
  39. twobells/core/safexml.py +130 -0
  40. twobells/core/walk.py +43 -0
  41. twobells/core/words.py +54 -0
  42. twobells/dashboard/__init__.py +1 -0
  43. twobells/dashboard/area.py +164 -0
  44. twobells/dashboard/collector.py +621 -0
  45. twobells/dashboard/config.py +389 -0
  46. twobells/dashboard/enrich.py +257 -0
  47. twobells/dashboard/history.py +440 -0
  48. twobells/dashboard/snapshot.py +732 -0
  49. twobells/dashboard_static/alerts.js +136 -0
  50. twobells/dashboard_static/api.js +129 -0
  51. twobells/dashboard_static/app.css +1234 -0
  52. twobells/dashboard_static/area.js +372 -0
  53. twobells/dashboard_static/board.js +507 -0
  54. twobells/dashboard_static/disruptions.js +312 -0
  55. twobells/dashboard_static/dom.js +176 -0
  56. twobells/dashboard_static/extras.js +89 -0
  57. twobells/dashboard_static/fonts/DepartureboardDots.woff2 +0 -0
  58. twobells/dashboard_static/fonts/LICENSE +28 -0
  59. twobells/dashboard_static/fonts/SHA256SUMS +2 -0
  60. twobells/dashboard_static/header.js +76 -0
  61. twobells/dashboard_static/hero.js +203 -0
  62. twobells/dashboard_static/history.js +69 -0
  63. twobells/dashboard_static/icons/apple-touch-icon.png +0 -0
  64. twobells/dashboard_static/icons/icon-192.png +0 -0
  65. twobells/dashboard_static/icons/icon-512.png +0 -0
  66. twobells/dashboard_static/icons/icon.svg +10 -0
  67. twobells/dashboard_static/icons.js +65 -0
  68. twobells/dashboard_static/index.html +180 -0
  69. twobells/dashboard_static/kiosk.js +89 -0
  70. twobells/dashboard_static/live.js +105 -0
  71. twobells/dashboard_static/main.js +173 -0
  72. twobells/dashboard_static/manifest.webmanifest +16 -0
  73. twobells/dashboard_static/map.js +204 -0
  74. twobells/dashboard_static/sheet.js +129 -0
  75. twobells/dashboard_static/strip.js +105 -0
  76. twobells/dashboard_static/theme.js +16 -0
  77. twobells/dashboard_static/time.js +76 -0
  78. twobells/dashboard_static/train.js +153 -0
  79. twobells/dashboard_static/vendor/inter-4.1/InterVariable.woff2 +0 -0
  80. twobells/dashboard_static/vendor/inter-4.1/LICENSE +92 -0
  81. twobells/dashboard_static/vendor/inter-4.1/SHA256SUMS +2 -0
  82. twobells/dashboard_static/vendor/leaflet-1.9.4/LICENSE +26 -0
  83. twobells/dashboard_static/vendor/leaflet-1.9.4/SHA256SUMS +3 -0
  84. twobells/dashboard_static/vendor/leaflet-1.9.4/leaflet-src.esm.js +14419 -0
  85. twobells/dashboard_static/vendor/leaflet-1.9.4/leaflet.css +661 -0
  86. twobells/dashboard_static/weather.js +181 -0
  87. twobells/data/gb_stations.csv +2644 -0
  88. twobells/data/naptan_area_grid.csv +3248 -0
  89. twobells/emulator/__init__.py +1 -0
  90. twobells/emulator/emulation.py +156 -0
  91. twobells/emulator/emulator.html +392 -0
  92. twobells/emulator/fake.py +313 -0
  93. twobells/emulator/server.py +175 -0
  94. twobells/fetch/__init__.py +1 -0
  95. twobells/fetch/handoff.py +352 -0
  96. twobells/fetch/service.py +209 -0
  97. twobells/settings/__init__.py +1 -0
  98. twobells/settings/config.py +617 -0
  99. twobells/settings/installs.py +99 -0
  100. twobells/settings/keyresults.py +135 -0
  101. twobells/settings/overlay.py +283 -0
  102. twobells/settings/schema.py +661 -0
  103. twobells/settings/store.py +312 -0
  104. twobells/setup/__init__.py +1 -0
  105. twobells/setup/api.py +1037 -0
  106. twobells/setup/discover.py +696 -0
  107. twobells/setup/keytest.py +420 -0
  108. twobells/setup/lookups.py +665 -0
  109. twobells/setup/spool/__init__.py +83 -0
  110. twobells/setup/spool/checks.py +346 -0
  111. twobells/setup/spool/folder.py +143 -0
  112. twobells/setup/spool/runner.py +256 -0
  113. twobells/setup/spool/worker.py +393 -0
  114. twobells/sources/__init__.py +1 -0
  115. twobells/sources/bods.py +167 -0
  116. twobells/sources/bodsbus.py +262 -0
  117. twobells/sources/darwin.py +761 -0
  118. twobells/sources/disruptions.py +701 -0
  119. twobells/sources/holidays.py +280 -0
  120. twobells/sources/livebus.py +422 -0
  121. twobells/sources/transportapi.py +169 -0
  122. twobells/sources/weather.py +367 -0
  123. twobells/timetable/__init__.py +1 -0
  124. twobells/timetable/build.py +1491 -0
  125. twobells/timetable/model.py +432 -0
  126. twobells/timetable/roads.py +276 -0
  127. twobells/timetable/routesetup.py +417 -0
  128. twobells/version.py +50 -0
  129. twobells/web/__init__.py +1 -0
  130. twobells/web/auth.py +919 -0
  131. twobells/web/formpages.py +606 -0
  132. twobells/web/groupform.py +769 -0
  133. twobells/web/icons.py +53 -0
  134. twobells/web/keyspage.py +375 -0
  135. twobells/web/keystate.py +147 -0
  136. twobells/web/netguard.py +122 -0
  137. twobells/web/overview.py +555 -0
  138. twobells/web/places.py +76 -0
  139. twobells/web/server.py +532 -0
  140. twobells/web/settings.js +180 -0
  141. twobells/web/settingsite.py +616 -0
  142. twobells/web/status.py +69 -0
  143. twobells/web/web.css +384 -0
  144. twobells/web/windowrows.py +252 -0
  145. twobells/wizard_static/api.js +62 -0
  146. twobells/wizard_static/finish.js +197 -0
  147. twobells/wizard_static/home.js +120 -0
  148. twobells/wizard_static/index.html +241 -0
  149. twobells/wizard_static/keys.js +151 -0
  150. twobells/wizard_static/maps.js +105 -0
  151. twobells/wizard_static/roads.js +99 -0
  152. twobells/wizard_static/say.js +50 -0
  153. twobells/wizard_static/state.js +51 -0
  154. twobells/wizard_static/station.js +145 -0
  155. twobells/wizard_static/steps.js +80 -0
  156. twobells/wizard_static/stops.js +165 -0
  157. twobells/wizard_static/views.js +43 -0
  158. twobells/wizard_static/walk.js +121 -0
  159. twobells/wizard_static/wizard.css +335 -0
  160. twobells/wizard_static/wizard.js +144 -0
  161. twobells-0.1.0.dist-info/METADATA +228 -0
  162. twobells-0.1.0.dist-info/RECORD +166 -0
  163. twobells-0.1.0.dist-info/WHEEL +5 -0
  164. twobells-0.1.0.dist-info/entry_points.txt +2 -0
  165. twobells-0.1.0.dist-info/licenses/LICENSE +21 -0
  166. twobells-0.1.0.dist-info/top_level.txt +1 -0
twobells/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Train and bus departures for GB, on an e-paper or HDMI board and a web dashboard."""
twobells/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m twobells`: the command line, as `twobells`."""
2
+
3
+ import sys
4
+
5
+ from .cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """The board view: what it shows and how each look draws it."""
twobells/board/app.py ADDED
@@ -0,0 +1,581 @@
1
+ """One tick of the board: fetch whatever is due, redraw only if something changed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Protocol, cast
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from PIL.Image import Image
17
+
18
+ from ..core.budget import DailyBudget
19
+ from ..core.errors import safe_message
20
+ from ..core.model import Departure, minutes_between
21
+ from ..core.polling import PollPolicy
22
+ from ..core.walk import WalkTimes, leave_time
23
+ from ..sources.bods import Vehicle
24
+ from ..sources.weather import Hour, hours_from_dicts, hours_to_dicts, rain_line
25
+ from ..web.auth import ResetCodes, spaced
26
+ from . import positions as row_positions
27
+ from .panel import Panel
28
+ from .positions import Positions
29
+ from .render import Palette
30
+ from .route import RouteStrip, RouteView, locate, target, where
31
+ from .scroll import Frame
32
+ from .state import (
33
+ MEMORY_ONLY,
34
+ departure_to_dict,
35
+ departures_from_dicts,
36
+ from_iso,
37
+ to_iso,
38
+ vehicle_to_dict,
39
+ vehicles_from_dicts,
40
+ )
41
+ from .themes import THEMES, Theme
42
+ from .view import BoardView, CodeView, SetupView, build_view, stop_names_shown
43
+
44
+ log = logging.getLogger(__name__)
45
+
46
+
47
+ class DepartureSource(Protocol):
48
+ name: str
49
+
50
+ def fetch(self) -> list[Departure]: ...
51
+
52
+
53
+ class Display(Protocol):
54
+ palette: Palette
55
+ panel: Panel
56
+
57
+ def show(self, image: Image) -> None: ...
58
+
59
+
60
+ class ScrollingDisplay(Display, Protocol):
61
+ """A screen that can scroll text between redraws: it takes a Frame, and
62
+ `scroll` moves its scrolling fields to a time on the board's clock."""
63
+
64
+ @property
65
+ def scrolling(self) -> bool: ...
66
+
67
+ def show_frame(self, frame: Frame, seconds: float) -> None: ...
68
+
69
+ def scroll(self, seconds: float) -> None: ...
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class SourceConfig:
74
+ source: DepartureSource
75
+ policy: PollPolicy
76
+ stale_after: timedelta
77
+ daily_budget: int | None = None
78
+ # While the source says it is `falling_back` to a costlier one (the bus
79
+ # timetable to TransportAPI), it is polled at this pace instead.
80
+ fallback_policy: PollPolicy | None = None
81
+
82
+ def current_policy(self) -> PollPolicy:
83
+ if self.fallback_policy is not None and getattr(self.source, "falling_back", False):
84
+ return self.fallback_policy
85
+ return self.policy
86
+
87
+
88
+ class VehicleSource(Protocol):
89
+ def fetch(self) -> list[Vehicle]: ...
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Tracker:
94
+ """Live bus positions for the route strip page, on screens only."""
95
+
96
+ source: VehicleSource
97
+ routes: dict[str, RouteStrip] # by line
98
+ policy: PollPolicy
99
+ stale_after: timedelta = timedelta(minutes=2)
100
+
101
+
102
+ class WeatherSource(Protocol):
103
+ def fetch(self) -> list[Hour]: ...
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Weather:
108
+ """The rain warning: an hourly forecast, polled while any source's window is open."""
109
+
110
+ source: WeatherSource
111
+ policy: PollPolicy
112
+ threshold: int = 50 # per cent chance of rain worth a warning
113
+ # A forecast this old is no longer shown; it should be renewed every 30 minutes.
114
+ max_age: timedelta = timedelta(hours=3)
115
+
116
+
117
+ # Without walk times, how far ahead the rain warning looks.
118
+ _RAIN_LOOKAHEAD = timedelta(minutes=60)
119
+
120
+
121
+ # A screen with a route page shows departures for 15 s, then the bus for 10 s.
122
+ _PAGE_CYCLE_S = 25
123
+ _DEPARTURES_S = 15
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Board:
128
+ title: str
129
+ sources: list[SourceConfig]
130
+ # E-paper refreshes are slow and wear the panel: never redraw more often than this.
131
+ # A screen has no such limit.
132
+ min_redraw: timedelta = timedelta(minutes=2)
133
+ walk: WalkTimes = field(default_factory=WalkTimes)
134
+ theme: Theme = THEMES["plain"]
135
+ tracker: Tracker | None = None
136
+ weather: Weather | None = None
137
+ # The settings site's reset code, while one is waiting to be read off the board.
138
+ reset_code: Callable[[], CodeView | None] | None = None
139
+ # Until the board is set up: where to do it, shown instead of departures
140
+ # (a waiting reset code still comes first, for claiming the settings site).
141
+ setup: SetupView | None = None
142
+ # Where each shown train and bus is, for a screen's rows; e-paper never asks.
143
+ positions: Positions | None = None
144
+
145
+
146
+ def reset_code_page(folder: Path, timezone: str) -> Callable[[], CodeView | None]:
147
+ """Asks the settings site's handover in `folder` for a code to show,
148
+ giving its expiry in the board's own time zone."""
149
+ codes, zone = ResetCodes(folder), ZoneInfo(timezone)
150
+
151
+ def page() -> CodeView | None:
152
+ pending = codes.pending(time.time())
153
+ if pending is None:
154
+ return None
155
+ until = datetime.fromtimestamp(pending.expires, zone)
156
+ return CodeView(spaced(pending.code), until.strftime("%H:%M"), pending.expires)
157
+
158
+ return page
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class RunResult:
163
+ view: BoardView
164
+ rendered: bool
165
+ route: RouteView | None = None
166
+ page: str = "departures"
167
+
168
+
169
+ def _poll(config: SourceConfig, state: dict, now: datetime, prime: bool = False) -> None:
170
+ """Fetch if due. `prime` fetches regardless of windows; the budget still applies."""
171
+ name = config.source.name
172
+ entry = state.setdefault("sources", {}).setdefault(name, {})
173
+ if not prime and not config.current_policy().is_due(now, from_iso(entry.get("last_attempt"))):
174
+ # Not checked because the fallback is waiting for its own windows: say why.
175
+ if config.fallback_policy is not None and getattr(config.source, "falling_back", False):
176
+ idle = getattr(config.source, "idle_note", None)
177
+ note = idle() if idle else None
178
+ if note:
179
+ entry["note"] = safe_message(note)
180
+ return
181
+
182
+ if config.daily_budget is not None:
183
+ budgets = state.setdefault("budgets", {})
184
+ budget = DailyBudget.from_dict(budgets.get(name), config.daily_budget)
185
+ if not budget.try_spend(now.date(), getattr(config.source, "cost", 1)):
186
+ log.warning("%s: daily budget of %d spent, skipping", name, config.daily_budget)
187
+ return
188
+ budgets[name] = budget.to_dict()
189
+
190
+ entry["last_attempt"] = to_iso(now)
191
+ try:
192
+ departures = config.source.fetch()
193
+ except Exception as error: # noqa: BLE001 - keep showing last good data on any failure
194
+ # The settings site shows the last error; neither it nor the log may carry a key.
195
+ message = safe_message(error)
196
+ log.warning("%s: fetch failed: %s", name, message)
197
+ entry["last_error"] = {"at": to_iso(now), "message": message}
198
+ return
199
+ entry["departures"] = [departure_to_dict(d) for d in departures]
200
+ entry["last_success"] = to_iso(now)
201
+ entry.pop("last_error", None)
202
+ # A source that got there the long way round (the bus timetable's fallback)
203
+ # says so, for the settings site.
204
+ note = getattr(config.source, "note", None)
205
+ if note:
206
+ entry["note"] = safe_message(note)
207
+ else:
208
+ entry.pop("note", None)
209
+
210
+
211
+ def _fresh(config: SourceConfig, fetched: datetime | None, now: datetime) -> bool:
212
+ """Whether data from `fetched` is recent enough to trust. Data from after
213
+ now (the clock went back, as when summer time ends) can't say how old it
214
+ is, so it doesn't count (PollPolicy.is_due fetches again then, too)."""
215
+ return fetched is not None and fetched <= now and now - fetched <= config.stale_after
216
+
217
+
218
+ def _is_stale(config: SourceConfig, entry: dict, now: datetime) -> bool:
219
+ if not config.policy.is_active(now):
220
+ return False
221
+ return not _fresh(config, from_iso(entry.get("last_success")), now)
222
+
223
+
224
+ def _departures(state: dict, name: str, now: datetime) -> list[Departure]:
225
+ """What was last fetched, less anything that has left since. Clock times
226
+ alone can't tell this morning from tonight, so each is dated by its fetch."""
227
+ entry = state.get("sources", {}).get(name, {})
228
+ fetched = from_iso(entry.get("last_success"))
229
+ if fetched is None:
230
+ return []
231
+ departures = departures_from_dicts(entry.get("departures", []))
232
+ return [d for d in departures if not d.has_left(now, fetched)]
233
+
234
+
235
+ def _keep_messages(config: SourceConfig, state: dict, before: str | None) -> None:
236
+ """A source with messages (Darwin's disruption notices) has them saved beside
237
+ the departures from the same fetch, once that fetch has succeeded."""
238
+ messages = getattr(config.source, "messages", None)
239
+ entry = state.get("sources", {}).get(config.source.name, {})
240
+ if messages is None or entry.get("last_success") in (None, before):
241
+ return
242
+ entry["messages"] = [m for m in messages if isinstance(m, str)]
243
+
244
+
245
+ def _notices(board: Board, state: dict, now: datetime) -> list[str]:
246
+ """Messages from fetches that are still fresh. They age out with the feed:
247
+ once its data is older than stale_after, a notice may no longer be true,
248
+ and a morning's disruption must not still be showing in the evening."""
249
+ notices = []
250
+ for config in board.sources:
251
+ entry = state.get("sources", {}).get(config.source.name, {})
252
+ if not _fresh(config, from_iso(entry.get("last_success")), now):
253
+ continue
254
+ messages = entry.get("messages")
255
+ if isinstance(messages, list):
256
+ notices.extend(m for m in messages if isinstance(m, str) and m)
257
+ return notices
258
+
259
+
260
+ def _poll_weather(weather: Weather, state: dict, now: datetime) -> None:
261
+ entry = state.get("weather")
262
+ if not isinstance(entry, dict):
263
+ entry = state["weather"] = {}
264
+ if not weather.policy.is_due(now, from_iso(entry.get("last_attempt"))):
265
+ return
266
+ entry["last_attempt"] = to_iso(now)
267
+ try:
268
+ hours = weather.source.fetch()
269
+ except Exception as error: # noqa: BLE001 - keep the last forecast until it is too old
270
+ log.warning("weather: fetch failed: %s", error)
271
+ return
272
+ entry["hours"] = hours_to_dicts(hours)
273
+ entry["last_success"] = to_iso(now)
274
+
275
+
276
+ def _rain_until(board: Board, departures: list[Departure], now: datetime) -> datetime:
277
+ """When you would reach the next train or bus you can still catch: its
278
+ leave time plus the walk, which is when it goes. Without walk times, or
279
+ with nothing to catch, the next hour."""
280
+ hhmm = now.strftime("%H:%M")
281
+ reachable = []
282
+ for dep in departures:
283
+ leave = leave_time(dep, board.walk)
284
+ if leave is not None and minutes_between(hhmm, leave) >= 0:
285
+ reachable.append((minutes_between(hhmm, leave), dep))
286
+ if not reachable:
287
+ return now + _RAIN_LOOKAHEAD
288
+ _, dep = min(reachable, key=lambda pair: pair[0])
289
+ return now.replace(second=0, microsecond=0) + timedelta(minutes=minutes_between(hhmm, dep.departs_at))
290
+
291
+
292
+ def _rain(board: Board, state: dict, departures: list[Departure], now: datetime, live: bool) -> str | None:
293
+ weather = board.weather
294
+ entry = state.get("weather")
295
+ if weather is None or not isinstance(entry, dict):
296
+ return None
297
+ fetched = from_iso(entry.get("last_success"))
298
+ if fetched is None or now - fetched > weather.max_age:
299
+ return None
300
+ upcoming = [d for d in departures if not d.is_gone(now.strftime("%H:%M"))]
301
+ until = _rain_until(board, upcoming, now)
302
+ return rain_line(hours_from_dicts(entry.get("hours")), now, until, weather.threshold, live)
303
+
304
+
305
+ def _poll_vehicles(tracker: Tracker, state: dict, now: datetime) -> None:
306
+ entry = state.setdefault("vehicles", {})
307
+ if not tracker.policy.is_due(now, from_iso(entry.get("last_attempt"))):
308
+ return
309
+ entry["last_attempt"] = to_iso(now)
310
+ try:
311
+ vehicles = tracker.source.fetch()
312
+ except Exception as error: # noqa: BLE001 - the page says positions have stopped
313
+ message = safe_message(error)
314
+ log.warning("vehicles: fetch failed: %s", message)
315
+ entry["last_error"] = {"at": to_iso(now), "message": message}
316
+ return
317
+ entry["items"] = [vehicle_to_dict(v) for v in vehicles]
318
+ entry["last_success"] = to_iso(now)
319
+ entry.pop("last_error", None)
320
+
321
+
322
+ def _route_view(board: Board, state: dict, view: BoardView, now: datetime, clock: str | None) -> RouteView | None:
323
+ """The page for the next bus you can catch that has a route strip, if any."""
324
+ tracker = board.tracker
325
+ if tracker is None or not tracker.policy.is_active(now):
326
+ return None
327
+ hhmm = now.strftime("%H:%M")
328
+ upcoming = [d for d in _departures(state, "bus", now) if not d.is_gone(hhmm)]
329
+
330
+ def catchable(dep: Departure) -> bool:
331
+ strip = tracker.routes.get(dep.service)
332
+ if strip is None or strip.stop != dep.stop:
333
+ return False
334
+ leave = leave_time(dep, board.walk)
335
+ return leave is None or minutes_between(hhmm, leave) >= 0
336
+
337
+ chosen = next((d for d in upcoming if catchable(d)), None)
338
+ if chosen is None:
339
+ return None
340
+ row = view.buses.rows[upcoming.index(chosen)]
341
+ strip = tracker.routes[chosen.service]
342
+ entry = state.get("vehicles", {})
343
+ last_success = from_iso(entry.get("last_success"))
344
+ stale = last_success is None or now - last_success > tracker.stale_after
345
+ vehicles = [] if stale else vehicles_from_dicts(entry.get("items", []))
346
+ bus_at = None if stale else locate(strip, vehicles, target(upcoming, chosen))
347
+ names = tuple(s.name for s in strip.stops)
348
+ return RouteView(
349
+ title=f"{chosen.service} to {chosen.destination}",
350
+ due=row.right,
351
+ leave=row.leave,
352
+ stops=names,
353
+ bus_at=bus_at,
354
+ where=where(names, bus_at, stale),
355
+ clock=clock,
356
+ )
357
+
358
+
359
+ def _stop_names(board: Board) -> dict[str, str]:
360
+ """What a screen's bus rows call your stops, by label, from the bus
361
+ source's timetable (sources/bodsbus.py); none when it has none to give,
362
+ and the rows show their labels."""
363
+ for config in board.sources:
364
+ names = getattr(config.source, "stop_names", None)
365
+ if names is None:
366
+ continue
367
+ try:
368
+ return stop_names_shown(names())
369
+ except Exception as error: # noqa: BLE001 - a name is never worth the board
370
+ log.debug("bus stop names not shown: %s", safe_message(error))
371
+ return {}
372
+ return {}
373
+
374
+
375
+ def _render_key(shown: BoardView | RouteView | CodeView | SetupView, theme: Theme, display: Display) -> str:
376
+ """What is on the panel: the words, and how they are drawn. A new look, accent
377
+ or rotation has to reach e-paper even when the words have not changed."""
378
+ # The display classes keep their rotation private; a public `rotate` wins if there is one.
379
+ rotate = getattr(display, "rotate", getattr(display, "_rotate", 0))
380
+ parts = [shown.signature(), theme.name, str(theme.text_size), repr(display.panel), repr(display.palette), rotate]
381
+ return hashlib.sha256(json.dumps(parts, default=str).encode()).hexdigest()
382
+
383
+
384
+ def run_once(
385
+ board: Board, state: dict, display: Display, now: datetime, prime: bool = False
386
+ ) -> RunResult:
387
+ # A screen can redraw every second, so it gets a running clock and countdowns.
388
+ live = not display.panel.epaper
389
+ fetched = set()
390
+ for config in board.sources:
391
+ before = state.get("sources", {}).get(config.source.name, {}).get("last_success")
392
+ _poll(config, state, now, prime)
393
+ _keep_messages(config, state, before)
394
+ if state.get("sources", {}).get(config.source.name, {}).get("last_success") != before:
395
+ fetched.add(config.source.name)
396
+ if live and board.tracker is not None:
397
+ _poll_vehicles(board.tracker, state, now)
398
+ strips = None
399
+ if live and board.positions is not None:
400
+ row_positions.poll(board.positions, board.sources, state, now, fetched)
401
+ strips = row_positions.strips(board.positions, board.sources, state, now)
402
+ if board.weather is not None:
403
+ _poll_weather(board.weather, state, now)
404
+ entries = state.get("sources", {})
405
+ stale = {
406
+ c.source.name for c in board.sources if _is_stale(c, entries.get(c.source.name, {}), now)
407
+ }
408
+ trains, buses = _departures(state, "train", now), _departures(state, "bus", now)
409
+ view = build_view(
410
+ title=board.title,
411
+ trains=trains,
412
+ buses=buses,
413
+ now=now.strftime("%H:%M"),
414
+ stale=stale,
415
+ walk=board.walk,
416
+ wording=board.theme.wording,
417
+ clock=now.strftime("%H:%M:%S") if live else None,
418
+ notices=_notices(board, state, now),
419
+ rain=_rain(board, state, trains + buses, now, live),
420
+ strips=strips,
421
+ # Only a screen wide enough draws them; e-paper never asks.
422
+ stop_names=_stop_names(board) if live else None,
423
+ )
424
+
425
+ code = _reset_code(board)
426
+ departures_page = code is None and board.setup is None
427
+ route = _route_view(board, state, view, now, view.clock) if live and departures_page else None
428
+ seconds = now.minute * 60 + now.second
429
+ if code is not None:
430
+ page, shown = "code", code
431
+ elif board.setup is not None:
432
+ page, shown = "setup", board.setup
433
+ elif route and seconds % _PAGE_CYCLE_S >= _DEPARTURES_S:
434
+ page, shown = "route", route
435
+ else:
436
+ page, shown = "departures", view
437
+
438
+ last = state.get("last_render", {})
439
+ last_at = from_iso(last.get("at"))
440
+ key = _render_key(shown, board.theme, display)
441
+ changed = key != last.get("signature")
442
+ # Someone is waiting at the settings site for the code, or has just used
443
+ # it, or has just set the board up: on and off the panel straight away,
444
+ # whatever min_redraw says.
445
+ urgent = page == "code" or last.get("page") in ("code", "setup")
446
+ # A last redraw in the future means the clock went back (summer time ending): not too soon.
447
+ too_soon = (not live and not urgent and last_at is not None
448
+ and timedelta(0) <= now - last_at < board.min_redraw)
449
+
450
+ if changed and not too_soon:
451
+ _show(board.theme, page, shown, display, now, live)
452
+ state["last_render"] = {"signature": key, "at": to_iso(now), "page": page}
453
+ return RunResult(view, rendered=True, route=route, page=page)
454
+ return RunResult(view, rendered=False, route=route, page=page)
455
+
456
+
457
+ def board_seconds(now: datetime) -> int:
458
+ """The board's clock as seconds into the day: what scrolling text is timed by."""
459
+ return now.hour * 3600 + now.minute * 60 + now.second
460
+
461
+
462
+ def _show(theme: Theme, page: str, shown, display: Display, now: datetime, live: bool) -> None:
463
+ """Draw `shown` on the display. A screen's departures and route pages
464
+ come as a Frame, with any text too long for its space scrolling, for a
465
+ display that can scroll it; any other display gets the still image."""
466
+ if live and page in ("departures", "route"):
467
+ draw_frame = theme.frame if page == "departures" else theme.route_frame
468
+ frame = draw_frame(shown, display.palette, display.panel)
469
+ else:
470
+ draw = {"code": theme.render_code, "route": theme.render_route,
471
+ "setup": theme.render_setup}.get(page, theme.render)
472
+ frame = Frame(draw(shown, display.palette, display.panel))
473
+ show_frame = getattr(display, "show_frame", None)
474
+ if show_frame is not None:
475
+ show_frame(frame, board_seconds(now))
476
+ else:
477
+ display.show(frame.image)
478
+
479
+
480
+ def _reset_code(board: Board) -> CodeView | None:
481
+ """The reset code page, if a code is waiting. A fault reading it must not
482
+ stop the departures."""
483
+ if board.reset_code is None:
484
+ return None
485
+ try:
486
+ return board.reset_code()
487
+ except Exception as error: # noqa: BLE001 - the board goes on without the code
488
+ log.warning("Can't check for a settings reset code: %s", type(error).__name__)
489
+ return None
490
+
491
+
492
+ _SAVE_EVERY = timedelta(minutes=5)
493
+
494
+
495
+ def _fetched(state: dict) -> str:
496
+ """The part of the state worth keeping: not the last redraw, nor bus positions
497
+ or row strips, which change every poll and are useless a few minutes later
498
+ (row strips are never saved at all: see StateStore.save)."""
499
+ kept = {k: v for k, v in state.items() if k not in ("last_render", "vehicles", MEMORY_ONLY)}
500
+ return json.dumps(kept, sort_keys=True)
501
+
502
+
503
+ def run_loop(
504
+ board: Board,
505
+ state: dict,
506
+ display: Display,
507
+ clock,
508
+ tick_seconds: float,
509
+ ticks: int | None = None,
510
+ sleep=None,
511
+ on_tick=None,
512
+ store=None,
513
+ prime: bool = False,
514
+ reload: Callable[[], Board | None] | None = None,
515
+ frame_seconds: float | None = None,
516
+ wall: Callable[[], float] = time.time,
517
+ ) -> None:
518
+ """Tick forever (or `ticks` times). A clock with `advance()` is simulated.
519
+ With `prime`, the first tick fetches every source so the board starts filled.
520
+ State is written only when what was fetched changes: a screen redraws every
521
+ second, and writing the SD card that often would wear it out. `reload`, if
522
+ given, is asked each tick for a new Board when the settings have changed.
523
+
524
+ With `frame_seconds`, a display with text scrolling gets a frame that
525
+ often between ticks (see _frames); with nothing scrolling it waits for
526
+ the next tick, as without. Fetching, stale checks and saving keep to
527
+ the tick either way."""
528
+ sleep = sleep or time.sleep
529
+ count = 0
530
+ saved, saved_at = None, None
531
+ while ticks is None or count < ticks:
532
+ if reload is not None:
533
+ board = reload() or board
534
+ now = clock()
535
+ try:
536
+ result = run_once(board, state, display, now, prime=prime and count == 0)
537
+ except Exception:
538
+ # Keep what was fetched, so a display fault doesn't cost a refetch on restart.
539
+ if store is not None:
540
+ store.save(state)
541
+ raise
542
+ if store is not None:
543
+ fetched = _fetched(state)
544
+ # Also every few minutes, so the settings site's status (last redraw,
545
+ # bus positions) doesn't go stale while nothing new is fetched.
546
+ overdue = saved_at is None or not timedelta(0) <= now - saved_at < _SAVE_EVERY
547
+ if fetched != saved or overdue:
548
+ store.save(state)
549
+ saved, saved_at = fetched, now
550
+ if on_tick is not None:
551
+ on_tick(now, result)
552
+ if frame_seconds and getattr(display, "scrolling", False):
553
+ _frames(cast(ScrollingDisplay, display), board_seconds(now), tick_seconds, frame_seconds, sleep, wall)
554
+ else:
555
+ sleep(tick_seconds)
556
+ if hasattr(clock, "advance"):
557
+ clock.advance()
558
+ count += 1
559
+
560
+
561
+ def _frames(display: ScrollingDisplay, base: int, tick_seconds: float, frame_seconds: float, sleep,
562
+ wall: Callable[[], float]) -> None:
563
+ """Until the next tick, a frame every `frame_seconds`, each moving the
564
+ scrolling text to the board's clock: the tick's time (`base`) plus which
565
+ frame of the tick it is. The wall clock only says which frame is due, so
566
+ a slow frame is skipped rather than drawn late, and the next tick (the
567
+ running clock) is never put off. `sleep` lines up with whole multiples
568
+ of what it is given, as the screen loop's does."""
569
+ per_tick = round(tick_seconds / frame_seconds)
570
+ if per_tick < 2:
571
+ sleep(tick_seconds)
572
+ return
573
+ display.scroll(base) # the tick's own frame, if the board wasn't redrawn
574
+ last = 0
575
+ while True:
576
+ sleep(frame_seconds)
577
+ due = round(wall() % tick_seconds / frame_seconds) % per_tick
578
+ if due <= last:
579
+ return # the next tick is due
580
+ display.scroll(base + due * frame_seconds)
581
+ last = due
@@ -0,0 +1 @@
1
+ """The displays a board draws on: Inky and Waveshare e-paper, and an HDMI screen."""
@@ -0,0 +1,94 @@
1
+ """Output targets: a Pimoroni Inky, a Waveshare e-paper panel, or a PNG for development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PIL.Image import Image
8
+
9
+ from ..panel import HDMI_1280X400, HDMI_1920X440, PHAT, PHAT_V1, WAVESHARE_10IN85G, Panel
10
+ from ..render import Palette
11
+
12
+ # What each `display` setting draws on. An Inky reports its own size once opened.
13
+ DISPLAY_PANELS = {
14
+ "inky": PHAT,
15
+ "inky-phat-v1": PHAT_V1,
16
+ "waveshare-10in85g": WAVESHARE_10IN85G,
17
+ "hdmi-1280x400": HDMI_1280X400,
18
+ "hdmi-1920x440": HDMI_1920X440,
19
+ }
20
+
21
+
22
+ def _detect():
23
+ from inky.auto import auto # pyright: ignore[reportMissingImports] # imported lazily: only present on the Pi
24
+
25
+ return auto()
26
+
27
+
28
+ def _original_phat(colour: str):
29
+ """The original pHAT can't be detected; the library is told its colour."""
30
+ from inky import InkyPHAT # pyright: ignore[reportMissingImports] # imported lazily: only present on the Pi
31
+
32
+ return InkyPHAT(colour if colour in ("red", "yellow") else "black")
33
+
34
+
35
+ class InkyDisplay:
36
+ def __init__(self, accent: str = "red", device=None, rotate_180: bool = False, detect=_detect) -> None:
37
+ if device is None:
38
+ try:
39
+ device = detect()
40
+ except RuntimeError as error:
41
+ raise RuntimeError(
42
+ f"{error} An original Inky pHAT (before 2019) has no EEPROM: "
43
+ 'set display = "inky-phat-v1" in config.toml.'
44
+ ) from error
45
+ self._device = device
46
+ self._rotate = rotate_180
47
+ # The pHAT, or any other Inky the library detects, at its own size.
48
+ width, height = getattr(device, "resolution", PHAT.size)
49
+ known = {panel.size: panel for panel in (PHAT, PHAT_V1)}
50
+ if (width, height) in known:
51
+ self.panel = known[(width, height)]
52
+ else:
53
+ self.panel = Panel.sized(f"inky-{width}x{height}", width, height)
54
+ black = device.BLACK
55
+ self.palette = Palette(
56
+ white=device.WHITE,
57
+ black=black,
58
+ # A mono panel has no accent; text still carries the meaning.
59
+ accent=getattr(device, accent.upper(), black),
60
+ )
61
+
62
+ def show(self, image: Image) -> None:
63
+ if self._rotate:
64
+ image = image.rotate(180)
65
+ self._device.set_image(image)
66
+ self._device.show()
67
+
68
+
69
+ def open_display(name: str, accent: str, rotate: int = 0, panel_colour: str | None = None):
70
+ """The display a `display` setting names. Hardware libraries load only when used.
71
+ E-paper panels turn 0 or 180 degrees; a screen turns any quarter."""
72
+ if name == "inky":
73
+ return InkyDisplay(accent, rotate_180=rotate == 180)
74
+ if name == "inky-phat-v1":
75
+ return InkyDisplay(accent, device=_original_phat(panel_colour or accent), rotate_180=rotate == 180)
76
+ if name == "waveshare-10in85g":
77
+ from .waveshare import WaveshareDisplay
78
+
79
+ return WaveshareDisplay(accent, rotate == 180)
80
+ if name in DISPLAY_PANELS and not DISPLAY_PANELS[name].epaper:
81
+ from .screen import ScreenDisplay
82
+
83
+ return ScreenDisplay(accent, rotate, panel=DISPLAY_PANELS[name])
84
+ raise ValueError(f"Unknown display {name!r}; choose from {', '.join(sorted(DISPLAY_PANELS))}")
85
+
86
+
87
+ class PngDisplay:
88
+ def __init__(self, path: Path | str, accent: str = "red", panel: Panel = PHAT) -> None:
89
+ self._path = Path(path)
90
+ self.panel = panel
91
+ self.palette = Palette(white=0, black=1, accent=2).with_preview(accent)
92
+
93
+ def show(self, image: Image) -> None:
94
+ image.convert("RGB").save(self._path)