labpulse 0.3.6__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.
Files changed (102) hide show
  1. labpulse/README.md +35 -0
  2. labpulse/__init__.py +9 -0
  3. labpulse/backup.py +524 -0
  4. labpulse/common/README.md +32 -0
  5. labpulse/common/__init__.py +1 -0
  6. labpulse/common/config.py +620 -0
  7. labpulse/common/fake_config.py +11 -0
  8. labpulse/common/generated_files.py +27 -0
  9. labpulse/common/identity.py +32 -0
  10. labpulse/common/logging_config.py +58 -0
  11. labpulse/common/measurement_config.py +330 -0
  12. labpulse/common/mqtt_contracts.py +89 -0
  13. labpulse/common/output_config.py +52 -0
  14. labpulse/common/service_config.py +199 -0
  15. labpulse/common/sms_templates.py +76 -0
  16. labpulse/common/sms_templates.yaml +73 -0
  17. labpulse/control.py +1007 -0
  18. labpulse/deployment/README.md +24 -0
  19. labpulse/deployment/__init__.py +1 -0
  20. labpulse/deployment/__main__.py +7 -0
  21. labpulse/deployment/compose.py +197 -0
  22. labpulse/deployment/generate.py +225 -0
  23. labpulse/deployment/mosquitto.py +60 -0
  24. labpulse/doctor.py +635 -0
  25. labpulse/hardware/README.md +31 -0
  26. labpulse/hardware/__init__.py +1 -0
  27. labpulse/hardware/__main__.py +93 -0
  28. labpulse/hardware/_simulation.py +140 -0
  29. labpulse/hardware/driver.py +166 -0
  30. labpulse/hardware/drivers/README.md +38 -0
  31. labpulse/hardware/drivers/__init__.py +1 -0
  32. labpulse/hardware/drivers/_gpio.py +44 -0
  33. labpulse/hardware/drivers/dht11.py +118 -0
  34. labpulse/hardware/drivers/gpio_input.py +118 -0
  35. labpulse/hardware/drivers/gpio_output.py +163 -0
  36. labpulse/hardware/drivers/mqtt_json.py +404 -0
  37. labpulse/hardware/drivers/serial_pipe.py +145 -0
  38. labpulse/hardware/drivers/sht40.py +170 -0
  39. labpulse/hardware/drivers/x1200.py +180 -0
  40. labpulse/hardware/homeassistant_publisher.py +224 -0
  41. labpulse/hardware/registry.py +40 -0
  42. labpulse/hardware/runner.py +338 -0
  43. labpulse/homeassistant/README.md +30 -0
  44. labpulse/homeassistant/__init__.py +1 -0
  45. labpulse/homeassistant/__main__.py +7 -0
  46. labpulse/homeassistant/alarm.py +653 -0
  47. labpulse/homeassistant/generator.py +143 -0
  48. labpulse/homeassistant/templates/README.md +32 -0
  49. labpulse/homeassistant/templates/alarm/alarm_package.yaml.j2 +15 -0
  50. labpulse/homeassistant/templates/alarm/automations/installation.yaml.j2 +31 -0
  51. labpulse/homeassistant/templates/alarm/automations/measurement.yaml.j2 +4 -0
  52. labpulse/homeassistant/templates/alarm/automations/measurement_missing_reading.yaml.j2 +148 -0
  53. labpulse/homeassistant/templates/alarm/automations/measurement_state.yaml.j2 +167 -0
  54. labpulse/homeassistant/templates/alarm/automations/power.yaml.j2 +6 -0
  55. labpulse/homeassistant/templates/alarm/automations/power_state.yaml.j2 +142 -0
  56. labpulse/homeassistant/templates/alarm/automations/service_health.yaml.j2 +134 -0
  57. labpulse/homeassistant/templates/alarm/derived_entities.yaml.j2 +191 -0
  58. labpulse/homeassistant/templates/alarm/helpers.yaml.j2 +236 -0
  59. labpulse/homeassistant/templates/alarm/scripts.yaml.j2 +263 -0
  60. labpulse/homeassistant/templates/configuration.yaml.j2 +54 -0
  61. labpulse/homeassistant/templates/dashboard/alarm_setup/bulk_editor.yaml.j2 +204 -0
  62. labpulse/homeassistant/templates/dashboard/alarm_setup/notification_controls.yaml.j2 +30 -0
  63. labpulse/homeassistant/templates/dashboard/alarm_setup/targets.yaml.j2 +102 -0
  64. labpulse/homeassistant/templates/dashboard/alarm_setup.yaml.j2 +11 -0
  65. labpulse/homeassistant/templates/dashboard/custom_dashboards.yaml.j2 +18 -0
  66. labpulse/homeassistant/templates/dashboard/monitor.yaml.j2 +83 -0
  67. labpulse/homeassistant/templates/dashboard/monitor_status.yaml.j2 +135 -0
  68. labpulse/homeassistant/templates/dashboard/power_subviews.yaml.j2 +40 -0
  69. labpulse/homeassistant/templates/dashboard/setup_measurements.yaml.j2 +38 -0
  70. labpulse/homeassistant/templates/dashboard/setup_subviews/measurement_cards.yaml.j2 +329 -0
  71. labpulse/homeassistant/templates/dashboard/setup_subviews/setup_header.yaml.j2 +73 -0
  72. labpulse/homeassistant/templates/dashboard/setup_subviews.yaml.j2 +14 -0
  73. labpulse/homeassistant/templates/dashboard/system_status.yaml.j2 +151 -0
  74. labpulse/homeassistant/templates/dashboard.yaml.j2 +12 -0
  75. labpulse/installer.py +71 -0
  76. labpulse/output/README.md +24 -0
  77. labpulse/output/__init__.py +1 -0
  78. labpulse/output/__main__.py +80 -0
  79. labpulse/output/service.py +323 -0
  80. labpulse/sms/README.md +28 -0
  81. labpulse/sms/__init__.py +1 -0
  82. labpulse/sms/__main__.py +76 -0
  83. labpulse/sms/sender.py +545 -0
  84. labpulse/sms/subscriber.py +295 -0
  85. labpulse-0.3.6.data/data/share/labpulse/config.d/triton-01-measurements.yaml +14 -0
  86. labpulse-0.3.6.data/data/share/labpulse/config.d/triton-02-measurements.yaml +14 -0
  87. labpulse-0.3.6.data/data/share/labpulse/config.yaml +354 -0
  88. labpulse-0.3.6.data/data/share/labpulse/deployment/edit_config.sh +277 -0
  89. labpulse-0.3.6.data/data/share/labpulse/deployment/generate_compose.sh +75 -0
  90. labpulse-0.3.6.data/data/share/labpulse/deployment/generate_homeassistant_config.sh +106 -0
  91. labpulse-0.3.6.data/data/share/labpulse/deployment/setup_container_fs.sh +345 -0
  92. labpulse-0.3.6.data/data/share/labpulse/requirements-host.txt +6 -0
  93. labpulse-0.3.6.data/data/share/labpulse/setup_usb_devices.py +315 -0
  94. labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/hardware_fault_common.sh +121 -0
  95. labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/test_dht11_fault.sh +143 -0
  96. labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/test_x1200_faults.sh +161 -0
  97. labpulse-0.3.6.dist-info/METADATA +228 -0
  98. labpulse-0.3.6.dist-info/RECORD +102 -0
  99. labpulse-0.3.6.dist-info/WHEEL +5 -0
  100. labpulse-0.3.6.dist-info/entry_points.txt +10 -0
  101. labpulse-0.3.6.dist-info/licenses/LICENSE +21 -0
  102. labpulse-0.3.6.dist-info/top_level.txt +1 -0
