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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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"
|