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.
- epd_server-0.9.0/PKG-INFO +304 -0
- epd_server-0.9.0/README.md +284 -0
- epd_server-0.9.0/epd_server/__init__.py +71 -0
- epd_server-0.9.0/epd_server/_version.py +8 -0
- epd_server-0.9.0/epd_server/app.py +593 -0
- epd_server-0.9.0/epd_server/cache.py +96 -0
- epd_server-0.9.0/epd_server/compat.py +51 -0
- epd_server-0.9.0/epd_server/config.py +425 -0
- epd_server-0.9.0/epd_server/firmware.py +326 -0
- epd_server-0.9.0/epd_server/headers.py +30 -0
- epd_server-0.9.0/epd_server/logs.py +138 -0
- epd_server-0.9.0/epd_server/mqtt.py +73 -0
- epd_server-0.9.0/epd_server/page.py +173 -0
- epd_server-0.9.0/epd_server/pipeline.py +100 -0
- epd_server-0.9.0/epd_server/posix_tz.py +36 -0
- epd_server-0.9.0/epd_server/quantise.py +88 -0
- epd_server-0.9.0/epd_server/registry.py +80 -0
- epd_server-0.9.0/epd_server/render.py +117 -0
- epd_server-0.9.0/epd_server/scheduling.py +260 -0
- epd_server-0.9.0/epd_server/source.py +93 -0
- epd_server-0.9.0/epd_server/store.py +142 -0
- epd_server-0.9.0/epd_server/timeranges.py +287 -0
- epd_server-0.9.0/epd_server.egg-info/PKG-INFO +304 -0
- epd_server-0.9.0/epd_server.egg-info/SOURCES.txt +47 -0
- epd_server-0.9.0/epd_server.egg-info/dependency_links.txt +1 -0
- epd_server-0.9.0/epd_server.egg-info/requires.txt +11 -0
- epd_server-0.9.0/epd_server.egg-info/top_level.txt +1 -0
- epd_server-0.9.0/pyproject.toml +36 -0
- epd_server-0.9.0/setup.cfg +4 -0
- epd_server-0.9.0/tests/test_app.py +859 -0
- epd_server-0.9.0/tests/test_cache.py +51 -0
- epd_server-0.9.0/tests/test_compat.py +33 -0
- epd_server-0.9.0/tests/test_config.py +153 -0
- epd_server-0.9.0/tests/test_examples.py +163 -0
- epd_server-0.9.0/tests/test_firmware.py +371 -0
- epd_server-0.9.0/tests/test_logs.py +78 -0
- epd_server-0.9.0/tests/test_mqtt.py +109 -0
- epd_server-0.9.0/tests/test_page.py +124 -0
- epd_server-0.9.0/tests/test_pipeline.py +116 -0
- epd_server-0.9.0/tests/test_posix_tz.py +59 -0
- epd_server-0.9.0/tests/test_quantise.py +150 -0
- epd_server-0.9.0/tests/test_registry.py +55 -0
- epd_server-0.9.0/tests/test_render.py +42 -0
- epd_server-0.9.0/tests/test_scheduling.py +330 -0
- epd_server-0.9.0/tests/test_settings.py +342 -0
- epd_server-0.9.0/tests/test_source.py +92 -0
- epd_server-0.9.0/tests/test_store.py +125 -0
- epd_server-0.9.0/tests/test_timeranges.py +266 -0
- 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
|
+
]
|