Merge pull request 'Initial MVP commits' (#1) from basic_func into main

Created initial deployed version

Reviewed-on: #1
This commit is contained in:
Mike Bloy 2021-03-09 11:58:03 -06:00
commit f0a61cafd4
11 changed files with 373 additions and 31 deletions

16
requirements.txt Normal file
View 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

View File

@ -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

View File

@ -1,7 +1,9 @@
"""Home Assistant Kiosk."""
from ._version import version as __version__
from .runner import run
__all__ = [
"run",
"__version__",
]

View File

@ -0,0 +1,6 @@
"""Main module runner."""
from .runner import run
if __name__ == "__main__":
run()

72
src/hasskiosk/config.py Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@
"""Test configuration and fixtures."""

88
tests/test_config.py Normal file
View 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
View 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
View File

@ -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/*