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.
- twobells/__init__.py +1 -0
- twobells/__main__.py +8 -0
- twobells/board/__init__.py +1 -0
- twobells/board/app.py +581 -0
- twobells/board/displays/__init__.py +1 -0
- twobells/board/displays/display.py +94 -0
- twobells/board/displays/screen.py +150 -0
- twobells/board/displays/waveshare.py +239 -0
- twobells/board/dotfont.py +187 -0
- twobells/board/fonts/Inter-ExtraBold.ttf +0 -0
- twobells/board/fonts/Inter-Regular.ttf +0 -0
- twobells/board/fonts/LICENSE +92 -0
- twobells/board/fonts/SHA256SUMS +3 -0
- twobells/board/panel.py +84 -0
- twobells/board/positions.py +149 -0
- twobells/board/render.py +852 -0
- twobells/board/route.py +131 -0
- twobells/board/scroll.py +137 -0
- twobells/board/sign.py +592 -0
- twobells/board/state.py +105 -0
- twobells/board/themes.py +63 -0
- twobells/board/view.py +268 -0
- twobells/cli/__init__.py +1 -0
- twobells/cli/common.py +94 -0
- twobells/cli/dev.py +266 -0
- twobells/cli/main.py +196 -0
- twobells/cli/run.py +283 -0
- twobells/cli/serve.py +439 -0
- twobells/cli/setup.py +114 -0
- twobells/cli/timetable.py +55 -0
- twobells/core/__init__.py +1 -0
- twobells/core/budget.py +34 -0
- twobells/core/errors.py +29 -0
- twobells/core/files.py +80 -0
- twobells/core/model.py +164 -0
- twobells/core/net.py +265 -0
- twobells/core/osgb.py +118 -0
- twobells/core/polling.py +80 -0
- twobells/core/safexml.py +130 -0
- twobells/core/walk.py +43 -0
- twobells/core/words.py +54 -0
- twobells/dashboard/__init__.py +1 -0
- twobells/dashboard/area.py +164 -0
- twobells/dashboard/collector.py +621 -0
- twobells/dashboard/config.py +389 -0
- twobells/dashboard/enrich.py +257 -0
- twobells/dashboard/history.py +440 -0
- twobells/dashboard/snapshot.py +732 -0
- twobells/dashboard_static/alerts.js +136 -0
- twobells/dashboard_static/api.js +129 -0
- twobells/dashboard_static/app.css +1234 -0
- twobells/dashboard_static/area.js +372 -0
- twobells/dashboard_static/board.js +507 -0
- twobells/dashboard_static/disruptions.js +312 -0
- twobells/dashboard_static/dom.js +176 -0
- twobells/dashboard_static/extras.js +89 -0
- twobells/dashboard_static/fonts/DepartureboardDots.woff2 +0 -0
- twobells/dashboard_static/fonts/LICENSE +28 -0
- twobells/dashboard_static/fonts/SHA256SUMS +2 -0
- twobells/dashboard_static/header.js +76 -0
- twobells/dashboard_static/hero.js +203 -0
- twobells/dashboard_static/history.js +69 -0
- twobells/dashboard_static/icons/apple-touch-icon.png +0 -0
- twobells/dashboard_static/icons/icon-192.png +0 -0
- twobells/dashboard_static/icons/icon-512.png +0 -0
- twobells/dashboard_static/icons/icon.svg +10 -0
- twobells/dashboard_static/icons.js +65 -0
- twobells/dashboard_static/index.html +180 -0
- twobells/dashboard_static/kiosk.js +89 -0
- twobells/dashboard_static/live.js +105 -0
- twobells/dashboard_static/main.js +173 -0
- twobells/dashboard_static/manifest.webmanifest +16 -0
- twobells/dashboard_static/map.js +204 -0
- twobells/dashboard_static/sheet.js +129 -0
- twobells/dashboard_static/strip.js +105 -0
- twobells/dashboard_static/theme.js +16 -0
- twobells/dashboard_static/time.js +76 -0
- twobells/dashboard_static/train.js +153 -0
- twobells/dashboard_static/vendor/inter-4.1/InterVariable.woff2 +0 -0
- twobells/dashboard_static/vendor/inter-4.1/LICENSE +92 -0
- twobells/dashboard_static/vendor/inter-4.1/SHA256SUMS +2 -0
- twobells/dashboard_static/vendor/leaflet-1.9.4/LICENSE +26 -0
- twobells/dashboard_static/vendor/leaflet-1.9.4/SHA256SUMS +3 -0
- twobells/dashboard_static/vendor/leaflet-1.9.4/leaflet-src.esm.js +14419 -0
- twobells/dashboard_static/vendor/leaflet-1.9.4/leaflet.css +661 -0
- twobells/dashboard_static/weather.js +181 -0
- twobells/data/gb_stations.csv +2644 -0
- twobells/data/naptan_area_grid.csv +3248 -0
- twobells/emulator/__init__.py +1 -0
- twobells/emulator/emulation.py +156 -0
- twobells/emulator/emulator.html +392 -0
- twobells/emulator/fake.py +313 -0
- twobells/emulator/server.py +175 -0
- twobells/fetch/__init__.py +1 -0
- twobells/fetch/handoff.py +352 -0
- twobells/fetch/service.py +209 -0
- twobells/settings/__init__.py +1 -0
- twobells/settings/config.py +617 -0
- twobells/settings/installs.py +99 -0
- twobells/settings/keyresults.py +135 -0
- twobells/settings/overlay.py +283 -0
- twobells/settings/schema.py +661 -0
- twobells/settings/store.py +312 -0
- twobells/setup/__init__.py +1 -0
- twobells/setup/api.py +1037 -0
- twobells/setup/discover.py +696 -0
- twobells/setup/keytest.py +420 -0
- twobells/setup/lookups.py +665 -0
- twobells/setup/spool/__init__.py +83 -0
- twobells/setup/spool/checks.py +346 -0
- twobells/setup/spool/folder.py +143 -0
- twobells/setup/spool/runner.py +256 -0
- twobells/setup/spool/worker.py +393 -0
- twobells/sources/__init__.py +1 -0
- twobells/sources/bods.py +167 -0
- twobells/sources/bodsbus.py +262 -0
- twobells/sources/darwin.py +761 -0
- twobells/sources/disruptions.py +701 -0
- twobells/sources/holidays.py +280 -0
- twobells/sources/livebus.py +422 -0
- twobells/sources/transportapi.py +169 -0
- twobells/sources/weather.py +367 -0
- twobells/timetable/__init__.py +1 -0
- twobells/timetable/build.py +1491 -0
- twobells/timetable/model.py +432 -0
- twobells/timetable/roads.py +276 -0
- twobells/timetable/routesetup.py +417 -0
- twobells/version.py +50 -0
- twobells/web/__init__.py +1 -0
- twobells/web/auth.py +919 -0
- twobells/web/formpages.py +606 -0
- twobells/web/groupform.py +769 -0
- twobells/web/icons.py +53 -0
- twobells/web/keyspage.py +375 -0
- twobells/web/keystate.py +147 -0
- twobells/web/netguard.py +122 -0
- twobells/web/overview.py +555 -0
- twobells/web/places.py +76 -0
- twobells/web/server.py +532 -0
- twobells/web/settings.js +180 -0
- twobells/web/settingsite.py +616 -0
- twobells/web/status.py +69 -0
- twobells/web/web.css +384 -0
- twobells/web/windowrows.py +252 -0
- twobells/wizard_static/api.js +62 -0
- twobells/wizard_static/finish.js +197 -0
- twobells/wizard_static/home.js +120 -0
- twobells/wizard_static/index.html +241 -0
- twobells/wizard_static/keys.js +151 -0
- twobells/wizard_static/maps.js +105 -0
- twobells/wizard_static/roads.js +99 -0
- twobells/wizard_static/say.js +50 -0
- twobells/wizard_static/state.js +51 -0
- twobells/wizard_static/station.js +145 -0
- twobells/wizard_static/steps.js +80 -0
- twobells/wizard_static/stops.js +165 -0
- twobells/wizard_static/views.js +43 -0
- twobells/wizard_static/walk.js +121 -0
- twobells/wizard_static/wizard.css +335 -0
- twobells/wizard_static/wizard.js +144 -0
- twobells-0.1.0.dist-info/METADATA +228 -0
- twobells-0.1.0.dist-info/RECORD +166 -0
- twobells-0.1.0.dist-info/WHEEL +5 -0
- twobells-0.1.0.dist-info/entry_points.txt +2 -0
- twobells-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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 @@
|
|
|
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)
|