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 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
@@ -0,0 +1,8 @@
1
+ """The package version.
2
+
3
+ It sits in a module of its own because ``app`` needs it for the
4
+ ``EPD-Server-Version`` header and ``__init__`` re-exports it, so it cannot
5
+ live in either without the two importing a circle.
6
+ """
7
+
8
+ __version__ = "0.9.0"
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")