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/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
+ )