Add content

This commit is contained in:
Edward Brown 2023-08-29 16:01:23 +01:00
commit 8b9b7226b1
43 changed files with 2307 additions and 0 deletions

4
.coveragerc Normal file
View file

@ -0,0 +1,4 @@
[run]
omit =
setup.py
*tests*

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
**/artifacts/**
artifacts/**

70
.github/workflows/main.yml vendored Normal file
View file

@ -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/*

144
.gitignore vendored Normal file
View file

@ -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

21
Dockerfile Normal file
View file

@ -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

0
README.md Normal file
View file

320
berry/haco.be Normal file
View file

@ -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

5
berry/haco_version.be Normal file
View file

@ -0,0 +1,5 @@
var VERSION='0.0.0'
var mod = module("haco_version")
mod.VERSION=VERSION
return mod

28
berry/manifest.yaml Normal file
View file

@ -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}'

1
ha/addon/CHANGELOG.md Normal file
View file

@ -0,0 +1 @@
For the latest changes, see **[the project releases page](https://link.frontmatter.ai/haco/changes)**.

1
ha/addon/DOCS.md Normal file
View file

@ -0,0 +1 @@
For general documentation, see **[the project wiki](https://link.frontmatter.ai/haco/doc)**.

10
ha/addon/Dockerfile Normal file
View file

@ -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" ]

1
ha/addon/README.md Normal file
View file

@ -0,0 +1 @@
../../README.md

19
ha/addon/config.yaml Normal file
View file

@ -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

10
ha/addon/run.sh Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
---
configuration:

0
haco/__init__.py Normal file
View file

26
haco/berry.py Normal file
View file

@ -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)"

109
haco/callback.py Normal file
View file

@ -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.')

112
haco/climate.py Normal file
View file

@ -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

0
haco/configs/__init__.py Normal file
View file

160
haco/configs/air_fryer.py Normal file
View file

@ -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'}

182
haco/configs/development.py Normal file
View file

@ -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()]
)

5
haco/constants.py Normal file
View file

@ -0,0 +1,5 @@
import string
CHARS_ALLOWED = string.ascii_lowercase + string.digits
SEPS_ALLOWED = '_- '
BRANCH = 'release'

107
haco/control.py Normal file
View file

@ -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"

15
haco/debugging.py Normal file
View file

@ -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

114
haco/device.py Normal file
View file

@ -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)

62
haco/load_configs.py Normal file
View file

@ -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

62
haco/mqtt.py Normal file
View file

@ -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))

36
haco/number.py Normal file
View file

@ -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

18
haco/pulldown.py Normal file
View file

@ -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

379
haco/schema.py Normal file
View file

@ -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

37
haco/sensor.py Normal file
View file

@ -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

11
haco/start.py Normal file
View file

@ -0,0 +1,11 @@
import asyncio
from haco.mqtt import start_mqtt
def main():
return asyncio.run(start_mqtt())
if __name__ == '__main__':
main()

14
haco/switch.py Normal file
View file

@ -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

3
haco/tasmota.py Normal file
View file

@ -0,0 +1,3 @@
from typing import Annotated
Tasmota = Annotated

27
haco/text.py Normal file
View file

@ -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):
...

94
haco/tools.py Normal file
View file

@ -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

16
haco/trigger.py Normal file
View file

@ -0,0 +1,16 @@
from dataclasses import dataclass
@dataclass
class Trigger:
trigger: str
@dataclass
class Rule(Trigger):
...
@dataclass
class Cron(Trigger):
...

12
haco/tuya.py Normal file
View file

@ -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}'

1
haco/version Normal file
View file

@ -0,0 +1 @@
0.0.0

38
setup.py Normal file
View file

@ -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',
],
}
)

29
update_version.py Normal file
View file

@ -0,0 +1,29 @@
import sys
import re
from pathlib import Path
PATH_ROOT = Path(__file__).parent.absolute()
ENCODING = 'UTF-8'
PATTERN_VERSION = r'(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<pre>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?(?:\+(?P<build>[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}"')