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.
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""DTLS-layer liveness via CoAP empty-CON ping + poll-success watchdog.
|
|
|
|
Each interval_s the task:
|
|
1. Sends a CoAP ping. This is fire-and-forget — Samsung's RT-OCF
|
|
doesn't reliably reply with an RST, so the send itself is the
|
|
keepalive (it tickles Samsung's observer state). The send only
|
|
fails if the underlying socket is gone, in which case the failure
|
|
counts toward fail_threshold.
|
|
2. Calls liveness_fn() if provided. This is the real half-open
|
|
detection: PollScheduler exposes last_success_ts, and the bridge
|
|
wraps it as "did we get a 2.05 in the last 60s". If not, count
|
|
the tick as a failure even though the ping send succeeded.
|
|
|
|
After fail_threshold consecutive failures, fires on_unreachable. First
|
|
success after a fail streak fires on_reachable. Bridge wires these to
|
|
MQTT availability.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Callable, Optional
|
|
|
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
|
|
|
|
|
class KeepaliveTask:
|
|
|
|
def __init__(self,
|
|
session: DtlsCoapSession,
|
|
interval_s: float = 25.0,
|
|
fail_threshold: int = 3,
|
|
on_reachable: Optional[Callable[[], None]] = None,
|
|
on_unreachable: Optional[Callable[[], None]] = None,
|
|
logger=None,
|
|
liveness_fn: Optional[Callable[[], bool]] = None):
|
|
self.session = session
|
|
self.interval_s = interval_s
|
|
self.fail_threshold = fail_threshold
|
|
self.on_reachable = on_reachable
|
|
self.on_unreachable = on_unreachable
|
|
self.log = logger
|
|
self.liveness_fn = liveness_fn
|
|
|
|
self._fail_streak = 0
|
|
self._reachable = True
|
|
self._ping_count = 0
|
|
self._ping_fail_count = 0
|
|
|
|
@property
|
|
def ping_count(self) -> int:
|
|
return self._ping_count
|
|
|
|
@property
|
|
def ping_fail_count(self) -> int:
|
|
return self._ping_fail_count
|
|
|
|
@property
|
|
def reachable(self) -> bool:
|
|
return self._reachable
|
|
|
|
def run_forever(self, stop: threading.Event) -> None:
|
|
while not stop.wait(self.interval_s):
|
|
self._tick()
|
|
|
|
def _tick(self) -> None:
|
|
ok = False
|
|
try:
|
|
self.session.ping()
|
|
ok = True
|
|
except ConnectionError as e:
|
|
if self.log: self.log.debug("ping: %s", e)
|
|
except Exception as e:
|
|
if self.log: self.log.warning("ping: %s", e)
|
|
# Real half-open detection: ping sends can succeed against a
|
|
# silently-wedged peer, but polls won't. If the scheduler
|
|
# hasn't recorded a 2.05 inside the liveness window, treat
|
|
# this tick as a failure even though the ping itself went out.
|
|
if ok and self.liveness_fn is not None:
|
|
try:
|
|
alive = bool(self.liveness_fn())
|
|
except Exception as e:
|
|
if self.log: self.log.warning("liveness_fn: %s", e)
|
|
alive = True
|
|
if not alive:
|
|
if self.log:
|
|
self.log.warning("liveness: no successful poll "
|
|
"in the liveness window")
|
|
ok = False
|
|
self._ping_count += 1
|
|
if ok:
|
|
if not self._reachable:
|
|
if self.log:
|
|
self.log.info("ping recovered after %d fails",
|
|
self._fail_streak)
|
|
self._reachable = True
|
|
if self.on_reachable is not None:
|
|
try: self.on_reachable()
|
|
except Exception as e:
|
|
if self.log: self.log.warning("on_reachable: %s", e)
|
|
self._fail_streak = 0
|
|
return
|
|
self._ping_fail_count += 1
|
|
self._fail_streak += 1
|
|
if self._reachable and self._fail_streak >= self.fail_threshold:
|
|
if self.log:
|
|
self.log.warning("device unreachable after %d ping failures",
|
|
self._fail_streak)
|
|
self._reachable = False
|
|
if self.on_unreachable is not None:
|
|
try: self.on_unreachable()
|
|
except Exception as e:
|
|
if self.log: self.log.warning("on_unreachable: %s", e)
|