Refine data model/announce and subscriptions and add sandbox command/wrapper/callback/state workflow - and adapt for aiomqtt v2

This commit is contained in:
Edward Brown 2025-11-20 12:34:50 +00:00 • committed by Edward Brown
parent a568e35d0b
commit 4d35286ff9
10 changed files with 136 additions and 39 deletions

View file

@ -1,4 +1,4 @@
CHANNEL="prod"
CHANNEL="dev"
#ORG="fmtr"
PACKAGE="haco"
PORT="06"

View file

@ -16,6 +16,8 @@ class Capability(Base):
state: AnnounceTopicState | None = field(default_factory=AnnounceTopicState)
command: AnnounceTopicCommand | None = field(default_factory=AnnounceTopicCommand)
subscriptions: dict | None = field(default=None, metadata=dict(exclude=True), init=False)
parent: Control | None = None
announce = None
@ -23,6 +25,9 @@ class Capability(Base):
self.parent = control
for topic in (t for t in (self.state, self.command) if t):
topic.set_parent(self)
self.subscriptions = self.get_subscriptions()
self.announce = self.get_announce()
def get_announce(self) -> dict:
@ -34,3 +39,9 @@ class Capability(Base):
@property
def control(self):
return self.parent
def get_subscriptions(self) -> dict:
data = {}
for topic in (t for t in (self.state, self.command) if t):
data |= topic.subscriptions
return data

View file

@ -1,16 +1,41 @@
from fmtr.tools import mqtt
import aiomqtt
from fmtr.tools import mqtt, Constants
from haco import constants
aiomqtt.Client
class ClientHaco(mqtt.Client):
def __init__(self, *args, device=None, will=None, client_id=None, **kwargs):
def __init__(self, *args, device=None, will=None, identifier=None, **kwargs):
self.will = will or mqtt.Will(topic=str(constants.TOPIC_AVAIL), payload="offline", retain=True, qos=1, )
client_id = client_id or constants.CLIENT_ID
identifier = identifier or constants.CLIENT_ID
self.device = device
self.device.set_parent(self)
super().__init__(*args, will=self.will, client_id=client_id, **kwargs)
super().__init__(*args, will=self.will, identifier=identifier, **kwargs)
@property
def topic(self):
return constants.TOPIC_CLIENT
async def handle(self):
"""Listen for command messages from HA."""
# subs = control.subscriptions
for topic_sub in self.device.subscriptions.keys():
print(f"Subscribing to {topic_sub}")
await self.subscribe(topic_sub)
async for message in self.messages:
payload = message.payload.decode()
print(f"{message.topic.value}{Constants.ARROW}{payload}")
topic_command = self.device.subscriptions[message.topic.value]
echo_val = topic_command.wrap_back(message)
echo_val
topic_state = topic_command.state
await topic_state.wrap_back(echo_val)
# Echo back as new state
# await self.publish(STATE_TOPIC, payload, retain=True)

View file

@ -22,6 +22,21 @@ class Climate(Control):
def temperature_command(self, value):
return value
def mode_command(self, value):
return value
def fan_mode_command(self, value):
return value
def fan_mode_state(self, value):
return value
def power_command(self, value):
return value
def power_state(self, value):
return value
@classmethod
def get_capabilities(cls):
return [
@ -31,4 +46,5 @@ class Climate(Control):
Capability(name="target_humidity"),
Capability(name="mode"),
Capability(name="fan_mode"),
Capability(name="power"),
]

View file

@ -16,17 +16,29 @@ class Control(Base):
name: str
platform: str
device: Device = field(default=None)
device: Device = field(default=None, init=False)
capabilities: None | list[Capability] = field(default=None, metadata=dict(exclude=True))
unique_id: str | None = field(default=None, init=False)
availability_topic: str | None = field(default=None, init=False)
announce: dict | None = field(default=None, metadata=dict(exclude=True))
subscriptions: dict | None = field(default=None, metadata=dict(exclude=True), init=False)
def __post_init__(self):
self.capabilities = self.get_capabilities()
def set_parent(self, device):
self.device = device
for cap in self.capabilities:
cap.set_parent(self)
self.unique_id = self.get_unique_id()
self.availability_topic = self.get_availability_topic()
self.subscriptions = self.get_subscriptions()
self.announce = self.get_announce()
@ -49,9 +61,7 @@ class Control(Base):
Capability(name='default')
]
@property
def unique_id(self) -> str:
def get_unique_id(self) -> str:
return f"{self.device.name_san}-{self.name_san}"
@property
@ -66,12 +76,10 @@ class Control(Base):
def announce_topic(self):
return f"homeassistant/{self.platform}/{self.unique_id}/config"
@property
def availability_topic(self) -> str:
def get_availability_topic(self) -> str:
return self.device.client.will.topic
@property
def subscriptions(self) -> dict:
def get_subscriptions(self) -> dict:
data = {}

