alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,95 @@
1
+ """Tapo ecosystem storage layer — local SQLite, mode 600.
2
+
3
+ Persists hub-scoped device events. Room labels (member-authored at
4
+ pairing) are deliberately NOT in the schema — they live at the bridge
5
+ layer and never reach the daemon's storage boundary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import sqlite3
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from alter_runtime.config import data_dir
17
+
18
+ __all__ = ["TapoEcosystemStorage", "DB_FILENAME"]
19
+
20
+ logger = logging.getLogger("alter_runtime.adapters.household.tapo_ecosystem.storage")
21
+
22
+ DB_FILENAME: str = "tapo_ecosystem.db"
23
+
24
+ _SCHEMA: str = """
25
+ CREATE TABLE IF NOT EXISTS hub_device_events (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ hub_mac TEXT NOT NULL,
28
+ device_mac TEXT NOT NULL,
29
+ device_kind TEXT NOT NULL,
30
+ ts TEXT,
31
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_hub_events_hub
34
+ ON hub_device_events (hub_mac);
35
+ CREATE INDEX IF NOT EXISTS idx_hub_events_device
36
+ ON hub_device_events (device_mac);
37
+ """
38
+
39
+
40
+ class TapoEcosystemStorage:
41
+ def __init__(self, db_path: Path | None = None) -> None:
42
+ self._db_path = db_path or (data_dir() / DB_FILENAME)
43
+ self._lock = asyncio.Lock()
44
+ self._ensure_db()
45
+
46
+ @property
47
+ def db_path(self) -> Path:
48
+ return self._db_path
49
+
50
+ def _ensure_db(self) -> None:
51
+ self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ if not self._db_path.exists():
53
+ self._db_path.touch(mode=0o600, exist_ok=True)
54
+ else:
55
+ try:
56
+ self._db_path.chmod(0o600)
57
+ except OSError as exc: # pragma: no cover
58
+ logger.warning("tapo_ecosystem chmod 0o600 failed: %s", exc)
59
+ with sqlite3.connect(self._db_path) as conn:
60
+ conn.executescript(_SCHEMA)
61
+
62
+ async def record_event(
63
+ self,
64
+ *,
65
+ hub_mac: str,
66
+ device_mac: str,
67
+ device_kind: str,
68
+ ts: Any | None = None,
69
+ ) -> None:
70
+ async with self._lock:
71
+ await asyncio.to_thread(self._insert_event, hub_mac, device_mac, device_kind, ts)
72
+
73
+ def _insert_event(
74
+ self,
75
+ hub_mac: str,
76
+ device_mac: str,
77
+ device_kind: str,
78
+ ts: Any | None,
79
+ ) -> None:
80
+ with sqlite3.connect(self._db_path) as conn:
81
+ conn.execute(
82
+ "INSERT INTO hub_device_events "
83
+ "(hub_mac, device_mac, device_kind, ts) VALUES (?, ?, ?, ?)",
84
+ (hub_mac, device_mac, device_kind, str(ts) if ts is not None else None),
85
+ )
86
+ conn.commit()
87
+
88
+ async def event_count(self) -> int:
89
+ return await asyncio.to_thread(self._event_count_sync)
90
+
91
+ def _event_count_sync(self) -> int:
92
+ with sqlite3.connect(self._db_path) as conn:
93
+ cur = conn.execute("SELECT COUNT(*) FROM hub_device_events")
94
+ row = cur.fetchone()
95
+ return int(row[0]) if row else 0
@@ -0,0 +1,55 @@
1
+ """Tests for TapoEcosystemAdapter — hub-level event ingest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from alter_runtime.adapters.household.tapo_ecosystem.adapter import (
10
+ TAPO_HUB_ENERGY_TOPIC,
11
+ TAPO_HUB_STATE_TOPIC,
12
+ TapoEcosystemAdapter,
13
+ )
14
+ from alter_runtime.adapters.household.tapo_ecosystem.storage import (
15
+ TapoEcosystemStorage,
16
+ )
17
+ from alter_runtime.config import DaemonConfig
18
+ from alter_runtime.subscribers.bus import EventBus
19
+
20
+
21
+ def test_two_topic_globs() -> None:
22
+ assert TAPO_HUB_STATE_TOPIC == "tapo/+/+/state"
23
+ assert TAPO_HUB_ENERGY_TOPIC == "tapo/+/+/energy"
24
+ assert set(TapoEcosystemAdapter.subscribe_topics) == {
25
+ TAPO_HUB_STATE_TOPIC,
26
+ TAPO_HUB_ENERGY_TOPIC,
27
+ }
28
+
29
+
30
+ async def test_adapter_persists_hub_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
31
+ db_path = tmp_path / "tapo_ecosystem.db"
32
+ monkeypatch.setattr(
33
+ "alter_runtime.adapters.household.tapo_ecosystem.adapter.TapoEcosystemStorage",
34
+ lambda: TapoEcosystemStorage(db_path=db_path),
35
+ )
36
+ bus = EventBus()
37
+ adapter = TapoEcosystemAdapter(config=DaemonConfig(), bus=bus)
38
+ await adapter.handle_event(
39
+ {
40
+ "hub_mac": "AA:BB:CC:DD:EE:00",
41
+ "device_mac": "AA:BB:CC:DD:EE:01",
42
+ "device_kind": "plug",
43
+ "ts": "2026-05-27T00:00:00Z",
44
+ }
45
+ )
46
+ assert await TapoEcosystemStorage(db_path=db_path).event_count() == 1
47
+
48
+
49
+ async def test_adapter_drops_incomplete_event() -> None:
50
+ bus = EventBus()
51
+ adapter = TapoEcosystemAdapter(config=DaemonConfig(), bus=bus)
52
+ # All three core fields missing — must not raise.
53
+ await adapter.handle_event({"device_kind": "plug"})
54
+ await adapter.handle_event({})
55
+ await adapter.handle_event("not a dict")
@@ -0,0 +1,28 @@
1
+ """Tests for tapo_ecosystem SQLite storage — mode 600 + event insert."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ from alter_runtime.adapters.household.tapo_ecosystem.storage import (
9
+ TapoEcosystemStorage,
10
+ )
11
+
12
+
13
+ async def test_db_file_mode_is_600(tmp_path: Path) -> None:
14
+ db_path = tmp_path / "tapo_ecosystem.db"
15
+ TapoEcosystemStorage(db_path=db_path)
16
+ mode = stat.S_IMODE(db_path.stat().st_mode)
17
+ assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
18
+
19
+
20
+ async def test_record_event_persists_row(tmp_path: Path) -> None:
21
+ db_path = tmp_path / "tapo_ecosystem.db"
22
+ storage = TapoEcosystemStorage(db_path=db_path)
23
+ assert await storage.event_count() == 0
24
+ await storage.record_event(hub_mac="HUB:01", device_mac="DEV:01", device_kind="plug", ts=None)
25
+ await storage.record_event(
26
+ hub_mac="HUB:01", device_mac="DEV:02", device_kind="temp_humid", ts=None
27
+ )
28
+ assert await storage.event_count() == 2
@@ -0,0 +1,45 @@
1
+ """Tests for tapo_ecosystem trait emitter — Clause-4 banlist + bands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from alter_runtime.adapters.household.tapo_ecosystem.traits import (
8
+ CLAUSE_4_BANLIST,
9
+ BannedTraitKindError,
10
+ TapoEcosystemTraits,
11
+ )
12
+
13
+
14
+ def test_banlist_contains_all_spec_kinds() -> None:
15
+ expected = {
16
+ "household-presence-inference",
17
+ "co-occupant-counting",
18
+ "bedroom-presence",
19
+ "sleep-cycle",
20
+ "motion-as-affect",
21
+ "classroom-Tapo-in-school-suitability",
22
+ "family-violence-stalker-hardware-detection",
23
+ "landlord-tenant-monitoring",
24
+ }
25
+ assert expected.issubset(CLAUSE_4_BANLIST)
26
+
27
+
28
+ @pytest.mark.parametrize("banned_kind", sorted(CLAUSE_4_BANLIST))
29
+ def test_banned_kinds_are_refused(banned_kind: str) -> None:
30
+ traits = TapoEcosystemTraits()
31
+ with pytest.raises(BannedTraitKindError):
32
+ traits.emit(banned_kind, band="moderate")
33
+
34
+
35
+ def test_three_canonical_traits_emit() -> None:
36
+ traits = TapoEcosystemTraits()
37
+ a = traits.emit_household_appliance_stewardship("high")
38
+ b = traits.emit_household_routine_cadence("steady")
39
+ c = traits.emit_ambient_stewardship("tended")
40
+ assert a.kind == "household_appliance_stewardship" and a.band == "high"
41
+ assert b.kind == "household_routine_cadence" and b.band == "steady"
42
+ assert c.kind == "ambient_stewardship" and c.band == "tended"
43
+ for emission in (a, b, c):
44
+ assert emission.provenance == "passive_local_multi_parameter"
45
+ assert emission.stream == "tapo"
@@ -0,0 +1,97 @@
1
+ """Tapo ecosystem trait band emitters (Eco-14) with Clause-4 banlist.
2
+
3
+ Three banded traits (stub spec §5):
4
+
5
+ * ``household_appliance_stewardship``
6
+ * ``household_routine_cadence``
7
+ * ``ambient_stewardship``
8
+
9
+ Clause-4 banlist (stub spec §5):
10
+
11
+ * ``household-presence-inference``
12
+ * ``co-occupant-counting``
13
+ * ``bedroom-presence``
14
+ * ``sleep-cycle``
15
+ * ``motion-as-affect``
16
+ * ``classroom-Tapo-in-school-suitability``
17
+ * ``family-violence-stalker-hardware-detection``
18
+ * ``landlord-tenant-monitoring``
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Literal
25
+
26
+ from pydantic import BaseModel, Field
27
+
28
+ __all__ = [
29
+ "AppliancesStewardshipBand",
30
+ "AmbientStewardshipBand",
31
+ "BannedTraitKindError",
32
+ "CLAUSE_4_BANLIST",
33
+ "RoutineCadenceBand",
34
+ "TapoEcosystemTraitEmission",
35
+ "TapoEcosystemTraits",
36
+ ]
37
+
38
+ logger = logging.getLogger("alter_runtime.adapters.household.tapo_ecosystem.traits")
39
+
40
+ CLAUSE_4_BANLIST: frozenset[str] = frozenset(
41
+ {
42
+ "household-presence-inference",
43
+ "co-occupant-counting",
44
+ "bedroom-presence",
45
+ "sleep-cycle",
46
+ "motion-as-affect",
47
+ "classroom-Tapo-in-school-suitability",
48
+ "family-violence-stalker-hardware-detection",
49
+ "landlord-tenant-monitoring",
50
+ }
51
+ )
52
+
53
+ AppliancesStewardshipBand = Literal["low", "moderate", "high", "exemplary"]
54
+ RoutineCadenceBand = Literal["sparse", "uneven", "steady", "anchored"]
55
+ AmbientStewardshipBand = Literal["volatile", "drifting", "tended", "well-tended"]
56
+
57
+
58
+ class BannedTraitKindError(RuntimeError):
59
+ """Raised when a caller requests a Clause-4-banned trait kind."""
60
+
61
+
62
+ class TapoEcosystemTraitEmission(BaseModel):
63
+ kind: str = Field(description="Trait kind, e.g. 'household_appliance_stewardship'")
64
+ band: str = Field(description="Banded label (never numeric)")
65
+ provenance: str = Field(default="passive_local_multi_parameter")
66
+ stream: str = Field(default="tapo")
67
+
68
+
69
+ class TapoEcosystemTraits:
70
+ """Tapo ecosystem trait emitter with Clause-4 banlist enforcement."""
71
+
72
+ def emit(self, kind: str, band: str) -> TapoEcosystemTraitEmission:
73
+ self._refuse_if_banned(kind)
74
+ # TODO(clause-3-identity-income): return/x402 hook attaches here per
75
+ # D-PROV-1 + clause-3 ratification. Each emission upward is the
76
+ # point at which Identity-Income return-flow is metered. Hook
77
+ # implementation lives outside this scaffold pass.
78
+ return TapoEcosystemTraitEmission(kind=kind, band=band)
79
+
80
+ def emit_household_appliance_stewardship(
81
+ self, band: AppliancesStewardshipBand
82
+ ) -> TapoEcosystemTraitEmission:
83
+ return self.emit("household_appliance_stewardship", band)
84
+
85
+ def emit_household_routine_cadence(
86
+ self, band: RoutineCadenceBand
87
+ ) -> TapoEcosystemTraitEmission:
88
+ return self.emit("household_routine_cadence", band)
89
+
90
+ def emit_ambient_stewardship(self, band: AmbientStewardshipBand) -> TapoEcosystemTraitEmission:
91
+ return self.emit("ambient_stewardship", band)
92
+
93
+ @staticmethod
94
+ def _refuse_if_banned(kind: str) -> None:
95
+ if kind in CLAUSE_4_BANLIST:
96
+ logger.warning("tapo_ecosystem refusing Clause-4-banned trait kind=%s", kind)
97
+ raise BannedTraitKindError(f"trait kind {kind!r} is Clause-4 banned for tapo_ecosystem")
@@ -0,0 +1,25 @@
1
+ """Workshop-tools substrate adapter (Eco-12).
2
+
3
+ Subscribes to MQTT topic glob ``tapo/plug/+/energy`` (via the household
4
+ event-bus bridge) and computes per-plug rolling-30-day Wh signature,
5
+ diversity, and cadence band. Persists to
6
+ ``~/.local/share/alter-runtime/workshop_tools.db`` (mode 600). Emits
7
+ trait bands only — never raw Wh.
8
+
9
+ Spec: ``.repos/internal/02-Technical-Strategy/workshop-tool-substrate-adapter-2026-05-19.md``
10
+ Stub: ``phase2-wave2-stub-specs-pack-2026-05-27.md`` (Eco-12 line).
11
+ """
12
+
13
+ from alter_runtime.adapters.household.workshop_tools.adapter import (
14
+ WorkshopToolsAdapter,
15
+ )
16
+ from alter_runtime.adapters.household.workshop_tools.traits import (
17
+ CLAUSE_4_BANLIST,
18
+ WorkshopToolsTraits,
19
+ )
20
+
21
+ __all__ = [
22
+ "CLAUSE_4_BANLIST",
23
+ "WorkshopToolsAdapter",
24
+ "WorkshopToolsTraits",
25
+ ]
@@ -0,0 +1,77 @@
1
+ """Workshop-tools adapter (Eco-12) — EventBusSubscriber over MQTT.
2
+
3
+ The household event-bus bridge republishes Home-Assistant /
4
+ Mosquitto-relayed Tapo plug telemetry onto the in-process bus. This
5
+ adapter subscribes to the topic glob ``tapo/plug/+/energy``, normalises
6
+ each event into a per-plug Wh sample, and feeds the trait band
7
+ computation.
8
+
9
+ Per the spec (§5) raw Wh traces, fingerprint vectors, and maker-authored
10
+ plug labels never leave the daemon. This module deliberately stops at
11
+ sample-ingest + storage hand-off; the NILMTK signature classifier and
12
+ the 30-day rolling fingerprint are downstream of the storage layer in a
13
+ follow-up implementation pass.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from alter_runtime.adapters.household._base import EventBusSubscriberBase
21
+ from alter_runtime.adapters.household.workshop_tools.storage import (
22
+ WorkshopToolsStorage,
23
+ )
24
+ from alter_runtime.adapters.household.workshop_tools.traits import (
25
+ WorkshopToolsTraits,
26
+ )
27
+ from alter_runtime.config import DaemonConfig
28
+ from alter_runtime.subscribers.bus import EventBus
29
+
30
+ __all__ = ["WorkshopToolsAdapter", "TAPO_PLUG_ENERGY_TOPIC"]
31
+
32
+ #: MQTT topic glob bridged onto the in-process bus.
33
+ TAPO_PLUG_ENERGY_TOPIC: str = "tapo/plug/+/energy"
34
+
35
+
36
+ class WorkshopToolsAdapter(EventBusSubscriberBase):
37
+ """Workshop-tools (Eco-12) adapter."""
38
+
39
+ name: str = "workshop_tools"
40
+ subscribe_topics: tuple[str, ...] = (TAPO_PLUG_ENERGY_TOPIC,)
41
+
42
+ def __init__(self, config: DaemonConfig, bus: EventBus) -> None:
43
+ super().__init__(config, bus)
44
+ self._storage = WorkshopToolsStorage()
45
+ self._traits = WorkshopToolsTraits()
46
+
47
+ async def handle_event(self, payload: Any) -> None:
48
+ """Normalise one MQTT-bridged energy event and persist a sample.
49
+
50
+ Expected payload shape (best-effort)::
51
+
52
+ {"plug_mac": "AA:BB:...", "watt_hours": 12.3, "ts": "2026-..."}
53
+
54
+ Unknown fields are tolerated; missing core fields are dropped
55
+ with a warning.
56
+ """
57
+ if not isinstance(payload, dict):
58
+ self._logger.debug("workshop_tools ignoring non-dict payload")
59
+ return
60
+ plug_mac = payload.get("plug_mac") or payload.get("device_id")
61
+ wh = payload.get("watt_hours") or payload.get("wh")
62
+ ts = payload.get("ts") or payload.get("timestamp")
63
+ if not plug_mac or wh is None:
64
+ self._logger.debug(
65
+ "workshop_tools dropping incomplete event keys=%s", list(payload.keys())
66
+ )
67
+ return
68
+
69
+ # TODO(eco-12): validate plug_mac shape (AA:BB:CC:DD:EE:FF) before persist.
70
+ # TODO(eco-12): NILMTK fingerprint classification on rolling power-curve
71
+ # window — current scaffold persists raw Wh samples only.
72
+ # TODO(eco-12): rolling-30-day diversity + cadence aggregation across
73
+ # per-plug samples; downstream of storage layer.
74
+ # TODO(eco-12): emit trait bands via self._traits.emit_*() once a
75
+ # classifier window closes; current scaffold writes samples and
76
+ # stops — no upward emission yet.
77
+ await self._storage.record_sample(plug_mac=str(plug_mac), wh=float(wh), ts=ts)
@@ -0,0 +1,92 @@
1
+ """Workshop-tools storage layer — local SQLite, mode 600.
2
+
3
+ Persists per-plug Wh samples in
4
+ ``~/.local/share/alter-runtime/workshop_tools.db``. The file is
5
+ created mode ``0o600`` before SQLite ever opens it, so a same-host
6
+ non-daemon user cannot read the trace.
7
+
8
+ This is a stub: schema and write path land; the rolling-30-day
9
+ aggregation, NILMTK fingerprint window, and trait-band derivation are
10
+ ``# TODO(eco-12)`` for the follow-up implementation pass.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import sqlite3
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from alter_runtime.config import data_dir
22
+
23
+ __all__ = ["WorkshopToolsStorage", "DB_FILENAME"]
24
+
25
+ logger = logging.getLogger("alter_runtime.adapters.household.workshop_tools.storage")
26
+
27
+ DB_FILENAME: str = "workshop_tools.db"
28
+
29
+ _SCHEMA: str = """
30
+ CREATE TABLE IF NOT EXISTS plug_energy_samples (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ plug_mac TEXT NOT NULL,
33
+ wh REAL NOT NULL,
34
+ ts TEXT,
35
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_samples_plug_mac
38
+ ON plug_energy_samples (plug_mac);
39
+ """
40
+
41
+
42
+ class WorkshopToolsStorage:
43
+ """Async-friendly wrapper around a tiny ``sqlite3`` store.
44
+
45
+ Stdlib ``sqlite3`` is sync; we run writes inside ``asyncio.to_thread``
46
+ so the supervisor loop is never blocked by disk IO.
47
+ """
48
+
49
+ def __init__(self, db_path: Path | None = None) -> None:
50
+ self._db_path = db_path or (data_dir() / DB_FILENAME)
51
+ self._lock = asyncio.Lock()
52
+ self._ensure_db()
53
+
54
+ @property
55
+ def db_path(self) -> Path:
56
+ return self._db_path
57
+
58
+ def _ensure_db(self) -> None:
59
+ """Touch the DB file mode 0o600 then init schema."""
60
+ self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
61
+ if not self._db_path.exists():
62
+ self._db_path.touch(mode=0o600, exist_ok=True)
63
+ else:
64
+ try:
65
+ self._db_path.chmod(0o600)
66
+ except OSError as exc: # pragma: no cover — best-effort tightening
67
+ logger.warning("workshop_tools chmod 0o600 failed: %s", exc)
68
+ with sqlite3.connect(self._db_path) as conn:
69
+ conn.executescript(_SCHEMA)
70
+
71
+ async def record_sample(self, *, plug_mac: str, wh: float, ts: Any | None = None) -> None:
72
+ """Persist one Wh sample. Returns once the row is committed."""
73
+ async with self._lock:
74
+ await asyncio.to_thread(self._insert_sample, plug_mac, wh, ts)
75
+
76
+ def _insert_sample(self, plug_mac: str, wh: float, ts: Any | None) -> None:
77
+ with sqlite3.connect(self._db_path) as conn:
78
+ conn.execute(
79
+ "INSERT INTO plug_energy_samples (plug_mac, wh, ts) VALUES (?, ?, ?)",
80
+ (plug_mac, wh, str(ts) if ts is not None else None),
81
+ )
82
+ conn.commit()
83
+
84
+ async def sample_count(self) -> int:
85
+ """Test introspection — number of rows currently persisted."""
86
+ return await asyncio.to_thread(self._sample_count_sync)
87
+
88
+ def _sample_count_sync(self) -> int:
89
+ with sqlite3.connect(self._db_path) as conn:
90
+ cur = conn.execute("SELECT COUNT(*) FROM plug_energy_samples")
91
+ row = cur.fetchone()
92
+ return int(row[0]) if row else 0
@@ -0,0 +1,48 @@
1
+ """Tests for WorkshopToolsAdapter — subscribe/handle round-trip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from alter_runtime.adapters.household.workshop_tools.adapter import (
10
+ TAPO_PLUG_ENERGY_TOPIC,
11
+ WorkshopToolsAdapter,
12
+ )
13
+ from alter_runtime.adapters.household.workshop_tools.storage import (
14
+ WorkshopToolsStorage,
15
+ )
16
+ from alter_runtime.config import DaemonConfig
17
+ from alter_runtime.subscribers.bus import EventBus
18
+
19
+
20
+ def test_subscribe_topic_matches_tapo_glob() -> None:
21
+ assert TAPO_PLUG_ENERGY_TOPIC == "tapo/plug/+/energy"
22
+ assert TAPO_PLUG_ENERGY_TOPIC in WorkshopToolsAdapter.subscribe_topics
23
+
24
+
25
+ async def test_adapter_persists_bridged_event(
26
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
27
+ ) -> None:
28
+ db_path = tmp_path / "workshop_tools.db"
29
+ # Stub the adapter's storage to our tmp DB without touching ~/.local/share.
30
+ monkeypatch.setattr(
31
+ "alter_runtime.adapters.household.workshop_tools.adapter.WorkshopToolsStorage",
32
+ lambda: WorkshopToolsStorage(db_path=db_path),
33
+ )
34
+
35
+ bus = EventBus()
36
+ adapter = WorkshopToolsAdapter(config=DaemonConfig(), bus=bus)
37
+
38
+ await adapter.handle_event({"plug_mac": "AA:BB:CC:DD:EE:01", "watt_hours": 42.0, "ts": None})
39
+ assert await WorkshopToolsStorage(db_path=db_path).sample_count() == 1
40
+
41
+
42
+ async def test_adapter_drops_incomplete_event(tmp_path: Path) -> None:
43
+ bus = EventBus()
44
+ adapter = WorkshopToolsAdapter(config=DaemonConfig(), bus=bus)
45
+ # Missing plug_mac — should silently drop, no exception.
46
+ await adapter.handle_event({"watt_hours": 12.3})
47
+ await adapter.handle_event("not a dict")
48
+ await adapter.handle_event(None)
@@ -0,0 +1,26 @@
1
+ """Tests for workshop_tools SQLite storage — mode 0o600 + sample insert."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ from alter_runtime.adapters.household.workshop_tools.storage import (
9
+ WorkshopToolsStorage,
10
+ )
11
+
12
+
13
+ async def test_db_file_mode_is_600(tmp_path: Path) -> None:
14
+ db_path = tmp_path / "workshop_tools.db"
15
+ WorkshopToolsStorage(db_path=db_path)
16
+ mode = stat.S_IMODE(db_path.stat().st_mode)
17
+ assert mode == 0o600, f"expected mode 0o600, got {oct(mode)}"
18
+
19
+
20
+ async def test_record_sample_persists_row(tmp_path: Path) -> None:
21
+ db_path = tmp_path / "workshop_tools.db"
22
+ storage = WorkshopToolsStorage(db_path=db_path)
23
+ assert await storage.sample_count() == 0
24
+ await storage.record_sample(plug_mac="AA:BB:CC:DD:EE:01", wh=12.3, ts=None)
25
+ await storage.record_sample(plug_mac="AA:BB:CC:DD:EE:02", wh=7.5, ts="2026-05-27")
26
+ assert await storage.sample_count() == 2
@@ -0,0 +1,45 @@
1
+ """Tests for workshop_tools trait emitter — Clause-4 banlist + band shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from alter_runtime.adapters.household.workshop_tools.traits import (
8
+ CLAUSE_4_BANLIST,
9
+ BannedTraitKindError,
10
+ WorkshopToolsTraits,
11
+ )
12
+
13
+
14
+ def test_banlist_contains_all_spec_kinds() -> None:
15
+ expected = {
16
+ "workshop-affect",
17
+ "craft-burnout",
18
+ "focus-fatigue",
19
+ "production-pressure",
20
+ "flow-state-inference",
21
+ "classroom-workshop-suitability",
22
+ }
23
+ assert expected.issubset(CLAUSE_4_BANLIST)
24
+
25
+
26
+ @pytest.mark.parametrize("banned_kind", sorted(CLAUSE_4_BANLIST))
27
+ def test_banned_kinds_are_refused(banned_kind: str) -> None:
28
+ traits = WorkshopToolsTraits()
29
+ with pytest.raises(BannedTraitKindError):
30
+ traits.emit(banned_kind, band="deep")
31
+
32
+
33
+ def test_focus_block_depth_emits_band() -> None:
34
+ traits = WorkshopToolsTraits()
35
+ emission = traits.emit_focus_block_depth("deep")
36
+ assert emission.kind == "focus_block_depth"
37
+ assert emission.band == "deep"
38
+ assert emission.provenance == "passive_local_sensor"
39
+ assert emission.stream == "workshop_tool"
40
+
41
+
42
+ def test_workshop_steward_active_bool_to_band() -> None:
43
+ traits = WorkshopToolsTraits()
44
+ assert traits.emit_workshop_steward_active(True).band == "active"
45
+ assert traits.emit_workshop_steward_active(False).band == "inactive"