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
f0a61cafd4
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
|
||||
|
||||
[metadata]
|
||||
name = hasskisok
|
||||
name = hasskiosk
|
||||
author = Mike Bloy
|
||||
author_email = mike@bloy.org
|
||||
description = Helper application for homeassistant kiosk screens
|
||||
@ -17,28 +17,12 @@ package_dir =
|
||||
=src
|
||||
packages = find:
|
||||
install_requires =
|
||||
paho-mqtt
|
||||
|
||||
[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
|
||||
asyncio_mqtt
|
||||
environs
|
||||
|
||||
[options.packages.find]
|
||||
where=src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
hasskiosk = hasskiosk:run
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"""Home Assistant Kiosk."""
|
||||
|
||||
from ._version import version as __version__
|
||||
from .runner import run
|
||||
|
||||
__all__ = [
|
||||
"run",
|
||||
"__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]
|
||||
envlist = py37,security,lint,bundle
|
||||
envlist = py37,security,lint
|
||||
isolated_build = True
|
||||
|
||||
[testenv]
|
||||
wheel = true
|
||||
wheel_build_env = build
|
||||
extras=
|
||||
dev
|
||||
basepython = python3.7
|
||||
|
||||
[testenv:build]
|
||||
deps = setuptools
|
||||
|
||||
|
||||
[testenv:py37]
|
||||
deps =
|
||||
importlib_metadata
|
||||
pytest
|
||||
pytest-asyncio
|
||||
pytest-cov
|
||||
pdbpp
|
||||
commands =
|
||||
pytest --cov={envsitepackagesdir}/hasskiosk \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml:coverage.xml \
|
||||
--junitxml=test-report.xml \
|
||||
# --cov-report=xml:coverage.xml \
|
||||
# --junitxml=test-report.xml \
|
||||
--cov-branch \
|
||||
--cov-fail-under=80
|
||||
--cov-fail-under=80 \
|
||||
{posargs}
|
||||
|
||||
[testenv:security]
|
||||
deps =
|
||||
bandit
|
||||
commands =
|
||||
bandit {envsitepackagesdir}/hasskiosk -r
|
||||
bandit {toxinidir}/src/hasskiosk -r
|
||||
|
||||
[testenv:lint]
|
||||
deps =
|
||||
flake8
|
||||
flake8-blind-except
|
||||
flake8-builtins
|
||||
flake8-docstrings
|
||||
flake8-executable
|
||||
flake8-isort
|
||||
flake8-logging-format
|
||||
pycodestyle
|
||||
pydocstyle
|
||||
commands =
|
||||
flake8 --output-file pylint-out.txt --format pylint --tee
|
||||
|
||||
@ -33,6 +50,22 @@ commands =
|
||||
commands =
|
||||
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]
|
||||
max-line-length = 87
|
||||
|
||||
@ -71,6 +104,7 @@ include =
|
||||
.tox/**/hasskiosk/
|
||||
omit =
|
||||
**/hasskiosk/_version.py
|
||||
**/hasskiosk/__main__.py
|
||||
setup.py
|
||||
tests/*
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user