epd-server 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. epd_server-0.9.0/PKG-INFO +304 -0
  2. epd_server-0.9.0/README.md +284 -0
  3. epd_server-0.9.0/epd_server/__init__.py +71 -0
  4. epd_server-0.9.0/epd_server/_version.py +8 -0
  5. epd_server-0.9.0/epd_server/app.py +593 -0
  6. epd_server-0.9.0/epd_server/cache.py +96 -0
  7. epd_server-0.9.0/epd_server/compat.py +51 -0
  8. epd_server-0.9.0/epd_server/config.py +425 -0
  9. epd_server-0.9.0/epd_server/firmware.py +326 -0
  10. epd_server-0.9.0/epd_server/headers.py +30 -0
  11. epd_server-0.9.0/epd_server/logs.py +138 -0
  12. epd_server-0.9.0/epd_server/mqtt.py +73 -0
  13. epd_server-0.9.0/epd_server/page.py +173 -0
  14. epd_server-0.9.0/epd_server/pipeline.py +100 -0
  15. epd_server-0.9.0/epd_server/posix_tz.py +36 -0
  16. epd_server-0.9.0/epd_server/quantise.py +88 -0
  17. epd_server-0.9.0/epd_server/registry.py +80 -0
  18. epd_server-0.9.0/epd_server/render.py +117 -0
  19. epd_server-0.9.0/epd_server/scheduling.py +260 -0
  20. epd_server-0.9.0/epd_server/source.py +93 -0
  21. epd_server-0.9.0/epd_server/store.py +142 -0
  22. epd_server-0.9.0/epd_server/timeranges.py +287 -0
  23. epd_server-0.9.0/epd_server.egg-info/PKG-INFO +304 -0
  24. epd_server-0.9.0/epd_server.egg-info/SOURCES.txt +47 -0
  25. epd_server-0.9.0/epd_server.egg-info/dependency_links.txt +1 -0
  26. epd_server-0.9.0/epd_server.egg-info/requires.txt +11 -0
  27. epd_server-0.9.0/epd_server.egg-info/top_level.txt +1 -0
  28. epd_server-0.9.0/pyproject.toml +36 -0
  29. epd_server-0.9.0/setup.cfg +4 -0
  30. epd_server-0.9.0/tests/test_app.py +859 -0
  31. epd_server-0.9.0/tests/test_cache.py +51 -0
  32. epd_server-0.9.0/tests/test_compat.py +33 -0
  33. epd_server-0.9.0/tests/test_config.py +153 -0
  34. epd_server-0.9.0/tests/test_examples.py +163 -0
  35. epd_server-0.9.0/tests/test_firmware.py +371 -0
  36. epd_server-0.9.0/tests/test_logs.py +78 -0
  37. epd_server-0.9.0/tests/test_mqtt.py +109 -0
  38. epd_server-0.9.0/tests/test_page.py +124 -0
  39. epd_server-0.9.0/tests/test_pipeline.py +116 -0
  40. epd_server-0.9.0/tests/test_posix_tz.py +59 -0
  41. epd_server-0.9.0/tests/test_quantise.py +150 -0
  42. epd_server-0.9.0/tests/test_registry.py +55 -0
  43. epd_server-0.9.0/tests/test_render.py +42 -0
  44. epd_server-0.9.0/tests/test_scheduling.py +330 -0
  45. epd_server-0.9.0/tests/test_settings.py +342 -0
  46. epd_server-0.9.0/tests/test_source.py +92 -0
  47. epd_server-0.9.0/tests/test_store.py +125 -0
  48. epd_server-0.9.0/tests/test_timeranges.py +266 -0
  49. epd_server-0.9.0/tests/test_version.py +45 -0
