commit 8b9b7226b1946f2179b07ebf51adba3a720f3865 Author: Edward Brown Date: Tue Aug 29 16:01:23 2023 +0100 Add content diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8909869 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + setup.py + *tests* \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfa1b9e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +**/artifacts/** +artifacts/** diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cb24eb2 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,70 @@ +name: Version and Deploy + +on: + workflow_dispatch: + inputs: + version: + description: 'Version' + required: true + + +jobs: + build: + runs-on: ubuntu-latest + + env: + VERSION: ${{ github.event.inputs.version }} + NAME: ${{ github.event.repository.name }} + NAME_SHORT: ${{ github.event.repository.name }} + ORG: ${{ github.repository_owner }} + REBRANDLY_API_KEY: ${{ secrets.REBRANDLY_API_KEY }} + LINK_ID: ca4942821f5740fbaa46d1066b5cd7e4 + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + + - name: Install Tools + run: | + pip install twine tappack + + - name: Increment Version + run: | + + git config --global user.name 'Frontmatter Actions' + git config --global user.email 'actions@frontmatter.ai' + echo -n ${VERSION} > version + python3 update_version.py ${VERSION} + tappack --module-path module/ --output ./${NAME_SHORT}.tapp + + git add ${NAME}/version + git add berry/${NAME_SHORT}_version.be + git add ha/addon/config.yaml + + git commit -am "Increment version number to ${VERSION} for release" + git push + git tag -a v${VERSION} -m "Release version ${VERSION}" + git checkout --track origin/release + git rebase master + git push --follow-tags + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: haco.tapp + tag_name: v${{ github.event.inputs.version }} + generate_release_notes: true + + - name: Build Wheel + run: | + python setup.py sdist bdist_wheel + + - name: Push Wheel + env: + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + TWINE_USERNAME: __token__ + run: | + twine upload dist/* + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3152761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +# Based on https://github.com/github/gitignore/blob/master/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +haco/.cache +nosetests.xml +coverage.xml +tests.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version.py + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version.py control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +**/artifacts +**/.idea +config.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ad53ec5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +ARG VERSION +ARG TYPE=development + +FROM edwardbrown/python as base +ENV DEBIAN_FRONTEND=noninteractive +RUN apt -qq update -y + +FROM base AS development +WORKDIR /usr/src +COPY . . +RUN pip install . + +FROM base AS release +RUN if [ -z "$VERSION" ] ; then pip3 install haco ; else pip3 install haco==${VERSION} ; fi + + +FROM ${TYPE} as image + +CMD haco + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/berry/haco.be b/berry/haco.be new file mode 100644 index 0000000..2c2b793 --- /dev/null +++ b/berry/haco.be @@ -0,0 +1,320 @@ +import tools, string, json, mqtt + +var mod = module("haco") + +var logger=tools.logger.logger + + +var CALLBACK_MAP={} +var BRANCH='release' + +mod.CONFIG_APPLIED=false + +for callback_type: [tools.callbacks.Rule,tools.callbacks.Timer,tools.callbacks.Cron] + + CALLBACK_MAP[classname(callback_type)]=callback_type +end + +class ConfigDownloader + + static var PART_DELAY_SECS=2 + var parts + var registry + var callback_complete + var id_downloading + + def init(callback_complete) + + self.parts=[] + self.registry=tools.callbacks.Registry() + self.callback_complete=callback_complete + + end + + def handle_config_message(config_data) + + var num_parts = config_data["size"] + var id = config_data["id"] + + + if self.id_downloading && self.id_downloading==id + logger.error(string.format("Got duplicate new config (same as currently being download) ID: %s. Will be ignored.",id)) + return + end + + logger.debug(string.format("Received new config. ID: %s. Parts: %s",id,num_parts)) + + if self.id_downloading + logger.error(string.format("Got new config ID: %s while existing download ID: %s incomplete.",id,self.id_downloading)) + end + + self.id_downloading=id + + logger.debug(['Cancelling any existing downloads...']) + self.registry.remove_all() + + self.parts = [] + for i:0..num_parts-1 + self.parts.push(nil) + end + + var rule + for i:0..num_parts-1 + + var topic = string.format('haco/%s/%s/config/%s',BRANCH,tasmota.hostname(),i) + var delay_trigger = string.format('delay:%s',topic) + + def callback_function(value) + + var id_part=value['id'] + var data=value['data'] + + if id_part!=id + logger.error(string.format('Part %s of %s ID did not match config ID (%s!=%s). Skipping...',i+1,num_parts,id_part,id)) + return + end + + logger.debug(string.format("Received Part %s of %s ID: %s Data: %s bytes",i+1,num_parts,id,size(data))) + self.parts[i] = data + #self.registry.remove(topic) + #self.registry.remove(delay_trigger) + + self.check_complete() + + end + + def delay_wrap() + var rule_delay=tools.callbacks.MqttSubscription(topic,callback_function) + self.registry.add(rule_delay) + end + + rule=tools.callbacks.Timer((self.PART_DELAY_SECS*1000)*(i+1),delay_wrap,delay_trigger) + + self.registry.add(rule) + + + + end + end + + def check_complete() + + var num_missing=self.num_missing() + + if num_missing + logger.debug(string.format("Config is not yet complete. Awaiting %s of %s parts.",num_missing,size(self.parts))) + else + logger.debug(string.format("Config is complete.")) + var config_complete=self.collate() + self.parts=[] + self.registry.remove_all() + self.id_downloading=nil + self.callback_complete(config_complete) + end + + end + + def collate() + + var config_str=self.parts.concat() + logger.debug_more(string.format('Collated raw config string. Size: %s bytes.',size(config_str))) + + var config=json.load(config_str) + logger.debug_more(string.format('Deserialized config string. Size: %s fields.',size(config))) + + return config + + end + + def num_missing() + var count=0 + for message:self.parts + if message==nil count+=1 end + end + return count + + end + + def close() + self.registry.remove_all() + end + + +end + + +class Daemon + + static var DELAY_APPLY=30 + static var DELAY_SUBSCRIBE=30 + + var registry_daemon + var registry_config + var downloader + var config_id + var mqtt_data + + + + static var TOPIC_CONFIG=string.format('haco/%s/%s/config',BRANCH,tasmota.hostname()) + static var TOPIC_CONTROL=string.format('haco/%s/%s/control/+/+/tasmota/in',BRANCH,tasmota.hostname()) + static var TOPIC_ANNOUNCE=string.format('haco/%s/%s/announce',BRANCH,tasmota.hostname()) + + def init() + + logger.info(['Starting haco daemon...']) + + self.registry_daemon=tools.callbacks.Registry() + self.registry_config=tools.callbacks.Registry() + self.mqtt_data={} + self.downloader=ConfigDownloader(/configs->self.apply_configs(configs)) + + var rule_subscribe_daemon=tools.callbacks.Timer(self.DELAY_SUBSCRIBE*1000,/->self.subscribe_daemon()) + self.registry_daemon.add(rule_subscribe_daemon) + + end + + def subscribe_daemon() + + var rule_announce=tools.callbacks.Rule('mqtt#connected',/->print('announced called by mqtt#connected',self.publish_announce())) + self.registry_daemon.add(rule_announce) + + var rule_config=tools.callbacks.MqttSubscription( + self.TOPIC_CONFIG, + /value->self.handle_config_message(value) + ) + self.registry_daemon.add(rule_config) + + if mqtt.connected() self.publish_announce() end + + end + + def handle_config_message(data) + + if data['id']==self.config_id && (!data["force_update"]) + logger.error(string.format('Received duplicate config ID: %s. Will be ignored.',self.config_id)) + return + end + + self.downloader.handle_config_message(data) + + end + + def publish_announce() + var data=self.get_data_announce() + logger.debug(['Publishing announce data...']) + return tools.mqtt.publish_json(self.TOPIC_ANNOUNCE,data,true) + end + + def get_data_announce() + + return { + 'hostname': tasmota.hostname(), + 'eth': tasmota.eth(), + 'wifi': tasmota.wifi(), + 'timestamp': tasmota.rtc(), + 'config_id': self.config_id, + 'topic': tools.mqtt.get_topic(), + 'device_name': tools.platform.get_device_name(), + } + end + + def apply_configs(data) + + logger.debug(string.format('Clearing existing config ID %s...',self.config_id)) + self.registry_config.remove_all() + + var id=data['id'] + logger.info(string.format('Applying new config ID %s...',id)) + + var rule + for config_cb: data['callbacks'] + rule=self.apply_config_callback(config_cb) + self.registry_config.add(rule) + end + + var mqtt_data=data['mqtt'] + for topic: mqtt_data.keys() + mqtt_data[topic]['function']=tools.compile.evaluate(mqtt_data[topic]['function']) + end + + self.mqtt_data=mqtt_data + + var run_config_post=data['run_config_post'] + if run_config_post + logger.info(['Running run_config_post',tools.compile.evaluate(run_config_post)()]) + end + + if self.config_id==nil + var rule_control=tools.callbacks.MqttSubscription( + self.TOPIC_CONTROL, + /value,data->self.handle_control_message(value,data) + ) + self.registry_daemon.add(rule_control) + end + + self.config_id=id + self.publish_announce() + + logger.info(string.format('New config ID %s applied.',id)) + + end + + def handle_control_message(value,data) + + var mqtt_data=self.mqtt_data[data['topic']] + var topic=mqtt_data['topic'] + var function=mqtt_data['function'] + + logger.debug_more(['mqtt<- ', data['topic'], value]) + + return self.publish_output(function,value,data,topic) + + end + + def publish_output(function,value,data,topic) + + var output=function(value,data) + + if topic!=nil + logger.debug_more(['mqtt-> ', data['registration'].id ,topic, output]) + tools.mqtt.publish_json(topic,output) + end + + end + + + def apply_config_callback(data) + + var callback_type=CALLBACK_MAP[data.find('type', classname(tools.callbacks.Rule))] + var topic=data['topic'] + var trigger=data['trigger'] + + var function + + if data.find('function') + import string + function=tools.compile.evaluate(data['function']) + else + logger.error(string.format('Using default function for %s',trigger)) + function=/value data->{'value':value,'data':data} + end + + + return callback_type(trigger,/value data->self.publish_output(function,value,data,topic)) + + end + + def close() + self.registry_daemon.remove_all() + self.registry_config.remove_all() + self.downloader.close() + end + +end + + +mod.daemon=Daemon() + + +return mod \ No newline at end of file diff --git a/berry/haco_version.be b/berry/haco_version.be new file mode 100644 index 0000000..68ea408 --- /dev/null +++ b/berry/haco_version.be @@ -0,0 +1,5 @@ +var VERSION='0.0.0' + +var mod = module("haco_version") +mod.VERSION=VERSION +return mod \ No newline at end of file diff --git a/berry/manifest.yaml b/berry/manifest.yaml new file mode 100644 index 0000000..3187968 --- /dev/null +++ b/berry/manifest.yaml @@ -0,0 +1,28 @@ +name: haco + +autoexec: + import: false + .channels: + development: + import: true + +dependencies: + + tools: + .type: GitHubReleaseAsset + org: fmtr + repo: tools.be + filename: tools.tapp + + .channels: + development: + .type: LocalPath + path: /fm/tools.be/module + +patches: + - .channels: + development: + .type: Version + path: haco_version.be + pattern: \bvar\s+VERSION\s*=\s*'{version}' + diff --git a/ha/addon/CHANGELOG.md b/ha/addon/CHANGELOG.md new file mode 100644 index 0000000..5bf094e --- /dev/null +++ b/ha/addon/CHANGELOG.md @@ -0,0 +1 @@ +For the latest changes, see **[the project releases page](https://link.frontmatter.ai/haco/changes)**. \ No newline at end of file diff --git a/ha/addon/DOCS.md b/ha/addon/DOCS.md new file mode 100644 index 0000000..67c5929 --- /dev/null +++ b/ha/addon/DOCS.md @@ -0,0 +1 @@ +For general documentation, see **[the project wiki](https://link.frontmatter.ai/haco/doc)**. \ No newline at end of file diff --git a/ha/addon/Dockerfile b/ha/addon/Dockerfile new file mode 100644 index 0000000..4d743a4 --- /dev/null +++ b/ha/addon/Dockerfile @@ -0,0 +1,10 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +ARG BUILD_VERSION +RUN pip install haco==${BUILD_VERSION} + +COPY run.sh / +RUN chmod a+x /run.sh + +CMD [ "/run.sh" ] \ No newline at end of file diff --git a/ha/addon/README.md b/ha/addon/README.md new file mode 100644 index 0000000..fe84005 --- /dev/null +++ b/ha/addon/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/ha/addon/config.yaml b/ha/addon/config.yaml new file mode 100644 index 0000000..8a68883 --- /dev/null +++ b/ha/addon/config.yaml @@ -0,0 +1,19 @@ +arch: + - aarch64 + - amd64 + - armhf + - armv7 + - i386 +description: Python-based controls for Tasmota devices. +init: false +map: + - config:rw +name: haco +options: +#panel_icon: mdi:nature-people +schema: +services: + - mqtt:need +slug: haco +url: https://link.frontmatter.ai/haco +version: 0.0.0 diff --git a/ha/addon/run.sh b/ha/addon/run.sh new file mode 100644 index 0000000..5a084cf --- /dev/null +++ b/ha/addon/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/with-contenv bashio + +export HACO_IS_ADDON=true +export HACO_CONFIGS_PATH="/config/haco" +export MQTT_HOST="$(bashio::services mqtt 'host')" +export MQTT_PASSWORD="$(bashio::services mqtt 'password')" +export MQTT_PORT="$(bashio::services mqtt 'port')" +export MQTT_USERNAME="$(bashio::services mqtt 'username')" + +haco-daemon \ No newline at end of file diff --git a/ha/addon/translations/en.yaml b/ha/addon/translations/en.yaml new file mode 100644 index 0000000..d7356f1 --- /dev/null +++ b/ha/addon/translations/en.yaml @@ -0,0 +1,2 @@ +--- +configuration: diff --git a/haco/__init__.py b/haco/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/haco/berry.py b/haco/berry.py new file mode 100644 index 0000000..fe9119c --- /dev/null +++ b/haco/berry.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import ClassVar + + +@dataclass +class Function: + mask: ClassVar[str] = '{code}' + code: str + + def __str__(self): + raw = self.mask.format(code=self.code) + lines = [line.strip() for line in raw.splitlines()] + line = ' '.join(lines).strip() + return line + + +@dataclass +class Expression(Function): + mask: ClassVar[str] = 'def (value,data) return {code} end' + + +@dataclass +class FunctionExpression(Function): + + def __str__(self): + return f"{super().__str__()}(value,data)" diff --git a/haco/callback.py b/haco/callback.py new file mode 100644 index 0000000..b149f16 --- /dev/null +++ b/haco/callback.py @@ -0,0 +1,109 @@ +from collections import namedtuple + +import inspect +import typing +from typing import _AnnotatedAlias + +from haco import berry +from haco.trigger import Trigger, Rule + +Signature = namedtuple('Signature', ['type', 'expression']) + + +class Callback: + + def __init__(self, capability, platform, trigger, function): + + self.platform = platform + self.function = function + self.capability = capability + + if trigger: + if not isinstance(trigger, Trigger): + trigger = Rule(trigger=trigger) + self.trigger = trigger.trigger + self.type_id = trigger.__class__.__name__ + else: + self.trigger = trigger + + self.validate() + + def get_function_data(self): + len_data = len(Signature._fields) + signature = inspect.signature(self.function) + arg_data = {param.name: Signature(*([None] * len_data)) for param in signature.parameters.values()} + hints = typing.get_type_hints(self.function, include_extras=True) + hints.setdefault('return', None) + + for key, value in hints.items(): + if type(value) is _AnnotatedAlias: + value = typing.get_args(value) + else: + value = [value] + value = [*value, *[None] * (len_data - len(value))] + arg_data[key] = Signature(*value) + + return arg_data + + def is_berry_function(self, string: str): + + return (stripped := string.strip()).startswith('def') or stripped.startswith('/') + + def get_berry_exp(self, exp): + + if not self.is_berry_function(exp): + return exp + + return f'({exp}(value,data))' + + @property + def function_tasmota(self): + + arg_data = self.get_function_data() + + return_data = arg_data.pop('return') + + known_args = {'value', 'data'} + skip_args = {'control'} + + if self.platform == 'tasmota': + + berry_exp = {} + for name, (type, exp) in arg_data.items(): + if not exp: + + if name in skip_args: + continue + + if name not in known_args: + msg = f'Callback {self.function_name} argument {name} requires a Berry expression annotation.' + raise ValueError(msg) + + exp = name + berry_exp[name] = self.get_berry_exp(exp) + + berry_exp_str = ','.join([f"{repr(key)}:{value}" for key, value in berry_exp.items()]) + berry_exp_str = f"{{{berry_exp_str}}}" + berry_exp_str = berry.Expression(berry_exp_str) + + else: + + if not return_data.expression: + msg = f'Callback function for {self.function_name} return type requires a Berry expression annotation.' + raise ValueError(msg) + + berry_exp_str = berry.Expression(self.get_berry_exp(return_data.expression)) + + return str(berry_exp_str) + + @property + def function_name(self): + return self.function.__name__ + + def validate(self): + + self.function_tasmota + + if self.platform == 'tasmota': + if not self.trigger: + raise ValueError(f'Callback {self.function_name} must have a trigger.') diff --git a/haco/climate.py b/haco/climate.py new file mode 100644 index 0000000..0387355 --- /dev/null +++ b/haco/climate.py @@ -0,0 +1,112 @@ +from haco.control import Control +from haco.schema import Schema, Capability, AnnounceTopic, Tasmota +from haco.tools import get_range_pair + +CELSIUS_C = 'C' +FAHRENHEIT_F = 'F' + + +class Climate(Control): + NAME = 'climate' + + @classmethod + def get_schema(cls): + schema = Schema( + capabilities=[ + Capability( + name='mode', + type=str + ), + Capability( + name='target_humidity' + ), + Capability( + name='temperature', + alias='target_temperature' + ), + Capability( + name='temperature_high' + ), + Capability( + name='temperature_low' + ), + Capability( + name='swing_mode', + type=str + ), + Capability( + name='preset_mode', + type=str + ), + Capability( + name='current_humidity', + ha=None, + tamota=Tasmota(announce_data=AnnounceTopic(key='{capability}_topic')) + ), + Capability( + name='current_temperature', + ha=None, + tamota=Tasmota(announce_data=AnnounceTopic(key='{capability}_topic')) + ), + Capability( + name='action', + type=str, + ha=None, + tamota=Tasmota(announce_data=AnnounceTopic(key='{capability}_topic')) + ) + ] + ) + return schema + + # def init(name, entity_id, icon, temperature_unit, temperature_range, humidity_range, modes, preset_modes, fan_modes, swing_modes, precision, callbacks) + + def __init__(self, name=None, icon=None, temperature_unit=None, temperature_range=None, humidity_range=None, + modes=None, preset_modes=None, fan_modes=None, swing_modes=None, precision=None): + self.temperature_unit = temperature_unit + + self.temperature_min, self.temperature_max = get_range_pair(temperature_range) + self.humidity_min, self.humidity_max = get_range_pair(humidity_range) + + self.precision = precision + # self.type = float if self.precision is None else int if self.precision == 1 else float if self.temperature_unit == 'C' else int + + self.fan_modes = fan_modes + self.modes = modes + self.preset_modes = preset_modes + self.swing_modes = swing_modes + + super().__init__(name, icon=icon) + + @property + def control_type(self): + + if self.precision == 1: + return int + + if self.precision: + return float + + unit = self.temperature_unit or CELSIUS_C + + if unit == CELSIUS_C: + return float + else: + return int + + def get_config_ha_ex(self): + data = { + + 'fan_modes': self.fan_modes, + 'preset_modes': self.preset_modes, + 'swing_modes': self.swing_modes, + 'modes': self.modes, + 'min_temp': self.temperature_min, + 'max_temp': self.temperature_max, + 'min_humidity': self.humidity_min, + 'max_humidity': self.humidity_max, + 'precision': self.precision, + 'temperature_unit': self.temperature_unit + + } + + return data diff --git a/haco/configs/__init__.py b/haco/configs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/haco/configs/air_fryer.py b/haco/configs/air_fryer.py new file mode 100644 index 0000000..087d615 --- /dev/null +++ b/haco/configs/air_fryer.py @@ -0,0 +1,160 @@ +import math + +from haco import tuya +from haco.pulldown import Pulldown +from haco.sensor import Sensor +from haco.tasmota import Tasmota +from haco.tools import add_tuya_io + +OPTIONS = ['Default', 'Fries', 'Shrimp', 'Pizza', 'Chicken', 'Fish', 'Steak', 'Cake', 'Bacon', 'Preheat', 'Custom'] + +cookbook = Pulldown('Cookbook', options=OPTIONS, icon='mdi: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] + + +time_remaining = Sensor( + name='Time Remaining', + uom='minutes', + icon='mdi:timer', + device_class='DURATION' +) + + +@time_remaining.callback(trigger=tuya.received(2, 8)) +def tasmota(value: int): + return value + + +from haco.switch import Switch + +from haco.number import Number +from haco import tuya + +cooking_time = Number( + name='Cooking Time', + number_range=range(1, 60), + uom='minutes', + icon='mdi:timer' +) + + +@cooking_time.callback(trigger=tuya.received(2, 7)) +def tasmota(value: int): + return value + + +@cooking_time.callback() +def ha(value: int) -> Tasmota[int, tuya.send(2, 7)]: + return value + + +cooking_temp = Number( + name='Cooking Temperature (F)', + number_range=range(1, 60), + uom='minutes', + icon='mdi:timer' +) + + +@cooking_temp.callback(trigger=tuya.received(2, 7)) +def tasmota(value: int): + return value + + +@cooking_temp.callback() +def ha(value: int) -> Tasmota[int, tuya.send(2, 7)]: + return value + + +cooking_temp_f = Number( + 'Cooking Temperature (F)', + number_range=range(170, 399), + mode='slider', + uom='°F', + icon='mdi:temperature-fahrenheit' +) + + +@cooking_temp_f.callback() +def ha(value: float) -> Tasmota[int, tuya.send(2, 103)]: + return value + + +@cooking_temp_f.callback(trigger=tuya.received(2, 103)) +def tasmota(value: float): + return value + + +cooking_temp_c = Number( + 'Cooking Temperature (C)', + number_range=range(77, 204), + mode='slider', + uom='°C', + icon='mdi:temperature-celsius' +) + + +@cooking_temp_c.callback() +def ha(value: float) -> Tasmota[int, tuya.send(2, 103)]: + return (value * 1.8) + 32 + + +@cooking_temp_c.callback(trigger=tuya.received(2, 103)) +def tasmota(value: float): + return math.ceil((value - 32) / 1.8) + + +cook_pause = Switch( + name='Cook/Pause', + icon='mdi:play-pause' +) + +add_tuya_io(cook_pause, int, 1, 2) + +power = Switch( + name='Power', + icon='mdi:power' +) + +add_tuya_io(power, int, 1, 1) + +delay_time = Number( + name='Delay Time', + number_range=range(0, 720), + uom='minutes', + icon='mdi:timer-pause' +) + +add_tuya_io(delay_time, int, 2, 6) + +status = Sensor( + name='Status', + icon='mdi:playlist-play', + device_class='ENUM' +) + +keep_warm_time = Number( + name='Keep Warm Time', + number_range=range(0, 120), + uom='minutes', + icon='mdi:timer-sync' +) + +add_tuya_io(keep_warm_time, int, 2, 105) + + +@status.callback(trigger=tuya.received(4, 5)) +def tasmota(value: int): + return ['Ready', 'Delayed Cook', 'Cooking', 'Keep Warm', 'Off', 'Cooking Complete'][value] + + +DEVICE_CONFIG = {'run_config_post': f'def () return tasmota.cmd("TuyaSend0") end'} diff --git a/haco/configs/development.py b/haco/configs/development.py new file mode 100644 index 0000000..39468c6 --- /dev/null +++ b/haco/configs/development.py @@ -0,0 +1,182 @@ +from haco.climate import Climate +from haco.debugging import get_memory_sensor +from haco.device import Device +from haco.number import Number +from haco.pulldown import Pulldown +from haco.sensor import Sensor +from haco.tasmota import Tasmota +from haco.text import Text, Password + +number = Number( + 'Dev Number Test Control', + number_range=range(3, 91), + step=0.5, + mode='box', + uom='mm' +) + + +@number.callback() +def ha(value: str) -> Tasmota[float, 'tasmota.cmd("var10 "+str(value))']: + return value + + +@number.callback('var10#state') +def tasmota(value: float, control): + return value + + +climate = Climate( + 'Dev Climate Test Control', + swing_modes=["Fixed 90°", "Swing", "Fixed 45°"], + preset_modes=['Smart', 'Purify', 'High', 'Low'], + modes=['dry', 'fan_only', 'off'], + temperature_range=range(10, 90), + humidity_range=range(20, 80) + +) + + +@climate.callback() +def mode_ha(value: str) -> Tasmota[int, 'tasmota.cmd("var1 "+str(value))']: + return ['dry', 'fan_only', 'off'].index(value) + + +@climate.callback('var1#state') +def mode_tasmota(value: int): + return ['dry', 'fan_only', 'off'][value] + + +@climate.callback() +def target_humidity_ha(value: int) -> Tasmota[int, 'tasmota.cmd("var2 "+str(value))']: + return value + + +@climate.callback('var2#state') +def target_humidity_tasmota(value: int): + return value + + +@climate.callback('var2#state') +def current_humidity_tasmota(value: int): + return value + 5 + + +@climate.callback('var3#state') +def current_temperature_tasmota(value: int): + return value + 10 + + +@climate.callback() +def target_temperature_ha(value: float) -> Tasmota[float, 'tasmota.cmd("var3 "+str(value))']: + return value + + +@climate.callback('var3#state') +def target_temperature_tasmota(value: int): + return value + + +@climate.callback('var1#state') +def action_tasmota(value: int, is_on: Tasmota[bool, 'tasmota.get_power()[0]']): + if not is_on: + return 'off' + + return 'fan' if value == 1 else 'drying' + + +@climate.callback() +def swing_mode_ha(value: str) -> Tasmota[int, 'tasmota.cmd("var4 "+str(value))']: + return ['Fixed 90°', 'Fixed 45°', 'Swing'].index(value) + + +@climate.callback('var4#state') +def swing_mode_tasmota(value: int): + return ['Fixed 90°', 'Fixed 45°', 'Swing'][value] + + +@climate.callback() +def preset_mode_ha(value: str) -> Tasmota[int, 'tasmota.cmd("var7 "+str(value))']: + return ['Smart', 'Purify', 'High', 'Low'].index(value) + + +@climate.callback('var7#state') +def preset_mode_tasmota(value: int): + return ['Smart', 'Purify', 'High', 'Low'][value] + + +@climate.callback() +def temperature_high_ha(value: float) -> Tasmota[float, 'tasmota.cmd("var5 "+str(value))']: + return value + + +@climate.callback('var5#state') +def temperature_high_tasmota(value: int): + return value + + +@climate.callback() +def temperature_low_ha(value: float) -> Tasmota[float, 'tasmota.cmd("var6 "+str(value))']: + return value + + +@climate.callback('var6#state') +def temperature_low_tasmota(value: int): + return value + + +pulldown = Pulldown( + 'Dev Select Test Control', + options=list('ABC') +) + + +@pulldown.callback() +def ha(value: str) -> Tasmota[str, 'tasmota.cmd("var12 "+str(value))']: + return value + + +@pulldown.callback('var12#state') +def tasmota(value: str): + return value + + +sensor = Sensor('Dev Sensor Test Control') + + +@sensor.callback('var13#state') +def tasmota(value: str): + return value + + +text = Text('Dev Text Test Control') +password = Password('Dev Password Test Control') + +from haco.configs.development import text, password +from haco.tasmota import Tasmota + + +@text.callback() +def ha(value: str) -> Tasmota[str, 'tasmota.cmd("var11 "+str(value))']: + return value + + +@text.callback('var11#state') +def tasmota(value: str): + return value + + +@password.callback() +def ha(value: str) -> Tasmota[str, 'tasmota.cmd("var14 "+str(value))']: + return value + + +@password.callback('var14#state') +def tasmota(value: str): + return value + + +dev_dehum = Device( + mac='8C:4B:14:85:8D:98', + controls=[number, climate, text, password, sensor, pulldown, get_memory_sensor()] +) diff --git a/haco/constants.py b/haco/constants.py new file mode 100644 index 0000000..bac17ee --- /dev/null +++ b/haco/constants.py @@ -0,0 +1,5 @@ +import string + +CHARS_ALLOWED = string.ascii_lowercase + string.digits +SEPS_ALLOWED = '_- ' +BRANCH = 'release' diff --git a/haco/control.py b/haco/control.py new file mode 100644 index 0000000..c09743d --- /dev/null +++ b/haco/control.py @@ -0,0 +1,107 @@ +import logging +from functools import partial + +from haco import schema, tools +from haco.callback import Callback +from haco.schema import Schema, Platform, Tasmota, HomeAssistant, AnnounceTopic, Capability +from haco.tools import log_publish + + +class Control: + NAME = None + + control_type = str + + @classmethod + def get_schema(cls): + schema = Schema( + capabilities=[ + Capability( + name='state', + ha=HomeAssistant(announce_data=AnnounceTopic(key='{io_ha}_topic')), + tamota=Tasmota(announce_data=AnnounceTopic(key='{io_ha}_topic')) + ) + ] + ) + return schema + + def __init__(self, name, icon=None): + + from device import Device + + self.name = name + + self.device: Device = None + self.schema = self.get_schema() + self.get = self.schema.get + self.schema.control = self + + self.icon = icon + + @property + def name_sanitized(self): + return tools.sanitize_name(self.name) + + def callback(self, trigger=None): + return partial(self.add_callback, trigger) + + def add_callback(self, trigger, function): + + if (function_name := function.__name__) in {Tasmota.PLATFORM, HomeAssistant.PLATFORM}: + function_name = f'state_{function_name}' + + callback_names = self.schema.get_callback_function_names() + + if function_name not in callback_names: + msg = f'Callback function "{function_name}" name is not valid. Must be one of: {sorted(callback_names)}' + raise ValueError(msg) + + capability_id, platform_id = function_name.rsplit('_', 1) + callback = Callback(capability=capability_id, platform=platform_id, trigger=trigger, function=function) + platform: Platform = self.schema.get_alias(capability_id).get(platform_id) + platform.callback = callback + + def get_subscriptions(self): + return self.schema.get_subscriptions() + + def get_config_tasmota(self): + return self.schema.get_config_tasmota() + + def get_config_ha_base(self): + data = { + "unique_id": f"{self.device.name_sanitized}-{self.device.mac_short}-{self.name_sanitized}", + "name": self.name, + "availability_topic": f"tele/{self.device.topic}/LWT", + "payload_off": schema.OFF, + "payload_available": "Online", + "payload_on": schema.ON, + "payload_not_available": "Offline", + "force_update": True, + "icon": self.icon, + # "entity_id": self.entity_id + } + data.update(self.schema.get_config_ha()) + + return data + + def get_config_ha(self): + data = self.get_config_ha_base() | self.get_config_ha_ex() + data = {k: v for k, v in data.items() if v is not None} + return data + + def get_config_ha_ex(self): + return {} + + async def process_message(self, client, capability_id, platform_id, message): + + if not message.payload: + logging.warning(f'Topic {message.topic.value} got empty payload. It may have been deleted.') + return + + platform = self.schema.get(capability_id).get(platform_id) + output = platform.handle(message) + topic = str(platform.get_topic_publish()) + await log_publish(client, topic, output) + + def get_announce_topic(self): + return f"homeassistant/{self.NAME}/{self.device.name_sanitized}/{self.name_sanitized}/config" diff --git a/haco/debugging.py b/haco/debugging.py new file mode 100644 index 0000000..9db48ea --- /dev/null +++ b/haco/debugging.py @@ -0,0 +1,15 @@ +from typing import Any + +from haco import trigger +from haco.sensor import Sensor +from haco.tasmota import Tasmota + + +def get_memory_sensor(): + memory_sensor = Sensor('Memory Free', uom='kB', device_class='DATA_SIZE', data_type=int) + + @memory_sensor.callback(trigger.Cron('*/30 * * * * *')) + def tasmota(value: Tasmota[Any, 'tasmota.memory()']): + return value.get('heap_free', 'unknown') + + return memory_sensor diff --git a/haco/device.py b/haco/device.py new file mode 100644 index 0000000..0d2d161 --- /dev/null +++ b/haco/device.py @@ -0,0 +1,114 @@ +import json +from pathlib import Path +from typing import List + +from haco import tools, constants +from haco.control import Control +from haco.tools import log_publish, split_into_chunks, log_subscribe + + +class Device: + + def __init__(self, mac, controls: List[Control], run_config_post=None): + self.name = None + self.topic = None + self.hostname = None + self.mac = mac + self.run_config_post = run_config_post + self.controls = controls + self.config_id = None + for control in self.controls: + control.device = self + + self.contols_dict = {control.name_sanitized: control for control in self.controls} + self.get = self.contols_dict.get + + @property + def name_sanitized(self): + return tools.sanitize_name(self.name) + + @property + def mac_short(self): + return self.mac.replace(':', '').lower() + + def get_config_ha(self): + + data_device = { + "connections": [["mac", self.mac]], + "identifiers": self.mac + } + + data = {} + for control in self.controls: + data_control = control.get_config_ha() + data_control['device'] = data_device + data[control.get_announce_topic()] = data_control + + return data + + def get_config_tasmota(self): + config = [] + for control in self.controls: + config += control.get_config_tasmota() + return config + + def get_subscriptions(self): + data = {} + for control in self.controls: + data.update(control.get_subscriptions()) + + return data + + async def publish_config_ha(self, client): + data = self.get_config_ha() + for topic, data in data.items(): + await log_publish(client, topic, payload=json.dumps(data), retain=True) + + async def publish_config_tasmota(self, client): + data = self.get_config_tasmota() + + data_mqtt = {datum['trigger']: datum for datum in data if datum['type'] == 'MqttSubscription'} + data_callbacks = [datum for datum in data if datum['type'] != 'MqttSubscription'] + + tasmota_config = { + # 'id': self.config_id, + 'callbacks': data_callbacks, + 'mqtt': data_mqtt, + 'run_config_post': self.run_config_post, + + } + + self.config_id = tools.hash_string(json.dumps(tasmota_config)) + + tasmota_config['id'] = self.config_id + + tasmota_config_str = json.dumps(tasmota_config) + tasmota_config_topic = Path(f"haco/{constants.BRANCH}/{self.hostname}/config") + parts = split_into_chunks(tasmota_config_str, 750) + for i, part in enumerate(parts): + part = json.dumps({"id": self.config_id, "data": part}) + if len(part) > 1200: + raise ValueError(f'Config part too large.') + await log_publish(client, str(tasmota_config_topic / str(i)), payload=part, retain=True) + + payload_header = json.dumps({"size": len(parts), "id": self.config_id, 'force_update': True}) + await log_publish(client, str(tasmota_config_topic), payload=payload_header, retain=True) + + payload_full = json.dumps({"size": len(parts), "id": self.config_id, "data": tasmota_config}) + await log_publish(client, str(tasmota_config_topic / 'full'), payload=payload_full, retain=True) + + async def do_subscriptions(self, client): + data = self.get_subscriptions() + for topic in data.keys(): + await log_subscribe(client, str(topic)) + + async def bind(self, client, data_announce): + print(f'Found match for self: {self}') + self.name = data_announce['device_name'] + self.topic = data_announce['topic'] + self.hostname = data_announce['hostname'] + + await self.do_subscriptions(client) + + await self.publish_config_ha(client) + await self.publish_config_tasmota(client) diff --git a/haco/load_configs.py b/haco/load_configs.py new file mode 100644 index 0000000..90fd275 --- /dev/null +++ b/haco/load_configs.py @@ -0,0 +1,62 @@ +from importlib import reload + +import importlib.util +import os +import yaml +from pathlib import Path + +from haco.control import Control +from haco.device import Device + +CONFIGS_PATH = Path(os.environ['HACO_CONFIGS_PATH']) + + +def load_yaml_file(file_path): + with open(file_path, 'r') as yaml_file: + data = yaml.safe_load(yaml_file) + return data + + +def load_controls_from_directory(directory_path): + assn = load_yaml_file(directory_path / 'assignments.yaml') + + controls = [] + for name, macs in assn.items(): + + if type(macs) is not list: + macs = [macs] + + macs + + for mac in macs: + + args = {'mac': mac} + + py_file = directory_path / Path(name).with_suffix('.py') + module_name = py_file.stem + + try: + module = importlib.import_module(f'haco.configs.{module_name}') + reload(module) + except ImportError: + spec = importlib.util.spec_from_file_location(module_name, str(py_file)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + for obj_name in dir(module): + obj = getattr(module, obj_name) + if obj_name == 'DEVICE_CONFIG': + args.update(obj) + if isinstance(obj, Control): + args.setdefault('controls', []) + args['controls'].append(obj) + + controls.append(args) + + return controls + + +def load_devices(): + devices_args = load_controls_from_directory(CONFIGS_PATH) + devices = [Device(**args) for args in devices_args] + return devices diff --git a/haco/mqtt.py b/haco/mqtt.py new file mode 100644 index 0000000..ecb2110 --- /dev/null +++ b/haco/mqtt.py @@ -0,0 +1,62 @@ +import aiomqtt +import asyncio +import json +import logging +import os +from pathlib import Path + +from haco import constants +from haco.device import Device +from haco.load_configs import load_devices +from haco.tools import log_received + +MQTT_PASSWORD = os.environ['MQTT_PASSWORD'] +MQTT_HOST = os.environ['MQTT_HOST'] +MQTT_PORT = int(os.environ['MQTT_PORT']) +MQTT_USERNAME = os.environ['MQTT_USERNAME'] + + +async def listen(client, devices): + TOPIC_ANNOUNCE = f"haco/{constants.BRANCH}/+/announce" + await client.subscribe(TOPIC_ANNOUNCE) + + async with client.messages() as messages: + + async for message in messages: + await log_received(message) + if message.topic.matches(TOPIC_ANNOUNCE): + + if not message.payload: + logging.warning(f'Topic {message.topic.value} got empty payload. It may have been deleted.') + continue + + data_announce = json.loads(message.payload) + mac = data_announce['wifi']['mac'] + if mac not in devices: + print(f'Got announce from device with no config: {data_announce}') + continue + + device: Device = devices[mac] + + if device.config_id and data_announce['config_id'] == device.config_id: + print(f'Device {device.name} ({device.hostname}:{device.mac}) successfully configured.') + continue + + await device.bind(client, data_announce) + devices[device.hostname] = device + + device + + else: + _, _, device_name, _, control, capability_id, platform, io = Path(message.topic.value).parts + device = devices[device_name] + await device.get(control).process_message(client, capability_id, platform, message) + + +async def start_mqtt(): + devices = load_devices() + devices = {device.mac: device for device in devices} + + async with aiomqtt.Client(hostname=MQTT_HOST, port=MQTT_PORT, username=MQTT_USERNAME, + password=MQTT_PASSWORD) as client: + await asyncio.gather(listen(client, devices)) diff --git a/haco/number.py b/haco/number.py new file mode 100644 index 0000000..c87057b --- /dev/null +++ b/haco/number.py @@ -0,0 +1,36 @@ +from haco.control import Control + +from haco.tools import get_range_pair + + +class Number(Control): + NAME = 'number' + + # def init(name, number_range, mode, step, uom, entity_id, icon, callbacks) + def __init__(self, name, number_range: range, icon=None, mode=None, step=None, uom=None): + + self.min, self.max = get_range_pair(number_range) + self.mode = mode + self.step = step + self.uom = uom + + super().__init__(name, icon=icon) + + @property + def control_type(self): + + if self.step is None or self.step == 1: + return int + else: + return float + + def get_config_ha_ex(self): + data = { + 'min': self.min, + 'max': self.max, + 'mode': self.mode, + 'unit_of_measurement': self.uom, + 'step': self.step + } + + return data diff --git a/haco/pulldown.py b/haco/pulldown.py new file mode 100644 index 0000000..b5276ee --- /dev/null +++ b/haco/pulldown.py @@ -0,0 +1,18 @@ +from typing import List + +from haco.control import Control + + +class Pulldown(Control): + NAME = 'select' + + def __init__(self, name, options: List[str], icon=None): + self.options = options + super().__init__(name, icon=icon) + + def get_config_ha_ex(self): + data = { + 'options': self.options, + } + + return data diff --git a/haco/schema.py b/haco/schema.py new file mode 100644 index 0000000..362eb1d --- /dev/null +++ b/haco/schema.py @@ -0,0 +1,379 @@ +import json +import logging +import typing +from pathlib import Path +from typing import List + +from haco import tools, constants + +DEVICE = 'development-eth' +CONTROL = 'DEVELOPMENT-DEHUMIDIFIER' + +ON = 'ON' +OFF = 'OFF' + + +class ControlType: + ... + + +class AnnounceDatum: + DEFAULT_KEY = None + DEFAULT_VALUE = None + + def __init__(self, key=None, value=None): + self.platform = None + self.key = key or self.DEFAULT_KEY + self.value = value or self.DEFAULT_VALUE + + def get_config_ha(self): + fields_data = dict( + path=str(self.platform.capability.get_path()), + # platform='ha', + io=self.platform.IO_REVERSE, + io_ha=self.platform.IO_HA, + ha=HomeAssistant.PLATFORM, + capability=self.platform.capability.name + ) + + filler = lambda s: s.format(**fields_data) + data = {filler(self.key): filler(self.value)} + return data + + +class AnnounceTopic(AnnounceDatum): + DEFAULT_KEY = '{capability}_{io_ha}_topic' + DEFAULT_VALUE = '{path}/{ha}/{io}' + + +# class AnnouncePayload(AnnounceDatum): +# +# ON='ON' +# OFF='OFF' +# BOOL_MAP={True:ON,False:OFF} +# DEFAULT_KEY = '{capability}_{io_ha}_topic' +# DEFAULT_VALUE = '{path}/{ha}/{io}' +# +# @classmethod +# def from_bool(cls, value): +# return cls.BOOL_MAP[value] + +class Platform: + IO = None + PLATFORM = None + IO_REVERSE = None + PLATFORM_REVERSE = None + + IO_HA = None + + def __init__(self, announce_data: List[AnnounceDatum] | AnnounceDatum = None, + callback_default=tools.callback_default): + self.capability: Capability = None + if announce_data is None: + announce_data = AnnounceTopic() + self.announce_data = announce_data if type(announce_data) is list else [announce_data] + for datum in self.announce_data: + datum.platform = self + + self.callback_default = callback_default + + self.callback = None + + @property + def callback_function_name(self): + return f'{self.capability.alias}_{self.PLATFORM}' + + @property + def capability_type(self): + capability_type = self.capability.type + + if capability_type is ControlType: + return self.capability.schema.control.control_type + else: + return capability_type + + def deserialize(self, value): + return json.loads(value) + + def serialize(self, value): + raise NotImplementedError() + + def get_handler_name(self): + return f'callback_{self.capability.name}_{self.IO}' + + def handle(self, message): + + # handler = getattr(self.capability.schema.control, self.get_handler_name()) + + payload_str = message.payload.decode('utf-8') + payload = self.deserialize(payload_str) + + output_raw = self.get_callback_output(payload) + + output = self.serialize(self.convert_out(output_raw)) + + return output + + def get_callback_output(self, data): + raise NotImplementedError() + + def get_subscriptions(self): # Map incoming to handler + + if not self.callback: + return {} + + return {self.get_topic_subscription(): self.get_handler_name()} + + def get_config_ha(self): + + data = {} + + if not self.callback: + return data + + for datum in self.announce_data: + config = datum.get_config_ha() + data.update(config) + + return data + + def get_config_tasmota(self): + raise NotImplementedError() + + def convert_out(self, value): + raise NotImplementedError() + + def convert_in(self, value): + raise NotImplementedError() + + +class HomeAssistant(Platform): + IO = 'in' + PLATFORM = 'ha' + + IO_REVERSE = 'out' + PLATFORM_REVERSE = 'tasmota' + + IO_HA = 'command' + + def get_callback_output(self, data): + + sigs = self.callback.get_function_data() + return_type, return_exp = sigs.pop('return') + + if len(sigs) > 2: + raise ValueError('Callback has too many args?') + + data = self.convert_in(data) + data = [data] + + if 'control' in sigs: + data.append(self.capability.schema.control) + + handler = self.callback.function + output_raw = handler(*data) + + if return_type: + output_raw = return_type(output_raw) + + return output_raw + + def get_topic_subscription(self): # Map incoming to handler + + if not self.callback: + return None + + return self.capability.get_path() / self.PLATFORM / self.IO_REVERSE + + def get_topic_publish(self): # Map incoming to handler + if not self.callback: + return None + return self.capability.get_path() / self.PLATFORM_REVERSE / self.IO + + def convert_out(self, value): + return value + + def convert_in(self, value): + return self.capability_type(value) + + def deserialize(self, value): + return str(value) + + def serialize(self, value): + return json.dumps(value) + + def get_config_tasmota(self): + # Must be MQTT. Fixed trigger. Fixed topic. + + if not self.callback: + return {} + + trigger = self.get_topic_publish() + topic = trigger / 'output' + + config = { + 'topic': None, + 'trigger': str(trigger), + 'function': self.callback.function_tasmota, + 'type': 'MqttSubscription' + } + + return config + + +class Tasmota(Platform): + IO = 'out' + PLATFORM = 'tasmota' + + IO_REVERSE = 'in' + PLATFORM_REVERSE = 'ha' + + IO_HA = 'state' + + def get_callback_output(self, data): + + handler = self.callback.function + + sigs = self.callback.get_function_data() + sig_return = sigs.pop('return') + + extra_args = set(data.keys()) - set(sigs.keys()) + + for arg in extra_args: + msg = f'Callback {self.callback.function_name} got argument ({arg}={extra_args[arg]}) not in callback function. Will be ignored.' + logging.warning(msg) + data.pop(arg) + + for name, (type_type, exp) in sigs.items(): + if type_type and type_type is not typing.Any: + try: + data[name] = type_type(data[name]) + except TypeError as error: + logging.warning(f'Type conversion failed: {repr(error)}') + + if 'control' in sigs: + data['control'] = self.capability.schema.control + + output_raw = handler(**data) + return output_raw + + def get_topic_subscription(self): # Map incoming to handler + return self.capability.get_path() / self.PLATFORM / self.IO + + def get_topic_publish(self): # Map incoming to handler + return self.capability.get_path() / self.PLATFORM_REVERSE / self.IO_REVERSE + + def deserialize(self, value): + return json.loads(value) + + def serialize(self, value): + return str(value) + + def convert_out(self, value): + return self.capability_type(value) + + def convert_in(self, value): + return value + + def get_config_tasmota(self): + # Any type. Any trigger. Fixed topic. + + if not self.callback: + return {} + + config = { + 'topic': str(self.get_topic_subscription()), + 'trigger': self.callback.trigger, + 'function': self.callback.function_tasmota, + 'type': self.callback.type_id + } + + return config + + +class Default: + ... + + +class Capability: + + def __init__(self, name, type=ControlType, tamota: Tasmota = Default, ha: HomeAssistant = Default, alias=None): + self.name = name + self.alias = alias or name + self.type = type + self.schema = None + + self.tasmota = tamota if tamota is not Default else Tasmota() + self.ha = ha if ha is not Default else HomeAssistant() + self.platforms = [io for io in {self.tasmota, self.ha} if io] + + for platform in self.platforms: + platform.capability = self + + self.platforms_dict = {io.PLATFORM: io for io in self.platforms} + self.get = self.platforms_dict.get + + def get_callback_function_names(self): + return {platform.callback_function_name for platform in self.platforms} + + def get_path(self): + return self.schema.get_path() / self.name + + def get_config_ha(self): + data = {} + for io in self.platforms: + config = io.get_config_ha() + data.update(config) + return data + + def get_config_tasmota(self): + return [config for io in self.platforms if (config := io.get_config_tasmota())] + + def get_subscriptions(self): + data = {} + for io in self.platforms: + data.update(io.get_subscriptions()) + return data + + +class Schema: + + def __init__(self, capabilities: List[Capability]): + self.capabilities = capabilities + + self.capabilities_dict = {capability.name: capability for capability in self.capabilities} + self.capabilities_dict_alias = {capability.alias: capability for capability in self.capabilities} + self.get = self.capabilities_dict.get + self.get_alias = self.capabilities_dict_alias.get + + for capability in self.capabilities: + capability.schema = self + + self.control = None + + def get_callback_function_names(self): + names = set() + for capability in self.capabilities: + names.update(capability.get_callback_function_names()) + return names + + def get_path(self): + return Path(f"haco") / constants.BRANCH / self.control.device.hostname / 'control' / self.control.name_sanitized + + def get_config_ha(self): + data = {} + for capability in self.capabilities: + data.update(capability.get_config_ha()) + return data + + def get_config_tasmota(self): + config = [] + for capability in self.capabilities: + config += capability.get_config_tasmota() + return config + + def get_subscriptions(self): + data = {} + for capability in self.capabilities: + data.update(capability.get_subscriptions()) + return data diff --git a/haco/sensor.py b/haco/sensor.py new file mode 100644 index 0000000..2fd5c33 --- /dev/null +++ b/haco/sensor.py @@ -0,0 +1,37 @@ +import schema +from haco.control import Control + + +class Sensor(Control): + NAME = 'sensor' + + @classmethod + def get_schema(cls): + return schema.Schema( + capabilities=[ + schema.Capability( + name='state', + ha=None, + tamota=schema.Tasmota(announce_data=schema.AnnounceTopic(key='{io_ha}_topic')) + ) + ] + ) + + def __init__(self, name, data_type=None, uom=None, icon=None, device_class=None): + self.data_type = data_type + self.device_class = device_class + self.uom = uom + + super().__init__(name, icon=icon) + + @property + def control_type(self): + return self.data_type or (lambda v: v) + + def get_config_ha_ex(self): + data = { + 'unit_of_measurement': self.uom, + 'device_class': self.device_class + } + + return data diff --git a/haco/start.py b/haco/start.py new file mode 100644 index 0000000..cb676df --- /dev/null +++ b/haco/start.py @@ -0,0 +1,11 @@ +import asyncio + +from haco.mqtt import start_mqtt + + +def main(): + return asyncio.run(start_mqtt()) + + +if __name__ == '__main__': + main() diff --git a/haco/switch.py b/haco/switch.py new file mode 100644 index 0000000..2cb2320 --- /dev/null +++ b/haco/switch.py @@ -0,0 +1,14 @@ +from haco.control import Control + +from haco.tools import invert_bool + + +class Switch(Control): + NAME = 'switch' + + def __init__(self, name, icon=None): + super().__init__(name, icon=icon) + + @property + def control_type(self): + return invert_bool diff --git a/haco/tasmota.py b/haco/tasmota.py new file mode 100644 index 0000000..c36d955 --- /dev/null +++ b/haco/tasmota.py @@ -0,0 +1,3 @@ +from typing import Annotated + +Tasmota = Annotated diff --git a/haco/text.py b/haco/text.py new file mode 100644 index 0000000..8a21b55 --- /dev/null +++ b/haco/text.py @@ -0,0 +1,27 @@ +from haco.control import Control + +from haco.tools import get_range_pair + + +class Text(Control): + NAME = 'text' + + def __init__(self, name, icon=None, size_range: range = None, pattern: str = None): + self.min, self.max = get_range_pair(size_range) + self.pattern = pattern + + super().__init__(name, icon=icon) + + def get_config_ha_ex(self): + data = { + 'min': self.min, + 'max': self.max, + 'pattern': self.pattern, + 'mode': self.__class__.__name__.lower() + } + + return data + + +class Password(Text): + ... diff --git a/haco/tools.py b/haco/tools.py new file mode 100644 index 0000000..2e18a75 --- /dev/null +++ b/haco/tools.py @@ -0,0 +1,94 @@ +import hashlib + +from haco import constants, tuya +from haco.tasmota import Tasmota + + +def split_into_chunks(input_string, chunk_size): + return [input_string[i:i + chunk_size] for i in range(0, len(input_string), chunk_size)] + + +def get_range_pair(data: range): + if not data: + return None, None + + return data.start, data.stop + + +def truncate_middle(input_string, max_length): + if len(input_string) <= max_length: + return input_string + + len_start = (max_length - 3) // 2 + len_end = max_length - 3 - len_start + + truncated = f'{input_string[:len_start]}...{input_string[-len_end:]}' + + return truncated + + +async def log_publish(client, topic, payload, retain=False): + log_retain = '|' if retain else ' ' + message = f'mqtt->{log_retain} {topic} {truncate_middle(payload, 100)}' + print(message) + await client.publish(topic=topic, payload=payload, retain=retain) + + +async def log_subscribe(client, topic): + message = f'mqtt+{topic}' + print(message) + await client.subscribe(topic=topic) + + +async def log_received(message): + log_retain = '|' if message.retain else ' ' + message = f'mqtt<-{log_retain} {message.topic} {truncate_middle(message.payload.decode("utf-8"), 100)}' + print(message) + + +def callback_default(value, data=None): + return value + + +def invert_bool(value): + return {True: 'ON', False: 'OFF', 'ON': True, 'OFF': False}[value] + + +def sanitize_name(s, sep='-'): + chars = [] + + for c in s.lower(): + if c in constants.SEPS_ALLOWED: + chars.append(sep) + elif c in constants.CHARS_ALLOWED: + chars.append(c) + + sanitized = ''.join(chars) + + if not sanitized: + raise ValueError("Sanitized string is empty") + + return sanitized + + +def add_tuya_io(control, control_type, type_id, dp_id): + @control.callback(trigger=tuya.received(type_id, dp_id)) + def tasmota(value: control_type): + return value + + @control.callback() + def ha(value: control_type) -> Tasmota[control_type, tuya.send(type_id, dp_id)]: + return value + + +def hash_string(input_string, size=16): + # Create a hashlib object for the SHA-256 algorithm + hash_object = hashlib.sha256() + + # Update the hash object with the bytes of the input string + hash_object.update(input_string.encode('utf-8')) + + # Get the hexadecimal representation of the hash + hash_result = hash_object.hexdigest()[:size] + + return hash_result diff --git a/haco/trigger.py b/haco/trigger.py new file mode 100644 index 0000000..dd76678 --- /dev/null +++ b/haco/trigger.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass +class Trigger: + trigger: str + + +@dataclass +class Rule(Trigger): + ... + + +@dataclass +class Cron(Trigger): + ... diff --git a/haco/tuya.py b/haco/tuya.py new file mode 100644 index 0000000..eeacbc0 --- /dev/null +++ b/haco/tuya.py @@ -0,0 +1,12 @@ +def send(type_id, dp_id): + return f'def (value,data) import string import uuid print(data["topic"], uuid.uuid4()) var cmd=string.format("TuyaSend{type_id} {dp_id},%s", value) return tasmota.cmd(cmd) end' + + +def cmd(mask): + cmd = mask.format(value='%s') + + return f'def (value,data) import string var cmd=string.format("{cmd}", value) return tasmota.cmd(cmd) end' + + +def received(type_id, dp_id): + return f'tuyareceived#dptype{type_id}id{dp_id}' diff --git a/haco/version b/haco/version new file mode 100644 index 0000000..bd52db8 --- /dev/null +++ b/haco/version @@ -0,0 +1 @@ +0.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..440ca38 --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from setuptools import find_packages, setup + +packages = find_packages() +name = next(iter(packages)) +path_base = Path(__file__).absolute().parent +path = path_base / name / 'version' +__version__ = path.read_text().strip() + +setup( + long_description=(path_base / 'README.md').read_text(), + long_description_content_type='text/markdown', + name=name, + version=__version__, + url=f'https://link.frontmatter.ai/{name}', + license='Copyright © 2023 Frontmatter. All rights reserved.', + author='Frontmatter', + description='Home Assistant Control Objects', + keywords='homeassistant controls python tasmota', + packages=packages, + package_data={ + name: [f'version'], + }, + install_requires=[ + 'aiomqtt' + ], + extras_require={ + 'tasmota': [ + + ] + }, + entry_points={ + 'console_scripts': [ + f'{name}-daemon = {name}.start:main', + ], + } +) diff --git a/update_version.py b/update_version.py new file mode 100644 index 0000000..167cbe2 --- /dev/null +++ b/update_version.py @@ -0,0 +1,29 @@ +import sys + +import re +from pathlib import Path + +PATH_ROOT = Path(__file__).parent.absolute() +ENCODING = 'UTF-8' +PATTERN_VERSION = r'(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P
[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?'
+STEMS_MASKS = {
+    'berry/haco_version.be': r"var\s+VERSION\s*=\s*'({})'",
+    'ha/addon/config.yaml': r"version:\s+({})"
+}
+VERSION = sys.argv[1]
+
+
+def replacer(match):
+    text_new = match.group(0).replace(match.group(1), VERSION)
+    return text_new
+
+
+if __name__ == '__main__':
+    for stem, mask in STEMS_MASKS.items():
+        path = PATH_ROOT / stem
+        text_old = path.read_text(encoding=ENCODING)
+        pattern = mask.format(PATTERN_VERSION)
+        replacement = pattern.format(VERSION)
+        text = re.sub(pattern, replacer, text_old)
+        path.write_text(text, encoding=ENCODING)
+        print(f'Wrote versions to file: "{path}"')