View file

@ -21,11 +21,11 @@ class Device(Base):
identifiers: list[str] | None = field(default=None, init=False)
controls: List[Control] = field(default_factory=list, metadata=dict(exclude=True))
parent: ClientHaco | None = field(metadata=dict(exclude=True))
announce: dict | None = field(default=None, metadata=dict(exclude=True))
# def __post_init__(self):
# self.identifiers=['ident']
parent: ClientHaco | None = field(metadata=dict(exclude=True), init=False)
announce: dict | None = field(default=None, metadata=dict(exclude=True), init=False)
subscriptions: dict | None = field(default=None, metadata=dict(exclude=True), init=False)
def set_parent(self, client):
self.parent = client
@ -35,6 +35,7 @@ class Device(Base):
for control in self.controls:
control.set_parent(self)
self.announce = self.get_announce()
self.subscriptions = self.get_subscriptions()
self
@ -57,8 +58,7 @@ class Device(Base):
def client(self):
return self.parent
@cached_property
def subscriptions(self) -> dict:
def get_subscriptions(self) -> dict:
data = {}
for control in self.controls:
data |= control.subscriptions

View file

@ -1,16 +1,44 @@
import asyncio
import aiomqtt
from fmtr.tools.json_tools import to_json
from haco.client import ClientHaco
from haco.climate import Climate
from haco.constants import MQTT_HOST
from haco.control import Control
from haco.device import Device
control = Control(name="test", platform="test")
device = Device(name="test", parent=None, controls=[control])
client = ClientHaco(
async def main():
climate_a = Climate(name="Dev Climate A", fan_modes=['a', 'b', 'c'])
climate_b = Climate(name="Dev Climate B", fan_modes=['a', 'b', 'c'])
device = Device(name="Dev Device", controls=[climate_a, climate_b])
hostname=MQTT_HOST,
client = ClientHaco(hostname=MQTT_HOST, device=device)
device=device
)
client
client
while True:
try:
async with client:
for topic, data in device.announce.items():
data_json = to_json(data)
print(f'Announcing {topic} with {data_json}')
await client.publish(topic, payload=data_json, retain=True)
# Online now
await client.publish(client.will.topic, "online", retain=True)
await client.publish('haco/development/3012edb1a6d4-1ed9c3469373/dev-device/dev-climate-b/power/command', "OFF", retain=True)
# Wait for commands
await client.handle()
except aiomqtt.MqttError as e:
print(f"⚠️ MQTT disconnected: {e}; retrying in 5s")
await asyncio.sleep(5)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -16,10 +16,12 @@ class AnnounceTopic:
parent: Capability | None = None
announce: dict | None = field(default=None, metadata=dict(exclude=True))
subscriptions: dict | None = field(default=None, metadata=dict(exclude=True), init=False)
def set_parent(self, control):
self.parent = control
def set_parent(self, capability: Capability):
self.parent = capability
self.announce = self.get_announce()
self.subscriptions = self.get_subscriptions()
def fill(self, mask):
return mask.format(**self.fills)
@ -47,8 +49,7 @@ class AnnounceTopic:
def callback_name(self):
return f'{self.capability.name}_{self.IO}'
@property
def subscriptions(self):
def get_subscriptions(self):
return {}
@cached_property
@ -60,18 +61,27 @@ class AnnounceTopic:
class AnnounceTopicState(AnnounceTopic):
IO: ClassVar[str] = 'state'
@property
def subscriptions(self):
def get_subscriptions(self):
return {}
async def wrap_back(self, value):
method = getattr(self.capability.control, self.callback_name, None)
value_raw = method(value)
await self.capability.control.device.client.publish(self.topic, value_raw)
return value_raw
@dataclass(kw_only=True)
class AnnounceTopicCommand(AnnounceTopic):
IO: ClassVar[str] = 'command'
@property
def subscriptions(self):
def state(self):
return self.capability.state
def wrap_back(self, message):
method = getattr(self.capability.control, self.callback_name, None)
if not method:
return {}
return {self.topic: method}
value = method(message.payload.decode('utf-8'))
return value
def get_subscriptions(self):
return {self.topic: self}

View file

@ -4,8 +4,7 @@ setup = Setup(
org=None,
dependencies=dict(
install=[
'fmtr.tools[version.dev,logging,sets,yaml,debug,caching,api]',
'aiomqtt<2'
'fmtr.tools[version.dev,logging,sets,yaml,debug,caching,api,mqtt]',
]
),
description='Home Assistant Control Objects',