@@ -0,0 +1,304 @@
1
+ Metadata-Version: 2.4
2
+ Name: epd-server
3
+ Version: 0.9.0
4
+ Summary: Scheduled HTML-to-PNG rendering for battery-powered e-paper clients.
5
+ Author: Chris Twomey
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/chrisjtwomey/epd
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: airium>=0.2.7
11
+ Requires-Dist: Pillow>=10
12
+ Requires-Dist: selenium>=4.10
13
+ Requires-Dist: paho-mqtt>=2.0
14
+ Requires-Dist: PyYAML>=6.0
15
+ Requires-Dist: Flask>=3.0
16
+ Requires-Dist: Werkzeug>=3.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.4; extra == "dev"
19
+ Requires-Dist: freezegun>=1.4; extra == "dev"
20
+
21
+ # epd-server
22
+
23
+ The generic half of a scheduled e-paper image server: config resolution,
24
+ a plugin registry, a disk cache, HTML-to-PNG page rendering, and the
25
+ DST-correct wake/regeneration maths behind the `EPD-Next-Display-Refresh-Seconds` /
26
+ `EPD-Next-URL` headers.
27
+
28
+ A project supplies its pages and its data sources; this package supplies
29
+ everything that does not depend on what is being displayed.
30
+
31
+ ## Install
32
+
33
+ Local checkout, editable:
34
+
35
+ ```sh
36
+ pip install -e ../epd/server
37
+ ```
38
+
39
+ Install it after the project's requirements. pip treats a `requirements.txt`
40
+ pin like the one below as a direct reference, so any later `pip install -r`
41
+ replaces the editable checkout with the tagged release, whatever the versions.
42
+
43
+ From GitHub, in a `requirements.txt`, pinned to a release tag:
44
+
45
+ ```
46
+ epd-server @ git+https://github.com/chrisjtwomey/epd.git@v<version>#subdirectory=server
47
+ ```
48
+
49
+ pip honours `#subdirectory=` only for VCS URLs, so a git binary is needed
50
+ wherever this is installed; add one to your Dockerfile.
51
+
52
+ ## Modules
53
+
54
+ | Module | Provides |
55
+ |---|---|
56
+ | `epd_server.config` | `get_prop`, `get_prop_by_keys` — env var > YAML > default, with type coercion. `load_core_config()` validates the `server`, `image`, `mqtt`, `display` and `debug` blocks into typed settings; `load_yaml()` reads the file |
57
+ | `epd_server.registry` | `Registry` — name → class, `create()` forwards only declared kwargs |
58
+ | `epd_server.cache` | `DiskCache` — JSON file cache with per-key TTL, datetimes round-trip |
59
+ | `epd_server.page` | `Page` — build HTML with Airium, then `save()` renders and quantises it. Both steps are pluggable. |
60
+ | `epd_server.render` | `Renderer` protocol; `ChromiumRenderer` (headless, via Selenium) is the default |
61
+ | `epd_server.quantise` | `Quantiser` protocol; `GreyscaleQuantiser(levels=4)` default, `PaletteQuantiser` for colour panels, `IdentityQuantiser` for none |
62
+ | `epd_server.scheduling` | `Pools`, `TimesSchedule`, `TimeRangesSchedule` — what shows and when; `next_wake`, `next_regen`, `seconds_until` underneath |
63
+ | `epd_server.timeranges` | `TimeRanges` — a day of time ranges, each with an interval; `Week` — groups of days, each with its day of ranges; the slots in them, DST-correct |
64
+ | `epd_server.firmware` | `FirmwareStore` — a directory of `<version>.bin`; `ReleaseWatcher` — fill it from a repository's releases; `client_from_headers`, `parse_user_agent`, `is_clean_tag`, `update_applies` — which board an image is an update for |
65
+ | `epd_server.mqtt` | `client_log_subscriber` — relay the client's MQTT log topic into Python logging |
66
+ | `epd_server.source` | `DataSource` — named, lazily fetched datasets; `StaticSource` for constants; `CompositeSource` to merge; `IngestSource` — what a board posted, from a `ReadingsStore` |
67
+ | `epd_server.store` | `ReadingsStore` — what a board posts, in SQLite, kept by its `device` and `ts` and read back by time |
68
+ | `epd_server.pipeline` | `regenerate(pages, source, only=, force_refresh=)` — fetch what the selected pages need, once each; render; save |
69
+ | `epd_server.app` | `DisplayServer(pages, source, schedule, tz, …).run()` — routes, `EPD-Next-*` headers, ingest and query routes, regen loop, client log relay, signals. `align_process_timezone()` |
70
+ | `epd_server.headers` | `Wire` — every header name, for one product's prefix |
71
+ | `epd_server.compat` | Whether a board's version and the server's work together, by the rule in [docs/protocol.md](../docs/protocol.md) |
72
+ | `epd_server.posix_tz` | The server's time zone as a POSIX TZ string, for `EPD-Server-Timezone` |
73
+ | `epd_server.logs` | What each board logs over MQTT, kept on disk and read back in order |
74
+
75
+ ## Tests
76
+
77
+ ```sh
78
+ pip install -e '.[dev]'
79
+ pytest
80
+ ```
81
+
82
+ Nothing here needs Chromium or a network: `Page.save()` is tested with a fake `Renderer`,
83
+ `DisplayServer` with Flask's test client and a stand-in shutdown event,
84
+ and `GreyscaleQuantiser(levels=4)` is checked byte-for-byte against the
85
+ algorithm it replaced.
86
+
87
+ ## A whole server
88
+
89
+ ```python
90
+ from epd_server import DisplayServer, align_process_timezone, load_core_config, load_yaml
91
+ from epd_server.config import MqttSettings
92
+
93
+ raw = load_yaml("config.yaml")
94
+ core = load_core_config(raw, default_display={"pools": {"now": ["now.png"]},
95
+ "schedule": {"type": "times", "08:00:00": "now"}})
96
+ align_process_timezone(core.server.timezone)
97
+
98
+ DisplayServer(
99
+ pages=[NowPage("now", **core.image.page_kwargs(), html_dir=..., png_dir=...)],
100
+ source=Sensors(),
101
+ schedule=core.server.schedule,
102
+ tz=core.server.timezone,
103
+ regen_lead_seconds=core.server.regen_lead_seconds,
104
+ port=core.server.port,
105
+ mqtt=core.mqtt,
106
+ ).run(once="--once" in sys.argv)
107
+ ```
108
+
109
+ `run()` starts the HTTP server on a thread, relays the client's MQTT log
110
+ topic if enabled, and renders every page on a thread of its own, so the
111
+ server answers while it renders. A page asked for before its first render
112
+ gets `503` with `Retry-After`. `run()` then sleeps until `regen_lead_seconds`
113
+ before each scheduled wake, regenerating that wake's page with a fresh
114
+ fetch. `SIGTERM` / `SIGINT` stop it cleanly.
115
+
116
+ Routes come from the page list — `/<page>.png` for each — plus `/`, which
117
+ returns the page list, the schedule and the next wake as JSON. The schedule
118
+ is checked against the pages at construction, so a typo in `config.yaml`
119
+ fails at startup instead of silently regenerating nothing.
120
+
121
+ ## Readings from a board
122
+
123
+ A board can post what it measures. `ingest={name: handler}`
124
+ gives the server a `POST /<name>` route, and `queries={name: handler}` a
125
+ `GET /<name>` one; [docs/protocol.md](../docs/protocol.md) has both. To keep
126
+ what arrives, hand the route to a `ReadingsStore` and serve the store to the
127
+ pages through an `IngestSource`:
128
+
129
+ ```python
130
+ from epd_server import IngestSource, ReadingsStore
131
+
132
+ store = ReadingsStore("readings.db")
133
+ DisplayServer(..., source=IngestSource(store, hours=(24, 72)),
134
+ ingest={"readings": store.add_many})
135
+ ```
136
+
137
+ The pages then ask for `latest`, the newest document or None before the
138
+ first, and `history_24h` and `history_72h`, the documents of each window,
139
+ oldest first. A document is kept by its own `ts`, so one a board held while
140
+ the server was down lands where it belongs, and a second copy of the same
141
+ `device` and `ts` is ignored. `add_many` writes a batch in one transaction
142
+ and answers with which documents were new. `store.prune(before)` deletes
143
+ older ones.
144
+
145
+ ## Config
146
+
147
+ Every epd server shares the same generic blocks. Validate them once, then
148
+ read your own keys with the same env-overridable lookups:
149
+
150
+ ```python
151
+ from epd_server import ConfigError, load_core_config, load_yaml
152
+ from epd_server.config import get_prop_by_keys
153
+
154
+ raw = load_yaml("config.yaml")
155
+ try:
156
+ core = load_core_config(raw, default_display={"pools": {"now": ["now.png"]},
157
+ "schedule": {"type": "times", "08:00:00": "now"}})
158
+ broker = get_prop_by_keys(raw, "sensors", "broker", required=True) # SENSORS_BROKER env works too
159
+ except (ConfigError, KeyError) as exc:
160
+ sys.exit(f"config: {exc.args[0]}")
161
+
162
+ core.server.port, core.server.timezone, core.server.schedule
163
+ core.image.page_kwargs() # -> kwargs for Page(...)
164
+ core.mqtt.enabled, core.mqtt.host, core.mqtt.port, core.mqtt.prefix
165
+ ```
166
+
167
+ ```yaml
168
+ server:
169
+ port: 8080
170
+ timezone: Europe/Dublin # IANA; default is the host's zone
171
+ regen_lead_seconds: 120 # regenerate this long before each wake
172
+ display:
173
+ pools: # what shows: each pool is read in turn
174
+ morning: [now.png]
175
+ evening: [trend.png, week.png]
176
+ schedule: # when: one type
177
+ type: times # a pool at each HH:MM:SS in server.timezone
178
+ "08:00:00": morning
179
+ "20:00:00": evening
180
+ # schedule:
181
+ # type: timeranges # or a page at each slot of ranges round the clock,
182
+ # week: # for each group of days, each range running until
183
+ # - days: [mon, tue, wed, thu, fri, sat, sun] # the next starts; every: 0 is off
184
+ # ranges:
185
+ # - {from: "07:00", every: 300}
186
+ # - {from: "23:00", every: 0}
187
+ # order: [morning, evening] # visited in turn; default: every pool, as listed
188
+ # reshuffle_hours: 3 # each pool's random start moves this often
189
+ image:
190
+ width: 825
191
+ height: 1200
192
+ innerWidth: 825 # content box, <= width
193
+ innerHeight: 1200
194
+ innerAlignX: center # left | center | right
195
+ innerAlignY: center # top | center | bottom
196
+ client: # what the boards this server serves run
197
+ firmware: # server-driven client updates
198
+ enabled: false
199
+ dir: firmware # a directory of <version>.bin; nothing is removed from it
200
+ product: my-display # the client name a board reports
201
+ # products: [my-display, my-sensor] # several, each in dir/<product>/
202
+ offer_dev_builds: false # true offers every developer build the image, not only an older one
203
+ mqtt: # relay every board's log topic, <prefix>/<board>
204
+ enabled: false
205
+ host: localhost
206
+ port: 1883
207
+ prefix: mqtt/epd
208
+ debug: false
209
+ ```
210
+
211
+ `client.firmware` lets the server flash the boards it serves. It sits under
212
+ `client` because every key in it describes the board rather than this
213
+ server. Put an image in `dir` named for its version, `v1.6.0.bin`, and every
214
+ board of that product running a different version is offered it on its next
215
+ request. The version is the filename, so nothing else has to be written.
216
+ With `DisplayServer(version_gate=True)` the offer is the newest image that
217
+ can work with the server's own version, not the newest file, so older images
218
+ stay. A
219
+ relative `dir` is resolved against the directory holding `config.yaml`.
220
+ `products` lists several products, each with its images in a subdirectory of
221
+ its name.
222
+
223
+ A board built from a tag takes the update. One built from a working tree
224
+ (`v1.5.1-3-gab12cd4`, `-dirty`) takes it only when the image is newer than
225
+ its version, so a board tested on a commit moves to the release tagged on it,
226
+ and a bench build past the release is not flashed back. A version the server
227
+ cannot read (`dev`) is left alone. `offer_dev_builds` offers every developer
228
+ build the image. A project passes its own client name as
229
+ `default_firmware_product=` to `load_core_config`, so the config file only
230
+ needs `enabled: true`.
231
+
232
+ Add a `source` block and the server fills `dir` itself, from a
233
+ repository's releases:
234
+
235
+ ```yaml
236
+ client:
237
+ firmware:
238
+ enabled: true
239
+ source:
240
+ github: owner/repo
241
+ asset: firmware.bin
242
+ poll_seconds: 3600
243
+ token: "" # a private repository
244
+ ```
245
+
246
+ It asks GitHub for the latest release on a background thread, and takes the
247
+ named asset whenever the tag is not the version already held. An `ETag`
248
+ makes an unchanged answer cheap. A private repository needs a token, which
249
+ belongs in `CLIENT_FIRMWARE_SOURCE_TOKEN` rather than the file. With
250
+ `source` set, `product` defaults to the repository name.
251
+
252
+ Every key can be overridden by an env var named from its path:
253
+ `SERVER_PORT`, `IMAGE_INNERWIDTH`, `MQTT_ENABLED`, `DEBUG`.
254
+
255
+ ## Wiring a project
256
+
257
+ A project supplies pages and a data source; the kit joins them.
258
+
259
+ ```python
260
+ from epd_server import Page, DataSource, StaticSource, CompositeSource, SkipPage, regenerate
261
+
262
+ class Sensors(DataSource):
263
+ def datasets(self):
264
+ return {"readings": self.read_now, "history": self.read_history} # lazy
265
+ def invalidate(self):
266
+ self.cache.clear()
267
+
268
+ class NowPage(Page):
269
+ requires = ("readings",) # names from datasets()
270
+ def template(self, readings): # arrives as kwargs
271
+ ...build self.airium...
272
+
273
+ class TrendPage(Page):
274
+ requires = ("readings", "history")
275
+ def template(self, readings, history):
276
+ if len(history) < 2:
277
+ raise SkipPage("not enough history yet") # keeps the old PNG
278
+ ...
279
+
280
+ pages = [NowPage("now", 800, 600, html_dir=..., png_dir=...), TrendPage(...)]
281
+ source = CompositeSource(StaticSource(title="Kitchen"), Sensors())
282
+
283
+ regenerate(pages, source) # all pages, each dataset fetched once
284
+ regenerate(pages, source, only="trend.png", force_refresh=True)
285
+ ```
286
+
287
+ `regenerate` raises `ValueError` for an unknown `only`, and `KeyError` if a
288
+ page requires a dataset the source does not provide — both before fetching
289
+ anything.
290
+
291
+ ## Matching a panel
292
+
293
+ ```python
294
+ from epd_server import Page, GreyscaleQuantiser, PaletteQuantiser
295
+
296
+ Page(..., quantiser=GreyscaleQuantiser(levels=2)) # 1-bit mono
297
+ Page(..., quantiser=GreyscaleQuantiser(levels=8)) # 3-bit grey (Inkplate 10, 5 Gen2)
298
+ Page(..., quantiser=PaletteQuantiser([ # 7-colour ACeP
299
+ (0,0,0), (255,255,255), (0,255,0), (0,0,255),
300
+ (255,0,0), (255,255,0), (255,128,0),
301
+ ]))
302
+ ```
303
+
304
+ The default stays at four greys, which suits a monochrome Inkplate panel.
@@ -0,0 +1,284 @@
1
+ # epd-server
2
+
3
+ The generic half of a scheduled e-paper image server: config resolution,
4
+ a plugin registry, a disk cache, HTML-to-PNG page rendering, and the
5
+ DST-correct wake/regeneration maths behind the `EPD-Next-Display-Refresh-Seconds` /
6
+ `EPD-Next-URL` headers.
7
+
8
+ A project supplies its pages and its data sources; this package supplies
9
+ everything that does not depend on what is being displayed.
10
+
11
+ ## Install
12
+
13
+ Local checkout, editable:
14
+
15
+ ```sh
16
+ pip install -e ../epd/server
17
+ ```
18
+
19
+ Install it after the project's requirements. pip treats a `requirements.txt`
20
+ pin like the one below as a direct reference, so any later `pip install -r`
21
+ replaces the editable checkout with the tagged release, whatever the versions.
22
+
23
+ From GitHub, in a `requirements.txt`, pinned to a release tag:
24
+
25
+ ```
26
+ epd-server @ git+https://github.com/chrisjtwomey/epd.git@v<version>#subdirectory=server
27
+ ```
28
+
29
+ pip honours `#subdirectory=` only for VCS URLs, so a git binary is needed
30
+ wherever this is installed; add one to your Dockerfile.
31
+
32
+ ## Modules
33
+
34
+ | Module | Provides |
35
+ |---|---|
36
+ | `epd_server.config` | `get_prop`, `get_prop_by_keys` — env var > YAML > default, with type coercion. `load_core_config()` validates the `server`, `image`, `mqtt`, `display` and `debug` blocks into typed settings; `load_yaml()` reads the file |
37
+ | `epd_server.registry` | `Registry` — name → class, `create()` forwards only declared kwargs |
38
+ | `epd_server.cache` | `DiskCache` — JSON file cache with per-key TTL, datetimes round-trip |
39
+ | `epd_server.page` | `Page` — build HTML with Airium, then `save()` renders and quantises it. Both steps are pluggable. |
40
+ | `epd_server.render` | `Renderer` protocol; `ChromiumRenderer` (headless, via Selenium) is the default |
41
+ | `epd_server.quantise` | `Quantiser` protocol; `GreyscaleQuantiser(levels=4)` default, `PaletteQuantiser` for colour panels, `IdentityQuantiser` for none |
42
+ | `epd_server.scheduling` | `Pools`, `TimesSchedule`, `TimeRangesSchedule` — what shows and when; `next_wake`, `next_regen`, `seconds_until` underneath |
43
+ | `epd_server.timeranges` | `TimeRanges` — a day of time ranges, each with an interval; `Week` — groups of days, each with its day of ranges; the slots in them, DST-correct |
44
+ | `epd_server.firmware` | `FirmwareStore` — a directory of `<version>.bin`; `ReleaseWatcher` — fill it from a repository's releases; `client_from_headers`, `parse_user_agent`, `is_clean_tag`, `update_applies` — which board an image is an update for |
45
+ | `epd_server.mqtt` | `client_log_subscriber` — relay the client's MQTT log topic into Python logging |
46
+ | `epd_server.source` | `DataSource` — named, lazily fetched datasets; `StaticSource` for constants; `CompositeSource` to merge; `IngestSource` — what a board posted, from a `ReadingsStore` |
47
+ | `epd_server.store` | `ReadingsStore` — what a board posts, in SQLite, kept by its `device` and `ts` and read back by time |
48
+ | `epd_server.pipeline` | `regenerate(pages, source, only=, force_refresh=)` — fetch what the selected pages need, once each; render; save |
49
+ | `epd_server.app` | `DisplayServer(pages, source, schedule, tz, …).run()` — routes, `EPD-Next-*` headers, ingest and query routes, regen loop, client log relay, signals. `align_process_timezone()` |
50
+ | `epd_server.headers` | `Wire` — every header name, for one product's prefix |
51
+ | `epd_server.compat` | Whether a board's version and the server's work together, by the rule in [docs/protocol.md](../docs/protocol.md) |
52
+ | `epd_server.posix_tz` | The server's time zone as a POSIX TZ string, for `EPD-Server-Timezone` |
53
+ | `epd_server.logs` | What each board logs over MQTT, kept on disk and read back in order |
54
+
55
+ ## Tests
56
+
57
+ ```sh
58
+ pip install -e '.[dev]'
59
+ pytest
60
+ ```
61
+
62
+ Nothing here needs Chromium or a network: `Page.save()` is tested with a fake `Renderer`,
63
+ `DisplayServer` with Flask's test client and a stand-in shutdown event,
64
+ and `GreyscaleQuantiser(levels=4)` is checked byte-for-byte against the
65
+ algorithm it replaced.
66
+
67
+ ## A whole server
68
+
69
+ ```python
70
+ from epd_server import DisplayServer, align_process_timezone, load_core_config, load_yaml
71
+ from epd_server.config import MqttSettings
72
+
73
+ raw = load_yaml("config.yaml")
74
+ core = load_core_config(raw, default_display={"pools": {"now": ["now.png"]},
75
+ "schedule": {"type": "times", "08:00:00": "now"}})
76
+ align_process_timezone(core.server.timezone)
77
+
78
+ DisplayServer(
79
+ pages=[NowPage("now", **core.image.page_kwargs(), html_dir=..., png_dir=...)],
80
+ source=Sensors(),
81
+ schedule=core.server.schedule,
82
+ tz=core.server.timezone,
83
+ regen_lead_seconds=core.server.regen_lead_seconds,
84
+ port=core.server.port,
85
+ mqtt=core.mqtt,
86
+ ).run(once="--once" in sys.argv)
87
+ ```
88
+
89
+ `run()` starts the HTTP server on a thread, relays the client's MQTT log
90
+ topic if enabled, and renders every page on a thread of its own, so the
91
+ server answers while it renders. A page asked for before its first render
92
+ gets `503` with `Retry-After`. `run()` then sleeps until `regen_lead_seconds`
93
+ before each scheduled wake, regenerating that wake's page with a fresh
94
+ fetch. `SIGTERM` / `SIGINT` stop it cleanly.
95
+
96
+ Routes come from the page list — `/<page>.png` for each — plus `/`, which
97
+ returns the page list, the schedule and the next wake as JSON. The schedule
98
+ is checked against the pages at construction, so a typo in `config.yaml`
99
+ fails at startup instead of silently regenerating nothing.
100
+
101
+ ## Readings from a board
102
+
103
+ A board can post what it measures. `ingest={name: handler}`
104
+ gives the server a `POST /<name>` route, and `queries={name: handler}` a
105
+ `GET /<name>` one; [docs/protocol.md](../docs/protocol.md) has both. To keep
106
+ what arrives, hand the route to a `ReadingsStore` and serve the store to the
107
+ pages through an `IngestSource`:
108
+
109
+ ```python
110
+ from epd_server import IngestSource, ReadingsStore
111
+
112
+ store = ReadingsStore("readings.db")
113
+ DisplayServer(..., source=IngestSource(store, hours=(24, 72)),
114
+ ingest={"readings": store.add_many})
115
+ ```
116
+
117
+ The pages then ask for `latest`, the newest document or None before the
118
+ first, and `history_24h` and `history_72h`, the documents of each window,
119
+ oldest first. A document is kept by its own `ts`, so one a board held while
120
+ the server was down lands where it belongs, and a second copy of the same
121
+ `device` and `ts` is ignored. `add_many` writes a batch in one transaction
122
+ and answers with which documents were new. `store.prune(before)` deletes
123
+ older ones.
124
+
125
+ ## Config
126
+
127
+ Every epd server shares the same generic blocks. Validate them once, then
128
+ read your own keys with the same env-overridable lookups:
129
+
130
+ ```python
131
+ from epd_server import ConfigError, load_core_config, load_yaml
132
+ from epd_server.config import get_prop_by_keys
133
+
134
+ raw = load_yaml("config.yaml")
135
+ try:
136
+ core = load_core_config(raw, default_display={"pools": {"now": ["now.png"]},
137
+ "schedule": {"type": "times", "08:00:00": "now"}})
138
+ broker = get_prop_by_keys(raw, "sensors", "broker", required=True) # SENSORS_BROKER env works too
139
+ except (ConfigError, KeyError) as exc:
140
+ sys.exit(f"config: {exc.args[0]}")
141
+
142
+ core.server.port, core.server.timezone, core.server.schedule
143
+ core.image.page_kwargs() # -> kwargs for Page(...)
144
+ core.mqtt.enabled, core.mqtt.host, core.mqtt.port, core.mqtt.prefix
145
+ ```
146
+
147
+ ```yaml
148
+ server:
149
+ port: 8080
150
+ timezone: Europe/Dublin # IANA; default is the host's zone
151
+ regen_lead_seconds: 120 # regenerate this long before each wake
152
+ display:
153
+ pools: # what shows: each pool is read in turn
154
+ morning: [now.png]
155
+ evening: [trend.png, week.png]
156
+ schedule: # when: one type
157
+ type: times # a pool at each HH:MM:SS in server.timezone
158
+ "08:00:00": morning
159
+ "20:00:00": evening
160
+ # schedule:
161
+ # type: timeranges # or a page at each slot of ranges round the clock,
162
+ # week: # for each group of days, each range running until
163
+ # - days: [mon, tue, wed, thu, fri, sat, sun] # the next starts; every: 0 is off
164
+ # ranges:
165
+ # - {from: "07:00", every: 300}
166
+ # - {from: "23:00", every: 0}
167
+ # order: [morning, evening] # visited in turn; default: every pool, as listed
168
+ # reshuffle_hours: 3 # each pool's random start moves this often
169
+ image:
170
+ width: 825
171
+ height: 1200
172
+ innerWidth: 825 # content box, <= width
173
+ innerHeight: 1200
174
+ innerAlignX: center # left | center | right
175
+ innerAlignY: center # top | center | bottom
176
+ client: # what the boards this server serves run
177
+ firmware: # server-driven client updates
178
+ enabled: false
179
+ dir: firmware # a directory of <version>.bin; nothing is removed from it
180
+ product: my-display # the client name a board reports
181
+ # products: [my-display, my-sensor] # several, each in dir/<product>/
182
+ offer_dev_builds: false # true offers every developer build the image, not only an older one
183
+ mqtt: # relay every board's log topic, <prefix>/<board>
184
+ enabled: false
185
+ host: localhost
186
+ port: 1883
187
+ prefix: mqtt/epd
188
+ debug: false
189
+ ```
190
+
191
+ `client.firmware` lets the server flash the boards it serves. It sits under
192
+ `client` because every key in it describes the board rather than this
193
+ server. Put an image in `dir` named for its version, `v1.6.0.bin`, and every
194
+ board of that product running a different version is offered it on its next
195
+ request. The version is the filename, so nothing else has to be written.
196
+ With `DisplayServer(version_gate=True)` the offer is the newest image that
197
+ can work with the server's own version, not the newest file, so older images
198
+ stay. A
199
+ relative `dir` is resolved against the directory holding `config.yaml`.
200
+ `products` lists several products, each with its images in a subdirectory of
201
+ its name.
202
+
203
+ A board built from a tag takes the update. One built from a working tree
204
+ (`v1.5.1-3-gab12cd4`, `-dirty`) takes it only when the image is newer than
205
+ its version, so a board tested on a commit moves to the release tagged on it,
206
+ and a bench build past the release is not flashed back. A version the server
207
+ cannot read (`dev`) is left alone. `offer_dev_builds` offers every developer
208
+ build the image. A project passes its own client name as
209
+ `default_firmware_product=` to `load_core_config`, so the config file only
210
+ needs `enabled: true`.
211
+
212
+ Add a `source` block and the server fills `dir` itself, from a
213
+ repository's releases:
214
+
215
+ ```yaml
216
+ client:
217
+ firmware:
218
+ enabled: true
219
+ source:
220
+ github: owner/repo
221
+ asset: firmware.bin
222
+ poll_seconds: 3600
223
+ token: "" # a private repository
224
+ ```
225
+
226
+ It asks GitHub for the latest release on a background thread, and takes the
227
+ named asset whenever the tag is not the version already held. An `ETag`
228
+ makes an unchanged answer cheap. A private repository needs a token, which
229
+ belongs in `CLIENT_FIRMWARE_SOURCE_TOKEN` rather than the file. With
230
+ `source` set, `product` defaults to the repository name.
231
+
232
+ Every key can be overridden by an env var named from its path:
233
+ `SERVER_PORT`, `IMAGE_INNERWIDTH`, `MQTT_ENABLED`, `DEBUG`.
234
+
235
+ ## Wiring a project
236
+
237
+ A project supplies pages and a data source; the kit joins them.
238
+
239
+ ```python
240
+ from epd_server import Page, DataSource, StaticSource, CompositeSource, SkipPage, regenerate
241
+
242
+ class Sensors(DataSource):
243
+ def datasets(self):
244
+ return {"readings": self.read_now, "history": self.read_history} # lazy
245
+ def invalidate(self):
246
+ self.cache.clear()
247
+
248
+ class NowPage(Page):
249
+ requires = ("readings",) # names from datasets()
250
+ def template(self, readings): # arrives as kwargs
251
+ ...build self.airium...
252
+
253
+ class TrendPage(Page):
254
+ requires = ("readings", "history")
255
+ def template(self, readings, history):
256
+ if len(history) < 2:
257
+ raise SkipPage("not enough history yet") # keeps the old PNG
258
+ ...
259
+
260
+ pages = [NowPage("now", 800, 600, html_dir=..., png_dir=...), TrendPage(...)]
261
+ source = CompositeSource(StaticSource(title="Kitchen"), Sensors())
262
+
263
+ regenerate(pages, source) # all pages, each dataset fetched once
264
+ regenerate(pages, source, only="trend.png", force_refresh=True)
265
+ ```
266
+
267
+ `regenerate` raises `ValueError` for an unknown `only`, and `KeyError` if a
268
+ page requires a dataset the source does not provide — both before fetching
269
+ anything.
270
+
271
+ ## Matching a panel
272
+
273
+ ```python
274
+ from epd_server import Page, GreyscaleQuantiser, PaletteQuantiser
275
+
276
+ Page(..., quantiser=GreyscaleQuantiser(levels=2)) # 1-bit mono
277
+ Page(..., quantiser=GreyscaleQuantiser(levels=8)) # 3-bit grey (Inkplate 10, 5 Gen2)
278
+ Page(..., quantiser=PaletteQuantiser([ # 7-colour ACeP
279
+ (0,0,0), (255,255,255), (0,255,0), (0,0,255),
280
+ (255,0,0), (255,255,0), (255,128,0),
281
+ ]))
282
+ ```
283
+
284
+ The default stays at four greys, which suits a monochrome Inkplate panel.
@@ -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
+ ]
@@ -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"