Merge pull request 'Initial MVP commits' (#1) from basic_func into main
Created initial deployed version Reviewed-on: #1
This commit is contained in:
commit
e5bc2ac462
16
requirements.txt
Normal file
16
requirements.txt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile
|
||||||
|
# To update, run:
|
||||||
|
#
|
||||||
|
# pip-compile
|
||||||
|
#
|
||||||
|
asyncio-mqtt==0.8.1
|
||||||
|
# via hasskiosk (setup.py)
|
||||||
|
environs==9.3.1
|
||||||
|
# via hasskiosk (setup.py)
|
||||||
|
marshmallow==3.10.0
|
||||||
|
# via environs
|
||||||
|
paho-mqtt==1.5.1
|
||||||
|
# via asyncio-mqtt
|
||||||
|
python-dotenv==0.15.0
|
||||||
|
# via environs
|
||||||
30
setup.cfg
30
setup.cfg
@ -1,7 +1,7 @@
|
|||||||
# https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files
|
# https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
name = hasskisok
|
name = hasskiosk
|
||||||
author = Mike Bloy
|
author = Mike Bloy
|
||||||
author_email = mike@bloy.org
|
author_email = mike@bloy.org
|
||||||
description = Helper application for homeassistant kiosk screens
|
description = Helper application for homeassistant kiosk screens
|
||||||
@ -17,28 +17,12 @@ package_dir =
|
|||||||
=src
|
=src
|
||||||
packages = find:
|
packages = find:
|
||||||
install_requires =
|
install_requires =
|
||||||
paho-mqtt
|
asyncio_mqtt
|
||||||
|
environs
|
||||||
[options.extras_require]
|
|
||||||
dev =
|
|
||||||
bpython
|
|
||||||
flake8
|
|
||||||
flake8-bandit
|
|
||||||
flake8-blind-except
|
|
||||||
flake8-builtins
|
|
||||||
flake8-docstrings
|
|
||||||
flake8-executable
|
|
||||||
flake8-isort
|
|
||||||
flake8-logging-format
|
|
||||||
mypy
|
|
||||||
pdbpp
|
|
||||||
pycodestyle
|
|
||||||
pydocstyle
|
|
||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
python-language-server
|
|
||||||
rope
|
|
||||||
sphinx
|
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
where=src
|
where=src
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
hasskiosk = hasskiosk:run
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"""Home Assistant Kiosk."""
|
"""Home Assistant Kiosk."""
|
||||||
|
|
||||||
from ._version import version as __version__
|
from ._version import version as __version__
|
||||||
|
from .runner import run
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"run",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
6
src/hasskiosk/__main__.py
Normal file
6
src/hasskiosk/__main__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""Main module runner."""
|
||||||
|
|
||||||
|
from .runner import run
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
72
src/hasskiosk/config.py
Normal file
72
src/hasskiosk/config.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Configuration management from environment."""
|
||||||
|
|
||||||
|
import logging.config
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from environs import Env
|
||||||
|
|
||||||
|
from ._version import version
|
||||||
|
|
||||||
|
|
||||||
|
def read_config() -> Dict[str, Any]:
|
||||||
|
"""Read the configuration from the environment."""
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
with env.prefixed("HASSKIOSK_"):
|
||||||
|
config = {
|
||||||
|
"sysname": env("SYSTEM_NAME", "hasskiosk"),
|
||||||
|
"version": version,
|
||||||
|
"logging": {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"simple": {
|
||||||
|
"format": "%(asctime)s %(levelname)s (%(name)s) %(message)s",
|
||||||
|
"converter": "time.gmtime",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"stdout": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": env("LOG_LEVEL", "INFO"),
|
||||||
|
"formatter": "simple",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"hasskiosk": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"propagate": "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "WARN",
|
||||||
|
"handlers": ["stdout"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with env.prefixed("MQTT_"):
|
||||||
|
config["mqtt"] = {
|
||||||
|
"host": env("HOST"),
|
||||||
|
"port": env.int("PORT", 1883),
|
||||||
|
"username": env("USERNAME"),
|
||||||
|
"password": env("PASSWORD"),
|
||||||
|
"keepalive": env.int("KEEPALIVE", 60),
|
||||||
|
"subscription": env("SUBSCRIBE_TOPIC"),
|
||||||
|
"screen_state_topic": env.str("SCREEN_STATE_TOPIC", "home/+/presence"),
|
||||||
|
}
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
pid = os.getpid()
|
||||||
|
sysname = config["sysname"]
|
||||||
|
config["client_id"] = f"{sysname}-{hostname}-{pid}"
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(config: Dict[str, Any]) -> None:
|
||||||
|
"""Configure logging using the logging key from the configuration."""
|
||||||
|
logging.config.dictConfig(config["logging"])
|
||||||
|
log = getLogger(__name__)
|
||||||
|
log.info("%s v%s starting up.", config["sysname"], config["version"])
|
||||||
52
src/hasskiosk/mqtt.py
Normal file
52
src/hasskiosk/mqtt.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""Manage mqtt connections."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import AsyncExitStack
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
|
from asyncio_mqtt import Client
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTManager:
|
||||||
|
"""MQTT manager class."""
|
||||||
|
|
||||||
|
def __init__(self, hostname: str, port: int, username: str, password: str):
|
||||||
|
"""Initialize with the following data.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
hostname: MQTT host to connect to
|
||||||
|
port: port to use for connecting
|
||||||
|
username: authentication username
|
||||||
|
password: authentication password
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self._hostname = hostname
|
||||||
|
self._port = port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._tasks: Set[asyncio.Task] = set()
|
||||||
|
self._mqtt: Client = None
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""MQTT async runner."""
|
||||||
|
async with AsyncExitStack() as ctx:
|
||||||
|
self._tasks = set()
|
||||||
|
ctx.push_async_callback(self._cancel_tasks)
|
||||||
|
self._mqtt = Client(
|
||||||
|
hostname=self._host,
|
||||||
|
port=self._port,
|
||||||
|
username=self._username,
|
||||||
|
password=self._password,
|
||||||
|
clean_session=True,
|
||||||
|
)
|
||||||
|
await self.enter_async_context(self._mqtt)
|
||||||
|
|
||||||
|
async def _cancel_tasks(self):
|
||||||
|
for task in self._tasks:
|
||||||
|
if task.done():
|
||||||
|
continue
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
77
src/hasskiosk/runner.py
Normal file
77
src/hasskiosk/runner.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Runner and daemon management."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from contextlib import AsyncExitStack
|
||||||
|
from typing import Any, Dict, Set
|
||||||
|
|
||||||
|
from asyncio_mqtt import Client
|
||||||
|
|
||||||
|
from .config import configure_logging, read_config
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
"""Run the daemon."""
|
||||||
|
config = read_config()
|
||||||
|
configure_logging(config)
|
||||||
|
asyncio.run(main(config))
|
||||||
|
|
||||||
|
|
||||||
|
async def main(config: Dict[str, Any]):
|
||||||
|
"""Setup and run the async tasks."""
|
||||||
|
async with AsyncExitStack() as exit_stack:
|
||||||
|
tasks: Set[asyncio.Task] = set()
|
||||||
|
exit_stack.push_async_callback(cancel_tasks, tasks)
|
||||||
|
|
||||||
|
mqtt = Client(
|
||||||
|
hostname=config["mqtt"]["host"],
|
||||||
|
port=config["mqtt"]["port"],
|
||||||
|
username=config["mqtt"]["username"],
|
||||||
|
password=config["mqtt"]["password"],
|
||||||
|
clean_session=True,
|
||||||
|
)
|
||||||
|
await exit_stack.enter_async_context(mqtt)
|
||||||
|
|
||||||
|
topic_handlers = (
|
||||||
|
(config["mqtt"]["screen_state_topic"], screen_state_mqtt_handler),
|
||||||
|
)
|
||||||
|
for topic, handler in topic_handlers:
|
||||||
|
manager = mqtt.filtered_messages(topic)
|
||||||
|
messages = await exit_stack.enter_async_context(manager)
|
||||||
|
tasks.add(asyncio.create_task(handler(messages)))
|
||||||
|
|
||||||
|
await mqtt.subscribe(config["mqtt"]["subscription"])
|
||||||
|
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def screen_state_mqtt_handler(messages):
|
||||||
|
"""Screen state handler, reacts on presence messages."""
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
async for message in messages:
|
||||||
|
payload = message.payload.decode()
|
||||||
|
log.info("screen sate message on topic %s: %s", message.topic, payload)
|
||||||
|
if payload == "on":
|
||||||
|
cmd = ["xset", "dpms", "force", "on"]
|
||||||
|
log.info("motion detected, forcing screen state to on. CMD: %s", cmd)
|
||||||
|
result = subprocess.run(cmd, capture_output=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
log.error(
|
||||||
|
"nonzero return code: %s. stderr: %r, stdout: %r",
|
||||||
|
result.returncode,
|
||||||
|
result.stderr,
|
||||||
|
result.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_tasks(tasks):
|
||||||
|
"""Cancel tasks on shutdown."""
|
||||||
|
for task in tasks:
|
||||||
|
if task.done():
|
||||||
|
continue
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
1
tests/conftest.py
Normal file
1
tests/conftest.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Test configuration and fixtures."""
|
||||||
88
tests/test_config.py
Normal file
88
tests/test_config.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Tests for the configuration management."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hasskiosk import __version__
|
||||||
|
from hasskiosk.config import configure_logging, read_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_env(monkeypatch):
|
||||||
|
"""Environment mock to test values."""
|
||||||
|
monkeypatch.setenv("HASSKIOSK_MQTT_SUBSCRIBE_TOPIC", "home/test/#")
|
||||||
|
monkeypatch.setenv("HASSKIOSK_MQTT_HOST", "ha.example.com")
|
||||||
|
monkeypatch.setenv("HASSKIOSK_MQTT_USERNAME", "testymctesterson")
|
||||||
|
monkeypatch.setenv("HASSKIOSK_MQTT_PASSWORD", "hunter2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logging_config() -> Dict[str, Any]:
|
||||||
|
"""Logging configuration fixture."""
|
||||||
|
config = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"simple": {
|
||||||
|
"format": "%(asctime)s %(levelname)s (%(name)s) %(message)s",
|
||||||
|
"converter": "time.gmtime",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"stdout": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": "DEBUG",
|
||||||
|
"formatter": "simple",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"hasskiosk": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"propagate": "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "WARN",
|
||||||
|
"handlers": ["stdout"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config():
|
||||||
|
"""Test the read_config function."""
|
||||||
|
config = read_config()
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
pid = os.getpid()
|
||||||
|
assert config["version"] == __version__
|
||||||
|
assert config["sysname"] == "hasskiosk"
|
||||||
|
assert config["client_id"] == f"hasskiosk-{hostname}-{pid}"
|
||||||
|
assert config["mqtt"] == {
|
||||||
|
"host": "ha.example.com",
|
||||||
|
"username": "testymctesterson",
|
||||||
|
"password": "hunter2",
|
||||||
|
"port": 1883,
|
||||||
|
"keepalive": 60,
|
||||||
|
"subscription": "home/test/#",
|
||||||
|
"screen_state_topic": "home/+/presence",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_logging(logging_config):
|
||||||
|
"""Test the logging configuration."""
|
||||||
|
config = {
|
||||||
|
"version": "1.2.3.test",
|
||||||
|
"sysname": "testkiosk",
|
||||||
|
"logging": logging_config,
|
||||||
|
}
|
||||||
|
configure_logging(config)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
rootlog = log
|
||||||
|
while rootlog.parent:
|
||||||
|
rootlog = rootlog.parent
|
||||||
|
assert len(rootlog.handlers) == 1
|
||||||
10
tests/test_version.py
Normal file
10
tests/test_version.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""Test version number handling."""
|
||||||
|
|
||||||
|
from importlib_metadata import version
|
||||||
|
|
||||||
|
from hasskiosk import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_version_matches_dunder_version():
|
||||||
|
"""Test that package metadata version matches the package __version__."""
|
||||||
|
assert __version__ == version("hasskiosk")
|
||||||
50
tox.ini
50
tox.ini
@ -1,31 +1,48 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py37,security,lint,bundle
|
envlist = py37,security,lint
|
||||||
isolated_build = True
|
isolated_build = True
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
wheel = true
|
wheel = true
|
||||||
wheel_build_env = build
|
wheel_build_env = build
|
||||||
extras=
|
basepython = python3.7
|
||||||
dev
|
|
||||||
|
|
||||||
[testenv:build]
|
[testenv:build]
|
||||||
deps = setuptools
|
deps = setuptools
|
||||||
|
|
||||||
|
|
||||||
[testenv:py37]
|
[testenv:py37]
|
||||||
|
deps =
|
||||||
|
importlib_metadata
|
||||||
|
pytest
|
||||||
|
pytest-asyncio
|
||||||
|
pytest-cov
|
||||||
|
pdbpp
|
||||||
commands =
|
commands =
|
||||||
pytest --cov={envsitepackagesdir}/hasskiosk \
|
pytest --cov={envsitepackagesdir}/hasskiosk \
|
||||||
--cov-report=term-missing \
|
--cov-report=term-missing \
|
||||||
--cov-report=xml:coverage.xml \
|
# --cov-report=xml:coverage.xml \
|
||||||
--junitxml=test-report.xml \
|
# --junitxml=test-report.xml \
|
||||||
--cov-branch \
|
--cov-branch \
|
||||||
--cov-fail-under=80
|
--cov-fail-under=80 \
|
||||||
|
{posargs}
|
||||||
|
|
||||||
[testenv:security]
|
[testenv:security]
|
||||||
|
deps =
|
||||||
|
bandit
|
||||||
commands =
|
commands =
|
||||||
bandit {envsitepackagesdir}/hasskiosk -r
|
bandit {toxinidir}/src/hasskiosk -r
|
||||||
|
|
||||||
[testenv:lint]
|
[testenv:lint]
|
||||||
|
deps =
|
||||||
|
flake8
|
||||||
|
flake8-blind-except
|
||||||
|
flake8-builtins
|
||||||
|
flake8-docstrings
|
||||||
|
flake8-executable
|
||||||
|
flake8-isort
|
||||||
|
flake8-logging-format
|
||||||
|
pycodestyle
|
||||||
|
pydocstyle
|
||||||
commands =
|
commands =
|
||||||
flake8 --output-file pylint-out.txt --format pylint --tee
|
flake8 --output-file pylint-out.txt --format pylint --tee
|
||||||
|
|
||||||
@ -33,6 +50,22 @@ commands =
|
|||||||
commands =
|
commands =
|
||||||
python setup.py sdist bdist_wheel
|
python setup.py sdist bdist_wheel
|
||||||
|
|
||||||
|
[testenv:dev]
|
||||||
|
# this environment lets tox create a development env quickly and easily
|
||||||
|
recreate=True
|
||||||
|
deps =
|
||||||
|
bpython
|
||||||
|
python-language-server
|
||||||
|
rope
|
||||||
|
pip-tools
|
||||||
|
-r{toxinidir}/requirements.txt
|
||||||
|
{[testenv:py37]deps}
|
||||||
|
{[testenv:security]deps}
|
||||||
|
{[testenv:security]deps}
|
||||||
|
{[testenv:lint]deps}
|
||||||
|
#envdir = {toxinidir}/.venv
|
||||||
|
usedevelop = True
|
||||||
|
|
||||||
[pycodestyle]
|
[pycodestyle]
|
||||||
max-line-length = 87
|
max-line-length = 87
|
||||||
|
|
||||||
@ -71,6 +104,7 @@ include =
|
|||||||
.tox/**/hasskiosk/
|
.tox/**/hasskiosk/
|
||||||
omit =
|
omit =
|
||||||
**/hasskiosk/_version.py
|
**/hasskiosk/_version.py
|
||||||
|
**/hasskiosk/__main__.py
|
||||||
setup.py
|
setup.py
|
||||||
tests/*
|
tests/*
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user