feat(packaging): nest protocol/ + ocf/ under smartthings_local, add PyPI packaging
Nest the two library packages under a single import namespace so they can ship as one distribution: protocol/ -> smartthings_local/protocol/ ocf/ -> smartthings_local/ocf/ (git mv, history preserved) - Rewrite all imports protocol.* -> smartthings_local.protocol.*, ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/ (bridge, descriptor, samples), and tests. - Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning from v* tags, wheel ships only smartthings_local/. - Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on v* tags (OIDC, no stored token). - Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts: it is tracked but matches .gitignore's *.pem, so hatchling's VCS file selection would drop it — and dtls_session.py loads it at runtime. - Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the single smartthings_local/ package. - .gitignore: build artifacts (_version.py, dist/, *.egg-info/). Validated: pytest tests/ (11 passed), python -m build produces sdist + wheel with the pem bundled, fresh pip install resolves all nested imports with the pem readable from site-packages.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""Single source of truth for one appliance's state.
|
||||
|
||||
All writers (OBSERVE notify, poll, seed, optimistic) call apply_rep().
|
||||
A registered on_change callback fires after any apply that mutated the
|
||||
cache, which the bridge wires to its MQTT publish gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional, Protocol
|
||||
|
||||
|
||||
class _ObservationHook(Protocol):
|
||||
def on_observation(self, state: dict, href: str, rep: dict) -> None: ...
|
||||
|
||||
|
||||
class StateCache:
|
||||
|
||||
def __init__(self, descriptor: '_ObservationHook'):
|
||||
self.descriptor = descriptor
|
||||
self.links: dict[str, dict] = {}
|
||||
self.last_updated: dict[str, float] = {}
|
||||
self.source: dict[str, str] = {}
|
||||
self.descriptor_state: dict = {}
|
||||
self._on_change: Optional[Callable[[bool, str], None]] = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def set_on_change(self, cb: Callable[[bool, str], None]) -> None:
|
||||
self._on_change = cb
|
||||
|
||||
def apply_rep(self, href: str, rep: dict, source: str) -> bool:
|
||||
if not isinstance(rep, dict):
|
||||
return False
|
||||
with self._lock:
|
||||
prior = self.links.get(href)
|
||||
changed = prior != rep
|
||||
self.links[href] = rep
|
||||
self.last_updated[href] = time.time()
|
||||
self.source[href] = source
|
||||
hook = self.descriptor.on_observation
|
||||
if hook is not None:
|
||||
try:
|
||||
hook(self.descriptor_state, href, rep)
|
||||
except Exception:
|
||||
pass
|
||||
if self._on_change is not None:
|
||||
try:
|
||||
self._on_change(changed, source)
|
||||
except Exception:
|
||||
pass
|
||||
return changed
|
||||
|
||||
def apply_optimistic(self, href: str, body: dict) -> bool:
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
with self._lock:
|
||||
merged = dict(self.links.get(href) or {})
|
||||
merged.update(body)
|
||||
return self.apply_rep(href, merged, source='optimistic')
|
||||
|
||||
@staticmethod
|
||||
def index_device_tree(device0_body) -> dict[str, dict]:
|
||||
"""Turn a /device/0 CBOR list-of-{href, rep} sweep response into
|
||||
a dict keyed by href. Entry [0] is the device-level rep itself
|
||||
and isn't useful here, so it's skipped.
|
||||
|
||||
Replaces the old standalone sensors.index_links — folded in
|
||||
here because every current and future caller immediately feeds
|
||||
the result into apply_rep on this same cache."""
|
||||
out: dict[str, dict] = {}
|
||||
if not isinstance(device0_body, list):
|
||||
return out
|
||||
for entry in device0_body[1:]:
|
||||
if isinstance(entry, dict) and 'href' in entry:
|
||||
out[entry['href']] = entry.get('rep') or {}
|
||||
return out
|
||||
|
||||
def get(self, href: str) -> Optional[dict]:
|
||||
with self._lock:
|
||||
return self.links.get(href)
|
||||
|
||||
def snapshot(self) -> dict[str, dict]:
|
||||
with self._lock:
|
||||
return dict(self.links)
|
||||
|
||||
def freshness_s(self, href: str) -> Optional[float]:
|
||||
ts = self.last_updated.get(href)
|
||||
return None if ts is None else (time.time() - ts)
|
||||
|
||||
def stalest(self) -> Optional[tuple[str, float]]:
|
||||
with self._lock:
|
||||
if not self.last_updated:
|
||||
return None
|
||||
href = min(self.last_updated, key=self.last_updated.get)
|
||||
return href, time.time() - self.last_updated[href]
|
||||
Reference in New Issue
Block a user