diff --git a/haco/v0/README.md b/README.md similarity index 100% rename from haco/v0/README.md rename to README.md diff --git a/build.env b/build.env index 8d30f31..40d5026 100644 --- a/build.env +++ b/build.env @@ -1,4 +1,4 @@ -CHANNEL="prod" +CHANNEL="dev" #ORG="fmtr" PACKAGE="haco" PORT="06" diff --git a/haco/capabilities.py b/haco/capabilities.py index 3eb75ed..a03d070 100644 --- a/haco/capabilities.py +++ b/haco/capabilities.py @@ -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 diff --git a/haco/client.py b/haco/client.py index c3b39f5..9da8fd7 100644 --- a/haco/client.py +++ b/haco/client.py @@ -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) diff --git a/haco/climate.py b/haco/climate.py index 95c6c36..154045c 100644 --- a/haco/climate.py +++ b/haco/climate.py @@ -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"), ] diff --git a/haco/control.py b/haco/control.py index 1ebded7..237eea5 100644 --- a/haco/control.py +++ b/haco/control.py @@ -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 = {} diff --git a/haco/device.py b/haco/device.py index c815ed7..f28e16b 100644 --- a/haco/device.py +++ b/haco/device.py @@ -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 diff --git a/haco/sb3.py b/haco/sb3.py index 5487d1a..ee9fdda 100644 --- a/haco/sb3.py +++ b/haco/sb3.py @@ -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()) diff --git a/haco/topics.py b/haco/topics.py index 14b5cbd..ebea120 100644 --- a/haco/topics.py +++ b/haco/topics.py @@ -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} diff --git a/setup.py b/setup.py index 0d7ce36..d966648 100644 --- a/setup.py +++ b/setup.py @@ -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',