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