mirror of
https://github.com/fmtr/haco.git
synced 2026-10-25 18:40:15 -04:00
Add content
This commit is contained in:
commit
8b9b7226b1
43 changed files with 2307 additions and 0 deletions
4
.coveragerc
Normal file
4
.coveragerc
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[run]
|
||||||
|
omit =
|
||||||
|
setup.py
|
||||||
|
*tests*
|
||||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
**/artifacts/**
|
||||||
|
artifacts/**
|
||||||
70
.github/workflows/main.yml
vendored
Normal file
70
.github/workflows/main.yml
vendored
Normal 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
144
.gitignore
vendored
Normal 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
21
Dockerfile
Normal 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
0
README.md
Normal file
320
berry/haco.be
Normal file
320
berry/haco.be
Normal 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
5
berry/haco_version.be
Normal 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
28
berry/manifest.yaml
Normal 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
1
ha/addon/CHANGELOG.md
Normal 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
1
ha/addon/DOCS.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
For general documentation, see **[the project wiki](https://link.frontmatter.ai/haco/doc)**.
|
||||||
10
ha/addon/Dockerfile
Normal file
10
ha/addon/Dockerfile
Normal 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
1
ha/addon/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
../../README.md
|
||||||
19
ha/addon/config.yaml
Normal file
19
ha/addon/config.yaml
Normal 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
10
ha/addon/run.sh
Normal 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
|
||||||
2
ha/addon/translations/en.yaml
Normal file
2
ha/addon/translations/en.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
---
|
||||||
|
configuration:
|
||||||
0
haco/__init__.py
Normal file
0
haco/__init__.py
Normal file
26
haco/berry.py
Normal file
26
haco/berry.py
Normal 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
109
haco/callback.py
Normal 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
112
haco/climate.py
Normal 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
0
haco/configs/__init__.py
Normal file
160
haco/configs/air_fryer.py
Normal file
160
haco/configs/air_fryer.py
Normal 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
182
haco/configs/development.py
Normal 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
5
haco/constants.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import string
|
||||||
|
|
||||||
|
CHARS_ALLOWED = string.ascii_lowercase + string.digits
|
||||||
|
SEPS_ALLOWED = '_- '
|
||||||
|
BRANCH = 'release'
|
||||||
107
haco/control.py
Normal file
107
haco/control.py
Normal 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
15
haco/debugging.py
Normal 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
114
haco/device.py
Normal 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
62
haco/load_configs.py
Normal 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
62
haco/mqtt.py
Normal 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
36
haco/number.py
Normal 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
18
haco/pulldown.py
Normal 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
379
haco/schema.py
Normal 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
37
haco/sensor.py
Normal 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
11
haco/start.py
Normal 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
14
haco/switch.py
Normal 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
3
haco/tasmota.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
Tasmota = Annotated
|
||||||
27
haco/text.py
Normal file
27
haco/text.py
Normal 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
94
haco/tools.py
Normal 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
16
haco/trigger.py
Normal 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
12
haco/tuya.py
Normal 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
1
haco/version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.0.0
|
||||||
38
setup.py
Normal file
38
setup.py
Normal 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
29
update_version.py
Normal 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}"')
|
||||||
Loading…
Add table
Add a link
Reference in a new issue