epd-server 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- epd_server/__init__.py +71 -0
- epd_server/_version.py +8 -0
- epd_server/app.py +593 -0
- epd_server/cache.py +96 -0
- epd_server/compat.py +51 -0
- epd_server/config.py +425 -0
- epd_server/firmware.py +326 -0
- epd_server/headers.py +30 -0
- epd_server/logs.py +138 -0
- epd_server/mqtt.py +73 -0
- epd_server/page.py +173 -0
- epd_server/pipeline.py +100 -0
- epd_server/posix_tz.py +36 -0
- epd_server/quantise.py +88 -0
- epd_server/registry.py +80 -0
- epd_server/render.py +117 -0
- epd_server/scheduling.py +260 -0
- epd_server/source.py +93 -0
- epd_server/store.py +142 -0
- epd_server/timeranges.py +287 -0
- epd_server-0.9.0.dist-info/METADATA +304 -0
- epd_server-0.9.0.dist-info/RECORD +24 -0
- epd_server-0.9.0.dist-info/WHEEL +5 -0
- epd_server-0.9.0.dist-info/top_level.txt +1 -0
epd_server/cache.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared JSON-backed disk cache with per-entry TTL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_TTL = 3300.0 # 55 minutes
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json_default(obj):
|
|
16
|
+
if isinstance(obj, datetime):
|
|
17
|
+
return {"__dt__": obj.isoformat()}
|
|
18
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json_hook(d: dict):
|
|
22
|
+
if "__dt__" in d:
|
|
23
|
+
return datetime.fromisoformat(d["__dt__"])
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DiskCache:
|
|
28
|
+
"""A simple JSON file-backed key/value cache with per-entry TTL.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
cache = DiskCache(Path(__file__).parent / ".cache.json", "MyService")
|
|
33
|
+
|
|
34
|
+
# TTL-based (default 55 min)
|
|
35
|
+
data = cache.get("forecast") # None on miss/expiry
|
|
36
|
+
cache.set("forecast", payload)
|
|
37
|
+
|
|
38
|
+
# Indefinite (e.g. geocoding results)
|
|
39
|
+
coords = cache.get("coords", ttl=None)
|
|
40
|
+
cache.set("coords", {"lat": ..., "lon": ...})
|
|
41
|
+
|
|
42
|
+
# Invalidate computed results without clearing coords
|
|
43
|
+
cache.delete("forecast", "daily_summary", "hourly_forecast")
|
|
44
|
+
|
|
45
|
+
``datetime`` values round-trip transparently.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: str | Path, service_name: str = ""):
|
|
49
|
+
self._path = Path(path)
|
|
50
|
+
self._service_name = service_name or str(self._path)
|
|
51
|
+
|
|
52
|
+
# ── Low-level I/O ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def load(self) -> dict:
|
|
55
|
+
try:
|
|
56
|
+
with open(self._path) as f:
|
|
57
|
+
return json.load(f, object_hook=_json_hook)
|
|
58
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
def save(self, data: dict) -> None:
|
|
62
|
+
try:
|
|
63
|
+
with open(self._path, "w") as f:
|
|
64
|
+
json.dump(data, f, default=_json_default)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
log.warning("Could not write %s cache: %s", self._service_name, exc)
|
|
67
|
+
|
|
68
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def get(self, key: str, ttl: float | None = DEFAULT_TTL):
|
|
71
|
+
"""Return cached data for *key*, or ``None`` if absent or expired.
|
|
72
|
+
|
|
73
|
+
Pass ``ttl=None`` to disable expiry (cache indefinitely).
|
|
74
|
+
"""
|
|
75
|
+
entry = self.load().get(key)
|
|
76
|
+
if entry is None:
|
|
77
|
+
return None
|
|
78
|
+
if ttl is not None and time.time() - entry.get("__ts__", 0) >= ttl:
|
|
79
|
+
return None
|
|
80
|
+
log.debug("%s cache hit: %s", self._service_name, key)
|
|
81
|
+
return entry["__data__"]
|
|
82
|
+
|
|
83
|
+
def set(self, key: str, data) -> None:
|
|
84
|
+
"""Store *data* under *key*, recording the current timestamp."""
|
|
85
|
+
cache = self.load()
|
|
86
|
+
cache[key] = {"__ts__": time.time(), "__data__": data}
|
|
87
|
+
self.save(cache)
|
|
88
|
+
|
|
89
|
+
def delete(self, *keys: str) -> None:
|
|
90
|
+
"""Remove *keys* from the cache (silently ignores missing keys)."""
|
|
91
|
+
cache = self.load()
|
|
92
|
+
if not any(k in cache for k in keys):
|
|
93
|
+
return
|
|
94
|
+
for key in keys:
|
|
95
|
+
cache.pop(key, None)
|
|
96
|
+
self.save(cache)
|
epd_server/compat.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Whether a board and a server can work together, judged by their versions.
|
|
2
|
+
|
|
3
|
+
They match when their major numbers match, or, while the major is 0, their
|
|
4
|
+
major and minor. That is semantic versioning's own rule: before 1.0.0 a minor
|
|
5
|
+
release may break the contract, and after it only a major one may.
|
|
6
|
+
|
|
7
|
+
The firmware applies the same rule in ``version_compat.h``, so both ends
|
|
8
|
+
reach the same answer about each other.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# "v1.2.3", then anything git describe or semver adds: "-44-g2fe55a4-dirty".
|
|
15
|
+
_VERSION = re.compile(r"[vV]?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?")
|
|
16
|
+
# What git describe adds to a build past a tag: how many commits past it.
|
|
17
|
+
_DESCRIBE = re.compile(r"-(\d+)-g[0-9a-fA-F]+(?:-dirty)?")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compatibility_key(version: str | None) -> tuple[int, ...] | None:
|
|
21
|
+
"""The part of a version that decides compatibility, or None when it is
|
|
22
|
+
not a version that can be read, such as ``dev``."""
|
|
23
|
+
if not version:
|
|
24
|
+
return None
|
|
25
|
+
m = _VERSION.fullmatch(version.strip())
|
|
26
|
+
if m is None:
|
|
27
|
+
return None
|
|
28
|
+
major, minor = int(m[1]), int(m[2])
|
|
29
|
+
return (0, minor) if major == 0 else (major,)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def version_order(version: str | None) -> tuple[int, int, int, int] | None:
|
|
33
|
+
"""``(major, minor, patch, commits past the tag)`` for sorting versions,
|
|
34
|
+
or None when it is not a version that can be read. A build past a tag
|
|
35
|
+
sorts after the tag, and after a build fewer commits past it."""
|
|
36
|
+
if not version:
|
|
37
|
+
return None
|
|
38
|
+
version = version.strip()
|
|
39
|
+
m = _VERSION.fullmatch(version)
|
|
40
|
+
if m is None:
|
|
41
|
+
return None
|
|
42
|
+
past = _DESCRIBE.fullmatch(version[m.end(3):])
|
|
43
|
+
return int(m[1]), int(m[2]), int(m[3]), int(past[1]) if past else 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compatible(a: str | None, b: str | None) -> bool | None:
|
|
47
|
+
"""True or False when both can be judged; None when either cannot."""
|
|
48
|
+
ka, kb = compatibility_key(a), compatibility_key(b)
|
|
49
|
+
if ka is None or kb is None:
|
|
50
|
+
return None
|
|
51
|
+
return ka == kb
|
epd_server/config.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Resolve config values with environment-variable override.
|
|
2
|
+
|
|
3
|
+
Precedence for every lookup: **env var > YAML value > default**.
|
|
4
|
+
|
|
5
|
+
The env-var name is derived from the key path, upper-cased and joined with
|
|
6
|
+
underscores: ``("server", "timezone")`` -> ``SERVER_TIMEZONE``. Env strings are
|
|
7
|
+
coerced to the type of the YAML value they replace (or of the default when
|
|
8
|
+
the YAML key is absent), so ``MQTT_ENABLED=true`` yields ``True`` and
|
|
9
|
+
``SERVER_PORT=9090`` yields ``9090``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import operator
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from functools import reduce
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_by_path(root, items):
|
|
21
|
+
"""Access a nested object in ``root`` by a sequence of keys."""
|
|
22
|
+
return reduce(operator.getitem, items, root)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_name(keys) -> str:
|
|
26
|
+
"""``("server", "timezone")`` -> ``"SERVER_TIMEZONE"``."""
|
|
27
|
+
return "_".join(str(k).upper() for k in keys)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _coerce_env(raw: str, reference):
|
|
31
|
+
"""Coerce a string env-var value to match the type of ``reference``.
|
|
32
|
+
|
|
33
|
+
``bool`` is checked before ``int`` because ``bool`` subclasses ``int``.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(reference, bool):
|
|
36
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
37
|
+
if isinstance(reference, int):
|
|
38
|
+
return int(raw)
|
|
39
|
+
if isinstance(reference, float):
|
|
40
|
+
return float(raw)
|
|
41
|
+
if isinstance(reference, list):
|
|
42
|
+
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
43
|
+
return raw
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve(keys, yaml_lookup: Callable[[], Any], default, required: bool) -> Any:
|
|
47
|
+
"""Resolve one value: env var, then ``yaml_lookup()``, then ``default``.
|
|
48
|
+
|
|
49
|
+
Raises ``KeyError`` when the value is required and none of the three
|
|
50
|
+
sources provide it.
|
|
51
|
+
"""
|
|
52
|
+
raw_env = os.environ.get(_env_name(keys))
|
|
53
|
+
try:
|
|
54
|
+
yaml_val = yaml_lookup()
|
|
55
|
+
yaml_present = True
|
|
56
|
+
except (KeyError, TypeError):
|
|
57
|
+
yaml_val = None
|
|
58
|
+
yaml_present = False
|
|
59
|
+
|
|
60
|
+
if raw_env is not None:
|
|
61
|
+
reference = yaml_val if yaml_present else default
|
|
62
|
+
return _coerce_env(raw_env, reference) if reference is not None else raw_env
|
|
63
|
+
|
|
64
|
+
if yaml_present:
|
|
65
|
+
return yaml_val
|
|
66
|
+
|
|
67
|
+
if default is None and required:
|
|
68
|
+
raise KeyError("{} not in config but is required".format(".".join(keys)))
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_prop_by_keys(config, *keys, default=None, required=True) -> Any:
|
|
73
|
+
"""Resolve a nested config value, e.g. ``get_prop_by_keys(cfg, "server", "port")``."""
|
|
74
|
+
return _resolve(keys, lambda: get_by_path(config, keys), default, required)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_prop(config, prop, default=None, required=True) -> Any:
|
|
78
|
+
"""Resolve a top-level config value, e.g. ``get_prop(cfg, "location")``."""
|
|
79
|
+
return _resolve((prop,), lambda: config[prop], default, required)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
83
|
+
# Typed settings for the blocks every epd server has
|
|
84
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
#
|
|
86
|
+
# A project validates its own keys (an API key, a broker topic, a location)
|
|
87
|
+
# and calls load_core_config() for the rest. Every check raises ConfigError
|
|
88
|
+
# with a message meant for the person editing config.yaml; the caller
|
|
89
|
+
# decides whether that means "log and exit".
|
|
90
|
+
|
|
91
|
+
from dataclasses import dataclass
|
|
92
|
+
from datetime import datetime as _datetime
|
|
93
|
+
from datetime import tzinfo as _tzinfo
|
|
94
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
95
|
+
|
|
96
|
+
import yaml
|
|
97
|
+
|
|
98
|
+
from .scheduling import Pools, TimeRangesSchedule, TimesSchedule, WakeSchedule
|
|
99
|
+
from .timeranges import Week
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConfigError(ValueError):
|
|
103
|
+
"""A config value is missing or invalid. The message is user-facing."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_yaml(path) -> dict:
|
|
107
|
+
"""Read a YAML file into a dict. An empty file is an empty dict."""
|
|
108
|
+
with open(path) as f:
|
|
109
|
+
data = yaml.safe_load(f)
|
|
110
|
+
if data is None:
|
|
111
|
+
return {}
|
|
112
|
+
if not isinstance(data, dict):
|
|
113
|
+
raise ConfigError(f"{path}: top level must be a mapping")
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ServerSettings:
|
|
119
|
+
port: int
|
|
120
|
+
timezone: _tzinfo
|
|
121
|
+
schedule: WakeSchedule # from the display block: pools, and when they show
|
|
122
|
+
regen_lead_seconds: int # regenerate this long before each wake
|
|
123
|
+
debug: bool
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class ImageSettings:
|
|
128
|
+
width: int
|
|
129
|
+
height: int
|
|
130
|
+
inner_width: int
|
|
131
|
+
inner_height: int
|
|
132
|
+
inner_align_x: str # left | center | right
|
|
133
|
+
inner_align_y: str # top | center | bottom
|
|
134
|
+
|
|
135
|
+
def page_kwargs(self) -> dict:
|
|
136
|
+
"""Keyword arguments for :class:`epd_server.page.Page`."""
|
|
137
|
+
return dict(
|
|
138
|
+
width=self.width,
|
|
139
|
+
height=self.height,
|
|
140
|
+
inner_width=self.inner_width,
|
|
141
|
+
inner_height=self.inner_height,
|
|
142
|
+
inner_align_x=self.inner_align_x,
|
|
143
|
+
inner_align_y=self.inner_align_y,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class MqttSettings:
|
|
149
|
+
enabled: bool
|
|
150
|
+
host: str
|
|
151
|
+
port: int
|
|
152
|
+
prefix: str # each board logs to <prefix>/<board>
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class FirmwareSource:
|
|
157
|
+
"""Where new images come from, when the server fetches them itself."""
|
|
158
|
+
|
|
159
|
+
github: str # owner/repo
|
|
160
|
+
asset: str # the release asset to take
|
|
161
|
+
poll_seconds: int
|
|
162
|
+
token: str # for a private repository
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class FirmwareSettings:
|
|
167
|
+
enabled: bool
|
|
168
|
+
dir: str # a directory of <version>.bin, or of one per product
|
|
169
|
+
product: str # the client name an image is for
|
|
170
|
+
offer_dev_builds: bool # offer to boards not built from a tag
|
|
171
|
+
source: FirmwareSource | None = None
|
|
172
|
+
products: tuple[str, ...] = () # several products, each in dir/<product>/
|
|
173
|
+
|
|
174
|
+
def names(self) -> tuple[str, ...]:
|
|
175
|
+
"""Every product this server holds images for."""
|
|
176
|
+
return self.products or ((self.product,) if self.product else ())
|
|
177
|
+
|
|
178
|
+
def dir_for(self, product: str) -> str:
|
|
179
|
+
"""Where one product's images are: its own subdirectory when the
|
|
180
|
+
server holds several, the directory itself when it holds one."""
|
|
181
|
+
return os.path.join(self.dir, product) if self.products else self.dir
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class CoreConfig:
|
|
186
|
+
server: ServerSettings
|
|
187
|
+
image: ImageSettings
|
|
188
|
+
mqtt: MqttSettings
|
|
189
|
+
firmware: FirmwareSettings # from the client block: client.firmware
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _positive_int(key: str, value) -> int:
|
|
193
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
194
|
+
raise ConfigError(f"{key} must be a positive integer (got {value!r})")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_SCHEDULE_COMMON = {"type", "reshuffle_hours", "seed"}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_display(config: dict, tz: _tzinfo, default_display: dict | None = None) -> WakeSchedule:
|
|
202
|
+
"""The ``display`` block: ``pools`` of images, and a ``schedule`` of one type.
|
|
203
|
+
|
|
204
|
+
``times`` names a pool at each HH:MM:SS; ``timeranges`` visits the pools
|
|
205
|
+
in ``order`` at each slot of its ``week``, groups of days each with a day
|
|
206
|
+
of time ranges, each range with an interval. Either may set ``reshuffle_hours`` and ``seed`` for the
|
|
207
|
+
pools' random starts.
|
|
208
|
+
"""
|
|
209
|
+
raw = get_prop_by_keys(config, "display", default=default_display, required=False)
|
|
210
|
+
if raw is None:
|
|
211
|
+
raise ConfigError("display is required: pools of images and a schedule with a type")
|
|
212
|
+
if not isinstance(raw, dict):
|
|
213
|
+
raise ConfigError("display needs pools and schedule.")
|
|
214
|
+
unknown = sorted(set(raw) - {"pools", "schedule"})
|
|
215
|
+
if unknown:
|
|
216
|
+
raise ConfigError(f"display takes pools and schedule (got {unknown})")
|
|
217
|
+
|
|
218
|
+
pools_raw = raw.get("pools")
|
|
219
|
+
if not isinstance(pools_raw, dict) or not pools_raw:
|
|
220
|
+
raise ConfigError("display.pools must be a non-empty mapping of pool name to image list")
|
|
221
|
+
pools_clean: dict[str, list[str]] = {}
|
|
222
|
+
for name, pages in pools_raw.items():
|
|
223
|
+
if isinstance(pages, str):
|
|
224
|
+
pages = [pages]
|
|
225
|
+
if (not isinstance(pages, list) or not pages
|
|
226
|
+
or not all(isinstance(p, str) and p.strip() for p in pages)):
|
|
227
|
+
raise ConfigError(f"display.pools.{name} must be a non-empty list of image filenames")
|
|
228
|
+
pools_clean[str(name)] = [p.strip() for p in pages]
|
|
229
|
+
|
|
230
|
+
sched = raw.get("schedule")
|
|
231
|
+
if not isinstance(sched, dict) or not sched:
|
|
232
|
+
raise ConfigError("display.schedule must be a mapping with a type of times or timeranges")
|
|
233
|
+
kind = sched.get("type")
|
|
234
|
+
hours = sched.get("reshuffle_hours", 3)
|
|
235
|
+
if isinstance(hours, bool) or not isinstance(hours, (int, float)) or hours <= 0:
|
|
236
|
+
raise ConfigError("display.schedule.reshuffle_hours must be a positive number of hours")
|
|
237
|
+
seed = sched.get("seed", 0)
|
|
238
|
+
if isinstance(seed, bool) or not isinstance(seed, int):
|
|
239
|
+
raise ConfigError("display.schedule.seed must be an integer")
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
pools = Pools(pools_clean, reshuffle_hours=float(hours), seed=seed)
|
|
243
|
+
if kind == "times":
|
|
244
|
+
times = [(str(t), str(n).strip()) for t, n in sched.items() if t not in _SCHEDULE_COMMON]
|
|
245
|
+
if not times:
|
|
246
|
+
raise ConfigError("display.schedule of type times needs at least one HH:MM:SS: pool entry")
|
|
247
|
+
return TimesSchedule(times, pools, tz)
|
|
248
|
+
if kind == "timeranges":
|
|
249
|
+
extra = sorted(set(sched) - _SCHEDULE_COMMON - {"week", "order"})
|
|
250
|
+
if extra:
|
|
251
|
+
raise ConfigError(
|
|
252
|
+
f"display.schedule of type timeranges takes week, order, reshuffle_hours and seed (got {extra})")
|
|
253
|
+
week = Week.from_config(sched.get("week"), tz, "display.schedule.week")
|
|
254
|
+
order = sched.get("order")
|
|
255
|
+
if order is not None and (not isinstance(order, list) or not all(isinstance(o, str) for o in order)):
|
|
256
|
+
raise ConfigError("display.schedule.order must be a list of pool names")
|
|
257
|
+
return TimeRangesSchedule(week, pools, order=order)
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
if isinstance(exc, ConfigError):
|
|
260
|
+
raise
|
|
261
|
+
msg = str(exc)
|
|
262
|
+
raise ConfigError(msg if msg.startswith("display.") else f"display.schedule: {msg}") from None
|
|
263
|
+
raise ConfigError(f"display.schedule.type must be times or timeranges (got {kind!r})")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_server(
|
|
267
|
+
config: dict,
|
|
268
|
+
*,
|
|
269
|
+
default_display: dict | None = None,
|
|
270
|
+
default_port: int = 8080,
|
|
271
|
+
default_regen_lead_seconds: int = 120,
|
|
272
|
+
) -> ServerSettings:
|
|
273
|
+
port = _positive_int("server.port", get_prop_by_keys(config, "server", "port", default=default_port))
|
|
274
|
+
if port > 65535:
|
|
275
|
+
raise ConfigError(f"server.port must be 65535 or less. It is {port}.")
|
|
276
|
+
|
|
277
|
+
regen_lead = get_prop_by_keys(config, "server", "regen_lead_seconds",
|
|
278
|
+
default=default_regen_lead_seconds)
|
|
279
|
+
if isinstance(regen_lead, bool) or not isinstance(regen_lead, int) or regen_lead < 0:
|
|
280
|
+
raise ConfigError("server.regen_lead_seconds must be a non-negative integer (seconds)")
|
|
281
|
+
|
|
282
|
+
tz: _tzinfo = _datetime.now().astimezone().tzinfo # type: ignore[assignment]
|
|
283
|
+
tz_name = get_prop_by_keys(config, "server", "timezone", default=None, required=False)
|
|
284
|
+
if tz_name:
|
|
285
|
+
try:
|
|
286
|
+
tz = ZoneInfo(str(tz_name))
|
|
287
|
+
except ZoneInfoNotFoundError:
|
|
288
|
+
raise ConfigError(
|
|
289
|
+
f"server.timezone '{tz_name}' is not a valid IANA zone (e.g. Europe/Dublin)"
|
|
290
|
+
) from None
|
|
291
|
+
|
|
292
|
+
schedule = parse_display(config, tz, default_display)
|
|
293
|
+
debug = bool(get_prop(config, "debug", default=False, required=False))
|
|
294
|
+
|
|
295
|
+
return ServerSettings(
|
|
296
|
+
port=port,
|
|
297
|
+
timezone=tz,
|
|
298
|
+
schedule=schedule,
|
|
299
|
+
regen_lead_seconds=regen_lead,
|
|
300
|
+
debug=debug,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_ALIGN_X = ("left", "center", "right")
|
|
305
|
+
_ALIGN_Y = ("top", "center", "bottom")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def parse_image(config: dict, *, default_width: int = 825, default_height: int = 1200) -> ImageSettings:
|
|
309
|
+
width = _positive_int("image.width", get_prop_by_keys(config, "image", "width", default=default_width))
|
|
310
|
+
height = _positive_int("image.height", get_prop_by_keys(config, "image", "height", default=default_height))
|
|
311
|
+
inner_w = _positive_int("image.innerWidth",
|
|
312
|
+
get_prop_by_keys(config, "image", "innerWidth", default=width))
|
|
313
|
+
inner_h = _positive_int("image.innerHeight",
|
|
314
|
+
get_prop_by_keys(config, "image", "innerHeight", default=height))
|
|
315
|
+
if inner_w > width:
|
|
316
|
+
raise ConfigError(f"image.innerWidth ({inner_w}) cannot be greater than image.width ({width})")
|
|
317
|
+
if inner_h > height:
|
|
318
|
+
raise ConfigError(f"image.innerHeight ({inner_h}) cannot be greater than image.height ({height})")
|
|
319
|
+
|
|
320
|
+
align_x = str(get_prop_by_keys(config, "image", "innerAlignX", default="center")).strip().lower()
|
|
321
|
+
align_y = str(get_prop_by_keys(config, "image", "innerAlignY", default="center")).strip().lower()
|
|
322
|
+
if align_x not in _ALIGN_X:
|
|
323
|
+
raise ConfigError(f"image.innerAlignX must be one of {sorted(_ALIGN_X)} (got {align_x!r})")
|
|
324
|
+
if align_y not in _ALIGN_Y:
|
|
325
|
+
raise ConfigError(f"image.innerAlignY must be one of {sorted(_ALIGN_Y)} (got {align_y!r})")
|
|
326
|
+
|
|
327
|
+
return ImageSettings(width, height, inner_w, inner_h, align_x, align_y)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_mqtt(config: dict, *, default_prefix: str = "mqtt/epd") -> MqttSettings:
|
|
331
|
+
enabled = bool(get_prop_by_keys(config, "mqtt", "enabled", default=False))
|
|
332
|
+
host = str(get_prop_by_keys(config, "mqtt", "host", default="localhost"))
|
|
333
|
+
port = _positive_int("mqtt.port", get_prop_by_keys(config, "mqtt", "port", default=1883))
|
|
334
|
+
prefix = str(get_prop_by_keys(config, "mqtt", "prefix", default=default_prefix))
|
|
335
|
+
return MqttSettings(enabled, host, port, prefix)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
_GITHUB_REPO = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _parse_firmware_source(config: dict) -> FirmwareSource | None:
|
|
342
|
+
"""The optional ``client.firmware.source`` block. Absent means images are placed by hand."""
|
|
343
|
+
github = get_prop_by_keys(config, "client", "firmware", "source", "github",
|
|
344
|
+
default="", required=False)
|
|
345
|
+
github = str(github or "").strip()
|
|
346
|
+
if not github:
|
|
347
|
+
return None
|
|
348
|
+
if not _GITHUB_REPO.match(github):
|
|
349
|
+
raise ConfigError(f"client.firmware.source.github must be owner/repo (got {github!r})")
|
|
350
|
+
asset = str(get_prop_by_keys(config, "client", "firmware", "source", "asset",
|
|
351
|
+
default="firmware.bin")).strip()
|
|
352
|
+
if not asset:
|
|
353
|
+
raise ConfigError("client.firmware.source.asset must name the release asset to take")
|
|
354
|
+
poll = _positive_int("client.firmware.source.poll_seconds",
|
|
355
|
+
get_prop_by_keys(config, "client", "firmware", "source", "poll_seconds",
|
|
356
|
+
default=3600))
|
|
357
|
+
token = str(get_prop_by_keys(config, "client", "firmware", "source", "token",
|
|
358
|
+
default="", required=False) or "")
|
|
359
|
+
return FirmwareSource(github=github, asset=asset, poll_seconds=poll, token=token)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def parse_firmware(config: dict, *, default_product: str | None = None,
|
|
363
|
+
base_dir: str | None = None) -> FirmwareSettings:
|
|
364
|
+
"""The ``client.firmware`` block: where the images are, and who they are for.
|
|
365
|
+
|
|
366
|
+
It sits under ``client`` because every key in it describes the board, not
|
|
367
|
+
this server: the images are for the board to run, and ``product`` is the
|
|
368
|
+
name the board reports in its User-Agent. ``product`` is required once the
|
|
369
|
+
block is enabled, because offering one product's image to another
|
|
370
|
+
product's board would brick it. A relative ``dir`` is resolved against
|
|
371
|
+
``base_dir``, which a project sets to wherever its config.yaml is.
|
|
372
|
+
|
|
373
|
+
``products`` names several products instead, each with its images in a
|
|
374
|
+
subdirectory of its own name; ``product`` then defaults to the first.
|
|
375
|
+
"""
|
|
376
|
+
enabled = bool(get_prop_by_keys(config, "client", "firmware", "enabled", default=False))
|
|
377
|
+
directory = str(get_prop_by_keys(config, "client", "firmware", "dir", default="firmware"))
|
|
378
|
+
if base_dir:
|
|
379
|
+
directory = os.path.join(base_dir, directory)
|
|
380
|
+
offer_dev = bool(get_prop_by_keys(config, "client", "firmware", "offer_dev_builds", default=False))
|
|
381
|
+
source = _parse_firmware_source(config)
|
|
382
|
+
if source and not default_product:
|
|
383
|
+
default_product = source.github.split("/")[-1]
|
|
384
|
+
products = get_prop_by_keys(config, "client", "firmware", "products", default=None,
|
|
385
|
+
required=False) or ()
|
|
386
|
+
if isinstance(products, str) or not all(isinstance(p, str) and p.strip() for p in products):
|
|
387
|
+
raise ConfigError("client.firmware.products must be a list of client names")
|
|
388
|
+
products = tuple(p.strip() for p in products)
|
|
389
|
+
if products and not default_product:
|
|
390
|
+
default_product = products[0]
|
|
391
|
+
product = str(get_prop_by_keys(config, "client", "firmware", "product",
|
|
392
|
+
default=default_product or "", required=False) or "").strip()
|
|
393
|
+
if enabled and not product:
|
|
394
|
+
raise ConfigError("client.firmware.product is required when it is enabled: "
|
|
395
|
+
"the client name the board sends, e.g. my-display")
|
|
396
|
+
return FirmwareSettings(enabled=enabled, dir=directory, product=product,
|
|
397
|
+
offer_dev_builds=offer_dev, source=source, products=products)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def load_core_config(
|
|
401
|
+
config: dict,
|
|
402
|
+
*,
|
|
403
|
+
default_display: dict | None = None,
|
|
404
|
+
default_firmware_product: str | None = None,
|
|
405
|
+
base_dir: str | None = None,
|
|
406
|
+
default_port: int = 8080,
|
|
407
|
+
default_regen_lead_seconds: int = 120,
|
|
408
|
+
default_width: int = 825,
|
|
409
|
+
default_height: int = 1200,
|
|
410
|
+
default_mqtt_prefix: str = "mqtt/epd",
|
|
411
|
+
) -> CoreConfig:
|
|
412
|
+
"""Validate the generic blocks of a config dict.
|
|
413
|
+
|
|
414
|
+
Raises :class:`ConfigError` (a ``ValueError``) on the first problem, with
|
|
415
|
+
a message for the person editing the file. A project validates its own
|
|
416
|
+
keys separately and decides how to report — typically log and exit.
|
|
417
|
+
"""
|
|
418
|
+
return CoreConfig(
|
|
419
|
+
server=parse_server(config, default_display=default_display, default_port=default_port,
|
|
420
|
+
default_regen_lead_seconds=default_regen_lead_seconds),
|
|
421
|
+
image=parse_image(config, default_width=default_width, default_height=default_height),
|
|
422
|
+
mqtt=parse_mqtt(config, default_prefix=default_mqtt_prefix),
|
|
423
|
+
firmware=parse_firmware(config, default_product=default_firmware_product,
|
|
424
|
+
base_dir=base_dir),
|
|
425
|
+
)
|