labpulse/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # LabPulse Python package
2
+
3
+ This directory is the maintained Python implementation. The installed package
4
+ supplies commands that run on the Raspberry Pi host and processes that run in
5
+ Docker containers.
6
+
7
+ | Path | Responsibility | Runs in |
8
+ |---|---|---|
9
+ | `control.py` | Public `labpulse` command: setup, lifecycle, configuration, backup, restore and diagnostics | Pi host |
10
+ | `installer.py` | Locate packaged assets and launch the Linux bootstrap | Pi host |
11
+ | `backup.py` | Create, validate and restore checksummed state archives | Pi host |
12
+ | `doctor.py` | Read-only installation, hardware, Docker and endpoint checks | Pi host |
13
+ | `common/` | Configuration, identity, MQTT, logging and file contracts | Host and containers |
14
+ | `deployment/` | Generate Compose and staged Home Assistant output | Pi host |
15
+ | `hardware/` | Acquire and publish one sensor service | One container per service |
16
+ | `homeassistant/` | Generate dashboards, helpers and alarms | Pi host during generation |
17
+ | `output/` | Apply MQTT commands to one configured output | One container per output |
18
+ | `sms/` | Validate requests and deliver or dry-run SMS messages | SMS container |
19
+
20
+ `__init__.py` exposes the installed version. Executable subpackages use
21
+ `__main__.py` so Compose can run them with `python -m`.
22
+
23
+ Each process loads the validated configuration independently and coordinates
24
+ through MQTT rather than shared Python memory. The operator-owned source is
25
+ `~/labpulse-live/config.yaml`; the repository `config.yaml` is only an
26
+ installation template.
27
+
28
+ Configuration is validated at process boundaries. Drivers normalize hardware,
29
+ runners own retry and freshness, Home Assistant owns alarm decisions, and SMS
30
+ and output workers own their side effects. Do not recreate YAML, identity or
31
+ topic rules inside individual services.
32
+
33
+ Tests are mapped in [`testing/`](../../testing/README.md). Read the
34
+ [User Guide](../../docs/USER_GUIDE.md), [Architecture](../../docs/ARCHITECTURE.md)
35
+ and [Development guide](../../docs/DEVELOPMENT.md) for cross-package behaviour.
labpulse/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """LabPulse monitoring, generation, and alert-delivery packages."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("labpulse")
8
+ except PackageNotFoundError:
9
+ __version__ = "0+unknown"
labpulse/backup.py ADDED
@@ -0,0 +1,524 @@
1
+ """Create and restore guarded LabPulse state archives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from contextlib import contextmanager
7
+ from datetime import datetime, timezone
8
+ import hashlib
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ import json
11
+ import os
12
+ from pathlib import Path, PurePosixPath
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ import tarfile
17
+ from typing import Any, Iterator
18
+ from uuid import uuid4
19
+
20
+
21
+ BACKUP_FORMAT_VERSION = 1
22
+ MANIFEST_NAME = "manifest.json"
23
+ PAYLOAD_DIRECTORY = "payload"
24
+ SNAPSHOT_PATHS = (
25
+ "config.yaml",
26
+ "config.d",
27
+ "homeassistant/config",
28
+ "mosquitto/data",
29
+ "logs/sms_subscriptions.json",
30
+ "logs/sms_processed_requests.json",
31
+ )
32
+ MANDATORY_PATHS = ("config.yaml", "homeassistant/config")
33
+ CONTAINER_SNAPSHOT_PATHS = {
34
+ "homeassistant/config": ("homeassistant", "/config"),
35
+ "mosquitto/data": ("mosquitto", "/mosquitto/data"),
36
+ }
37
+ class BackupError(RuntimeError):
38
+ """Raised when a backup or restore cannot complete safely."""
39
+
40
+
41
+ @contextmanager
42
+ def _temporary_directory(parent: Path, prefix: str) -> Iterator[Path]:
43
+ """Create a private disposable directory without tempfile ACL surprises."""
44
+
45
+ path = parent / f".{prefix}{uuid4().hex}"
46
+ path.mkdir(parents=True)
47
+ if os.name != "nt":
48
+ path.chmod(0o700)
49
+ try:
50
+ yield path
51
+ finally:
52
+ shutil.rmtree(path, ignore_errors=False)
53
+
54
+
55
+ def _sha256(path: Path) -> str:
56
+ """Return the SHA-256 digest of one regular file."""
57
+
58
+ digest = hashlib.sha256()
59
+ with path.open("rb") as stream:
60
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
61
+ digest.update(block)
62
+ return digest.hexdigest()
63
+
64
+
65
+ def _capture_host_command(command: Sequence[str]) -> str:
66
+ """Capture one optional host-setting command without failing backup creation."""
67
+
68
+ try:
69
+ result = subprocess.run(
70
+ list(command),
71
+ capture_output=True,
72
+ text=True,
73
+ check=False,
74
+ timeout=10,
75
+ )
76
+ except (OSError, subprocess.SubprocessError) as error:
77
+ return f"unavailable: {error}"
78
+ output = (result.stdout or result.stderr).strip()
79
+ return output or f"exit {result.returncode}"
80
+
81
+
82
+ def _compose(
83
+ live_dir: Path,
84
+ docker_prefix: Sequence[str],
85
+ arguments: Sequence[str],
86
+ ) -> subprocess.CompletedProcess[str]:
87
+ """Run one bounded Compose command or raise an actionable error."""
88
+
89
+ command = [*docker_prefix, "compose", *arguments]
90
+ try:
91
+ result = subprocess.run(
92
+ command,
93
+ cwd=live_dir,
94
+ capture_output=True,
95
+ text=True,
96
+ check=False,
97
+ timeout=120,
98
+ )
99
+ except (OSError, subprocess.SubprocessError) as error:
100
+ raise BackupError(f"Cannot run {' '.join(command)}: {error}") from error
101
+ if result.returncode != 0:
102
+ detail = (result.stderr or result.stdout).strip()
103
+ raise BackupError(
104
+ f"{' '.join(command)} failed: {detail or f'exit {result.returncode}'}"
105
+ )
106
+ return result
107
+
108
+
109
+ def running_services(
110
+ live_dir: Path,
111
+ docker_prefix: Sequence[str],
112
+ ) -> tuple[str, ...]:
113
+ """Return the currently running Compose services in stable order."""
114
+
115
+ result = _compose(
116
+ live_dir,
117
+ docker_prefix,
118
+ ("ps", "--status", "running", "--services"),
119
+ )
120
+ return tuple(line.strip() for line in result.stdout.splitlines() if line.strip())
121
+
122
+
123
+ def stop_services(
124
+ live_dir: Path,
125
+ docker_prefix: Sequence[str],
126
+ services: Sequence[str],
127
+ ) -> None:
128
+ """Stop selected services for a consistent state snapshot."""
129
+
130
+ if services:
131
+ _compose(live_dir, docker_prefix, ("stop", *services))
132
+
133
+
134
+ def start_services(
135
+ live_dir: Path,
136
+ docker_prefix: Sequence[str],
137
+ services: Sequence[str],
138
+ ) -> None:
139
+ """Restart exactly the services stopped for a snapshot."""
140
+
141
+ if services:
142
+ _compose(live_dir, docker_prefix, ("start", *services))
143
+
144
+
145
+ def _copy_snapshot_path(source: Path, destination: Path) -> None:
146
+ """Copy one state path while rejecting links and special files."""
147
+
148
+ # A symbolic link could point outside ~/labpulse-live and pull an unrelated
149
+ # file into the archive, so backups only follow ordinary files/directories.
150
+ if source.is_symlink():
151
+ raise BackupError(f"Refusing to follow state symlink: {source}")
152
+ if source.is_file():
153
+ destination.parent.mkdir(parents=True, exist_ok=True)
154
+ shutil.copy2(source, destination)
155
+ return
156
+ if not source.is_dir():
157
+ raise BackupError(f"Unsupported state path type: {source}")
158
+
159
+ destination.mkdir(parents=True, exist_ok=True)
160
+ for child in sorted(source.iterdir(), key=lambda item: item.name):
161
+ if child.is_symlink():
162
+ raise BackupError(f"Refusing to follow state symlink: {child}")
163
+ target = destination / child.name
164
+ if child.is_dir():
165
+ _copy_snapshot_path(child, target)
166
+ elif child.is_file():
167
+ target.parent.mkdir(parents=True, exist_ok=True)
168
+ shutil.copy2(child, target)
169
+ else:
170
+ raise BackupError(f"Unsupported state path type: {child}")
171
+
172
+
173
+ def _validate_snapshot_path(path: Path) -> None:
174
+ """Reject links and special files in a container-assisted snapshot copy."""
175
+
176
+ if path.is_symlink():
177
+ raise BackupError(f"Refusing to archive state symlink: {path}")
178
+ if path.is_file():
179
+ return
180
+ if not path.is_dir():
181
+ raise BackupError(f"Unsupported state path type: {path}")
182
+ for child in sorted(path.iterdir(), key=lambda item: item.name):
183
+ _validate_snapshot_path(child)
184
+
185
+
186
+ def _copy_snapshot_from_container(
187
+ live_dir: Path,
188
+ relative: str,
189
+ destination: Path,
190
+ docker_prefix: Sequence[str],
191
+ ) -> None:
192
+ """Copy container-owned bind-mount state through its stopped service."""
193
+
194
+ service, container_path = CONTAINER_SNAPSHOT_PATHS[relative]
195
+ shutil.rmtree(destination, ignore_errors=False)
196
+ destination.mkdir(parents=True)
197
+ try:
198
+ _compose(
199
+ live_dir,
200
+ docker_prefix,
201
+ ("cp", f"{service}:{container_path}/.", str(destination)),
202
+ )
203
+ except BackupError as error:
204
+ raise BackupError(
205
+ f"Cannot read {live_dir / relative} as the current user and "
206
+ f"could not copy it through the {service} container: {error}"
207
+ ) from error
208
+ _validate_snapshot_path(destination)
209
+
210
+
211
+ def _assemble_snapshot(
212
+ live_dir: Path,
213
+ staging_root: Path,
214
+ docker_prefix: Sequence[str],
215
+ ) -> None:
216
+ """Copy selected state and write its checksum manifest."""
217
+
218
+ payload_root = staging_root / PAYLOAD_DIRECTORY
219
+ included: list[str] = []
220
+ for relative in SNAPSHOT_PATHS:
221
+ source = live_dir / relative
222
+ if not source.exists():
223
+ if relative in MANDATORY_PATHS:
224
+ raise BackupError(f"Required backup state is missing: {source}")
225
+ continue
226
+ destination = payload_root / relative
227
+ try:
228
+ _copy_snapshot_path(source, destination)
229
+ except PermissionError as error:
230
+ # Home Assistant and Mosquitto may create files owned by their
231
+ # container user. A stopped container can still copy that mounted
232
+ # state when the host user is not allowed to read it directly.
233
+ if relative not in CONTAINER_SNAPSHOT_PATHS:
234
+ raise BackupError(f"Cannot read backup state: {error.filename}") from error
235
+ _copy_snapshot_from_container(
236
+ live_dir,
237
+ relative,
238
+ destination,
239
+ docker_prefix,
240
+ )
241
+ included.append(relative)
242
+
243
+ # Record the digest of every file so restore can detect a missing, added,
244
+ # or modified payload before replacing live state.
245
+ checksums = {
246
+ path.relative_to(staging_root).as_posix(): _sha256(path)
247
+ for path in sorted(payload_root.rglob("*"))
248
+ if path.is_file()
249
+ }
250
+ try:
251
+ labpulse_version = version("labpulse")
252
+ except PackageNotFoundError:
253
+ labpulse_version = "source-checkout"
254
+ try:
255
+ compose_text = (live_dir / "compose.yaml").read_text(encoding="utf-8")
256
+ except OSError:
257
+ compose_text = ""
258
+ runtime_mode = "fake_usb" if "config.fake.yaml:/app/config.yaml" in compose_text else "real_hardware"
259
+ manifest: dict[str, Any] = {
260
+ "format_version": BACKUP_FORMAT_VERSION,
261
+ "created_at": datetime.now(timezone.utc).isoformat(),
262
+ "labpulse_version": labpulse_version,
263
+ "source_live_directory": str(live_dir),
264
+ "runtime_mode": runtime_mode,
265
+ "included_paths": included,
266
+ "files": checksums,
267
+ "host": {
268
+ "platform": platform.platform(),
269
+ "python": platform.python_version(),
270
+ "docker_command": " ".join(docker_prefix),
271
+ "clock": _capture_host_command(
272
+ ("timedatectl", "show", "--property=Timezone", "--property=NTPSynchronized")
273
+ ),
274
+ "watchdog": _capture_host_command(
275
+ ("systemctl", "show", "--property=RuntimeWatchdogUSec", "--value")
276
+ ),
277
+ },
278
+ }
279
+ (staging_root / MANIFEST_NAME).write_text(
280
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
281
+ encoding="utf-8",
282
+ )
283
+
284
+
285
+ def create_backup(
286
+ live_dir: Path,
287
+ archive_path: Path,
288
+ docker_prefix: Sequence[str],
289
+ *,
290
+ force: bool = False,
291
+ quiesce: bool = True,
292
+ ) -> Path:
293
+ """Create one checksummed private archive, restarting quiesced services."""
294
+
295
+ live_dir = live_dir.expanduser().resolve()
296
+ archive_path = archive_path.expanduser().resolve()
297
+ if not live_dir.is_dir() or not (live_dir / "compose.yaml").is_file():
298
+ raise BackupError(
299
+ f"LabPulse is not set up at {live_dir}; run 'labpulse setup' first"
300
+ )
301
+ if archive_path.is_relative_to(live_dir):
302
+ raise BackupError("Backup archive must be stored outside the live directory")
303
+ if archive_path.exists() and not force:
304
+ raise BackupError(f"Backup archive already exists: {archive_path}")
305
+ archive_path.parent.mkdir(parents=True, exist_ok=True)
306
+
307
+ active_services: tuple[str, ...] = ()
308
+ if quiesce:
309
+ active_services = running_services(live_dir, docker_prefix)
310
+
311
+ temporary_archive = archive_path.parent / (
312
+ f".{archive_path.name}.creating-{uuid4().hex}"
313
+ )
314
+ try:
315
+ # Services are stopped only while their mutable state is copied. The
316
+ # finally block restarts exactly those that were running beforehand.
317
+ if quiesce:
318
+ stop_services(live_dir, docker_prefix, active_services)
319
+ with _temporary_directory(
320
+ archive_path.parent,
321
+ "labpulse-backup-",
322
+ ) as staging_root:
323
+ _assemble_snapshot(live_dir, staging_root, docker_prefix)
324
+ with tarfile.open(temporary_archive, "w:gz") as archive:
325
+ archive.add(staging_root / MANIFEST_NAME, arcname=MANIFEST_NAME)
326
+ archive.add(staging_root / PAYLOAD_DIRECTORY, arcname=PAYLOAD_DIRECTORY)
327
+ os.chmod(temporary_archive, 0o600)
328
+ os.replace(temporary_archive, archive_path)
329
+ finally:
330
+ if temporary_archive.exists():
331
+ temporary_archive.unlink()
332
+ if quiesce:
333
+ start_services(live_dir, docker_prefix, active_services)
334
+
335
+ return archive_path
336
+
337
+
338
+ def _load_manifest(archive: tarfile.TarFile) -> dict[str, Any]:
339
+ """Read and minimally validate the archive manifest."""
340
+
341
+ try:
342
+ member = archive.getmember(MANIFEST_NAME)
343
+ stream = archive.extractfile(member)
344
+ except (KeyError, tarfile.TarError) as error:
345
+ raise BackupError("Backup manifest is missing") from error
346
+ if stream is None or not member.isreg():
347
+ raise BackupError("Backup manifest is not a regular file")
348
+ try:
349
+ manifest = json.load(stream)
350
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
351
+ raise BackupError(f"Backup manifest is invalid: {error}") from error
352
+ if not isinstance(manifest, dict):
353
+ raise BackupError("Backup manifest must be a JSON object")
354
+ if manifest.get("format_version") != BACKUP_FORMAT_VERSION:
355
+ raise BackupError(
356
+ f"Unsupported backup format: {manifest.get('format_version')!r}"
357
+ )
358
+ if manifest.get("runtime_mode") not in {"real_hardware", "fake_usb"}:
359
+ raise BackupError("Backup manifest has an invalid runtime mode")
360
+ files = manifest.get("files")
361
+ included = manifest.get("included_paths")
362
+ if not isinstance(files, dict) or not all(
363
+ isinstance(key, str) and isinstance(value, str)
364
+ for key, value in files.items()
365
+ ):
366
+ raise BackupError("Backup manifest has invalid file checksums")
367
+ if not isinstance(included, list) or not all(
368
+ isinstance(value, str) for value in included
369
+ ):
370
+ raise BackupError("Backup manifest has invalid included paths")
371
+ for mandatory in MANDATORY_PATHS:
372
+ if mandatory not in included:
373
+ raise BackupError(f"Backup is incomplete: missing {mandatory}")
374
+ return manifest
375
+
376
+
377
+ def inspect_backup(archive_path: Path) -> dict[str, Any]:
378
+ """Validate archive structure and every payload checksum."""
379
+
380
+ archive_path = archive_path.expanduser().resolve()
381
+ if not archive_path.is_file():
382
+ raise BackupError(f"Backup archive does not exist: {archive_path}")
383
+ try:
384
+ archive = tarfile.open(archive_path, "r:*")
385
+ except (OSError, tarfile.TarError) as error:
386
+ raise BackupError(f"Cannot open backup archive: {error}") from error
387
+
388
+ with archive:
389
+ manifest = _load_manifest(archive)
390
+ checksums: dict[str, str] = manifest["files"]
391
+ archive_files: set[str] = set()
392
+ for member in archive.getmembers():
393
+ member_path = PurePosixPath(member.name)
394
+ # Absolute paths or ".." components could make extraction write
395
+ # outside its temporary directory. Only the manifest and payload
396
+ # tree are valid members of a LabPulse archive.
397
+ safe_name = (
398
+ not member_path.is_absolute()
399
+ and ".." not in member_path.parts
400
+ and "." not in member_path.parts
401
+ and (
402
+ member.name == MANIFEST_NAME
403
+ or (bool(member_path.parts) and member_path.parts[0] == PAYLOAD_DIRECTORY)
404
+ )
405
+ )
406
+ if not safe_name:
407
+ raise BackupError(f"Unsafe archive path: {member.name}")
408
+ if not (member.isdir() or member.isreg()):
409
+ raise BackupError(f"Unsafe archive member type: {member.name}")
410
+ if member.isreg() and member.name.startswith(f"{PAYLOAD_DIRECTORY}/"):
411
+ archive_files.add(member.name)
412
+ if member.name not in checksums:
413
+ raise BackupError(f"Unmanifested backup file: {member.name}")
414
+ stream = archive.extractfile(member)
415
+ if stream is None:
416
+ raise BackupError(f"Cannot read backup file: {member.name}")
417
+ digest = hashlib.sha256()
418
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
419
+ digest.update(block)
420
+ if digest.hexdigest() != checksums[member.name]:
421
+ raise BackupError(f"Checksum mismatch: {member.name}")
422
+ if archive_files != set(checksums):
423
+ missing = sorted(set(checksums) - archive_files)
424
+ raise BackupError("Backup payload is missing: " + ", ".join(missing))
425
+ return manifest
426
+
427
+
428
+ def _extract_validated(archive_path: Path, destination: Path) -> dict[str, Any]:
429
+ """Validate and manually extract regular payload files."""
430
+
431
+ manifest = inspect_backup(archive_path)
432
+ with tarfile.open(archive_path, "r:*") as archive:
433
+ # Extract members ourselves instead of using tarfile.extract(). Their
434
+ # names and file types were checked above, and every destination remains
435
+ # visibly rooted in this temporary directory.
436
+ members = archive.getmembers()
437
+ for member in members:
438
+ if not member.isdir():
439
+ continue
440
+ target = destination.joinpath(*PurePosixPath(member.name).parts)
441
+ target.mkdir(parents=True, exist_ok=True)
442
+ for member in members:
443
+ if not member.isreg():
444
+ continue
445
+ target = destination.joinpath(*PurePosixPath(member.name).parts)
446
+ target.parent.mkdir(parents=True, exist_ok=True)
447
+ stream = archive.extractfile(member)
448
+ if stream is None:
449
+ raise BackupError(f"Cannot extract backup file: {member.name}")
450
+ with target.open("wb") as output:
451
+ shutil.copyfileobj(stream, output)
452
+ try:
453
+ os.chmod(target, member.mode & 0o777)
454
+ except OSError:
455
+ pass
456
+ return manifest
457
+
458
+
459
+ def _remove_path(path: Path) -> None:
460
+ """Remove one exact file, link, or directory target."""
461
+
462
+ if path.is_symlink() or path.is_file():
463
+ path.unlink()
464
+ elif path.is_dir():
465
+ shutil.rmtree(path)
466
+
467
+
468
+ def _copy_local_path(source: Path, destination: Path) -> None:
469
+ """Copy a staged or rollback path without following stored symlinks."""
470
+
471
+ destination.parent.mkdir(parents=True, exist_ok=True)
472
+ if source.is_symlink():
473
+ destination.symlink_to(os.readlink(source))
474
+ elif source.is_dir():
475
+ shutil.copytree(source, destination, symlinks=True)
476
+ else:
477
+ shutil.copy2(source, destination, follow_symlinks=False)
478
+
479
+
480
+ def _apply_payload(live_dir: Path, payload_root: Path) -> None:
481
+ """Replace snapshot-owned paths and roll back the filesystem on failure."""
482
+
483
+ with _temporary_directory(
484
+ live_dir.parent,
485
+ "labpulse-restore-rollback-",
486
+ ) as local_rollback:
487
+ # Copy the current state aside before removing anything. If any later
488
+ # copy fails, the except block can put the complete old state back.
489
+ existing: list[str] = []
490
+ for relative in SNAPSHOT_PATHS:
491
+ target = live_dir / relative
492
+ if target.exists() or target.is_symlink():
493
+ _copy_local_path(target, local_rollback / relative)
494
+ existing.append(relative)
495
+
496
+ try:
497
+ for relative in SNAPSHOT_PATHS:
498
+ target = live_dir / relative
499
+ _remove_path(target)
500
+ source = payload_root / relative
501
+ if source.exists() or source.is_symlink():
502
+ _copy_local_path(source, target)
503
+ except Exception as error:
504
+ for relative in SNAPSHOT_PATHS:
505
+ _remove_path(live_dir / relative)
506
+ for relative in existing:
507
+ _copy_local_path(local_rollback / relative, live_dir / relative)
508
+ raise BackupError(f"Restore failed and local state was rolled back: {error}") from error
509
+
510
+
511
+ def restore_backup(live_dir: Path, archive_path: Path) -> dict[str, Any]:
512
+ """Validate and apply archive state to an existing scaffolded live directory."""
513
+
514
+ live_dir = live_dir.expanduser().resolve()
515
+ archive_path = archive_path.expanduser().resolve()
516
+ if not live_dir.is_dir():
517
+ raise BackupError(f"Restore target does not exist: {live_dir}")
518
+ with _temporary_directory(
519
+ live_dir.parent,
520
+ "labpulse-restore-",
521
+ ) as extracted:
522
+ manifest = _extract_validated(archive_path, extracted)
523
+ _apply_payload(live_dir, extracted / PAYLOAD_DIRECTORY)
524
+ return manifest
@@ -0,0 +1,32 @@
1
+ # Shared contracts
2
+
3
+ `labpulse.common` contains definitions that must remain identical across host
4
+ generation, sensor workers, Home Assistant, SMS and outputs. It runs wherever
5
+ the Python package runs and deliberately has no hardware access.
6
+
7
+ | File | Contract |
8
+ |---|---|
9
+ | `config.py` | Central YAML loader, global models, cross-references and source-aware errors |
10
+ | `service_config.py` | Driver selection, service timing, measurements and power-service rules |
11
+ | `measurement_config.py` | Physical/calculated measurements and restricted formulas |
12
+ | `output_config.py` | Output-driver and safe-state configuration |
13
+ | `identity.py` | Stable slugs, IDs, titles and Home Assistant entity IDs |
14
+ | `mqtt_contracts.py` | Topic constructors and strict SMS requests |
15
+ | `fake_config.py` | Preserve the complete resolved fake-hardware runtime document |
16
+ | `logging_config.py` | Stdout and optional persistent logging |
17
+ | `generated_files.py` | Atomically replace one complete file |
18
+ | `sms_templates.py` / `sms_templates.yaml` | Validate and supply notification text |
19
+
20
+ Unknown fields are rejected. Driver options are converted to the selected
21
+ driver's typed model during the central load; consumers should not parse YAML
22
+ again. Calculated formulas accept only finite arithmetic and physical inputs.
23
+ Single-file replacement is atomic, but replacing several generated files is
24
+ not one filesystem transaction.
25
+
26
+ Keep reusable configuration, identity, topic and small shared utilities here.
27
+ Do not add hardware access, CLI parsing, dashboard policy or delivery effects.
28
+
29
+ Relevant tests include `test_config_pipeline.py`, `test_common_contracts.py`,
30
+ `test_custom_measurements.py` and `test_deployment_generation.py`. See
31
+ [Configuration](../../../docs/CONFIGURATION.md) and
32
+ [Architecture](../../../docs/ARCHITECTURE.md).
@@ -0,0 +1 @@
1
+ """Shared contracts and infrastructure used by LabPulse services."""