diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..97bbb8a --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/setup.cfg b/setup.cfg index cb55349..1e80a9f 100644 --- a/setup.cfg +++ b/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 diff --git a/src/hasskiosk/__init__.py b/src/hasskiosk/__init__.py index 70337f3..ec1b423 100644 --- a/src/hasskiosk/__init__.py +++ b/src/hasskiosk/__init__.py @@ -1,7 +1,9 @@ """Home Assistant Kiosk.""" from ._version import version as __version__ +from .runner import run __all__ = [ + "run", "__version__", ] diff --git a/src/hasskiosk/__main__.py b/src/hasskiosk/__main__.py new file mode 100644 index 0000000..57e2306 --- /dev/null +++ b/src/hasskiosk/__main__.py @@ -0,0 +1,6 @@ +"""Main module runner.""" + +from .runner import run + +if __name__ == "__main__": + run() diff --git a/src/hasskiosk/config.py b/src/hasskiosk/config.py new file mode 100644 index 0000000..6d2e646 --- /dev/null +++ b/src/hasskiosk/config.py @@ -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"]) diff --git a/src/hasskiosk/mqtt.py b/src/hasskiosk/mqtt.py new file mode 100644 index 0000000..b295901 --- /dev/null +++ b/src/hasskiosk/mqtt.py @@ -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 diff --git a/src/hasskiosk/runner.py b/src/hasskiosk/runner.py new file mode 100644 index 0000000..1bc2629 --- /dev/null +++ b/src/hasskiosk/runner.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b97cb1e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Test configuration and fixtures.""" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c494b9d --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..e8b4e3f --- /dev/null +++ b/tests/test_version.py @@ -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") diff --git a/tox.ini b/tox.ini index 14ebee4..3768f51 100644 --- a/tox.ini +++ b/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/*