From 25075debdf7da5b965cc1b0a35e9c149bde96c40 Mon Sep 17 00:00:00 2001 From: Edward Brown Date: Fri, 21 Nov 2025 09:58:53 +0000 Subject: [PATCH] Prep y0 code for release --- README.md | 394 +----------------------------------------------- haco/client.py | 37 +++-- haco/climate.py | 13 +- haco/control.py | 8 +- haco/obs.py | 11 ++ haco/sb3.py | 24 +-- haco/topics.py | 18 ++- haco/version | 2 +- haco/version.py | 3 + 9 files changed, 73 insertions(+), 437 deletions(-) create mode 100644 haco/obs.py create mode 100644 haco/version.py diff --git a/README.md b/README.md index 4ad8ec9..f3bb28c 100644 --- a/README.md +++ b/README.md @@ -1,396 +1,8 @@ # `haco` Home Assistant Control Objects -`haco` is a Home Assistant Add-On, and associated Tasmota daemon written in Berry, to greatly simplify the process of +`haco` is a Python/Berry library to greatly simplify the process of exposing Home -Assistant controls (e.g. Pull-down Lists, Number Sliders, Sensors, etc.) from a Tasmota device - and handling the +Assistant controls (e.g. Pull-down Lists, Number Sliders, Sensors, etc.) from a device - and handling the communication between both sides. -Using `haco` to expose, for example, a "Restart Tasmota" button to Home Assistant is as simple as writing the following -Python code: - -```py -from haco import Button, Tasmota - -restart = Button('Restart Tasmota', icon='restart') - - -@restart.callback() -def ha(value) -> Tasmota[str, 'tasmota.cmd("restart 1")']: - return value -``` - -A fully functional restart button will now appear associated with the device in Home Assistant: - -Restart Button - -Or, more practically, a cookbook pull-down menu, for a Tuya air fryer, might look like this: - -```py -OPTIONS = ['Default', 'Fries', 'Shrimp', 'Pizza', 'Chicken', 'Fish', 'Steak', 'Cake', 'Bacon', 'Preheat', 'Custom'] - -cookbook = Select('Cookbook', options=OPTIONS, icon='chef-hat') - - -@cookbook.callback() -def ha(value: str) -> Tasmota[int, tuya.send(4, 3)]: - return OPTIONS.index(value) - - -@cookbook.callback(trigger=tuya.received(4, 3)) -def tasmota(value: int): - return OPTIONS[value] -``` - -Air Fryer Cookbook - -## Do I Need This? Can't I Do this with Native Tasmota? - -You certainly can. - -But in my experience, the process is so fiddly, error-prone and hard to maintain that it's enough to -deter the casual user (as I am) entirely. Plus, sharing your configuration, once you've finally got it working, can mean -complex step-by-step guides, setting up triggers, finding MAC addresses and topics (in Tasmota) - and numerous -Blueprints, Helpers and Templates (on the Home Assistant side). - -You can see how much work creating such guides involves by seeing how it -was [heroically undertaken by Blakadder](https://blakadder.com/proscenic-in-home-assistant/), as -compared with -the [full `haco`-based equivalent](https://github.com/fmtr/haco/blob/release/haco/configs/proscenic_t21.py). - -With `haco`, on the other hand, the thorny parts of the initial setup are abstracted away and your final configuration -can often be shared via a single script. Below is a list of some of the tasks that `haco` -handles for you: - -* Announcing the entity via MQTT to Home Assistant -* Generating MQTT/HA-friendly unique IDs -* Associating the entity with its parent device -* Subscribing and publishing to the relevant MQTT topics -* Managing the relevant Tasmota rules -* Appropriate serialization of data -* Translating Home Assistant messages to their to appropriate Berry data types, and vice versa - -## Pre-Release - -:warning: This library is currently in a pre-release state. The configuration format is likely to change, and -only `Sensor`, `Select`, `Button`, `Number`, `BinarySensor`, `Switch`, `Text`, `Update`, `Fan` -and `Climate` entities are currently implemented. - -## Installation - -Installation involves installing two parts: the Tasmota daemon and the Home Assistant Add-On. Both are fairly simple. - -### Tasmota - -I'd advise first using a guinea pig Tasmota device to get `haco` up and running. The device needs to have MQTT -connected and be available to Home Assistant (i.e. showing up -in [the Devices section](https://my.home-assistant.io/redirect/devices/)). - -Once your test device is ready, with a recent (12.5+) version of Tasmota, simply paste the following into your Tasmota -Berry Script Console: - -```be -{"download":tasmota.urlfetch('https://fmtr.link/haco/tapp','haco.tapp'), "restart":tasmota.cmd("restart 1")} -``` - -You should see output like `{'restart': {'Restart': 'Restarting'}, 'download': 200}` and the device will restart. - -Once the device restarts, you should see the following among your Tasmota startup logs (in the regular console, not the -Berry one): - -``` -HACO: The haco daemon has started. Hostname: tasmota-test, MAC: 04:74:77:9B:CB:CC. Listening for configuration... -``` - -#### Older Tasmota Versions - -With older Tasmota versions, download the [`haco.tapp` file](https://fmtr.link/haco/tapp) on a desktop -computer, -upload it to your device and restart manually. - -### Home Assistant - -You can now install the Home Assistant Add-On: - -[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://fmtr.link/haco/add-on) - -Once the Add-On is installed, click into its Configuration tab and you'll see a setting called `assignments`: - -```yaml -- config: null - identifier: null -``` - -#### Assigning a configuration module to a device - -Now we're ready to assign a configuration module to our Tasmota device. For now, we can use a built-in test -module `test`. The `identifer` value should be the hostname (or MAC) of your Tasmota device. So for our example it -should look like this: - -```yaml -- config: test - identifier: tasmota-test # Hostname of Tasmota device -``` - -Saving the Configuration will restart the Add-On. After the restart, you should see the following in the Add-On logs: - -``` -Device "Tasmota Test Device" successfully configured. Hostname: tasmota-test MAC: 04:74:77:9B:CB:CC. -``` - -In Home Assistant, find the device in [the Devices section](https://my.home-assistant.io/redirect/devices/). You should -now see a test Select (pulldown) control called "Greeter". - -WebLog Select - -Selecting the greetings from the pulldown, you should see them appear in your Tasmota Berry console, e.g. - -```console -haco says: Hello World! -``` - -This means `haco` is set up and working properly. Good! You can now use `haco` to assign configuration modules to your -Tasmota devices. - -## Configuration Modules - -Configuration modules are a little like drivers, that tell `haco` how to communicate with a Tasmota device, what -controls to expose to Home Assistant, etc. - -So far we've only used the `test` configuration module, that exposed the Greeter pulldown, but to control -real-world devices, you'll need to assign them to a module written specifically for that device. There are currently two -types of modules, -Built-In and User-Defined. - -Whichever type you use, assigning them to a device is done as we saw above. - -### Built-In Modules - -`haco` ships with a small number of built-in configurations. These can be found in -the [configs directory](https://github.com/fmtr/haco/blob/release/haco/configs) in this repo. But in the likely event -that your device hasn't already -been added, you'll need to define your own. - -:information_source: The aim is to expand the number of built-in configurations over time. So if you define your own ( -see below), please submit it to this repo as a Pull Request, or raise an Issue linking to your code! - -### Creating Your Own Configuration Module - -#### General module structure - -A configuration module is, ultimately, just a typical Python module. In this case, they contain definitions from one or -more `Control` objects. Each control definition involves two things: - -* The control object assigned to a variable, e.g. `temperature_sensor=Sensor(...)` -* Any required callback functions for the control, e.g. `@temperature_sensor.callback(...)` - -#### Location - -User-Defined modules should be added to the `/config/haco` directory in Home Assistant, with a `.py` extension, -e.g. `/config/haco/my_config.py`. - -#### Defining controls - -Here are a couple of simple examples to help get you started defining your own configuration module. - -##### Tasmota-Only Controls - -First, let's define a Tasmota-only sensor to report how much free memory there is on the Tasmota device. This sensor -is "Tasmota-Only" as it only handles values sent _from_ Tasmota (and sends nothing back from Home Assistant). - -```python -from haco import Sensor - -memory = Sensor('Memory Free', icon='memory', uom=Sensor.UOM.DATA_KILOBYTES, uom_type=Sensor.UOM_TYPE.DATA_SIZE) - - -@memory.callback(trigger='Tele#Heap') -def tasmota(value: int): - return value - -``` - -Memory Sensor - -Let's break this control definition down into the control object, the callback decorator, and the callback itself. - -###### The control object - -First we initialise a `Sensor` control object, assign it to a variable called `memory`, give it a relevant icon, unit of -measure and type. - -###### The callback decorator - -The callback decorator `@memory.callback(trigger='Tele#Heap')` specifies a Tasmota trigger, the values from which will -be sent to the callback function. -This specific trigger, `Tele#Heap`, fires whenever telemetry data is returned from Tasmota and, specifically, returns -the `Heap` field, which contains the free -memory in kilobytes. - -:warning: Tasmota callbacks _must_ have such a `trigger` specified in this decorator. - -###### The callback - -Note that the callback function is named `tasmota`. This name is important as it tells `haco` that this callback -handles values _from_ Tasmota. - -The response from the trigger we specified in our callback decorator above gets fed into our callback as the `value` -argument. The (optional) type annotation for the `value` argument (the `int`) tells `haco` what data type to expect from -Tasmota, and type conversion gets done automatically. - -The return value from the function is what will be returned to Home Assistant as the value for the sensor. - -:information_source: Note that our callback function does not actually _do_ anything with our `value` other than return -it. But if we wanted to (for example) convert the value from kilobytes to bytes, this is where we'd do it, -with `return value*1000`. - -:information_source: You can find the list of available units of measure [here](../../blob/release/haco/data/uom.py) and -types [here](../../blob/main/haco/data/type_sensor.py). - -#### Home Assistant-Only Controls - -Moving on to the opposite type of control, that is one that only handles values _from_ Home Assistant. The Button -control does -exactly this, as it is triggered by being pressing in Home Assistant, but Tasmota returns nothing to acknowledge that -press. - -Here is how to define a `Button` control that, when pressed, restarts the Tasmota device. - -```py -from haco import Button, Tasmota - -restart = Button('Restart Tasmota', icon='restart') - - -@restart.callback() -def ha(value) -> Tasmota[str, 'tasmota.cmd("restart 1")']: - return value - -``` - -In the case we define a `Button` control, and again use our decorator to add a callback to handle values _from_ Home -Assistant, which must therefore be named `ha`. - -###### The `Tasmota` Type Annotation - -The most notable difference with a Home Assistant (`ha`) callback is the `Tasmota` return type annotation. This takes -two arguments, `Tasmota[,]`. The -first argument is the type returned by the callback function, as per usual, and which again lets `haco` convert the -return value accordingly. - -The second argument is an expression that defines _what to do_ on the Tasmota side when the button is pressed. In this -case, this -is just a Berry expression to restart Tasmota. - -:warning: Note that Home Assistant callbacks _must_ have such a `Tasmota` type annotation for their return type. - -:information_source: In the example above, the `value` argument received and returned by our callback is not actually -used by the Berry expression side. Home Assistant just sends a constant string (`PRESS`) when the button is pressed, so -the actual content of -the `value` argument is not important. - -#### Two-Way Controls - -Two-way controls need to handle values from _both_ Home Assistant and from Tasmota, and hence need both the callbacks -types - `ha` and `tasmota` we've seen above. - -Here is an example two-way `Select` (pulldown menu) object. It allows setting the Tasmota `WebLog` console debugging -level from Home Assistant, and also reflects any changes to that setting made on the Tasmota side. - -```python -from haco import Select, Tasmota - -LOG_LEVELS = ["None", "Error", "Info", "Debug", "Debug More"] - -weblog = Select('WebLog Level', icon='console', options=LOG_LEVELS) - - -@weblog.callback() -def ha(value) -> Tasmota[str, 'tasmota.cmd("WebLog "+value)']: - return LOG_LEVELS.index(value) - - -@weblog.callback(trigger='WebLog') -def tasmota(value: int): - return LOG_LEVELS[value] -``` - -The above is very similar to what we have already seen. - -The `ha` callback takes friendly names (e.g. `Error`, `Info`) selected by the user in Home Assistant, and returns them -as their corresponding number values (`WebLog 1`, `WebLog2`) used by Tasmota. - -The `tasmota` callback, naturally enough, does the exact opposite, returning the string names to Home Assistant whenever -the `WebLog` command is used on the Tasmota side. - -WebLog Select - -#### Debugging Your Configuration - -When you first assign a new configuration to a device, keep an eye on the Add-On logs for any errors. You'll see a -typical Python stack trace if anything goes wrong. - -:warning: Each time you edit your configuration module (`.py` file), you'll need to restart the Add-On. Hot -is reloading not yet supported. - -#### Additional Capabilities - -More complex device controls (e.g. `Climate`, `Fan`) have additional capabilities beyond a single value requiting -Tasmota/Home Assistant -callbacks. Adding callbacks for these extended capabilities is done similarly to the above, only the callbacks need -to include the name of the capability. So if you want to add callbacks around setting the `target_humidity` of -a `Climate` control, you'd need to call your callbacks `target_humidity_tasmota` and `target_humidity_ha`, etc. - -A full schema of these names will appear in subsequent documentation. But you can find example implementations for most -controls in the [built-in `development` config](https://github.com/fmtr/haco/tree/release/haco/configs/development.py). - -Anway, if you get the names wrong, you'll see an error like this in your Add-On logs: - -``` -ValueError: Callback function name "set_mode_ha" is not valid for control "Climate". Must be one of: ['action_tasmota', 'current_humidity_tasmota', 'current_temperature_tasmota', 'mode_ha', 'mode_tasmota', 'preset_mode_ha', 'preset_mode_tasmota', 'swing_mode_ha', 'swing_mode_tasmota', 'target_humidity_ha', 'target_humidity_tasmota', 'target_temperature_ha', 'target_temperature_tasmota', 'temperature_high_ha', 'temperature_high_tasmota', 'temperature_low_ha', 'temperature_low_tasmota'] -``` - -#### Additional Resources - -Beyond these examples, when creating your own configuration, you'll probably find -the [built-in configurations](https://github.com/fmtr/haco/tree/release/haco/configs) (especially -the [`development` config](https://github.com/fmtr/haco/tree/release/haco/configs/development.py)) useful. - -## How to Run Using Docker - -If you're not using Home Assistant OS (Supervised) you might want to run as a stand-alone Docker Container. There's no pre-built image currently, so you'll need to build and run manually: - -## Build - -```bash -git clone https://github.com/fmtr/haco.git -cd haco -docker build -t fmtr/haco:latest . -``` - -## Run - -Example Docker Compose: - -```yaml -version: '3' -services: - main: - image: fmtr/haco:latest - container_name: haco - restart: unless-stopped - - environment: - - MQTT_HOST=mqtt.service - - HACO_LOG_LEVEL=DEBUG - volumes: - - :/haco -``` - -Other environment variables: - -- `MQTT_HOST` -- `MQTT_PORT` -- `MQTT_USERNAME` -- `MQTT_PASSWORD` - - +It is in very early release. Documentation is (may) be forthcoming. diff --git a/haco/client.py b/haco/client.py index 9da8fd7..eeb2270 100644 --- a/haco/client.py +++ b/haco/client.py @@ -1,11 +1,15 @@ +import asyncio + import aiomqtt from fmtr.tools import mqtt, Constants +from fmtr.tools.json_tools import to_json from haco import constants +from haco.obs import logger -aiomqtt.Client class ClientHaco(mqtt.Client): + aiomqtt.Client 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, ) @@ -19,23 +23,38 @@ class ClientHaco(mqtt.Client): return constants.TOPIC_CLIENT async def handle(self): - """Listen for command messages from HA.""" - - # subs = control.subscriptions + """Listen for command messages from HA.""" for topic_sub in self.device.subscriptions.keys(): - print(f"Subscribing to {topic_sub}") + logger.info(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}") + logger.info(f"{message.topic.value}{Constants.ARROW}{payload}") topic_command = self.device.subscriptions[message.topic.value] echo_val = topic_command.wrap_back(message) - echo_val + # Echo back as new state 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) + async def start(self): + while True: + try: + async with self: + + for topic, data in self.device.announce.items(): + data_json = to_json(data) + logger.info(f'Announcing {topic} with {data_json}') + await self.publish(topic, payload=data_json, retain=True) + + await self.publish(self.will.topic, "online", retain=True) + + # await self.publish('haco/development/3012edb1a6d4-1ed9c3469373/dev-device/dev-climate-b/power/command', "OFF", retain=True) + + await self.handle() + + except aiomqtt.MqttError as e: + logger.info(f"⚠️ MQTT disconnected: {e}; retrying in 5s") + await asyncio.sleep(5) diff --git a/haco/climate.py b/haco/climate.py index 154045c..2cd4050 100644 --- a/haco/climate.py +++ b/haco/climate.py @@ -19,23 +19,24 @@ class Climate(Control): fan_modes: Optional[List[str]] = None swing_modes: Optional[List[str]] = None + def temperature_command(self, value): - return value + raise NotImplementedError() def mode_command(self, value): - return value + raise NotImplementedError() def fan_mode_command(self, value): - return value + raise NotImplementedError() def fan_mode_state(self, value): - return value + raise NotImplementedError() def power_command(self, value): - return value + raise NotImplementedError() def power_state(self, value): - return value + raise NotImplementedError() @classmethod def get_capabilities(cls): diff --git a/haco/control.py b/haco/control.py index 237eea5..da7eeee 100644 --- a/haco/control.py +++ b/haco/control.py @@ -36,13 +36,8 @@ class Control(Base): self.unique_id = self.get_unique_id() self.availability_topic = self.get_availability_topic() self.subscriptions = self.get_subscriptions() - - - self.announce = self.get_announce() - - @property def parent(self): return self.device @@ -88,6 +83,9 @@ class Control(Base): return data + async def initialise(self): + ... + if __name__ == "__main__": control = Control(name="test", platform="test") diff --git a/haco/obs.py b/haco/obs.py new file mode 100644 index 0000000..105e55c --- /dev/null +++ b/haco/obs.py @@ -0,0 +1,11 @@ +from fmtr.tools import logging, debug, Constants +from haco.paths import paths +from haco.version import __version__ + +debug.trace() + +logger = logging.get_logger( + name=paths.name_ns, + stream=Constants.INFRA, + version=__version__, +) diff --git a/haco/sb3.py b/haco/sb3.py index ee9fdda..cdb85a6 100644 --- a/haco/sb3.py +++ b/haco/sb3.py @@ -1,8 +1,5 @@ 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 @@ -16,28 +13,9 @@ async def main(): client = ClientHaco(hostname=MQTT_HOST, device=device) - client + await client.start() - 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__": diff --git a/haco/topics.py b/haco/topics.py index ebea120..48c4655 100644 --- a/haco/topics.py +++ b/haco/topics.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field from functools import cached_property from typing import ClassVar, TYPE_CHECKING +from haco.obs import logger + if TYPE_CHECKING: from capabilities import Capability @@ -66,7 +68,13 @@ class AnnounceTopicState(AnnounceTopic): async def wrap_back(self, value): method = getattr(self.capability.control, self.callback_name, None) - value_raw = method(value) + + try: + value_raw = method(value) + except NotImplementedError: + logger.warning(f'No method implemented for {self.topic}') + return + await self.capability.control.device.client.publish(self.topic, value_raw) return value_raw @@ -80,7 +88,13 @@ class AnnounceTopicCommand(AnnounceTopic): def wrap_back(self, message): method = getattr(self.capability.control, self.callback_name, None) - value = method(message.payload.decode('utf-8')) + + try: + value = method(message.payload.decode('utf-8')) + except NotImplementedError: + logger.warning(f'No method implemented for {self.topic}') + return + return value def get_subscriptions(self): diff --git a/haco/version b/haco/version index d34586a..6c6aa7c 100644 --- a/haco/version +++ b/haco/version @@ -1 +1 @@ -0.0.25 \ No newline at end of file +0.1.0 \ No newline at end of file diff --git a/haco/version.py b/haco/version.py new file mode 100644 index 0000000..7fcd766 --- /dev/null +++ b/haco/version.py @@ -0,0 +1,3 @@ +from fmtr.tools import version + +__version__ = version.read()