Skip to content

Commit 42036b6

Browse files
CloneBroCloneBro
andauthored
docs: add devices packs append-only policy (fixes #79) (#109)
* feat: devices export JSON snapshot CLI (#78) * feat: add pip caching + pytest-cov to CI (#18) * docs: add devices packs append-only policy (fixes #79) --------- Co-authored-by: CloneBro <rundm.wiechmann@gmail.com>
1 parent 0e9a2fc commit 42036b6

8 files changed

Lines changed: 761 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ jobs:
1919
- uses: actions/setup-python@v5
2020
with:
2121
python-version: ${{ matrix.python-version }}
22+
cache: pip
23+
cache-dependency-path: packages/bridge/pyproject.toml
2224
- name: Install
2325
run: |
2426
python -m pip install -U pip
2527
pip install -e ".[dev,api]"
2628
- name: Ruff
2729
run: ruff check src tests
2830
- name: Pytest
29-
run: pytest -q
31+
run: pytest -q --cov=hiri_bridge --cov-fail-under=60
3032
- name: Demo
3133
run: hiri-bridge demo

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ HIRI uses [MergeOS MRG bounties](docs/BOUNTY.md).
121121
| Large (major features) | 100 |
122122
| XL (end-to-end, platform) | 200 |
123123

124+
## Device packs
125+
126+
When creating device packs in `packages/bridge/data/packs/`, follow the **append-only policy**:
127+
128+
> ⚠️ **Important**: Device packs must **append** to the registry, never replace `devices.json`.
129+
> See [docs/devices-packs-policy.md](docs/devices-packs-policy.md) for the full policy.
130+
124131
## Code quality
125132

126133
- **Python**: `ruff check`, type hints encouraged, pytest for tests

docs/devices-packs-policy.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Device Packs Policy
2+
3+
## Append-Only for Device Packs
4+
5+
**Device packs** (in `packages/bridge/data/packs/`) must **append** to the device registry and **never replace** the entire `devices.json`.
6+
7+
## Why Append-Only?
8+
9+
- **Seed devices** live in `data/devices.json` as the canonical baseline
10+
- **Packs** add domain-specific device packs on top of the seed
11+
- Replacing the entire registry would **overwrite** seed devices and user-discovered devices
12+
- Appending preserves the merge chain: `seed → pack1 → pack2 → ...`
13+
14+
## How It Works
15+
16+
The `DeviceRegistry` class merges pack data into the existing registry:
17+
18+
```python
19+
# In src/hiri_bridge/config.py
20+
registry = DeviceRegistry.load(registry_path)
21+
registry.merge_pack(pack_data) # Appends, not replaces
22+
```
23+
24+
## Creating a Device Pack
25+
26+
1. Create your pack JSON in `packages/bridge/data/packs/<pack-name>.json`
27+
2. Devices in the pack are **merged** into the registry (keyed by `id`)
28+
3. The pack's devices are **appended** to the registry, never overwriting seed devices
29+
4. Run `hiri-bridge devices list` to verify pack devices are visible
30+
31+
## Anti-Truncation Rule
32+
33+
**Never** write a pack file that replaces `devices.json` entirely. The registry expects to be built incrementally:
34+
35+
```
36+
seed devices.json → merged with pack1 → merged with pack2 → ...
37+
```
38+
39+
If a pack replaces the file instead, seed devices and previously merged devices will be **lost**.
40+
41+
## Verification
42+
43+
After creating a pack, verify it doesn't truncate existing devices:
44+
45+
```bash
46+
hiri-bridge devices list | wc -l # Should show seed + pack devices
47+
```

packages/bridge/.coverage

52 KB
Binary file not shown.

packages/bridge/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ dependencies = [
1717
]
1818

1919
[project.optional-dependencies]
20-
dev = ["pytest>=8.0", "ruff>=0.4", "httpx>=0.27"]
20+
dev = ["pytest>=8.0", "pytest-cov>=4.0", "ruff>=0.4", "httpx>=0.27"]
2121
api = ["fastapi>=0.110", "uvicorn>=0.27", "httpx>=0.27"]
2222
mqtt = ["paho-mqtt>=2.0"]
2323

packages/bridge/src/hiri_bridge/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ def devices_sim_history(
213213
console.print_json(data={"id": device_id, "n": len(rows), "history": rows})
214214

215215

216+
@devices_app.command("export")
217+
def devices_export(
218+
out: Path = typer.Option(..., "--out", "-o", help="Output JSON file path"),
219+
) -> None:
220+
"""Export device registry as a JSON snapshot (no tokens/secrets)."""
221+
reg = _registry()
222+
snapshot = [d.model_dump() for d in reg.list()]
223+
out.parent.mkdir(parents=True, exist_ok=True)
224+
out.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
225+
console.print(f"[green]Wrote[/green] {out} devices={len(snapshot)}")
226+
227+
216228
@ha_app.command("discovery")
217229
def ha_discovery(out: Path | None = typer.Option(None, "--out", "-o")) -> None:
218230
reg = _registry()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
9+
10+
def test_devices_export_writes_json(tmp_path: Path) -> None:
11+
"""hiri-bridge devices export --out writes device registry snapshot."""
12+
out = tmp_path / "snapshot.json"
13+
result = subprocess.run(
14+
[sys.executable, "-m", "hiri_bridge.cli", "devices", "export", "--out", str(out)],
15+
capture_output=True,
16+
text=True,
17+
)
18+
assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}"
19+
assert out.exists(), f"Expected {out} to exist. stderr={result.stderr}"
20+
21+
data = json.loads(out.read_text(encoding="utf-8"))
22+
assert isinstance(data, list), f"Expected list, got {type(data)}"
23+
assert len(data) > 0, "Registry should contain seeded devices"
24+
25+
# Check structure of first device
26+
d = data[0]
27+
for field in ("id", "name", "domain", "manufacturer", "model", "area", "online", "state", "adapter"):
28+
assert field in d, f"Device missing field: {field}"
29+
30+
# Verify no tokens/secrets leaked
31+
text = out.read_text(encoding="utf-8").lower()
32+
assert "token" not in text or "code" not in text, "Snapshot should not contain tokens/codes"
33+
34+
# Verify output mentions device count
35+
assert "devices=" in result.stdout and str(len(data)) in result.stdout
36+
37+
38+
def test_devices_export_creates_parent_dirs(tmp_path: Path) -> None:
39+
"""export --out creates parent directories if they don't exist."""
40+
out = tmp_path / "nested" / "deep" / "snapshot.json"
41+
assert not out.parent.exists()
42+
result = subprocess.run(
43+
[sys.executable, "-m", "hiri_bridge.cli", "devices", "export", "--out", str(out)],
44+
capture_output=True,
45+
text=True,
46+
)
47+
assert result.returncode == 0, f"stdout={result.stdout} stderr={result.stderr}"
48+
assert out.exists()
49+
50+
51+
def test_devices_export_requires_out_option() -> None:
52+
"""export command requires --out argument."""
53+
result = subprocess.run(
54+
[sys.executable, "-m", "hiri_bridge.cli", "devices", "export"],
55+
capture_output=True,
56+
text=True,
57+
)
58+
assert result.returncode != 0, "export without --out should fail"

0 commit comments

Comments
 (0)