epd-server 0.9.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.
- epd_server/__init__.py +71 -0
- epd_server/_version.py +8 -0
- epd_server/app.py +593 -0
- epd_server/cache.py +96 -0
- epd_server/compat.py +51 -0
- epd_server/config.py +425 -0
- epd_server/firmware.py +326 -0
- epd_server/headers.py +30 -0
- epd_server/logs.py +138 -0
- epd_server/mqtt.py +73 -0
- epd_server/page.py +173 -0
- epd_server/pipeline.py +100 -0
- epd_server/posix_tz.py +36 -0
- epd_server/quantise.py +88 -0
- epd_server/registry.py +80 -0
- epd_server/render.py +117 -0
- epd_server/scheduling.py +260 -0
- epd_server/source.py +93 -0
- epd_server/store.py +142 -0
- epd_server/timeranges.py +287 -0
- epd_server-0.9.0.dist-info/METADATA +304 -0
- epd_server-0.9.0.dist-info/RECORD +24 -0
- epd_server-0.9.0.dist-info/WHEEL +5 -0
- epd_server-0.9.0.dist-info/top_level.txt +1 -0
epd_server/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""epd_server — scheduled HTML-to-PNG rendering for e-paper clients.
|
|
2
|
+
|
|
3
|
+
The pieces a project composes:
|
|
4
|
+
|
|
5
|
+
- :mod:`epd_server.config` env-var-overridable lookups + typed core settings
|
|
6
|
+
- :mod:`epd_server.registry` name -> class plugin registry
|
|
7
|
+
- :mod:`epd_server.cache` JSON disk cache with per-key TTL
|
|
8
|
+
- :mod:`epd_server.page` HTML page -> quantised PNG
|
|
9
|
+
- :mod:`epd_server.render` Renderer protocol; ChromiumRenderer default
|
|
10
|
+
- :mod:`epd_server.quantise` Quantiser protocol; greyscale / palette / identity
|
|
11
|
+
- :mod:`epd_server.scheduling` DST-correct next-wake / next-regen maths
|
|
12
|
+
- :mod:`epd_server.firmware` which client an image is an update for; the image store
|
|
13
|
+
- :mod:`epd_server.mqtt` subscribe to every board's remote log topic
|
|
14
|
+
- :mod:`epd_server.logs` LogStore: the boards' log lines, in SQLite, read back in order
|
|
15
|
+
- :mod:`epd_server.source` DataSource protocol; Static / Composite / Ingest helpers
|
|
16
|
+
- :mod:`epd_server.store` ReadingsStore: what a board posts, in SQLite, read back by time
|
|
17
|
+
- :mod:`epd_server.pipeline` regenerate(): fetch what pages need, render, save
|
|
18
|
+
- :mod:`epd_server.app` DisplayServer: routes, wire headers, regen loop
|
|
19
|
+
- :mod:`epd_server.headers` the header names, built from the product's prefix
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ._version import __version__ # noqa: E402
|
|
23
|
+
|
|
24
|
+
from .app import DisplayServer, align_process_timezone # noqa: E402
|
|
25
|
+
from .config import ( # noqa: E402
|
|
26
|
+
ConfigError,
|
|
27
|
+
CoreConfig,
|
|
28
|
+
FirmwareSettings,
|
|
29
|
+
FirmwareSource,
|
|
30
|
+
ImageSettings,
|
|
31
|
+
MqttSettings,
|
|
32
|
+
ServerSettings,
|
|
33
|
+
load_core_config,
|
|
34
|
+
load_yaml,
|
|
35
|
+
)
|
|
36
|
+
from .firmware import ( # noqa: E402
|
|
37
|
+
ClientId,
|
|
38
|
+
FirmwareImage,
|
|
39
|
+
FirmwareStore,
|
|
40
|
+
ReleaseWatcher,
|
|
41
|
+
is_clean_tag,
|
|
42
|
+
update_applies,
|
|
43
|
+
)
|
|
44
|
+
from .page import Page, SkipPage # noqa: E402
|
|
45
|
+
from .pipeline import regenerate, select_pages # noqa: E402
|
|
46
|
+
from .source import CompositeSource, DataSource, IngestSource, StaticSource # noqa: E402
|
|
47
|
+
from .logs import LogStore # noqa: E402
|
|
48
|
+
from .store import ReadingsStore # noqa: E402
|
|
49
|
+
from .quantise import ( # noqa: E402
|
|
50
|
+
GreyscaleQuantiser,
|
|
51
|
+
IdentityQuantiser,
|
|
52
|
+
PaletteQuantiser,
|
|
53
|
+
Quantiser,
|
|
54
|
+
grey_levels,
|
|
55
|
+
)
|
|
56
|
+
from .render import ChromiumRenderer, Renderer # noqa: E402
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DisplayServer", "align_process_timezone",
|
|
60
|
+
"ConfigError", "CoreConfig", "ServerSettings", "ImageSettings", "MqttSettings",
|
|
61
|
+
"FirmwareSettings", "FirmwareSource", "load_core_config", "load_yaml",
|
|
62
|
+
"ClientId", "FirmwareImage", "FirmwareStore", "ReleaseWatcher",
|
|
63
|
+
"is_clean_tag", "update_applies",
|
|
64
|
+
"Page", "SkipPage",
|
|
65
|
+
"DataSource", "StaticSource", "CompositeSource", "IngestSource", "ReadingsStore",
|
|
66
|
+
"LogStore",
|
|
67
|
+
"regenerate", "select_pages",
|
|
68
|
+
"Renderer", "ChromiumRenderer",
|
|
69
|
+
"Quantiser", "GreyscaleQuantiser", "PaletteQuantiser", "IdentityQuantiser",
|
|
70
|
+
"grey_levels",
|
|
71
|
+
]
|
epd_server/_version.py
ADDED
epd_server/app.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""The server: serve page PNGs on a schedule, regenerating each one just
|
|
2
|
+
before its client wake time.
|
|
3
|
+
|
|
4
|
+
A project builds its pages and a :class:`~epd_server.source.DataSource`,
|
|
5
|
+
hands them to :class:`DisplayServer` with the validated settings, and calls
|
|
6
|
+
:meth:`DisplayServer.run`. Everything else — HTTP routes, the schedule and
|
|
7
|
+
identity headers, the regeneration loop, the client log relay, signal
|
|
8
|
+
handling — lives here.
|
|
9
|
+
|
|
10
|
+
The wire contract with the client is small. Every name below is written with
|
|
11
|
+
the default prefix; a project sets its own, and :mod:`epd_server.headers`
|
|
12
|
+
builds the names from it::
|
|
13
|
+
|
|
14
|
+
GET /<page>.png
|
|
15
|
+
EPD-Device: <product> the board says who it is
|
|
16
|
+
EPD-Device-Version: <version> and what it runs
|
|
17
|
+
|
|
18
|
+
200 image/png
|
|
19
|
+
EPD-Next-Display-Refresh-Seconds: <seconds until the next scheduled wake>
|
|
20
|
+
EPD-Next-URL: http://host/<the page to fetch at that wake>
|
|
21
|
+
EPD-Server-Version: <version> this server, on every response
|
|
22
|
+
EPD-Server-Epoch-Seconds: <UTC seconds> its clock, on every response
|
|
23
|
+
EPD-Server-Timezone: <POSIX TZ string> its zone, on every response, when
|
|
24
|
+
the zone has an IANA name
|
|
25
|
+
EPD-Server-Firmware-Version: <version> on any response, when an update applies
|
|
26
|
+
EPD-Server-Firmware-URL: http://host/firmware.bin
|
|
27
|
+
EPD-Next-Sensor-Poll-Seconds: <seconds> when the board should post or sync
|
|
28
|
+
next, on every response when the
|
|
29
|
+
project's sensor_poll gives one for it
|
|
30
|
+
|
|
31
|
+
GET /firmware.bin[?product=<name>&version=<version>]
|
|
32
|
+
200 application/octet-stream, Content-Length, x-MD5
|
|
33
|
+
304 when the request's x-ESP32-version is the version held
|
|
34
|
+
404 when the server holds no such image
|
|
35
|
+
|
|
36
|
+
POST /<name> a project's ingest route: a JSON object, or an array
|
|
37
|
+
of them, in; 204 out, or 200 with the handler's JSON;
|
|
38
|
+
409 when version_gate is on and the board's version
|
|
39
|
+
cannot work with the server's
|
|
40
|
+
GET /<name>?k=v a project's query route: JSON out, 404 when there
|
|
41
|
+
is no answer
|
|
42
|
+
|
|
43
|
+
The server is the single source of truth for *when* the client wakes and
|
|
44
|
+
*what* it shows. The client does no timezone maths and holds no schedule.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import io
|
|
49
|
+
import logging
|
|
50
|
+
import os
|
|
51
|
+
import signal
|
|
52
|
+
import threading
|
|
53
|
+
import time
|
|
54
|
+
from datetime import datetime
|
|
55
|
+
from typing import Callable, Iterable, Mapping
|
|
56
|
+
from urllib.parse import urlencode
|
|
57
|
+
|
|
58
|
+
from flask import Flask, abort, jsonify, make_response, request, send_file
|
|
59
|
+
from werkzeug.serving import WSGIRequestHandler, make_server
|
|
60
|
+
|
|
61
|
+
from .compat import compatible, version_order
|
|
62
|
+
from .config import FirmwareSettings, MqttSettings
|
|
63
|
+
from ._version import __version__
|
|
64
|
+
from .headers import Wire
|
|
65
|
+
from .firmware import (FirmwareImage, FirmwareStore, ReleaseWatcher, client_from_headers,
|
|
66
|
+
update_applies)
|
|
67
|
+
from .logs import LogStore
|
|
68
|
+
from .mqtt import client_log_subscriber
|
|
69
|
+
from .page import Page
|
|
70
|
+
from .pipeline import regenerate as _regenerate
|
|
71
|
+
from .posix_tz import posix_tz
|
|
72
|
+
from .scheduling import Pools, Schedule, TimesSchedule, WakeSchedule, seconds_until
|
|
73
|
+
from .source import DataSource
|
|
74
|
+
|
|
75
|
+
log = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def align_process_timezone(tz) -> None:
|
|
79
|
+
"""Make ``time.localtime()`` — and so logging timestamps — use ``tz``.
|
|
80
|
+
|
|
81
|
+
Python's logging formats timestamps with ``time.localtime``, which
|
|
82
|
+
follows the process ``TZ``, not any tzinfo the application holds. Call
|
|
83
|
+
this once at startup with the configured zone so log lines and the
|
|
84
|
+
schedule agree. No-op for a tzinfo without an IANA key (e.g. a fixed
|
|
85
|
+
offset), or on platforms without ``time.tzset``.
|
|
86
|
+
"""
|
|
87
|
+
key = getattr(tz, "key", None)
|
|
88
|
+
if key and hasattr(time, "tzset"):
|
|
89
|
+
os.environ["TZ"] = key
|
|
90
|
+
time.tzset()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# What a page asked for before the first render has written it tells the
|
|
94
|
+
# board to wait: about as long as rendering every page takes.
|
|
95
|
+
FIRST_RENDER_RETRY_AFTER_S = 30
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
CONNECTION_TIMEOUT_S = 30
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ServerThread(threading.Thread):
|
|
102
|
+
"""Werkzeug's threaded dev server on a daemon thread, with a clean shutdown."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, app: Flask, host: str, port: int,
|
|
105
|
+
timeout_s: float = CONNECTION_TIMEOUT_S):
|
|
106
|
+
super().__init__(daemon=True, name="epd-http")
|
|
107
|
+
handler = type("TimedRequestHandler", (WSGIRequestHandler,), {"timeout": timeout_s})
|
|
108
|
+
self.server = make_server(host, port, app, threaded=True, request_handler=handler)
|
|
109
|
+
self.ctx = app.app_context()
|
|
110
|
+
self.ctx.push()
|
|
111
|
+
|
|
112
|
+
def run(self):
|
|
113
|
+
log.info("Starting http server on %s:%d", *self.server.server_address[:2])
|
|
114
|
+
self.server.serve_forever()
|
|
115
|
+
|
|
116
|
+
def shutdown(self):
|
|
117
|
+
log.info("Stopping http server")
|
|
118
|
+
self.server.shutdown()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DisplayServer:
|
|
122
|
+
"""Serve and regenerate a set of pages on a wake schedule.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
pages: every page the server offers. Each is served at
|
|
126
|
+
``/<page.png_filename>`` from ``page.png_path``.
|
|
127
|
+
source: where the pages' content comes from.
|
|
128
|
+
schedule: a :class:`~epd_server.scheduling.WakeSchedule`, from
|
|
129
|
+
:attr:`~epd_server.config.ServerSettings.schedule`; or, for
|
|
130
|
+
convenience, ``(HH:MM:SS, png_filename)`` pairs, which become a
|
|
131
|
+
times schedule of one-image pools. Every filename it can name
|
|
132
|
+
must belong to one of ``pages``.
|
|
133
|
+
tz: the timezone the schedule times are in. When it has an IANA name,
|
|
134
|
+
every response carries it as a POSIX TZ string.
|
|
135
|
+
regen_lead_seconds: regenerate this long before each wake.
|
|
136
|
+
host, port: where to listen.
|
|
137
|
+
mqtt: if given and ``enabled``, relay every board's log topic into
|
|
138
|
+
the ``client`` logger while running.
|
|
139
|
+
client_logs: where the relayed lines are kept, if anywhere.
|
|
140
|
+
mqtt_client_id: the id this server connects to the broker with.
|
|
141
|
+
ingest: routes that accept documents by POST, as ``{name: handler}``.
|
|
142
|
+
``POST /<name>`` takes one JSON object or an array of them and
|
|
143
|
+
calls ``handler(docs)`` once with the list, so a batch can be
|
|
144
|
+
written in one transaction. What the handler returns is sent as
|
|
145
|
+
JSON with a 200, and None is a 204; a ``ValueError`` is a 400.
|
|
146
|
+
queries: routes that answer a GET with JSON, as ``{name: handler}``.
|
|
147
|
+
``GET /<name>`` calls ``handler(args)`` with the query string as
|
|
148
|
+
a dict and sends what it returns; None is a 404 and a
|
|
149
|
+
``ValueError`` a 400. A name may have both kinds of route.
|
|
150
|
+
firmware: if given and ``enabled``, offer an image from its directory
|
|
151
|
+
to the boards it is for, on any response to one, and serve it at
|
|
152
|
+
``/firmware.bin``. With ``version_gate`` the offer is the newest
|
|
153
|
+
image that can work with ``server_version``, older than the
|
|
154
|
+
board's own or newer; without it, the newest file.
|
|
155
|
+
header_prefix: the product's name, which every header on the wire
|
|
156
|
+
starts with. See :mod:`epd_server.headers`.
|
|
157
|
+
server_version: what to report as this server's version. Defaults to
|
|
158
|
+
the version of this package, which is right until a project has
|
|
159
|
+
one of its own.
|
|
160
|
+
version_gate: refuse an ingest post, with 409, from a board whose
|
|
161
|
+
version cannot work with ``server_version``, and offer each board
|
|
162
|
+
the image its server calls for. Only for a project whose boards
|
|
163
|
+
and server take their versions from the same tags; see
|
|
164
|
+
:mod:`epd_server.compat`.
|
|
165
|
+
on_refused: called with the board's name and version each time the
|
|
166
|
+
version gate refuses one, so the project can say so on its pages.
|
|
167
|
+
sensor_poll: the seconds until a board should post or sync next,
|
|
168
|
+
given the epoch now and the name the board states, None when it
|
|
169
|
+
states none; None back sends no header. Sent on every response
|
|
170
|
+
it answers, so a board learns it from whatever request it last
|
|
171
|
+
made.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
pages: Iterable[Page],
|
|
178
|
+
source: DataSource,
|
|
179
|
+
schedule: Schedule | WakeSchedule,
|
|
180
|
+
tz,
|
|
181
|
+
regen_lead_seconds: int = 120,
|
|
182
|
+
host: str = "0.0.0.0",
|
|
183
|
+
port: int = 8080,
|
|
184
|
+
mqtt: MqttSettings | None = None,
|
|
185
|
+
mqtt_client_id: str = "epd-server",
|
|
186
|
+
client_logs: LogStore | None = None,
|
|
187
|
+
ingest: Mapping[str, Callable[[list[dict]], dict | None]] | None = None,
|
|
188
|
+
queries: Mapping[str, Callable[[dict], object]] | None = None,
|
|
189
|
+
firmware: FirmwareSettings | None = None,
|
|
190
|
+
header_prefix: str = "EPD",
|
|
191
|
+
server_version: str | None = None,
|
|
192
|
+
version_gate: bool = False,
|
|
193
|
+
sensor_poll: Callable[[float, str | None], int | None] | None = None,
|
|
194
|
+
on_refused: Callable[[str, str], None] | None = None,
|
|
195
|
+
):
|
|
196
|
+
self.pages = list(pages)
|
|
197
|
+
self.source = source
|
|
198
|
+
self.tz = tz
|
|
199
|
+
self.posix_tz = posix_tz(tz)
|
|
200
|
+
if isinstance(schedule, WakeSchedule):
|
|
201
|
+
self.schedule = schedule
|
|
202
|
+
else:
|
|
203
|
+
if not schedule:
|
|
204
|
+
raise ValueError("DisplayServer needs a non-empty schedule")
|
|
205
|
+
times = list(schedule)
|
|
206
|
+
self.schedule = TimesSchedule(times, Pools({p: [p] for _, p in times}), tz)
|
|
207
|
+
self.regen_lead_seconds = regen_lead_seconds
|
|
208
|
+
self.host = host
|
|
209
|
+
self.port = port
|
|
210
|
+
self.mqtt = mqtt
|
|
211
|
+
self.mqtt_client_id = mqtt_client_id
|
|
212
|
+
self.client_logs = client_logs
|
|
213
|
+
self.ingest = dict(ingest or {})
|
|
214
|
+
self.queries = dict(queries or {})
|
|
215
|
+
self.firmware = firmware
|
|
216
|
+
self.wire = Wire(header_prefix)
|
|
217
|
+
self.server_version = server_version or __version__
|
|
218
|
+
self.version_gate = version_gate
|
|
219
|
+
self.sensor_poll = sensor_poll
|
|
220
|
+
self.on_refused = on_refused
|
|
221
|
+
self.firmware_stores = ({p: FirmwareStore(firmware.dir_for(p)) for p in firmware.names()}
|
|
222
|
+
if firmware and firmware.enabled else {})
|
|
223
|
+
# The default product's: what the release watcher fills and / reports.
|
|
224
|
+
self.firmware_store = self.firmware_stores.get(firmware.product) if firmware else None
|
|
225
|
+
self.release_watcher: ReleaseWatcher | None = None
|
|
226
|
+
|
|
227
|
+
if not self.pages:
|
|
228
|
+
raise ValueError("DisplayServer needs at least one page")
|
|
229
|
+
served = {p.png_filename for p in self.pages}
|
|
230
|
+
unknown = sorted(self.schedule.pages() - served)
|
|
231
|
+
if unknown:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"display.schedule names {unknown}, but the pages only produce "
|
|
234
|
+
f"{sorted(served)}"
|
|
235
|
+
)
|
|
236
|
+
clash = sorted(set(self.ingest) & served)
|
|
237
|
+
if clash:
|
|
238
|
+
raise ValueError(f"ingest routes {clash} collide with page filenames")
|
|
239
|
+
clash = sorted(set(self.queries) & served)
|
|
240
|
+
if clash:
|
|
241
|
+
raise ValueError(f"query routes {clash} collide with page filenames")
|
|
242
|
+
|
|
243
|
+
# Serialises regenerations; a page's PNG is replaced atomically, so
|
|
244
|
+
# readers never wait on it.
|
|
245
|
+
self.regen_lock = threading.Lock()
|
|
246
|
+
# Set while run() renders every page for the first time, on its own
|
|
247
|
+
# thread, with the server already answering.
|
|
248
|
+
self.first_render_pending = threading.Event()
|
|
249
|
+
self.shutdown_event = threading.Event()
|
|
250
|
+
self.http: ServerThread | None = None
|
|
251
|
+
self.mqtt_client = None
|
|
252
|
+
|
|
253
|
+
self.app = self._build_app()
|
|
254
|
+
|
|
255
|
+
# ── Scheduling ────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def next_wake(self, now: datetime | None = None) -> tuple[int, str]:
|
|
258
|
+
"""``(seconds_until_next_wake, png_filename)`` — what the headers carry."""
|
|
259
|
+
if now is None:
|
|
260
|
+
now = datetime.now(tz=self.tz)
|
|
261
|
+
wake_dt, path = self.schedule.next_wake(now=now)
|
|
262
|
+
return seconds_until(now, wake_dt), path
|
|
263
|
+
|
|
264
|
+
# ── Regeneration ──────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def regenerate(self, only: str | None = None, force_refresh: bool = False) -> list[Page]:
|
|
267
|
+
"""Regenerate one page (by filename) or all of them, under the lock."""
|
|
268
|
+
with self.regen_lock:
|
|
269
|
+
log.info("Regenerating %s", only or "all pages")
|
|
270
|
+
rendered = _regenerate(self.pages, self.source, only=only, force_refresh=force_refresh)
|
|
271
|
+
log.info("Regeneration complete: %s",
|
|
272
|
+
", ".join(p.png_filename for p in rendered) or "nothing rendered")
|
|
273
|
+
return rendered
|
|
274
|
+
|
|
275
|
+
# ── HTTP ──────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def _build_app(self) -> Flask:
|
|
278
|
+
app = Flask("epd_server")
|
|
279
|
+
|
|
280
|
+
app.before_request(self._log_client)
|
|
281
|
+
app.after_request(self._add_server_headers)
|
|
282
|
+
|
|
283
|
+
@app.route("/")
|
|
284
|
+
def index():
|
|
285
|
+
seconds, path = self.next_wake()
|
|
286
|
+
image = self._offer_image(self.firmware.product) if self.firmware else None
|
|
287
|
+
return jsonify(
|
|
288
|
+
pages=[p.png_filename for p in self.pages],
|
|
289
|
+
schedule=self.schedule.describe(),
|
|
290
|
+
next_wake_seconds=seconds,
|
|
291
|
+
next_page=path,
|
|
292
|
+
firmware=None if image is None else {
|
|
293
|
+
"version": image.version, "size": image.size, "md5": image.md5,
|
|
294
|
+
"product": self.firmware.product if self.firmware else None,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if self.firmware_store is not None:
|
|
299
|
+
app.add_url_rule("/firmware.bin", endpoint="firmware",
|
|
300
|
+
view_func=self._serve_firmware)
|
|
301
|
+
|
|
302
|
+
for page in self.pages:
|
|
303
|
+
app.add_url_rule(
|
|
304
|
+
"/" + page.png_filename,
|
|
305
|
+
endpoint=page.name,
|
|
306
|
+
view_func=self._make_view(page),
|
|
307
|
+
)
|
|
308
|
+
for name, handler in self.ingest.items():
|
|
309
|
+
app.add_url_rule(
|
|
310
|
+
"/" + name,
|
|
311
|
+
endpoint=f"ingest_{name}",
|
|
312
|
+
view_func=self._make_ingest(name, handler),
|
|
313
|
+
methods=["POST"],
|
|
314
|
+
)
|
|
315
|
+
for name, handler in self.queries.items():
|
|
316
|
+
app.add_url_rule(
|
|
317
|
+
"/" + name,
|
|
318
|
+
endpoint=f"query_{name}",
|
|
319
|
+
view_func=self._make_query(name, handler),
|
|
320
|
+
methods=["GET"],
|
|
321
|
+
)
|
|
322
|
+
return app
|
|
323
|
+
|
|
324
|
+
def _make_query(self, name: str, handler: Callable[[dict], object]):
|
|
325
|
+
def answer():
|
|
326
|
+
try:
|
|
327
|
+
result = handler(request.args.to_dict())
|
|
328
|
+
except ValueError as exc:
|
|
329
|
+
abort(400, str(exc))
|
|
330
|
+
if result is None:
|
|
331
|
+
abort(404)
|
|
332
|
+
return jsonify(result)
|
|
333
|
+
answer.__name__ = f"query_{name}"
|
|
334
|
+
return answer
|
|
335
|
+
|
|
336
|
+
def _make_ingest(self, name: str, handler: Callable[[list[dict]], dict | None]):
|
|
337
|
+
def accept():
|
|
338
|
+
refusal = self._version_refusal()
|
|
339
|
+
if refusal is not None:
|
|
340
|
+
return refusal
|
|
341
|
+
body = request.get_json(silent=True)
|
|
342
|
+
docs = [body] if isinstance(body, dict) else body
|
|
343
|
+
if not isinstance(docs, list) or not docs or not all(isinstance(d, dict) for d in docs):
|
|
344
|
+
abort(400, "expected a JSON object or a non-empty array of objects")
|
|
345
|
+
try:
|
|
346
|
+
result = handler(docs)
|
|
347
|
+
except ValueError as exc:
|
|
348
|
+
abort(400, str(exc))
|
|
349
|
+
if result is None:
|
|
350
|
+
return "", 204
|
|
351
|
+
return jsonify(result)
|
|
352
|
+
accept.__name__ = f"ingest_{name}"
|
|
353
|
+
return accept
|
|
354
|
+
|
|
355
|
+
def _version_refusal(self):
|
|
356
|
+
"""A 409 for a board whose version cannot work with this server's.
|
|
357
|
+
|
|
358
|
+
A board that states no version, or one that cannot be read, is let
|
|
359
|
+
through: it cannot be judged, and refusing it would silently stop the
|
|
360
|
+
readings of every development build. The body names both versions so
|
|
361
|
+
the board can log them.
|
|
362
|
+
"""
|
|
363
|
+
if not self.version_gate:
|
|
364
|
+
return None
|
|
365
|
+
device = request.headers.get(self.wire.device)
|
|
366
|
+
version = request.headers.get(self.wire.device_version)
|
|
367
|
+
if compatible(version, self.server_version) is not False:
|
|
368
|
+
return None
|
|
369
|
+
log.warning("refused %s from %s %s: this server is %s", request.path,
|
|
370
|
+
device or "an unnamed client", version, self.server_version)
|
|
371
|
+
if self.on_refused is not None:
|
|
372
|
+
self.on_refused(device or "", version or "")
|
|
373
|
+
return jsonify(error="version", device=version, server=self.server_version), 409
|
|
374
|
+
|
|
375
|
+
def _offer_image(self, product: str) -> FirmwareImage | None:
|
|
376
|
+
"""The image a board of ``product`` should run, before asking whether
|
|
377
|
+
it runs it already."""
|
|
378
|
+
store = self.firmware_stores.get(product)
|
|
379
|
+
if store is None:
|
|
380
|
+
return None
|
|
381
|
+
return store.newest_compatible(self.server_version) if self.version_gate else store.current()
|
|
382
|
+
|
|
383
|
+
def _firmware_url(self, image: FirmwareImage, product: str) -> str:
|
|
384
|
+
url = request.host_url.rstrip("/") + "/firmware.bin"
|
|
385
|
+
if self.firmware is not None and self.firmware.products:
|
|
386
|
+
url += "?" + urlencode({"product": product, "version": image.version})
|
|
387
|
+
return url
|
|
388
|
+
|
|
389
|
+
def _serve_firmware(self):
|
|
390
|
+
"""The image itself. The board asks for this after a response offered it."""
|
|
391
|
+
assert self.firmware is not None # the route exists only when it does
|
|
392
|
+
product = request.args.get("product") or self.firmware.product
|
|
393
|
+
version = request.args.get("version")
|
|
394
|
+
store = self.firmware_stores.get(product)
|
|
395
|
+
if store is None:
|
|
396
|
+
abort(404)
|
|
397
|
+
image = store.image(version) if version else self._offer_image(product)
|
|
398
|
+
if image is None:
|
|
399
|
+
abort(404)
|
|
400
|
+
# HTTPUpdate sends the running version, so an image it already has
|
|
401
|
+
# costs one small response instead of a megabyte.
|
|
402
|
+
if request.headers.get("x-ESP32-version") == image.version:
|
|
403
|
+
log.info("%s already runs firmware %s", request.user_agent.string, image.version)
|
|
404
|
+
return "", 304
|
|
405
|
+
with open(image.path, "rb") as f:
|
|
406
|
+
data = f.read()
|
|
407
|
+
log.info("Serving firmware %s (%d bytes) to %s",
|
|
408
|
+
image.version, image.size, request.user_agent.string)
|
|
409
|
+
rsp = make_response(send_file(
|
|
410
|
+
io.BytesIO(data),
|
|
411
|
+
mimetype="application/octet-stream",
|
|
412
|
+
as_attachment=True,
|
|
413
|
+
download_name=image.version + ".bin",
|
|
414
|
+
))
|
|
415
|
+
rsp.headers["Content-Length"] = str(image.size)
|
|
416
|
+
rsp.headers["x-MD5"] = image.md5
|
|
417
|
+
return rsp
|
|
418
|
+
|
|
419
|
+
def _firmware_headers(self, rsp) -> None:
|
|
420
|
+
"""Add the offer headers when the requesting board has an update."""
|
|
421
|
+
if not self.firmware_stores or request.endpoint == "firmware":
|
|
422
|
+
return
|
|
423
|
+
firmware = self.firmware
|
|
424
|
+
assert firmware is not None # the stores exist only when it does
|
|
425
|
+
client = client_from_headers(request.headers.get(self.wire.device),
|
|
426
|
+
request.headers.get(self.wire.device_version))
|
|
427
|
+
image = self._offer_image(client.name) if client is not None else None
|
|
428
|
+
if not update_applies(client, image, firmware):
|
|
429
|
+
return
|
|
430
|
+
assert image is not None and client is not None # update_applies said so
|
|
431
|
+
url = self._firmware_url(image, client.name)
|
|
432
|
+
# The version travels with the URL because the board checks it against
|
|
433
|
+
# the one it rolled back from, before it downloads anything.
|
|
434
|
+
rsp.headers[self.wire.firmware_version] = image.version
|
|
435
|
+
rsp.headers[self.wire.firmware_url] = url
|
|
436
|
+
older = version_order(image.version) or (0, 0, 0, 0)
|
|
437
|
+
if older < (version_order(client.version) or (0, 0, 0, 0)):
|
|
438
|
+
log.warning("Offering %s %s an older firmware, %s: this server is %s",
|
|
439
|
+
client.name, client.version, image.version, self.server_version)
|
|
440
|
+
else:
|
|
441
|
+
log.info("Offering firmware %s to %s %s", image.version, client.name, client.version)
|
|
442
|
+
|
|
443
|
+
def _make_view(self, page: Page):
|
|
444
|
+
def serve():
|
|
445
|
+
return self._serve(page)
|
|
446
|
+
serve.__name__ = f"serve_{page.name}"
|
|
447
|
+
return serve
|
|
448
|
+
|
|
449
|
+
def _serve(self, page: Page):
|
|
450
|
+
path = page.png_path
|
|
451
|
+
if not os.path.exists(path):
|
|
452
|
+
if self.first_render_pending.is_set():
|
|
453
|
+
rsp = make_response("The pages are still being rendered after a start.\n", 503)
|
|
454
|
+
rsp.headers["Retry-After"] = str(FIRST_RENDER_RETRY_AFTER_S)
|
|
455
|
+
return rsp
|
|
456
|
+
log.error("%s: no such file exists", path)
|
|
457
|
+
abort(404)
|
|
458
|
+
|
|
459
|
+
with open(path, "rb") as f:
|
|
460
|
+
data = f.read()
|
|
461
|
+
|
|
462
|
+
seconds, next_path = self.next_wake()
|
|
463
|
+
next_url = request.host_url.rstrip("/") + "/" + next_path.lstrip("/")
|
|
464
|
+
|
|
465
|
+
rsp = make_response(send_file(
|
|
466
|
+
io.BytesIO(data),
|
|
467
|
+
mimetype="image/png",
|
|
468
|
+
as_attachment=True,
|
|
469
|
+
download_name=page.png_filename,
|
|
470
|
+
))
|
|
471
|
+
rsp.headers[self.wire.next_refresh] = str(seconds)
|
|
472
|
+
rsp.headers[self.wire.next_url] = next_url
|
|
473
|
+
return rsp
|
|
474
|
+
|
|
475
|
+
def _log_client(self) -> None:
|
|
476
|
+
"""Log the identity the board stated, when it stated one."""
|
|
477
|
+
name = request.headers.get(self.wire.device)
|
|
478
|
+
version = request.headers.get(self.wire.device_version)
|
|
479
|
+
if name or version:
|
|
480
|
+
log.info("%s %s asked for %s", name or "an unnamed client",
|
|
481
|
+
version or "of no stated version", request.path)
|
|
482
|
+
|
|
483
|
+
def _add_server_headers(self, rsp):
|
|
484
|
+
"""Stamp every response with who is serving it, and when and where.
|
|
485
|
+
|
|
486
|
+
The clock and the zone go out on every response so a board without
|
|
487
|
+
one of its own can keep local time from the server it already has to
|
|
488
|
+
reach.
|
|
489
|
+
"""
|
|
490
|
+
now = time.time()
|
|
491
|
+
rsp.headers[self.wire.server_version] = self.server_version
|
|
492
|
+
rsp.headers[self.wire.server_epoch] = str(int(now))
|
|
493
|
+
if self.posix_tz:
|
|
494
|
+
rsp.headers[self.wire.server_timezone] = self.posix_tz
|
|
495
|
+
if self.sensor_poll is not None:
|
|
496
|
+
name = (request.headers.get(self.wire.device) or "").strip() or None
|
|
497
|
+
seconds = self.sensor_poll(now, name)
|
|
498
|
+
if seconds is not None:
|
|
499
|
+
rsp.headers[self.wire.next_sensor_poll] = str(int(seconds))
|
|
500
|
+
self._firmware_headers(rsp)
|
|
501
|
+
return rsp
|
|
502
|
+
|
|
503
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
def run(self, once: bool = False, install_signal_handlers: bool = True) -> None:
|
|
506
|
+
"""Serve at once, render every page on a thread of its own, and follow
|
|
507
|
+
the schedule until stopped.
|
|
508
|
+
|
|
509
|
+
``once=True`` renders everything and returns without starting the
|
|
510
|
+
HTTP server, the log relay, or the loop — handy while iterating on
|
|
511
|
+
pages.
|
|
512
|
+
"""
|
|
513
|
+
if once:
|
|
514
|
+
self.regenerate()
|
|
515
|
+
log.info("once: images generated, not starting the server")
|
|
516
|
+
return
|
|
517
|
+
self.first_render_pending.set()
|
|
518
|
+
|
|
519
|
+
if self.mqtt is not None and self.mqtt.enabled:
|
|
520
|
+
self.mqtt_client = client_log_subscriber(
|
|
521
|
+
self.mqtt.host, self.mqtt.port, self.mqtt.prefix, client_id=self.mqtt_client_id,
|
|
522
|
+
on_line=self.client_logs.add if self.client_logs is not None else None,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
source = self.firmware.source if self.firmware else None
|
|
526
|
+
if self.firmware_store is not None and source is not None:
|
|
527
|
+
self.release_watcher = ReleaseWatcher(self.firmware_store, source)
|
|
528
|
+
log.info("Watching %s for releases every %ds", source.github,
|
|
529
|
+
source.poll_seconds)
|
|
530
|
+
threading.Thread(target=self.release_watcher.run, name="epd-releases",
|
|
531
|
+
daemon=True).start()
|
|
532
|
+
|
|
533
|
+
self.http = ServerThread(self.app, self.host, self.port)
|
|
534
|
+
self.http.start()
|
|
535
|
+
threading.Thread(target=self._first_render, name="epd-first-render", daemon=True).start()
|
|
536
|
+
|
|
537
|
+
if install_signal_handlers:
|
|
538
|
+
def handle(signum, _frame):
|
|
539
|
+
log.info("Received signal %d, shutting down", signum)
|
|
540
|
+
self.stop()
|
|
541
|
+
signal.signal(signal.SIGTERM, handle)
|
|
542
|
+
signal.signal(signal.SIGINT, handle)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
self._loop()
|
|
546
|
+
finally:
|
|
547
|
+
self._shutdown()
|
|
548
|
+
|
|
549
|
+
def _first_render(self) -> None:
|
|
550
|
+
"""Render every page, once, while the server already answers. A
|
|
551
|
+
failure leaves each page to its scheduled render."""
|
|
552
|
+
try:
|
|
553
|
+
self.regenerate()
|
|
554
|
+
except Exception: # noqa: BLE001 - the server keeps serving what it has
|
|
555
|
+
log.exception("First render failed; each page renders at its scheduled time")
|
|
556
|
+
finally:
|
|
557
|
+
self.first_render_pending.clear()
|
|
558
|
+
|
|
559
|
+
def stop(self) -> None:
|
|
560
|
+
"""Ask :meth:`run` to return. Safe to call from a signal handler or another thread."""
|
|
561
|
+
self.shutdown_event.set()
|
|
562
|
+
|
|
563
|
+
def _loop(self) -> None:
|
|
564
|
+
while not self.shutdown_event.is_set():
|
|
565
|
+
now = datetime.now(tz=self.tz)
|
|
566
|
+
regen_dt, wake_dt, path = self.schedule.next_regen(
|
|
567
|
+
lead_seconds=self.regen_lead_seconds, now=now,
|
|
568
|
+
)
|
|
569
|
+
# Timestamp arithmetic, never `regen_dt - now`: datetime subtraction
|
|
570
|
+
# is naive wall-clock when both sides share a tzinfo and silently
|
|
571
|
+
# drops the hour across a DST transition.
|
|
572
|
+
wait = max(0.0, regen_dt.timestamp() - now.timestamp())
|
|
573
|
+
log.info("Next client wake at %s -> %s", wake_dt.isoformat(), path)
|
|
574
|
+
log.info("Regenerating %s at %s (in %ds)", path, regen_dt.isoformat(), int(wait))
|
|
575
|
+
if self.shutdown_event.wait(wait):
|
|
576
|
+
break
|
|
577
|
+
try:
|
|
578
|
+
self.regenerate(only=path, force_refresh=True)
|
|
579
|
+
except Exception: # noqa: BLE001 - keep serving; retry at the next slot
|
|
580
|
+
log.exception("Scheduled regeneration failed; will retry at next regen time")
|
|
581
|
+
|
|
582
|
+
def _shutdown(self) -> None:
|
|
583
|
+
if self.release_watcher is not None:
|
|
584
|
+
self.release_watcher.stop()
|
|
585
|
+
self.release_watcher = None
|
|
586
|
+
if self.http is not None:
|
|
587
|
+
self.http.shutdown()
|
|
588
|
+
self.http = None
|
|
589
|
+
if self.mqtt_client is not None:
|
|
590
|
+
self.mqtt_client.loop_stop()
|
|
591
|
+
self.mqtt_client.disconnect()
|
|
592
|
+
self.mqtt_client = None
|
|
593
|
+
log.info("Exited")
|