feat: add adapter building RuntimeDescriptor from bound entities
This commit is contained in:
1 parent
6778c60a09
commit
299719f727
2 files changed
+281
No files matched your search
@@ -0,0 +1,241 @@
|
||||
"""Adapter: a discovered BoundEntity list -> a RuntimeDescriptor the bridge
|
||||
consumes. Replaces the per-appliance flatten/build_discovery/command_handlers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ..poll_scheduler import PollTier
|
||||
from .discovery import BoundEntity
|
||||
from .entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, PLATFORM_OF, SelectDesc,
|
||||
SensorDesc, SwitchDesc,
|
||||
)
|
||||
|
||||
_TIER_CADENCE = {'hot': (1.0, 0.5), 'warm': (15.0, None), 'cold': (300.0, None)}
|
||||
|
||||
|
||||
def _segs(href: str) -> list[str]:
|
||||
return [s for s in href.strip('/').split('/') if s]
|
||||
|
||||
|
||||
def _key(b: BoundEntity) -> str:
|
||||
return f"{b.desc.key}{b.instance}"
|
||||
|
||||
|
||||
def _encode(cfg: dict) -> bytes:
|
||||
return json.dumps(cfg).encode()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeDescriptor:
|
||||
name: str
|
||||
seed_path: list[str]
|
||||
default_observe_port: int
|
||||
observe_paths: list[list[str]]
|
||||
poll_tiers: list[PollTier]
|
||||
is_active: Optional[Callable[[dict], bool]]
|
||||
flatten: Callable[[dict], dict]
|
||||
discovery_payloads: list[tuple[str, bytes]]
|
||||
command_handlers: Callable[[], dict]
|
||||
project: Optional[Callable[[dict, dict], dict]]
|
||||
on_observation: Optional[Callable[[dict, str, dict], None]]
|
||||
remote_available_field: Optional[str]
|
||||
cycle_active_field: Optional[str]
|
||||
log_state_change: Optional[Callable[[dict], str]]
|
||||
|
||||
|
||||
def _make_flatten(bound: list[BoundEntity]) -> Callable[[dict], dict]:
|
||||
reads = [b for b in bound if b.desc.field] # buttons have no field
|
||||
|
||||
def flatten(links: dict) -> dict:
|
||||
out: dict = {}
|
||||
for b in reads:
|
||||
rep = links.get(b.href) or {}
|
||||
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep):
|
||||
continue
|
||||
out[_key(b)] = b.desc.value_fn(rep.get(b.desc.field))
|
||||
return out
|
||||
|
||||
return flatten
|
||||
|
||||
|
||||
def _make_observe_paths(bound: list[BoundEntity]) -> list[list[str]]:
|
||||
seen, out = set(), []
|
||||
for b in bound:
|
||||
if not b.capability.observe or b.href in seen:
|
||||
continue
|
||||
seen.add(b.href)
|
||||
out.append(_segs(b.href))
|
||||
return out
|
||||
|
||||
|
||||
def _make_poll_tiers(bound: list[BoundEntity]) -> list[PollTier]:
|
||||
groups: dict[str, set[str]] = {}
|
||||
for b in bound:
|
||||
groups.setdefault(b.capability.poll_tier, set()).add(b.href)
|
||||
tiers: list[PollTier] = []
|
||||
for name, hrefs in groups.items():
|
||||
interval, active = _TIER_CADENCE.get(name, (15.0, None))
|
||||
tiers.append(PollTier(
|
||||
name=name, interval_s=interval, active_interval_s=active,
|
||||
paths=tuple(tuple(_segs(h)) for h in sorted(hrefs))))
|
||||
tiers.append(PollTier(name='sweep', interval_s=300.0,
|
||||
paths=(('device', '0'),), is_sweep=True))
|
||||
return tiers
|
||||
|
||||
|
||||
def _make_is_active(bound):
|
||||
caps = {b.href: b.capability for b in bound if b.capability.active_when}
|
||||
if not caps:
|
||||
return None
|
||||
|
||||
def is_active(links: dict) -> bool:
|
||||
return any(cap.active_when(links.get(href) or {})
|
||||
for href, cap in caps.items())
|
||||
|
||||
return is_active
|
||||
|
||||
|
||||
def _make_on_observation(bound):
|
||||
caps = {b.href: b.capability for b in bound if b.capability.on_observation}
|
||||
if not caps:
|
||||
return None
|
||||
|
||||
def on_observation(state: dict, href: str, rep: dict) -> None:
|
||||
cap = caps.get(href)
|
||||
if cap is not None:
|
||||
cap.on_observation(state, rep)
|
||||
|
||||
return on_observation
|
||||
|
||||
|
||||
def _make_project(bound):
|
||||
projs = []
|
||||
seen = set()
|
||||
for b in bound:
|
||||
if b.capability.project and b.capability.href not in seen:
|
||||
seen.add(b.capability.href)
|
||||
projs.append(b.capability.project)
|
||||
if not projs:
|
||||
return None
|
||||
|
||||
def project(state: dict, sensors: dict) -> dict:
|
||||
for p in projs:
|
||||
sensors = p(state, sensors)
|
||||
return sensors
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def _make_command_handlers(bound):
|
||||
writers = [b for b in bound if getattr(b.desc, 'write_fn', None)]
|
||||
|
||||
def command_handlers() -> dict:
|
||||
out = {}
|
||||
for b in writers:
|
||||
href, fn = b.href, b.desc.write_fn
|
||||
|
||||
def handler(payload, links, _fn=fn, _href=href):
|
||||
rep = (links or {}).get(_href) or {}
|
||||
return _fn(payload, rep)
|
||||
|
||||
out[f"cmd/{_key(b)}"] = handler
|
||||
return out
|
||||
|
||||
return command_handlers
|
||||
|
||||
|
||||
# --- MQTT discovery config emitters (per platform) ------------------------
|
||||
def _avail(topic_prefix):
|
||||
return [{'topic': f"{topic_prefix}/availability",
|
||||
'payload_available': 'online', 'payload_not_available': 'offline'}]
|
||||
|
||||
|
||||
def _device(topic_prefix, device_name, model):
|
||||
return {'identifiers': [topic_prefix], 'name': device_name,
|
||||
'manufacturer': 'Samsung', 'model': model}
|
||||
|
||||
|
||||
def _discovery_payloads(bound, topic_prefix, ha_prefix, device_name, model):
|
||||
state_topic = f"{topic_prefix}/state"
|
||||
dev = _device(topic_prefix, device_name, model)
|
||||
avail = _avail(topic_prefix)
|
||||
out = []
|
||||
for b in bound:
|
||||
d = b.desc
|
||||
key = _key(b)
|
||||
platform = PLATFORM_OF[type(d)]
|
||||
uid = f"{topic_prefix}_{key}"
|
||||
cfg = {'name': d.name or key, 'unique_id': uid, 'object_id': uid,
|
||||
'device': dev, 'availability': avail}
|
||||
if d.icon:
|
||||
cfg['icon'] = d.icon
|
||||
if d.entity_category:
|
||||
cfg['entity_category'] = d.entity_category
|
||||
if isinstance(d, SensorDesc):
|
||||
cfg['state_topic'] = state_topic
|
||||
cfg['value_template'] = f"{{{{ value_json.{key} }}}}"
|
||||
for k, attr in (('unit_of_measurement', 'unit'),
|
||||
('device_class', 'device_class'),
|
||||
('state_class', 'state_class')):
|
||||
if getattr(d, attr):
|
||||
cfg[k] = getattr(d, attr)
|
||||
elif isinstance(d, BinarySensorDesc):
|
||||
cfg['state_topic'] = state_topic
|
||||
cfg['value_template'] = (
|
||||
f"{{{{ 'ON' if value_json.{key} else 'OFF' }}}}")
|
||||
cfg['payload_on'] = 'ON'
|
||||
cfg['payload_off'] = 'OFF'
|
||||
if d.device_class:
|
||||
cfg['device_class'] = d.device_class
|
||||
elif isinstance(d, SelectDesc):
|
||||
cfg['state_topic'] = state_topic
|
||||
cfg['value_template'] = f"{{{{ value_json.{key} }}}}"
|
||||
cfg['command_topic'] = f"{topic_prefix}/cmd/{key}"
|
||||
opts = d.options
|
||||
cfg['options'] = list(opts) if isinstance(opts, (list, tuple)) else []
|
||||
elif isinstance(d, SwitchDesc):
|
||||
cfg['state_topic'] = state_topic
|
||||
cfg['value_template'] = f"{{{{ value_json.{key} }}}}"
|
||||
cfg['command_topic'] = f"{topic_prefix}/cmd/{key}"
|
||||
elif isinstance(d, ButtonDesc):
|
||||
cfg['command_topic'] = f"{topic_prefix}/cmd/{key}"
|
||||
cfg['payload_press'] = d.payload
|
||||
elif isinstance(d, NumberDesc):
|
||||
cfg['state_topic'] = state_topic
|
||||
cfg['value_template'] = f"{{{{ value_json.{key} }}}}"
|
||||
cfg['command_topic'] = f"{topic_prefix}/cmd/{key}"
|
||||
for k, attr in (('unit_of_measurement', 'unit'),
|
||||
('device_class', 'device_class'),
|
||||
('min', 'native_min'), ('max', 'native_max'),
|
||||
('step', 'step')):
|
||||
if getattr(d, attr) is not None:
|
||||
cfg[k] = getattr(d, attr)
|
||||
out.append((f"{ha_prefix}/{platform}/{topic_prefix}/{key}/config",
|
||||
_encode(cfg)))
|
||||
return out
|
||||
|
||||
|
||||
def build_runtime_descriptor(bound, *, topic_prefix, ha_prefix, device_name,
|
||||
model, name, default_port) -> RuntimeDescriptor:
|
||||
produced = {_key(b) for b in bound}
|
||||
return RuntimeDescriptor(
|
||||
name=name,
|
||||
seed_path=['device', '0'],
|
||||
default_observe_port=default_port,
|
||||
observe_paths=_make_observe_paths(bound),
|
||||
poll_tiers=_make_poll_tiers(bound),
|
||||
is_active=_make_is_active(bound),
|
||||
flatten=_make_flatten(bound),
|
||||
discovery_payloads=_discovery_payloads(
|
||||
bound, topic_prefix, ha_prefix, device_name, model),
|
||||
command_handlers=_make_command_handlers(bound),
|
||||
project=_make_project(bound),
|
||||
on_observation=_make_on_observation(bound),
|
||||
remote_available_field='remote_control' if 'remote_control' in produced else None,
|
||||
cycle_active_field=None,
|
||||
log_state_change=None,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
|
||||
from samsung_appliance.registry.adapter import build_runtime_descriptor
|
||||
from samsung_appliance.registry.capabilities import common
|
||||
from samsung_appliance.registry.discovery import discover
|
||||
|
||||
|
||||
def _bound(resources):
|
||||
reg = {c.href: c for c in (common.KIDS_LOCK, common.ENERGY_METER)}
|
||||
return discover(resources, reg)
|
||||
|
||||
|
||||
def test_flatten_applies_value_fn(dishwasher_resources):
|
||||
rd = build_runtime_descriptor(
|
||||
_bound(dishwasher_resources), topic_prefix='t', ha_prefix='homeassistant',
|
||||
device_name='Dishwasher', model='M', name='dishwasher', default_port=49154)
|
||||
flat = rd.flatten(dishwasher_resources)
|
||||
assert flat['power_watts'] is not None
|
||||
assert isinstance(flat['child_lock'], bool)
|
||||
|
||||
|
||||
def test_observe_paths_are_segment_lists(dishwasher_resources):
|
||||
rd = build_runtime_descriptor(
|
||||
_bound(dishwasher_resources), topic_prefix='t', ha_prefix='homeassistant',
|
||||
device_name='Dishwasher', model='M', name='dishwasher', default_port=49154)
|
||||
assert ['kidslock', 'vs', '0'] in rd.observe_paths
|
||||
|
||||
|
||||
def test_discovery_payloads_have_unique_ids(dishwasher_resources):
|
||||
rd = build_runtime_descriptor(
|
||||
_bound(dishwasher_resources), topic_prefix='samsung_dishwasher',
|
||||
ha_prefix='homeassistant', device_name='Dishwasher', model='M',
|
||||
name='dishwasher', default_port=49154)
|
||||
topics = [t for t, _ in rd.discovery_payloads]
|
||||
# one config topic per produced sensor/binary_sensor entity
|
||||
assert any('/binary_sensor/samsung_dishwasher/child_lock/config' in t for t in topics)
|
||||
assert any('/sensor/samsung_dishwasher/power_watts/config' in t for t in topics)
|
||||
# payloads are JSON bytes carrying unique_id
|
||||
cfg = json.loads(next(p for t, p in rd.discovery_payloads if 'child_lock' in t))
|
||||
assert cfg['unique_id'] == 'samsung_dishwasher_child_lock'
|
||||
Reference in new issue
Block a user