From 7e53f4a26cf618c94ca728632ccade5effdad815 Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 07:12:24 +0000 Subject: [PATCH 1/6] update dev environment to python3.11 --- .devcontainer/configuration.yaml | 6 ---- .devcontainer/devcontainer.json | 59 ++++++++++++++++---------------- .github/workflows/cron.yaml | 2 +- .github/workflows/pull.yml | 21 +++++++----- .github/workflows/push.yml | 27 +++++++-------- .gitignore | 6 +++- .vscode/settings.json | 19 ++++++++-- .vscode/tasks.json | 18 ++-------- requirements_test.txt | 3 +- scripts/develop | 22 ++++++++++++ scripts/setup | 7 ++++ 11 files changed, 111 insertions(+), 79 deletions(-) create mode 100644 scripts/develop create mode 100644 scripts/setup diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index f2b5624..da1f19c 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -1,13 +1,7 @@ default_config: -homeassistant: - #external_url: http://192.168.1.201:9124 - #internal_url: http://192.168.1.64:8123 - - stream: - logger: default: info logs: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 53e19d2..7b25978 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,31 +1,32 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration-debian", - "name": "Blueprint integration development", - "context": "..", - "appPort": [ - "9124:8123" - ], - "forwardPorts": [8080, 8081], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } + "name": "ludeeus/integration_blueprint/motion_frontend", + "image": "mcr.microsoft.com/devcontainers/python:3.11", + "runArgs": [ "--network=host" ], + "postCreateCommand": "scripts/setup", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "root", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } } \ No newline at end of file diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml index 8d51d0f..1b2b291 100644 --- a/.github/workflows/cron.yaml +++ b/.github/workflows/cron.yaml @@ -9,7 +9,7 @@ jobs: runs-on: "ubuntu-latest" name: Validate steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v3" - name: HACS validation uses: "hacs/action@main" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index d895c86..687d403 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -3,12 +3,15 @@ name: Pull actions on: pull_request: +env: + DEFAULT_PYTHON: "3.11" + jobs: validate: runs-on: "ubuntu-latest" name: Validate steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v3" - name: HACS validation uses: "hacs/action@main" @@ -23,10 +26,10 @@ jobs: runs-on: "ubuntu-latest" name: Check style formatting steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" + - uses: "actions/checkout@v3" + - uses: "actions/setup-python@v4" with: - python-version: "3.x" + python-version: ${{ env.DEFAULT_PYTHON }} - run: python3 -m pip install black - run: black . @@ -35,21 +38,21 @@ jobs: name: Run tests steps: - name: Check out code from GitHub - uses: "actions/checkout@v2" + uses: "actions/checkout@v3" - name: Setup Python - uses: "actions/setup-python@v1" + uses: "actions/setup-python@v4" with: - python-version: "3.8" + python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements run: python3 -m pip install -r requirements_test.txt - name: Run tests run: | pytest \ -qq \ - --timeout=9 \ + --timeout=60 \ --durations=10 \ -n auto \ - --cov custom_components.integration_blueprint \ + --cov custom_components.motion_frontend \ -o console_output_style=count \ -p no:sugar \ tests diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d0ff7bf..e101583 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,17 +1,16 @@ name: Push actions -on: - push: - branches: - - master - - dev +on: push + +env: + DEFAULT_PYTHON: "3.11" jobs: validate: runs-on: "ubuntu-latest" name: Validate steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v3" - name: HACS validation uses: "hacs/action@main" @@ -26,10 +25,10 @@ jobs: runs-on: "ubuntu-latest" name: Check style formatting steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" + - uses: "actions/checkout@v3" + - uses: "actions/setup-python@v4" with: - python-version: "3.x" + python-version: ${{ env.DEFAULT_PYTHON }} - run: python3 -m pip install black - run: black . @@ -38,21 +37,21 @@ jobs: name: Run tests steps: - name: Check out code from GitHub - uses: "actions/checkout@v2" + uses: "actions/checkout@v3" - name: Setup Python - uses: "actions/setup-python@v1" + uses: "actions/setup-python@v4" with: - python-version: "3.8" + python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements run: python3 -m pip install -r requirements_test.txt - name: Run tests run: | pytest \ -qq \ - --timeout=9 \ + --timeout=60 \ --durations=10 \ -n auto \ - --cov custom_components.integration_blueprint \ + --cov custom_components.motion_frontend \ -o console_output_style=count \ -p no:sugar \ tests \ No newline at end of file diff --git a/.gitignore b/.gitignore index ff9a238..2f3b411 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ venv .coverage .ropeproject *.secret -*.user \ No newline at end of file +*.secret.json +*.user +traces +.DS_Store +config \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a3d535d..a7be5ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,21 @@ { - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.pythonPath": "/usr/local/bin/python", "files.associations": { "*.yaml": "home-assistant" + }, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticSeverityOverrides": { + "reportShadowedImports": "none" + }, + "python.analysis.extraPaths": [ + "./custom_components/motion_frontend" + ], + "testing.defaultGutterClickAction": "debug", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "tests" + ], + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" } } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d1a0ae7..c8f642e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,27 +2,15 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", + "command": "scripts/develop", "problemMatcher": [] }, { "label": "Upgrade Home Assistant to latest dev", "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", + "command": "pip3 install --upgrade git+https://github.com/home-assistant/core.git@dev", "problemMatcher": [] } ] diff --git a/requirements_test.txt b/requirements_test.txt index 1519e80..33ab61e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1 +1,2 @@ -pytest-homeassistant-custom-component==0.3.0 +#pytest-homeassistant-custom-component==0.3.2 +pytest-homeassistant-custom-component @ git+https://github.com/MatthewFlamm/pytest-homeassistant-custom-component@0.13.87 # HA core 2024.1.0 \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..8d59b28 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +cp "${PWD}/.devcontainer/configuration.yaml" "${PWD}/config/configuration.yaml" + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..e23d51a --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements_test.txt \ No newline at end of file From 3d202fb35518eb0f8230bd431f98a06c44e305c4 Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 07:12:59 +0000 Subject: [PATCH 2/6] update to latest HA core features --- custom_components/motion_frontend/__init__.py | 219 ++++--- .../motion_frontend/_media_source.py | 16 +- .../motion_frontend/alarm_control_panel.py | 137 ++-- custom_components/motion_frontend/camera.py | 235 +++---- .../motion_frontend/config_flow.py | 429 ++++++++----- custom_components/motion_frontend/helpers.py | 2 +- .../motion_frontend/motionclient/__init__.py | 363 ++++++----- .../motionclient/config_schema.py | 604 ++++++++++-------- 8 files changed, 1105 insertions(+), 900 deletions(-) diff --git a/custom_components/motion_frontend/__init__.py b/custom_components/motion_frontend/__init__.py index f06829a..31b2c3f 100644 --- a/custom_components/motion_frontend/__init__.py +++ b/custom_components/motion_frontend/__init__.py @@ -1,57 +1,66 @@ """The Motion Frontend integration.""" -from typing import Any, Callable, Dict, List, Optional, Union -from logging import WARNING, INFO +from __future__ import annotations import asyncio - -#from aiohttp.web import Request, Response -import aiohttp -import async_timeout +from logging import INFO, WARNING import os from pathlib import Path +import types +import typing +import aiohttp -from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady -from homeassistant.core import HomeAssistant, callback from homeassistant.components import mqtt -from homeassistant.helpers import device_registry -from homeassistant.util import raise_if_invalid_path -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( - CONF_HOST, CONF_PORT, - CONF_USERNAME, CONF_PASSWORD, ATTR_AREA_ID -) - - -from .motionclient import ( - MotionHttpClient, TlsMode, - MotionHttpClientError, MotionHttpClientConnectionError, - config_schema as cs -) - -from .helpers import ( - LOGGER, LOGGER_trap, + ATTR_AREA_ID, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, ) +from homeassistant.core import HomeAssistant, callback, CALLBACK_TYPE +from homeassistant.helpers import device_registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import raise_if_invalid_path +from .camera import MotionFrontendCamera from .const import ( - DOMAIN, PLATFORMS, - CONF_OPTION_NONE, CONF_OPTION_DEFAULT, CONF_OPTION_FORCE, CONF_OPTION_AUTO, - CONF_TLS_MODE, MAP_TLS_MODE, - CONF_WEBHOOK_MODE, - CONF_WEBHOOK_ADDRESS, CONF_OPTION_INTERNAL, CONF_OPTION_EXTERNAL, CONF_OPTION_CLOUD, CONF_MEDIASOURCE, + CONF_OPTION_AUTO, + CONF_OPTION_CLOUD, + CONF_OPTION_DEFAULT, + CONF_OPTION_EXTERNAL, + CONF_OPTION_FORCE, + CONF_OPTION_INTERNAL, + CONF_OPTION_NONE, + CONF_TLS_MODE, + CONF_WEBHOOK_ADDRESS, + CONF_WEBHOOK_MODE, + DOMAIN, EXTRA_ATTR_FILENAME, - MANAGED_EVENTS + MANAGED_EVENTS, + MAP_TLS_MODE, + PLATFORMS, +) +from .helpers import LOGGER, LOGGER_trap +from .motionclient import ( + MotionHttpClient, + MotionHttpClientConnectionError, + MotionHttpClientError, + TlsMode, + config_schema as cs, ) -from .camera import MotionFrontendCamera +if typing.TYPE_CHECKING: + import aiohttp.web + from .alarm_control_panel import MotionFrontendAlarmControlPanel -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: dict): return True async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): - hass.data.setdefault(DOMAIN, {}) data = config_entry.data @@ -66,7 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): if not api.is_available: raise ConfigEntryNotReady - api.unsub_entry_update_listener = config_entry.add_update_listener(api.entry_update_listener) + api.unsub_entry_update_listener = config_entry.add_update_listener( + api.entry_update_listener + ) hass.data[DOMAIN][config_entry.entry_id] = api @@ -75,38 +86,49 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): # setup webhook to manage 'push' from motion server try: webhook_id = f"{DOMAIN}_{config_entry.entry_id}" - hass.components.webhook.async_register(DOMAIN, DOMAIN, webhook_id, api.async_handle_webhook) - api.webhook_id = webhook_id #set here after succesfully calling async_register + hass.components.webhook.async_register( + DOMAIN, DOMAIN, webhook_id, api.async_handle_webhook + ) + api.webhook_id = ( + webhook_id # set here after succesfully calling async_register + ) webhook_address = data.get(CONF_WEBHOOK_ADDRESS, CONF_OPTION_DEFAULT) if webhook_address == CONF_OPTION_INTERNAL: api.webhook_url = hass.helpers.network.get_url( - allow_internal=True, - allow_external=False, - allow_cloud=False) + allow_internal=True, allow_external=False, allow_cloud=False + ) elif webhook_address == CONF_OPTION_EXTERNAL: api.webhook_url = hass.helpers.network.get_url( allow_internal=False, allow_external=True, - allow_cloud=True, prefer_cloud=False) + allow_cloud=True, + prefer_cloud=False, + ) elif webhook_address == CONF_OPTION_CLOUD: api.webhook_url = hass.helpers.network.get_url( allow_internal=False, allow_external=False, - allow_cloud=True, prefer_cloud=True) + allow_cloud=True, + prefer_cloud=True, + ) else: api.webhook_url = hass.helpers.network.get_url() - api.webhook_url += hass.components.webhook.async_generate_path(api.webhook_id) + api.webhook_url += hass.components.webhook.async_generate_path( + api.webhook_id + ) - force = (webhook_mode == CONF_OPTION_FORCE) + force = webhook_mode == CONF_OPTION_FORCE config = api.config for event in MANAGED_EVENTS: - hookcommand = f"curl%20-d%20'event={event}'%20" \ - "-d%20'camera_id=%t'%20" \ - "-d%20'event_id=%v'%20" \ - "-d%20'filename=%f'%20" \ - "-d%20'filetype=%n'%20" \ - f"{api.webhook_url}" + hookcommand = ( + f"curl%20-d%20'event={event}'%20" + "-d%20'camera_id=%t'%20" + "-d%20'event_id=%v'%20" + "-d%20'filename=%f'%20" + "-d%20'filetype=%n'%20" + f"{api.webhook_url}" + ) command = config.get(event) if (command != hookcommand) and (force or (command is None)): @@ -116,29 +138,36 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): except Exception as exception: LOGGER.exception("exception (%s) setting up webhook", str(exception)) if api.webhook_id: - hass.components.webhook.async_unregister(api.webhook_id) # this is actually 'safe' + hass.components.webhook.async_unregister( + api.webhook_id + ) # this is actually 'safe' api.webhook_id = None api.webhook_url = None - # setup media_source entry to access server recordings if they're local if data.get(CONF_MEDIASOURCE): try: media_dir_id = f"{DOMAIN}_{api.unique_id}" if media_dir_id not in hass.config.media_dirs: - target_dir = api.config.get(cs.TARGET_DIR) + target_dir: str | None = api.config.get(cs.TARGET_DIR) # type: ignore if target_dir: raise_if_invalid_path(target_dir) if os.access(target_dir, os.R_OK): hass.config.media_dirs[media_dir_id] = target_dir - LOGGER.info("Registered media_dirs[%s] for motion server target_dir", media_dir_id) + LOGGER.info( + "Registered media_dirs[%s] for motion server target_dir", + media_dir_id, + ) api.media_dir_id = media_dir_id else: - LOGGER.error("Missing read access for target recordings directory") + LOGGER.error( + "Missing read access for target recordings directory" + ) except Exception as err: - LOGGER.exception("exception (%s) setting up media_source directory", str(err)) - + LOGGER.exception( + "exception (%s) setting up media_source directory", str(err) + ) for p in PLATFORMS: hass.async_create_task( @@ -149,7 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): - unload_ok = all( await asyncio.gather( *[ @@ -167,7 +195,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): hass.components.webhook.async_unregister(api.webhook_id) api.webhook_id = None if api.media_dir_id: - try:# better be safe... + try: # better be safe... hass.config.media_dirs.pop(api.media_dir_id, None) except: pass @@ -176,27 +204,36 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): api.unsub_entry_update_listener = None hassdata.pop(config_entry.entry_id) - return unload_ok - class MotionFrontendApi(MotionHttpClient): - def __init__(self, hass: HomeAssistant, data: dict): + cameras: dict[str, MotionFrontendCamera] + + def __init__( + self, hass: HomeAssistant, data: types.MappingProxyType[str, typing.Any] + ): self.hass = hass self.config_data = data - self.webhook_id: str = None - self.webhook_url: str = None - self.media_dir_id: str = None - self.unsub_entry_update_listener = None - self.alarm_control_panel = None - MotionHttpClient.__init__(self, data[CONF_HOST], data[CONF_PORT], - username=data.get(CONF_USERNAME), password=data.get(CONF_PASSWORD), - tlsmode=MAP_TLS_MODE.get(data.get(CONF_TLS_MODE, CONF_OPTION_AUTO), TlsMode.AUTO), - session=async_get_clientsession(hass), - logger=LOGGER, - camera_factory=_entity_camera_factory) + self.webhook_id: str | None = None + self.webhook_url: str | None = None + self.media_dir_id: str | None = None + self.unsub_entry_update_listener: CALLBACK_TYPE | None = None + self.alarm_control_panel: MotionFrontendAlarmControlPanel | None = None + MotionHttpClient.__init__( + self, + data[CONF_HOST], + data[CONF_PORT], + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + tlsmode=MAP_TLS_MODE.get( + data.get(CONF_TLS_MODE, CONF_OPTION_AUTO), TlsMode.AUTO + ), + session=async_get_clientsession(hass), + logger=LOGGER, + camera_factory=_entity_camera_factory, # type: ignore + ) @property def device_info(self): @@ -207,37 +244,44 @@ def device_info(self): "sw_version": self.version, } - - async def async_handle_webhook(self, hass: HomeAssistant, webhook_id: str, request: aiohttp.web.Request): + async def async_handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: aiohttp.web.Request + ): try: - with async_timeout.timeout(5): + async with asyncio.timeout(5): data = dict(await request.post()) LOGGER.debug("Received webhook - (%s)", data) - camera = self.getcamera(data.get("camera_id")) + camera = typing.cast(MotionFrontendCamera, self.getcamera(str(data["camera_id"]))) if self.media_dir_id: - try:# fix the path as a media_source compatible url + try: # fix the path as a media_source compatible url filename = data.get(EXTRA_ATTR_FILENAME) if filename: - filename = Path(filename).relative_to(self.config.get(cs.TARGET_DIR)) - data[EXTRA_ATTR_FILENAME] = f"{self.media_dir_id}/{str(filename)}" + filename = Path(str(filename)).relative_to( + str(self.config[cs.TARGET_DIR]) + ) + data[ + EXTRA_ATTR_FILENAME + ] = f"{self.media_dir_id}/{str(filename)}" except: pass camera.handle_event(data) - except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: - LOGGER.error("async_handle_webhook - exception (%s)", error) - - return - + except Exception as exception: + LOGGER.error( + "async_handle_webhook - %s(%s)", + exception.__class__.__name__, + str(exception), + ) @callback - async def entry_update_listener(self, hass: HomeAssistant, config_entry: ConfigEntry): + async def entry_update_listener( + self, hass: HomeAssistant, config_entry: ConfigEntry + ): await hass.config_entries.async_reload(config_entry.entry_id) return - def notify_state_changed(self, camera: MotionFrontendCamera): """ called by cameras to synchronously update alarm panel @@ -245,5 +289,6 @@ def notify_state_changed(self, camera: MotionFrontendCamera): if self.alarm_control_panel: self.alarm_control_panel.notify_state_changed(camera) -def _entity_camera_factory(client: MotionHttpClient, id: str): + +def _entity_camera_factory(client: MotionFrontendApi, id: str): return MotionFrontendCamera(client, id) diff --git a/custom_components/motion_frontend/_media_source.py b/custom_components/motion_frontend/_media_source.py index 3b55aaa..2b79a57 100644 --- a/custom_components/motion_frontend/_media_source.py +++ b/custom_components/motion_frontend/_media_source.py @@ -22,10 +22,6 @@ import mimetypes from pathlib import Path -#from aiohttp import web -#from homeassistant.components.http import HomeAssistantView - - from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_VIDEO, @@ -33,8 +29,8 @@ ) from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source.const import ( + MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, - MEDIA_CLASS_MAP ) from homeassistant.components.media_source.error import MediaSourceError, Unresolvable from homeassistant.components.media_source.models import ( @@ -46,9 +42,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import raise_if_invalid_path -from .const import ( - DOMAIN -) +from .const import DOMAIN + +#from aiohttp import web +#from homeassistant.components.http import HomeAssistantView + + + async def async_get_media_source(hass: HomeAssistant): """Set up Netatmo media source.""" diff --git a/custom_components/motion_frontend/alarm_control_panel.py b/custom_components/motion_frontend/alarm_control_panel.py index 49585c8..4b427c1 100644 --- a/custom_components/motion_frontend/alarm_control_panel.py +++ b/custom_components/motion_frontend/alarm_control_panel.py @@ -1,51 +1,50 @@ """Support for Motion daemon DVR Alarm Control Panels.""" -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntity, - FORMAT_TEXT, FORMAT_NUMBER -) +from __future__ import annotations + +import typing + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_NIGHT + AlarmControlPanelEntityFeature, + CodeFormat, ) from homeassistant.const import ( CONF_PIN, - STATE_ALARM_DISARMED, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, - STATE_PAUSED, STATE_PROBLEM, + STATE_PAUSED, + STATE_PROBLEM, ) -from .motionclient import MotionHttpClient from .camera import MotionFrontendCamera -from .helpers import LOGGER from .const import ( - DOMAIN, - CONF_OPTION_ALARM, - CONF_ALARM_DISARMHOME_CAMERAS, CONF_ALARM_DISARMAWAY_CAMERAS, - CONF_ALARM_DISARMNIGHT_CAMERAS, CONF_ALARM_DISARMBYPASS_CAMERAS, + CONF_ALARM_DISARMHOME_CAMERAS, + CONF_ALARM_DISARMNIGHT_CAMERAS, CONF_ALARM_PAUSE_DISARMED, - EXTRA_ATTR_LAST_TRIGGERED, EXTRA_ATTR_LAST_PROBLEM, + CONF_OPTION_ALARM, + DOMAIN, + EXTRA_ATTR_LAST_PROBLEM, + EXTRA_ATTR_LAST_TRIGGERED, ) +from .helpers import LOGGER +if typing.TYPE_CHECKING: + from . import MotionFrontendApi -#Mock MotionFrontendApi -class MotionFrontendApi(MotionHttpClient): - pass async def async_setup_entry(hass, config_entry, async_add_entities): - async_add_entities( [MotionFrontendAlarmControlPanel(hass.data[DOMAIN][config_entry.entry_id])] ) class MotionFrontendAlarmControlPanel(AlarmControlPanelEntity): - def __init__(self, api: MotionFrontendApi): self._api = api self._unique_id = f"{api.unique_id}_CP" @@ -54,20 +53,32 @@ def __init__(self, api: MotionFrontendApi): self._attr_extra_state_attributes = {} self._armmode = STATE_ALARM_DISARMED data = api.config_data.get(CONF_OPTION_ALARM, {}) - self._pin: str = data.get(CONF_PIN) + self._pin: str = str(data.get(CONF_PIN)) self._pause_disarmed: bool = data.get(CONF_ALARM_PAUSE_DISARMED, False) - self._disarmhome_cameras: frozenset = frozenset(data.get(CONF_ALARM_DISARMHOME_CAMERAS, [])) - self._disarmaway_cameras: frozenset = frozenset(data.get(CONF_ALARM_DISARMAWAY_CAMERAS, [])) - self._disarmnight_cameras: frozenset = frozenset(data.get(CONF_ALARM_DISARMNIGHT_CAMERAS, [])) - self._disarmbypass_cameras: frozenset = frozenset(data.get(CONF_ALARM_DISARMBYPASS_CAMERAS, [])) - self._disarmed_cameras: frozenset = None + self._disarmhome_cameras: frozenset = frozenset( + data.get(CONF_ALARM_DISARMHOME_CAMERAS, []) + ) + self._disarmaway_cameras: frozenset = frozenset( + data.get(CONF_ALARM_DISARMAWAY_CAMERAS, []) + ) + self._disarmnight_cameras: frozenset = frozenset( + data.get(CONF_ALARM_DISARMNIGHT_CAMERAS, []) + ) + self._disarmbypass_cameras: frozenset = frozenset( + data.get(CONF_ALARM_DISARMBYPASS_CAMERAS, []) + ) + self._disarmed_cameras: frozenset = frozenset() """ The following code is a bit faulty since it depends on cameras being correctly initialized and updated at the moment of this execution """ # try to determine startup state by inspecting cameras setup - if self._pause_disarmed: # the set of disarmed cameras should match with some of our state-sets - disarmed = { camera.id for camera in self._api.cameras.values() if camera.paused } + if ( + self._pause_disarmed + ): # the set of disarmed cameras should match with some of our state-sets + disarmed = { + camera.id for camera in self._api.cameras.values() if camera.paused + } # bear in mind only the first matching state/set gets assigned # if 2 or more disarm...cameras are the same there's no way to tell the difference if disarmed == self._disarmhome_cameras: @@ -83,76 +94,67 @@ def __init__(self, api: MotionFrontendApi): self._disarmed_cameras = self._disarmbypass_cameras self._state = self._armmode = STATE_ALARM_ARMED_CUSTOM_BYPASS - @property def unique_id(self) -> str: return self._unique_id - @property def device_info(self): return self._api.device_info - @property def icon(self): return "mdi:security" - @property def assumed_state(self) -> bool: return False - @property def should_poll(self) -> bool: return True - @property def supported_features(self) -> int: - return SUPPORT_ALARM_ARM_HOME|SUPPORT_ALARM_ARM_AWAY \ - |SUPPORT_ALARM_ARM_CUSTOM_BYPASS|SUPPORT_ALARM_ARM_NIGHT - + return ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + | AlarmControlPanelEntityFeature.ARM_NIGHT + ) @property def code_format(self): if self._pin: - return FORMAT_NUMBER if self._pin.isnumeric() else FORMAT_TEXT + return CodeFormat.NUMBER if self._pin.isnumeric() else CodeFormat.TEXT return None - @property def code_arm_required(self): return self._pin is not None and len(self._pin) > 0 - @property def name(self) -> str: return self._name - @property def available(self) -> bool: return True - @property def state(self): return self._state - @property def extra_state_attributes(self): return self._attr_extra_state_attributes - async def async_update(self): """ - this polling is not necessary overall - since we're able to get notified from camera events and 'push' - state updates for this entity but sometimes when the server disconnects - we'll idle out not being able to receive any state change (not even the disconnection) + this polling is not necessary overall + since we're able to get notified from camera events and 'push' + state updates for this entity but sometimes when the server disconnects + we'll idle out not being able to receive any state change (not even the disconnection) """ """ if self.enabled:# in HA terms I guess we shouldnt get here if disabled (but better safe than sorry) @@ -168,56 +170,49 @@ async def async_update(self): async def async_added_to_hass(self) -> None: self._api.alarm_control_panel = self - async def async_will_remove_from_hass(self) -> None: self._api.alarm_control_panel = None - async def async_alarm_disarm(self, code=None): if code == self._pin: if self._pause_disarmed: for camera in self._api.cameras.values(): camera.paused = True - self._disarmed_cameras = None + self._disarmed_cameras = frozenset() self._set_armmode(STATE_ALARM_DISARMED) - async def async_alarm_arm_home(self, code=None): if code == self._pin: if self._pause_disarmed: for camera in self._api.cameras.values(): - camera.paused = (camera.id in self._disarmhome_cameras) + camera.paused = camera.id in self._disarmhome_cameras self._disarmed_cameras = self._disarmhome_cameras self._set_armmode(STATE_ALARM_ARMED_HOME) - async def async_alarm_arm_away(self, code=None): if code == self._pin: if self._pause_disarmed: for camera in self._api.cameras.values(): - camera.paused = (camera.id in self._disarmaway_cameras) + camera.paused = camera.id in self._disarmaway_cameras self._disarmed_cameras = self._disarmaway_cameras self._set_armmode(STATE_ALARM_ARMED_AWAY) - async def async_alarm_arm_night(self, code=None): if code == self._pin: if self._pause_disarmed: for camera in self._api.cameras.values(): - camera.paused = (camera.id in self._disarmnight_cameras) + camera.paused = camera.id in self._disarmnight_cameras self._disarmed_cameras = self._disarmnight_cameras self._set_armmode(STATE_ALARM_ARMED_NIGHT) - async def async_alarm_arm_custom_bypass(self, code=None): if code == self._pin: if self._pause_disarmed: for camera in self._api.cameras.values(): - camera.paused = (camera.id in self._disarmbypass_cameras) + camera.paused = camera.id in self._disarmbypass_cameras self._disarmed_cameras = self._disarmbypass_cameras self._set_armmode(STATE_ALARM_ARMED_CUSTOM_BYPASS) - def notify_state_changed(self, camera: MotionFrontendCamera): if self._armmode is STATE_ALARM_DISARMED: return @@ -226,35 +221,37 @@ def notify_state_changed(self, camera: MotionFrontendCamera): return if camera.is_triggered: - self._attr_extra_state_attributes[EXTRA_ATTR_LAST_TRIGGERED] = camera.entity_id + self._attr_extra_state_attributes[ + EXTRA_ATTR_LAST_TRIGGERED + ] = camera.entity_id self._set_state(STATE_ALARM_TRIGGERED) return if camera.state == STATE_PROBLEM: - self._attr_extra_state_attributes[EXTRA_ATTR_LAST_PROBLEM] = camera.entity_id + self._attr_extra_state_attributes[ + EXTRA_ATTR_LAST_PROBLEM + ] = camera.entity_id if self._state != STATE_ALARM_TRIGGERED: - self._set_state(STATE_PROBLEM) # report camera 'PROBLEM' to this alarm + self._set_state(STATE_PROBLEM) # report camera 'PROBLEM' to this alarm return # if not any 'rising' event then check the state of all the other problem = False - for id, camera in self._api.cameras.items(): + for _id, _camera in self._api.cameras.items(): if id in self._disarmed_cameras: continue - if camera.is_triggered: + if _camera.is_triggered: self._set_state(STATE_ALARM_TRIGGERED) break - problem |= (camera.state == STATE_PROBLEM) + problem |= (_camera.state == STATE_PROBLEM) else: self._set_state(STATE_PROBLEM if problem else self._armmode) - def _set_armmode(self, state: str) -> None: if self._armmode != state: self._armmode = state self._set_state(state) - def _set_state(self, state: str) -> None: if self._state != state: self._state = state diff --git a/custom_components/motion_frontend/camera.py b/custom_components/motion_frontend/camera.py index 8309d19..25b300b 100644 --- a/custom_components/motion_frontend/camera.py +++ b/custom_components/motion_frontend/camera.py @@ -1,96 +1,83 @@ from __future__ import annotations -from typing import Any, Mapping -import voluptuous as vol + +import asyncio from contextlib import closing from functools import partial +from typing import Any, Mapping +import typing + import aiohttp -import async_timeout -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from homeassistant.components import camera +from homeassistant import const as hac from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.typing import StateType -#from homeassistant.helpers import config_validation as cv + +# from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_web, async_get_clientsession, ) -from homeassistant.components.camera import ( - Camera, - SUPPORT_STREAM, SUPPORT_ON_OFF, - STATE_IDLE, STATE_RECORDING, STATE_STREAMING -) -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME, - STATE_PAUSED, STATE_PROBLEM, STATE_ALARM_TRIGGERED -) - -"""from homeassistant.const import ( - CONF_AUTHENTICATION, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, -)""" - - -from .motionclient import ( - TlsMode, - MotionHttpClient, MotionHttpClientError, - MotionCamera, - config_schema as cs -) +from homeassistant.helpers.typing import StateType +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import voluptuous as vol -from .helpers import LOGGER from .const import ( DOMAIN, - EXTRA_ATTR_EVENT_ID, EXTRA_ATTR_FILENAME, - EXTRA_ATTR_PAUSED, EXTRA_ATTR_TRIGGERED, EXTRA_ATTR_CONNECTED, - ON_CAMERA_FOUND, ON_CAMERA_LOST, - ON_EVENT_START, ON_EVENT_END, - ON_AREA_DETECTED, ON_MOTION_DETECTED, - ON_MOVIE_START, ON_MOVIE_END, ON_PICTURE_SAVE + EXTRA_ATTR_CONNECTED, + EXTRA_ATTR_EVENT_ID, + EXTRA_ATTR_FILENAME, + EXTRA_ATTR_PAUSED, + EXTRA_ATTR_TRIGGERED, + ON_AREA_DETECTED, + ON_CAMERA_FOUND, + ON_CAMERA_LOST, + ON_EVENT_END, + ON_EVENT_START, + ON_MOTION_DETECTED, + ON_MOVIE_END, + ON_MOVIE_START, + ON_PICTURE_SAVE, +) +from .helpers import LOGGER +from .motionclient import ( + MotionCamera, + TlsMode, + config_schema as cs, ) +if typing.TYPE_CHECKING: + from . import MotionFrontendApi SERVICE_KEY_PARAM = "param" SERVICE_KEY_VALUE = "value" CAMERA_SERVICES = ( - ("config_set", { - vol.Required(SERVICE_KEY_PARAM): str, - vol.Required(SERVICE_KEY_VALUE): str - }, - "async_config_set" - ), - ("makemovie", { - }, - "async_makemovie" - ), - ("snapshot", { - }, - "async_snapshot" + ( + "config_set", + vol.Schema({ + vol.Required(SERVICE_KEY_PARAM): str, + vol.Required(SERVICE_KEY_VALUE): str, + }), + "async_config_set", ), + ("makemovie", vol.Schema({}), "async_makemovie"), + ("snapshot", vol.Schema({}), "async_snapshot"), ) - async def async_setup_entry(hass, config_entry, async_add_entities): - api = hass.data[DOMAIN][config_entry.entry_id] async_add_entities(api.cameras.values()) - platform = entity_platform.current_platform.get() - for service, schema, method in CAMERA_SERVICES: - platform.async_register_entity_service(service, schema, method) + if platform := entity_platform.current_platform.get(): + for service, schema, method in CAMERA_SERVICES: + platform.async_register_entity_service(service, schema, method) async def async_unload_entry(hass: HomeAssistant, config_entry): - - if len(hass.data[DOMAIN]) == 1: # last config_entry for DOMAIN + if len(hass.data[DOMAIN]) == 1: # last config_entry for DOMAIN for service_entry in CAMERA_SERVICES: hass.services.async_remove(DOMAIN, service_entry[0]) @@ -107,89 +94,71 @@ def _extract_image_from_mjpeg(stream): return data[jpg_start : jpg_end + 2] +class MotionFrontendCamera(camera.Camera, MotionCamera): + client: MotionFrontendApi -class MotionFrontendCamera(Camera, MotionCamera): - def __init__(self, client: MotionHttpClient, id: str): + def __init__(self, client: MotionFrontendApi, id: str): MotionCamera.__init__(self, client, id) self._unique_id = f"{client.unique_id}_{self.camera_id}" self._recording = False self._triggered = False - self._state = STATE_PROBLEM if not self.connected else STATE_PAUSED if self.paused else STATE_IDLE + self._state = ( + hac.STATE_PROBLEM + if not self.connected + else hac.STATE_PAUSED + if self.paused + else camera.STATE_IDLE + ) self._attr_extra_state_attributes = {} - self._camera_image = None # cached copy + self._camera_image = None # cached copy self._available = True - Camera.__init__(self) - + camera.Camera.__init__(self) @property def unique_id(self) -> str: return self._unique_id - @property def device_info(self): return self.client.device_info - - @property - def supported_features(self) -> int: - return 0 - - @property def assumed_state(self) -> bool: return False - @property - def should_poll(self) -> bool: + def should_poll(self): return False - @property def icon(self): return "mdi:camcorder" - @property - def name(self) -> str: + def name(self): return self.config.get(cs.CAMERA_NAME, self.camera_id) - @property - def available(self) -> bool: + def available(self): return self.client.is_available and self._available - @property def state(self) -> StateType: return self._state - @property def extra_state_attributes(self) -> Mapping[str, Any]: return self._attr_extra_state_attributes - @property def is_recording(self): return self._recording - @property def motion_detection_enabled(self): return not self.paused - - async def async_added_to_hass(self) -> None: - return - - - async def async_will_remove_from_hass(self) -> None: - return - - # override async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -212,16 +181,17 @@ async def async_camera_image( return self._camera_image websession = async_get_clientsession( - self.hass, - verify_ssl=(self.client.tlsmode == TlsMode.STRICT) - ) + self.hass, verify_ssl=(self.client.tlsmode == TlsMode.STRICT) + ) if stream_auth_method == cs.AUTH_MODE_BASIC: stream_authentication = self.stream_authentication - auth = aiohttp.BasicAuth(stream_authentication[0], stream_authentication[-1]) + auth = aiohttp.BasicAuth( + stream_authentication[0], stream_authentication[-1] + ) else: auth = None - with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await websession.get(image_url, auth=auth) self._camera_image = await response.read() if not self._available: @@ -229,42 +199,46 @@ async def async_camera_image( return self._camera_image except Exception as exception: - LOGGER.warning("Error (%s) fetching image from %s", str(exception), self.name) + LOGGER.warning( + "Error (%s) fetching image from %s", str(exception), self.name + ) self._set_state(None) return self._camera_image - def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """ - Return a still image response from the camera. - This is called whenever auth is like DIGEST or - we don't have a real jpeg endpoint and we need - to parse the MJPEG stream + Return a still image response from the camera. + This is called whenever auth is like DIGEST or + we don't have a real jpeg endpoint and we need + to parse the MJPEG stream """ stream_auth_method = self.config.get(cs.STREAM_AUTH_METHOD) auth = None if stream_auth_method: stream_authentication = self.stream_authentication if stream_auth_method == cs.AUTH_MODE_BASIC: - auth = HTTPBasicAuth(stream_authentication[0], stream_authentication[-1]) + auth = HTTPBasicAuth( + stream_authentication[0], stream_authentication[-1] + ) elif stream_auth_method == cs.AUTH_MODE_DIGEST: - auth = HTTPDigestAuth(stream_authentication[0], stream_authentication[-1]) + auth = HTTPDigestAuth( + stream_authentication[0], stream_authentication[-1] + ) req = requests.get( - self.stream_url, - auth=auth, - stream=True, - timeout=10, - verify=(self.client.tlsmode == TlsMode.STRICT) - ) + self.stream_url, + auth=auth, + stream=True, + timeout=10, + verify=(self.client.tlsmode == TlsMode.STRICT), + ) with closing(req) as response: return _extract_image_from_mjpeg(response.iter_content(102400)) - async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" # aiohttp don't support DigestAuth -> Fallback @@ -274,10 +248,14 @@ async def handle_async_mjpeg_stream(self, request): return await super().handle_async_mjpeg_stream(request) elif stream_auth_method == cs.AUTH_MODE_BASIC: stream_authentication = self.stream_authentication - auth = aiohttp.BasicAuth(stream_authentication[0], stream_authentication[-1]) + auth = aiohttp.BasicAuth( + stream_authentication[0], stream_authentication[-1] + ) # connect to stream - websession = async_get_clientsession(self.hass, verify_ssl=(self.client.tlsmode == TlsMode.STRICT)) + websession = async_get_clientsession( + self.hass, verify_ssl=(self.client.tlsmode == TlsMode.STRICT) + ) stream_coro = websession.get(self.stream_url, auth=auth) return await async_aiohttp_proxy_web(self.hass, request, stream_coro) @@ -293,12 +271,10 @@ async def async_config_set(self, param: str, value: str, persist: bool) -> None: async def async_enable_motion_detection(self): await self.client.async_detection_start(self._id) - # inherited from camera platform service call async def async_disable_motion_detection(self): await self.client.async_detection_pause(self._id) - def handle_event(self, data: dict) -> None: event_id = data.get(EXTRA_ATTR_EVENT_ID) if event_id: @@ -322,35 +298,31 @@ def handle_event(self, data: dict) -> None: self._setconnected(False) return - def _setrecording(self, recording: bool): if self._recording != recording: self._recording = recording self._updatestate() - @property def is_triggered(self): return self._triggered + def _settriggered(self, triggered: bool): if self._triggered != triggered: self._triggered = triggered self._attr_extra_state_attributes[EXTRA_ATTR_TRIGGERED] = triggered self._updatestate() - - #override MotionCamera + # override MotionCamera def on_connected_changed(self): self._attr_extra_state_attributes[EXTRA_ATTR_CONNECTED] = self.connected self._updatestate() - - #override MotionCamera + # override MotionCamera def on_paused_changed(self): self._attr_extra_state_attributes[EXTRA_ATTR_PAUSED] = self.paused self._updatestate() - def _updatestate(self): """ called every time an underlying state related property changes @@ -358,21 +330,20 @@ def _updatestate(self): """ if self.connected: if self._recording: - self._set_state(STATE_RECORDING) + self._set_state(camera.STATE_RECORDING) elif self._triggered: - self._set_state(STATE_ALARM_TRIGGERED) + self._set_state(hac.STATE_ALARM_TRIGGERED) elif self.paused: - self._set_state(STATE_PAUSED) + self._set_state(hac.STATE_PAUSED) else: - self._set_state(STATE_IDLE) + self._set_state(camera.STATE_IDLE) else: - self._set_state(STATE_PROBLEM) + self._set_state(hac.STATE_PROBLEM) # we'll notify our api here since HA state could have not changed # but some inner property has self.client.notify_state_changed(self) - - def _set_state(self, state: str) -> None: + def _set_state(self, state: str | None) -> None: # we'll get here since an underlying state changed # we always save to HA since even tho _state has not changed # something might have in attributes diff --git a/custom_components/motion_frontend/config_flow.py b/custom_components/motion_frontend/config_flow.py index 61538a5..a91fd6d 100644 --- a/custom_components/motion_frontend/config_flow.py +++ b/custom_components/motion_frontend/config_flow.py @@ -1,41 +1,52 @@ """Config flow to configure Agent devices.""" -from types import MappingProxyType -from typing import MappingView -from custom_components.motion_frontend.motionclient.config_schema import Descriptor, Param, SECTION_DATABASE, SECTION_MMALCAM, SECTION_TRACK, SECTION_V4L2 -import voluptuous as vol +from __future__ import annotations from homeassistant.config_entries import ( CONN_CLASS_LOCAL_PUSH, - ConfigEntry, ConfigFlow, OptionsFlow + ConfigEntry, + ConfigFlow, + OptionsFlow, ) from homeassistant.const import ( - CONF_HOST, CONF_PORT, - CONF_USERNAME, CONF_PASSWORD, - CONF_PIN, CONF_ARMING_TIME, CONF_DELAY_TIME + CONF_ARMING_TIME, + CONF_DELAY_TIME, + CONF_HOST, + CONF_PASSWORD, + CONF_PIN, + CONF_PORT, + CONF_USERNAME, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +import voluptuous as vol -from .motionclient import ( - MotionHttpClient, - MotionHttpClientConnectionError, MotionHttpClientError, - config_schema as cs -) - -from .helpers import ( - LOGGER -) from .const import ( - DOMAIN, CONF_PORT_DEFAULT, - CONF_OPTION_NONE, CONF_OPTION_UNKNOWN, - CONF_OPTION_CONNECTION, CONF_OPTION_ALARM, - CONF_TLS_MODE, CONF_TLS_MODE_OPTIONS, MAP_TLS_MODE, - CONF_WEBHOOK_MODE, CONF_WEBHOOK_MODE_OPTIONS, - CONF_WEBHOOK_ADDRESS, CONF_WEBHOOK_ADDRESS_OPTIONS, - CONF_MEDIASOURCE, - CONF_ALARM_DISARMHOME_CAMERAS, CONF_ALARM_DISARMAWAY_CAMERAS, - CONF_ALARM_DISARMNIGHT_CAMERAS, CONF_ALARM_DISARMBYPASS_CAMERAS, + CONF_ALARM_DISARMAWAY_CAMERAS, + CONF_ALARM_DISARMBYPASS_CAMERAS, + CONF_ALARM_DISARMHOME_CAMERAS, + CONF_ALARM_DISARMNIGHT_CAMERAS, CONF_ALARM_PAUSE_DISARMED, + CONF_MEDIASOURCE, + CONF_OPTION_ALARM, + CONF_OPTION_CONNECTION, + CONF_OPTION_NONE, + CONF_OPTION_UNKNOWN, + CONF_PORT_DEFAULT, + CONF_TLS_MODE, + CONF_TLS_MODE_OPTIONS, + CONF_WEBHOOK_ADDRESS, + CONF_WEBHOOK_ADDRESS_OPTIONS, + CONF_WEBHOOK_MODE, + CONF_WEBHOOK_MODE_OPTIONS, + DOMAIN, + MAP_TLS_MODE, +) +from .helpers import LOGGER +from .motionclient import ( + MotionHttpClient, + MotionHttpClientConnectionError, + MotionHttpClientError, + config_schema as cs, ) # OptionsFlow: async_step_init @@ -65,9 +76,10 @@ cs.SECTION_MMALCAM: "Raspberry PI cameras", cs.SECTION_DATABASE: "Database", cs.SECTION_TRACK: "Tracking", - CONF_OPTION_UNKNOWN: "Miscellaneous" # add an entry to setup all of the config params we didn't normalize + CONF_OPTION_UNKNOWN: "Miscellaneous", # add an entry to setup all of the config params we didn't normalize } + def _map_motion_cs_validator(descriptor: cs.Descriptor): """ We're defining schemas in motionclient library bu validators there @@ -79,10 +91,13 @@ def _map_motion_cs_validator(descriptor: cs.Descriptor): val = descriptor.validator if val is cs.validate_userpass: return str - if isinstance(val, vol.Range):# HA frontend doesnt recognize range of int really well + if isinstance( + val, vol.Range + ): # HA frontend doesnt recognize range of int really well return int return val or str + # link ref to documentation (set of different tags among versions) SECTION_CONFIG_REF_MAP = { cs.SECTION_SYSTEM: ("OptDetail_System_Processing", "Options_System_Processing"), @@ -97,12 +112,13 @@ def _map_motion_cs_validator(descriptor: cs.Descriptor): cs.SECTION_MOVIE: ("OptDetail_Movies", "Options_Movies"), cs.SECTION_TIMELAPSE: ("OptDetail_Movies", "Options_Movies"), cs.SECTION_DATABASE: ("OptDetail_Database", "Options_Database"), - cs.SECTION_TRACK: ("OptDetail_Tracking", "Options_Tracking") + cs.SECTION_TRACK: ("OptDetail_Tracking", "Options_Tracking"), } + def _get_config_section_url(version: str, section: str) -> str: if not version: - version = "3.4.1" # last known older ver + version = "3.4.1" # last known older ver if version.startswith("4.2"): page = "motion_config.html" refver = 0 @@ -114,31 +130,31 @@ def _get_config_section_url(version: str, section: str) -> str: return f"https://motion-project.github.io/{version}/{page}#{href}" - class MotionFlowHandler(ConfigFlow, domain=DOMAIN): - CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH VERSION = 1 - + MINOR_VERSION = 1 @staticmethod def async_get_options_flow(config_entry): return OptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): - errors = {} - client: MotionHttpClient = None + client: MotionHttpClient | None = None if user_input is not None: - - client = MotionHttpClient(user_input[CONF_HOST], user_input[CONF_PORT], - username=user_input.get(CONF_USERNAME), password= user_input.get(CONF_PASSWORD), - tlsmode=MAP_TLS_MODE[user_input.get(CONF_TLS_MODE, CONF_TLS_MODE_OPTIONS[0])], + client = MotionHttpClient( + user_input[CONF_HOST], + user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tlsmode=MAP_TLS_MODE[ + user_input.get(CONF_TLS_MODE, CONF_TLS_MODE_OPTIONS[0]) + ], session=async_get_clientsession(self.hass), - logger=LOGGER - ) + logger=LOGGER, + ) try: await client.update() @@ -155,57 +171,71 @@ async def async_step_user(self, user_input=None): self._abort_if_unique_id_configured() return self.async_create_entry(title=client.unique_id, data=user_input) - schema = { - vol.Required(CONF_HOST, default="localhost", - description={"suggested_value": client.host} if client else None) - : str, - vol.Required(CONF_PORT, default=CONF_PORT_DEFAULT, - description={"suggested_value": client.port} if client else None) - : int, - vol.Optional(CONF_USERNAME, - description={"suggested_value": client.username} if client else None) - : str, - vol.Optional(CONF_PASSWORD, - description={"suggested_value": client.password} if client else None) - : str, - vol.Required(CONF_TLS_MODE, default=CONF_TLS_MODE_OPTIONS[0], - description={"suggested_value": user_input.get(CONF_TLS_MODE)} if user_input else CONF_TLS_MODE_OPTIONS[0]) - : vol.In(CONF_TLS_MODE_OPTIONS), - vol.Optional(CONF_WEBHOOK_MODE, default=CONF_WEBHOOK_MODE_OPTIONS[0], - description={"suggested_value": user_input.get(CONF_WEBHOOK_MODE)} if user_input else CONF_WEBHOOK_MODE_OPTIONS[0]) - : vol.In(CONF_WEBHOOK_MODE_OPTIONS), - vol.Optional(CONF_WEBHOOK_ADDRESS, default=CONF_WEBHOOK_ADDRESS_OPTIONS[0], - description={"suggested_value": user_input.get(CONF_WEBHOOK_ADDRESS)} if user_input else CONF_WEBHOOK_ADDRESS_OPTIONS[0]) - : vol.In(CONF_WEBHOOK_ADDRESS_OPTIONS), - vol.Optional(CONF_MEDIASOURCE, - description={"suggested_value": user_input.get(CONF_MEDIASOURCE)} if user_input else True) - : bool + vol.Required( + CONF_HOST, + default="localhost", # type: ignore + description={"suggested_value": client.host} if client else None, + ): str, + vol.Required( + CONF_PORT, + default=CONF_PORT_DEFAULT, # type: ignore + description={"suggested_value": client.port} if client else None, + ): int, + vol.Optional( + CONF_USERNAME, + description={"suggested_value": client.username} if client else None, + ): str, + vol.Optional( + CONF_PASSWORD, + description={"suggested_value": client.password} if client else None, + ): str, + vol.Required( + CONF_TLS_MODE, + default=CONF_TLS_MODE_OPTIONS[0], # type: ignore + description={"suggested_value": user_input.get(CONF_TLS_MODE)} + if user_input + else CONF_TLS_MODE_OPTIONS[0], + ): vol.In(CONF_TLS_MODE_OPTIONS), + vol.Optional( + CONF_WEBHOOK_MODE, + default=CONF_WEBHOOK_MODE_OPTIONS[0], # type: ignore + description={"suggested_value": user_input.get(CONF_WEBHOOK_MODE)} + if user_input + else CONF_WEBHOOK_MODE_OPTIONS[0], + ): vol.In(CONF_WEBHOOK_MODE_OPTIONS), + vol.Optional( + CONF_WEBHOOK_ADDRESS, + default=CONF_WEBHOOK_ADDRESS_OPTIONS[0], # type: ignore + description={"suggested_value": user_input.get(CONF_WEBHOOK_ADDRESS)} + if user_input + else CONF_WEBHOOK_ADDRESS_OPTIONS[0], + ): vol.In(CONF_WEBHOOK_ADDRESS_OPTIONS), + vol.Optional( + CONF_MEDIASOURCE, + description={"suggested_value": user_input.get(CONF_MEDIASOURCE)} + if user_input + else True, + ): bool, } return self.async_show_form( - step_id="user", - data_schema=vol.Schema(schema), - errors=errors + step_id="user", data_schema=vol.Schema(schema), errors=errors ) - class OptionsFlowHandler(OptionsFlow): - def __init__(self, config_entry: ConfigEntry): - self._config_entry: ConfigEntry = config_entry - self._data: dict = dict(config_entry.data) + self._config_entry = config_entry + self._data = dict(config_entry.data) - self._api: MotionHttpClient = None # init later since we don't have hass - self._config_set = {} # the actual config(s) of motion cameras - - self._config_id = None # camera_id under configuration (async_step_config) - self._config_section = None + self._api: MotionHttpClient | None = None # init later since we don't have hass + self._config_set = {} # the actual config(s) of motion cameras + self._config_id = "" # camera_id under configuration (async_step_config) + self._config_section = "" async def async_step_init(self, user_input=None): - if user_input is not None: selected = user_input.get(CONF_SELECT_FLOW) if selected == CONF_OPTION_CONNECTION: @@ -220,8 +250,10 @@ async def async_step_init(self, user_input=None): else: if self._api: await self._api.sync_config() - self.hass.config_entries.async_update_entry(self._config_entry, data=self._data) - return self.async_create_entry(title="", data=None) + self.hass.config_entries.async_update_entry( + self._config_entry, data=self._data + ) + return self.async_create_entry(title="", data=None) # type: ignore # cache here since we'll often use this if self._api is None: @@ -229,33 +261,43 @@ async def async_step_init(self, user_input=None): # entry could be not loaded!! (disabled or awaiting initial connection) if self._api: self._config_set = { - _id: "Global motion.conf" if _id == cs.GLOBAL_ID else config.get(cs.CAMERA_NAME, _id) + _id: "Global motion.conf" + if _id == cs.GLOBAL_ID + else config.get(cs.CAMERA_NAME, _id) for _id, config in self._api.configs.items() - } + } options = dict(CONF_SELECT_FLOW_OPTIONS) options.update(self._config_set) return self.async_show_form( step_id="init", - data_schema=vol.Schema({ - vol.Required(CONF_SELECT_FLOW, default = CONF_OPTION_NONE): vol.In(options) - }) + data_schema=vol.Schema( + { + vol.Required( + CONF_SELECT_FLOW, + default=CONF_OPTION_NONE, # type: ignore + ): vol.In(options) + } + ), ) - async def async_step_connection(self, user_input=None): errors = {} data = self._data if user_input is not None: - - client = MotionHttpClient(data[CONF_HOST], data[CONF_PORT], - username=user_input.get(CONF_USERNAME), password= user_input.get(CONF_PASSWORD), - tlsmode=MAP_TLS_MODE[user_input.get(CONF_TLS_MODE, CONF_TLS_MODE_OPTIONS[0])], + client = MotionHttpClient( + data[CONF_HOST], + data[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tlsmode=MAP_TLS_MODE[ + user_input.get(CONF_TLS_MODE, CONF_TLS_MODE_OPTIONS[0]) + ], session=async_get_clientsession(self.hass), - logger=LOGGER - ) + logger=LOGGER, + ) try: await client.update() @@ -278,30 +320,40 @@ async def async_step_connection(self, user_input=None): return self.async_show_form( step_id="connection", - data_schema=vol.Schema({ - vol.Optional(CONF_USERNAME, - description={"suggested_value": data.get(CONF_USERNAME)}) - : str, - vol.Optional(CONF_PASSWORD, - description={"suggested_value": data.get(CONF_PASSWORD)}) - : str, - vol.Optional(CONF_TLS_MODE, default=CONF_TLS_MODE_OPTIONS[0], - description={"suggested_value": data.get(CONF_TLS_MODE)}) - : vol.In(CONF_TLS_MODE_OPTIONS), - vol.Optional(CONF_WEBHOOK_MODE, default=CONF_WEBHOOK_MODE_OPTIONS[0], - description={"suggested_value": data.get(CONF_WEBHOOK_MODE)}) - : vol.In(CONF_WEBHOOK_MODE_OPTIONS), - vol.Optional(CONF_WEBHOOK_ADDRESS, default=CONF_WEBHOOK_ADDRESS_OPTIONS[0], - description={"suggested_value": data.get(CONF_WEBHOOK_ADDRESS)}) - : vol.In(CONF_WEBHOOK_ADDRESS_OPTIONS), - vol.Optional(CONF_MEDIASOURCE, - description={"suggested_value": data.get(CONF_MEDIASOURCE)}) - : bool - }), - errors=errors + data_schema=vol.Schema( + { + vol.Optional( + CONF_USERNAME, + description={"suggested_value": data.get(CONF_USERNAME)}, + ): str, + vol.Optional( + CONF_PASSWORD, + description={"suggested_value": data.get(CONF_PASSWORD)}, + ): str, + vol.Optional( + CONF_TLS_MODE, + default=CONF_TLS_MODE_OPTIONS[0], # type: ignore + description={"suggested_value": data.get(CONF_TLS_MODE)}, + ): vol.In(CONF_TLS_MODE_OPTIONS), + vol.Optional( + CONF_WEBHOOK_MODE, + default=CONF_WEBHOOK_MODE_OPTIONS[0], # type: ignore + description={"suggested_value": data.get(CONF_WEBHOOK_MODE)}, + ): vol.In(CONF_WEBHOOK_MODE_OPTIONS), + vol.Optional( + CONF_WEBHOOK_ADDRESS, + default=CONF_WEBHOOK_ADDRESS_OPTIONS[0], # type: ignore + description={"suggested_value": data.get(CONF_WEBHOOK_ADDRESS)}, + ): vol.In(CONF_WEBHOOK_ADDRESS_OPTIONS), + vol.Optional( + CONF_MEDIASOURCE, + description={"suggested_value": data.get(CONF_MEDIASOURCE)}, + ): bool, + } + ), + errors=errors, ) - async def async_step_alarm(self, user_input=None): errors = {} data = self._data.get(CONF_OPTION_ALARM, {}) @@ -314,69 +366,110 @@ async def async_step_alarm(self, user_input=None): cameras = dict(self._config_set) if len(cameras): cameras.pop(cs.GLOBAL_ID, None) - else: # add what we know so far in case the api is unavailable - cameras.update({_id: _id for _id in data.get(CONF_ALARM_DISARMHOME_CAMERAS)}) - cameras.update({_id: _id for _id in data.get(CONF_ALARM_DISARMAWAY_CAMERAS)}) - cameras.update({_id: _id for _id in data.get(CONF_ALARM_DISARMNIGHT_CAMERAS)}) - cameras.update({_id: _id for _id in data.get(CONF_ALARM_DISARMBYPASS_CAMERAS)}) + else: # add what we know so far in case the api is unavailable + cameras.update( + {_id: _id for _id in data.get(CONF_ALARM_DISARMHOME_CAMERAS)} + ) + cameras.update( + {_id: _id for _id in data.get(CONF_ALARM_DISARMAWAY_CAMERAS)} + ) + cameras.update( + {_id: _id for _id in data.get(CONF_ALARM_DISARMNIGHT_CAMERAS)} + ) + cameras.update( + {_id: _id for _id in data.get(CONF_ALARM_DISARMBYPASS_CAMERAS)} + ) validate_cameras = cv.multi_select(cameras) return self.async_show_form( step_id="alarm", - data_schema=vol.Schema({ - vol.Optional(CONF_PIN, - description={"suggested_value": data.get(CONF_PIN)}) - : str, # it looks like we cannot serialize other than str, int and few other + data_schema=vol.Schema( + { + vol.Optional( + CONF_PIN, description={"suggested_value": data.get(CONF_PIN)} + ): str, # it looks like we cannot serialize other than str, int and few other # it would be nice to have a nice regex here... - vol.Optional(CONF_ALARM_DISARMHOME_CAMERAS, - description={"suggested_value": data.get(CONF_ALARM_DISARMHOME_CAMERAS)}) - : validate_cameras, - vol.Optional(CONF_ALARM_DISARMAWAY_CAMERAS, - description={"suggested_value": data.get(CONF_ALARM_DISARMAWAY_CAMERAS)}) - : validate_cameras, - vol.Optional(CONF_ALARM_DISARMNIGHT_CAMERAS, - description={"suggested_value": data.get(CONF_ALARM_DISARMNIGHT_CAMERAS)}) - : validate_cameras, - vol.Optional(CONF_ALARM_DISARMBYPASS_CAMERAS, - description={"suggested_value": data.get(CONF_ALARM_DISARMBYPASS_CAMERAS)}) - : validate_cameras, - vol.Optional(CONF_ALARM_PAUSE_DISARMED, - description={"suggested_value": data.get(CONF_ALARM_PAUSE_DISARMED)}) - : bool - }), - errors=errors + vol.Optional( + CONF_ALARM_DISARMHOME_CAMERAS, + description={ + "suggested_value": data.get(CONF_ALARM_DISARMHOME_CAMERAS) + }, + ): validate_cameras, + vol.Optional( + CONF_ALARM_DISARMAWAY_CAMERAS, + description={ + "suggested_value": data.get(CONF_ALARM_DISARMAWAY_CAMERAS) + }, + ): validate_cameras, + vol.Optional( + CONF_ALARM_DISARMNIGHT_CAMERAS, + description={ + "suggested_value": data.get(CONF_ALARM_DISARMNIGHT_CAMERAS) + }, + ): validate_cameras, + vol.Optional( + CONF_ALARM_DISARMBYPASS_CAMERAS, + description={ + "suggested_value": data.get(CONF_ALARM_DISARMBYPASS_CAMERAS) + }, + ): validate_cameras, + vol.Optional( + CONF_ALARM_PAUSE_DISARMED, + description={ + "suggested_value": data.get(CONF_ALARM_PAUSE_DISARMED) + }, + ): bool, + } + ), + errors=errors, ) - async def async_step_config(self, user_input=None): errors = {} + assert self._api + if user_input is not None: for key, value in user_input.items(): if key == CONF_SELECT_CONFIG: self._config_section = value continue try: - await self._api.async_config_set(key=key, value=value, id=self._config_id, force=False, persist=False) + await self._api.async_config_set( + key=key, + value=value, + id=self._config_id, + force=False, + persist=False, + ) except Exception as e: errors["base"] = "cannot_connect" - LOGGER.warning("Error (%s) setting motion parameter '%s'", str(e), key) + LOGGER.warning( + "Error (%s) setting motion parameter '%s'", str(e), key + ) if self._config_section == CONF_OPTION_NONE: return await self.async_step_init() # else load another schema/options to edit - config: MappingProxyType[str, cs.Param] = self._api.configs.get(self._config_id, {}) - config_section_map: MappingProxyType[str, cs.Descriptor] = cs.SECTION_SET_MAP.get(self._config_section) + config = self._api.configs.get(self._config_id, {}) + config_section_map = cs.SECTION_SET_MAP.get(self._config_section) schema = {} if config_section_map: # expose a set of known motion params normalized through config_schema - config_exclusion_set = cs.CAMERACONFIG_SET if self._config_id == cs.GLOBAL_ID else cs.GLOBALCONFIG_SET + config_exclusion_set = ( + cs.CAMERACONFIG_SET + if self._config_id == cs.GLOBAL_ID + else cs.GLOBALCONFIG_SET + ) for key, descriptor in config_section_map.items(): if (key in config) and (key not in config_exclusion_set): param = config.get(key) - schema[vol.Optional(key, description={'suggested_value': param})] = \ - _map_motion_cs_validator(descriptor if param is None else param.descriptor) + schema[ + vol.Optional(key, description={"suggested_value": param}) + ] = _map_motion_cs_validator( + descriptor if param is None else param.descriptor + ) """ schema = { vol.Optional(key, description={'suggested_value': config.get(key)}) @@ -388,10 +481,15 @@ async def async_step_config(self, user_input=None): # expose any param we didn't normalize # This isnt working fine since the frontend is not able to render an unknown description for key in config.keys(): - if (key not in cs.SCHEMA.keys()): + if key not in cs.SCHEMA.keys(): param = config.get(key) - schema[vol.Optional(key, description={'suggested_value': param})] = \ - str if param is None else _map_motion_cs_validator(param.descriptor) + schema[ + vol.Optional(key, description={"suggested_value": param}) + ] = ( + str + if param is None + else _map_motion_cs_validator(param.descriptor) + ) """ schema = { vol.Optional(key, description={'suggested_value': config.get(key)}) @@ -400,17 +498,22 @@ async def async_step_config(self, user_input=None): } """ - schema.update({ - vol.Required(CONF_SELECT_CONFIG, default = CONF_OPTION_NONE): vol.In(CONF_SELECT_CONFIG_OPTIONS) - }) + schema.update( + { + vol.Required( + CONF_SELECT_CONFIG, + default=CONF_OPTION_NONE, # type: ignore + ): vol.In(CONF_SELECT_CONFIG_OPTIONS) + } + ) return self.async_show_form( - step_id='config', + step_id="config", data_schema=vol.Schema(schema), description_placeholders={ - 'camera_id': self._config_set.get(self._config_id), - 'config_section': f"" \ - f"{CONF_SELECT_CONFIG_OPTIONS[self._config_section]}" - }, - errors=errors + "camera_id": self._config_set.get(self._config_id), + "config_section": f"" + f"{CONF_SELECT_CONFIG_OPTIONS[self._config_section]}", + }, + errors=errors, ) diff --git a/custom_components/motion_frontend/helpers.py b/custom_components/motion_frontend/helpers.py index 2274ab5..1841980 100644 --- a/custom_components/motion_frontend/helpers.py +++ b/custom_components/motion_frontend/helpers.py @@ -1,4 +1,4 @@ - +from __future__ import annotations import logging from time import time diff --git a/custom_components/motion_frontend/motionclient/__init__.py b/custom_components/motion_frontend/motionclient/__init__.py index 6ac550b..acc71b3 100644 --- a/custom_components/motion_frontend/motionclient/__init__.py +++ b/custom_components/motion_frontend/motionclient/__init__.py @@ -1,25 +1,29 @@ """An Http API Client to interact with motion server""" -import logging -from types import MappingProxyType -from typing import List, MappingView, Optional, Dict, Any, Callable, Union +from __future__ import annotations +import asyncio +from datetime import datetime from enum import Enum - +import logging import re -import aiohttp -from yarl import URL import socket -import asyncio -import async_timeout +from types import MappingProxyType +from typing import Any, Callable +import aiohttp -from datetime import datetime +from yarl import URL from . import config_schema as cs -#cs = config_schema class MotionHttpClientError(Exception): - def __init__(self, client: 'MotionHttpClient', message: Optional[str], path: Optional[str], status: Optional[int]): # pylint: disable=unsubscriptable-object + def __init__( + self, + client: MotionHttpClient, + message: str | None, + path: str | None, + status: int | None, + ): # pylint: disable=unsubscriptable-object self.message = message self.status = status self.client = client @@ -31,41 +35,54 @@ class MotionHttpClientConnectionError(MotionHttpClientError): class TlsMode(Enum): - AUTO = 0 # tries to adapt to server responses enabling e 'best effort' behaviour - NONE = 1 # no TLS at all - RELAXED = 2 # use TLS but accept any certificate - STRICT = 3 # use TLS with all the goodies (based on default aiohttp SSL policy) + AUTO = 0 # tries to adapt to server responses enabling e 'best effort' behaviour + NONE = 1 # no TLS at all + RELAXED = 2 # use TLS but accept any certificate + STRICT = 3 # use TLS with all the goodies (based on default aiohttp SSL policy) """ default MotionCamera builder: this can be overriden by passing a similar function into MotionHttpClient constructor """ -def _default_camera_factory(client: "MotionHttpClient", id: str) -> "MotionCamera": - return MotionCamera(client, id) -class MotionHttpClient: +def _default_camera_factory(client: MotionHttpClient, id: str): + return MotionCamera(client, id) - DEFAULT_TIMEOUT = 5 # use a lower than 10 timeout in order to not annoy HA update cycle - def __init__(self, host, port, - username = None, password = None, - tlsmode: TlsMode = TlsMode.AUTO, - session: aiohttp.client.ClientSession = None, - logger: logging.Logger = None, - camera_factory: Callable[["MotionHttpClient", int], "MotionCamera"] = _default_camera_factory): +class MotionHttpClient: + DEFAULT_TIMEOUT = ( + 5 # use a lower than 10 timeout in order to not annoy HA update cycle + ) + + def __init__( + self, + host, + port, + username=None, + password=None, + tlsmode: TlsMode = TlsMode.AUTO, + session: aiohttp.client.ClientSession | None = None, + logger: logging.Logger | None = None, + camera_factory: Callable[ + [MotionHttpClient, str], MotionCamera + ] = _default_camera_factory, + ): self._host = host self._port = port self._username = username self._password = password - self._auth = aiohttp.BasicAuth(login=username, password=password) if username and password else None + self._auth = ( + aiohttp.BasicAuth(login=username, password=password) + if username and password + else None + ) self._tlsmode: TlsMode = tlsmode self._session = session or aiohttp.ClientSession() self._logger = logger or logging.getLogger(__name__) self._camera_factory = camera_factory - self._server_url = None # established only on first succesful connection - self._close_session = (session is None) + self._close_session = session is None self._conFailed = False self.disconnected = datetime.now() self.reconnect_interval = 10 @@ -75,118 +92,112 @@ def __init__(self, host, port, self._ver_major = 0 self._ver_minor = 0 self._ver_build = 0 - self._feature_webhtml = False # report if the webctrl server is configured for html - self._feature_advancedstream = False # if True (from ver 4.2 on) allows more uri options and multiple streams on the same port + self._feature_webhtml = ( + False # report if the webctrl server is configured for html + ) + self._feature_advancedstream = False # if True (from ver 4.2 on) allows more uri options and multiple streams on the same port self._feature_tls = False - self._feature_globalactions = False # if True (from ver 4.2 on) we can globally start/pause detection by issuing on threadid = 0 - self._configs : Dict[str, Dict[str, config_schema.Param]] = {} - self._cameras : Dict[str, 'MotionCamera'] = {} - self._config_is_dirty = False # set when we modify a motion config param (async_config_set) + self._feature_globalactions = False # if True (from ver 4.2 on) we can globally start/pause detection by issuing on threadid = 0 + self._configs: dict[str, dict[str, cs.AnyParam]] = {} + self._cameras: dict[str, MotionCamera] = {} + self._config_is_dirty = ( + False # set when we modify a motion config param (async_config_set) + ) self._config_need_restart = set() self._requestheaders = { "User-Agent": "HomeAssistant Motion Frontend", - "Accept": "*/*" - } + "Accept": "*/*", + } self._server_url = MotionHttpClient.generate_url( self._host, self._port, - "http" if self._tlsmode in (TlsMode.AUTO, TlsMode.NONE) else "https" - ) - + "http" if self._tlsmode in (TlsMode.AUTO, TlsMode.NONE) else "https", + ) @staticmethod - def generate_url(host, port, proto = "http") -> str: + def generate_url(host, port, proto="http") -> str: return f"{proto}://{host}:{port}" - @property def host(self): return self._host + @property def port(self): return self._port + @property def tlsmode(self) -> TlsMode: return self._tlsmode + @property def username(self): return self._username + @property def password(self): return self._password - @property def is_available(self) -> bool: return not self._conFailed - @property def unique_id(self) -> str: - return f'{self._host}_{self._port}' - + return f"{self._host}_{self._port}" @property def name(self) -> str: - return f'motion@{self._host}' - + return f"motion@{self._host}" @property def description(self) -> str: return self._description - @property def version(self) -> str: return self._version - @property def config_is_dirty(self) -> bool: return self._config_is_dirty - @property def server_url(self) -> str: return self._server_url - @property def stream_url(self) -> str: return MotionHttpClient.generate_url( self._host, self.config.get(cs.STREAM_PORT, 8081), - "https" if self.config.get(cs.STREAM_TLS, False) else "http" - ) - + "https" if self.config.get(cs.STREAM_TLS, False) else "http", + ) @property - def configs(self) -> MappingProxyType[str, MappingProxyType[str, config_schema.Param]]: + def configs( + self, + ): return self._configs - @property - def config(self) -> MappingProxyType[str, config_schema.Param]: - return self._configs.get(cs.GLOBAL_ID) - + def config(self): + return self._configs.get(cs.GLOBAL_ID, {}) @property - def cameras(self) -> MappingProxyType[str, 'MotionCamera']: + def cameras(self): return self._cameras - - def getcamera(self, camera_id: str) -> 'MotionCamera': + def getcamera(self, camera_id: str) -> MotionCamera: for camera in self._cameras.values(): if camera.camera_id == camera_id: return camera - return None - + raise Exception(f"Camera with id={camera_id} not found") async def close(self) -> None: if self._session and self._close_session: await self._session.close() - async def update(self, updatecameras: bool = False): content, _ = await self.async_request("/") @@ -243,21 +254,22 @@ async def add_camera(id: str): # here we're not relying on self._version being correctly parsed # since the matching code could fail in future # I prefer to rely on a well known config param which should be more stable - self._feature_tls = (cs.WEBCONTROL_TLS in self.config) - self._feature_advancedstream = self._feature_tls # these appear at the same time ;) + self._feature_tls = cs.WEBCONTROL_TLS in self.config + self._feature_advancedstream = ( + self._feature_tls + ) # these appear at the same time ;) self._feature_globalactions = self._feature_tls - if updatecameras: # request also camera status + if updatecameras: # request also camera status await self.async_detection_status() - async def sync_config(self) -> None: """ - Checks if we have pending changes to motion config(s) - and instruct the daemon to write config to filesystem - also checks if some (or all) threads need a restart to - reload changed configs (some config param changes work - on the fly some other dont) + Checks if we have pending changes to motion config(s) + and instruct the daemon to write config to filesystem + also checks if some (or all) threads need a restart to + reload changed configs (some config param changes work + on the fly some other dont) """ if self.config_is_dirty: await self.async_config_write() @@ -267,8 +279,7 @@ async def sync_config(self) -> None: for _id in frozenset(self._config_need_restart): await self.async_action_restart(_id) - - async def async_config_list(self, id) -> Dict[str, config_schema.Param]: + async def async_config_list(self, id) -> dict[str, cs.AnyParam]: config = {} content, _ = await self.async_request(f"/{id}/config/list") if content: @@ -290,9 +301,14 @@ async def async_config_list(self, id) -> Dict[str, config_schema.Param]: return config - - async def async_config_set(self, key: str, value: Any, force: bool = False, persist: bool = False, id: str = cs.GLOBAL_ID): - + async def async_config_set( + self, + key: str, + value: Any, + force: bool = False, + persist: bool = False, + id: str = cs.GLOBAL_ID, + ): config = self._configs.get(id) if (force is False) and config and (config.get(key) == value): return @@ -300,9 +316,11 @@ async def async_config_set(self, key: str, value: Any, force: bool = False, pers newvalue = cs.build_value(key, value) await self.async_request(f"/{id}/config/set?{key}={newvalue.__str__()}") - if id == cs.GLOBAL_ID: # motion will set all threads with this same value when setting global conf + if ( + id == cs.GLOBAL_ID + ): # motion will set all threads with this same value when setting global conf for config in self._configs.values(): - if key in config: # some params are only relevant to global conf + if key in config: # some params are only relevant to global conf config[key] = newvalue else: if config: @@ -315,15 +333,13 @@ async def async_config_set(self, key: str, value: Any, force: bool = False, pers if persist: await self.async_config_write() - async def async_config_write(self) -> None: """ - Motion saves all of the configs in 1 call: no option to differentiate atm + Motion saves all of the configs in 1 call: no option to differentiate atm """ await self.async_request(f"/0/config/writeyes") self._config_is_dirty = False - async def async_action_restart(self, id: str = cs.GLOBAL_ID) -> None: await self.async_request(f"/{id}/action/restart") if id == cs.GLOBAL_ID: @@ -332,19 +348,18 @@ async def async_action_restart(self, id: str = cs.GLOBAL_ID) -> None: else: self._config_need_restart.discard(id) - async def async_detection_status(self, id: str = cs.GLOBAL_ID) -> None: """ - When we want to poll the status (even for a single camera) - we'll try to optmize and just request the full camera list states - in order to not 'strike' the server webctrl with a load of requests + When we want to poll the status (even for a single camera) + we'll try to optmize and just request the full camera list states + in order to not 'strike' the server webctrl with a load of requests - id - is an hint on which camera wants an update in order - to handle legacy webctrl which doesnt support full list query + id - is an hint on which camera wants an update in order + to handle legacy webctrl which doesnt support full list query - Note on exceptions: at the moment we're trying our best to make this run - until the end 'silencing' intermediate exceptions and just warning - this is not a critical feature so we can live without it + Note on exceptions: at the moment we're trying our best to make this run + until the end 'silencing' intermediate exceptions and just warning + this is not a critical feature so we can live without it """ try: content, _ = await self.async_request(f"/{id}/detection/connection") @@ -352,10 +367,12 @@ async def async_detection_status(self, id: str = cs.GLOBAL_ID) -> None: try: self.getcamera(match.group(1))._setconnected(match.group(2) == "OK") except Exception as exception: - self._logger.warning("exception (%s) in async_detection_status", str(exception)) + self._logger.warning( + "exception (%s) in async_detection_status", str(exception) + ) if (id != cs.GLOBAL_ID) or self._feature_globalactions: - #recover all cameras in 1 pass + # recover all cameras in 1 pass content, _ = await self.async_request(f"/{id}/detection/status") else: content: str = "" @@ -366,17 +383,20 @@ async def async_detection_status(self, id: str = cs.GLOBAL_ID) -> None: try: self.getcamera(match.group(1))._setpaused(match.group(2) == "PAUSE") except Exception as exception: - self._logger.warning("exception (%s) in async_detection_status", str(exception)) + self._logger.warning( + "exception (%s) in async_detection_status", str(exception) + ) except Exception as exception: - self._logger.info("exception (%s) in async_detection_status", str(exception)) - + self._logger.info( + "exception (%s) in async_detection_status", str(exception) + ) async def async_detection_start(self, id: str = cs.GLOBAL_ID): try: if (id != cs.GLOBAL_ID) or self._feature_globalactions: response, _ = await self.async_request(f"/{id}/detection/start") # we might get a response or not...(html mode doesnt?) - paused = False # optimistic: should be instead invoke detection_status? + paused = False # optimistic: should be instead invoke detection_status? if "paused" in response: # not sure if it happens that the command fails on motion and # we still get a response. This is a guess @@ -393,13 +413,12 @@ async def async_detection_start(self, id: str = cs.GLOBAL_ID): except Exception as exception: self._logger.warning(str(exception)) - async def async_detection_pause(self, id: str = cs.GLOBAL_ID): try: if (id != cs.GLOBAL_ID) or self._feature_globalactions: response, _ = await self.async_request(f"/{id}/detection/pause") # we might get a response or not...(html mode doesnt?) - paused = True # optimistic: should be instead invoke detection_status? + paused = True # optimistic: should be instead invoke detection_status? if "resumed" in response: # not sure if it happens that the command fails on motion and # we still get a response. This is a guess @@ -416,85 +435,98 @@ async def async_detection_pause(self, id: str = cs.GLOBAL_ID): except Exception as exception: self._logger.warning(str(exception)) - - async def async_request(self, api_url, timeout=DEFAULT_TIMEOUT) -> str: + async def async_request(self, api_url, timeout=DEFAULT_TIMEOUT): if self._conFailed: - if (datetime.now()-self.disconnected).total_seconds() < self.reconnect_interval: - raise MotionHttpClientConnectionError(self, "Connection failed. Retry in few seconds..", api_url, -1) - - def _raise(exception, message, status = -1): + if ( + datetime.now() - self.disconnected + ).total_seconds() < self.reconnect_interval: + raise MotionHttpClientConnectionError( + self, "Connection failed. Retry in few seconds..", api_url, -1 + ) + + def _raise(exception, message, status=-1): self.disconnected = datetime.now() self._conFailed = True - raise MotionHttpClientConnectionError(self, message, api_url, status) from exception + raise MotionHttpClientConnectionError( + self, message, api_url, status + ) from exception looptry = 0 while True: try: - with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): url = URL(self._server_url + api_url) response = await self._session.request( "GET", url, auth=self._auth, headers=self._requestheaders, - ssl=self._tlsmode is TlsMode.STRICT + ssl=self._tlsmode is TlsMode.STRICT, ) response.raise_for_status() + text = await response.text() + self._conFailed = False + return text, response.headers + except asyncio.TimeoutError as exception: - _raise(exception, "Timeout occurred while connecting to motion http interface") + _raise( + exception, + "Timeout occurred while connecting to motion http interface", + ) except ( aiohttp.ClientError, aiohttp.ClientResponseError, ) as exception: message = str(exception) status = -1 - if hasattr(exception, 'message'): - message = exception.message - if hasattr(message, 'code'): - status = message.code - if hasattr(message, 'reason'): - message = message.reason - if (status == -1) and (looptry == 0) and (self._tlsmode is TlsMode.AUTO): # it should be an aiohttp.ServerDisconnectedError + if hasattr(exception, "message"): + message = exception.message # type: ignore + if hasattr(message, "code"): + status = message.code # type: ignore + if hasattr(message, "reason"): + message = message.reason # type: ignore + if ( + (status == -1) + and (looptry == 0) + and (self._tlsmode is TlsMode.AUTO) + ): # it should be an aiohttp.ServerDisconnectedError if self._server_url.startswith("http:"): - self._server_url = MotionHttpClient.generate_url(self._host, self._port, "https") + self._server_url = MotionHttpClient.generate_url( + self._host, self._port, "https" + ) else: - self._server_url = MotionHttpClient.generate_url(self._host, self._port, "http") + self._server_url = MotionHttpClient.generate_url( + self._host, self._port, "http" + ) looptry = 1 - continue # dirty flow behaviour: restart the request loop + continue # dirty flow behaviour: restart the request loop _raise(exception, message, status) except ( Exception, socket.gaierror, ) as exception: - _raise(exception, "Error occurred while communicating with motion server") - - text = await response.text() - self._conFailed = False - return text, response.headers - + _raise( + exception, "Error occurred while communicating with motion server" + ) class MotionCamera: - def __init__(self, client: MotionHttpClient, id: str): - self._client : MotionHttpClient = client + self._client: MotionHttpClient = client self._id: str = id - self._connected = False # netcam connected in 'motion' terms - self._paused = False # detection paused in 'motion' terms - + self._connected = False # netcam connected in 'motion' terms + self._paused = False # detection paused in 'motion' terms @property - def client(self) -> MotionHttpClient: + def client(self): return self._client - @property - def id(self) -> str: + def id(self): return self._id - @property - def camera_id(self) -> str: + def camera_id(self): """ camera_id has different usages among motion versions: in recent webctrl it looks like camera_id is used to build url path to access cameras @@ -505,21 +537,22 @@ def camera_id(self) -> str: """ return str(self.config.get(cs.CAMERA_ID, self._id)) - @property - def connected(self) -> bool: + def connected(self): return self._connected + def _setconnected(self, connected: bool): if self._connected != connected: self._connected = connected self.on_connected_changed() - def on_connected_changed(self): - pass # stub -> override or whatever to manage notification + def on_connected_changed(self): + pass # stub -> override or whatever to manage notification @property - def paused(self) -> bool: + def paused(self): return self._paused + @paused.setter def paused(self, paused: bool): if self._paused != paused: @@ -527,37 +560,35 @@ def paused(self, paused: bool): asyncio.create_task(self._client.async_detection_pause(self._id)) else: asyncio.create_task(self._client.async_detection_start(self._id)) + def _setpaused(self, paused: bool): if self._paused != paused: self._paused = paused self.on_paused_changed() - def on_paused_changed(self): - pass # stub -> override or whatever to manage notification + def on_paused_changed(self): + pass # stub -> override or whatever to manage notification @property - def config(self) -> Dict[str, object]: - return self._client._configs.get(self._id) - + def config(self): + return self._client._configs.get(self._id, {}) @property - def config_url(self) -> str: + def config_url(self): return f"{self._client.server_url}/{self._id}" - # MJPEG live stream @property - def stream_url(self) -> str: + def stream_url(self): port = self.config.get(cs.STREAM_PORT) if port: return f"{MotionHttpClient.generate_url(self._client._host, port)}" else: return f"{self._client.stream_url}/{self._id}/stream" - # JPEG currentsnapshot @property - def image_url(self) -> str: + def image_url(self): if not self._client._feature_advancedstream: return None @@ -568,28 +599,30 @@ def image_url(self) -> str: else: return f"{self._client.stream_url}/{self._id}/current" - @property - def stream_authentication(self) -> list: + def stream_authentication(self): """ - return a valid pair of credentials for streaming purposes - Tryes to match what the motion daemon is especting since - this is usually a global conf param but you can set it per camera aswell - (at least in some releases?) + return a valid pair of credentials for streaming purposes + Tryes to match what the motion daemon is especting since + this is usually a global conf param but you can set it per camera aswell + (at least in some releases?) """ stream_authentication = self.config.get(cs.STREAM_AUTHENTICATION) if not stream_authentication: - stream_authentication = self._client.config.get(cs.STREAM_AUTHENTICATION, ":") - return stream_authentication.split(":") - - - async def async_config_set(self, key: str, value: Any, force: bool = False, persist: bool = False): - await self._client.async_config_set(key=key, value=value, force=force, persist=persist, id=self._id) + stream_authentication = self._client.config.get( + cs.STREAM_AUTHENTICATION, ":" + ) + return str(stream_authentication).split(":") + async def async_config_set( + self, key: str, value: Any, force: bool = False, persist: bool = False + ): + await self._client.async_config_set( + key=key, value=value, force=force, persist=persist, id=self._id + ) async def async_makemovie(self): await self._client.async_request(f"/{self._id}/action/makemovie") - async def async_snapshot(self): await self._client.async_request(f"/{self._id}/action/snapshot") diff --git a/custom_components/motion_frontend/motionclient/config_schema.py b/custom_components/motion_frontend/motionclient/config_schema.py index a5b8507..b43742b 100644 --- a/custom_components/motion_frontend/motionclient/config_schema.py +++ b/custom_components/motion_frontend/motionclient/config_schema.py @@ -1,57 +1,77 @@ """ Motion daemon config parameters definitions """ -from types import MappingProxyType -from typing import Any, Dict, Union +from __future__ import annotations from collections import ChainMap -import voluptuous as vol +from typing import Any, Container +import typing +import voluptuous as vol -GLOBAL_ID = '0' # url/key for global motion.conf and actions +GLOBAL_ID = "0" # url/key for global motion.conf and actions # miscellaneus values for boolean or enum param types -NULL_SET = ("(not defined)", "(null)") -VALUE_ON = 'on' -VALUE_OFF = 'off' -BOOL_SET = (VALUE_ON, VALUE_OFF) -VALUE_FORCE = 'force' -VALUE_NONE = 'none' -VALUE_V = 'v' -VALUE_H = 'h' -VALUE_PREVIEW = 'preview' -VALUE_BOX = 'box' -VALUE_REDBOX = 'redbox' -VALUE_CROSS = 'cross' -VALUE_REDCROSS = 'redcross' -VALUE_FIRST = 'first' -VALUE_BEST = 'best' -VALUE_COR = 'COR' -VALUE_STR = 'STR' -VALUE_ENC = 'ENC' -VALUE_NET = 'NET' -VALUE_DBL = 'DBL' -VALUE_EVT = 'EVT' -VALUE_TRK = 'TRK' -VALUE_VID = 'VID' -VALUE_ALL = 'ALL' -LOG_TYPE_SET = ( - VALUE_COR, VALUE_STR, VALUE_ENC, VALUE_NET, - VALUE_DBL, VALUE_EVT, VALUE_TRK, VALUE_VID, VALUE_ALL) -MOVIE_CODEC_SET = ( - 'mpeg4', 'msmpeg4', 'swf', 'flv', 'ffv1', 'mov', 'mp4', 'mkv', 'hevc' -) -TIMELAPSE_MODE_SET = ( - 'hourly', 'daily', 'weekly-sunday', 'weekly-monday', 'monthly', 'manual' -) -TIMELAPSE_CODEC_SET = ( - 'mpg', 'mpeg4' -) -PICTURE_TYPE_SET = ( "jpeg", "webp", "ppm") +NULL_SET = {"(not defined)", "(null)"} +VALUE_ON = "on" +VALUE_OFF = "off" +BOOL_SET = {VALUE_ON, VALUE_OFF} +VALUE_FORCE = "force" +VALUE_NONE = "none" +VALUE_V = "v" +VALUE_H = "h" +VALUE_PREVIEW = "preview" +VALUE_BOX = "box" +VALUE_REDBOX = "redbox" +VALUE_CROSS = "cross" +VALUE_REDCROSS = "redcross" +VALUE_FIRST = "first" +VALUE_BEST = "best" +VALUE_COR = "COR" +VALUE_STR = "STR" +VALUE_ENC = "ENC" +VALUE_NET = "NET" +VALUE_DBL = "DBL" +VALUE_EVT = "EVT" +VALUE_TRK = "TRK" +VALUE_VID = "VID" +VALUE_ALL = "ALL" +LOG_TYPE_SET = { + VALUE_COR, + VALUE_STR, + VALUE_ENC, + VALUE_NET, + VALUE_DBL, + VALUE_EVT, + VALUE_TRK, + VALUE_VID, + VALUE_ALL, +} +MOVIE_CODEC_SET = { + "mpeg4", + "msmpeg4", + "swf", + "flv", + "ffv1", + "mov", + "mp4", + "mkv", + "hevc", +} +TIMELAPSE_MODE_SET = { + "hourly", + "daily", + "weekly-sunday", + "weekly-monday", + "monthly", + "manual", +} +TIMELAPSE_CODEC_SET = {"mpg", "mpeg4"} +PICTURE_TYPE_SET = {"jpeg", "webp", "ppm"} AUTH_MODE_NONE = 0 AUTH_MODE_BASIC = 1 AUTH_MODE_DIGEST = 2 -AUTH_MODE_SET = (AUTH_MODE_NONE, AUTH_MODE_BASIC, AUTH_MODE_DIGEST) +AUTH_MODE_SET = {AUTH_MODE_NONE, AUTH_MODE_BASIC, AUTH_MODE_DIGEST} # @@ -61,22 +81,24 @@ def validate_userpass(value): if value is None: return value - if ':' in value: + if ":" in value: return value raise vol.ValueInvalid("Missing ':' separator (username:password)") + # # TYPED MOTION PARAMETERS # class Param(str): """ - base class for parameter values - exposes parameter descriptor and validator + base class for parameter values + exposes parameter descriptor and validator """ - descriptor: 'Descriptor' - def __new__(cls, value: str, descriptor: 'Descriptor'): + descriptor: "Descriptor" + + def __new__(cls, value: str, descriptor: "Descriptor"): _self = str.__new__(cls, value) _self.descriptor = descriptor return _self @@ -88,9 +110,10 @@ def validator(self): class UpperStringParam(Param): """ - represent motion enumerations (uppercase enums) + represent motion enumerations (uppercase enums) """ - def __new__(cls, value: str, descriptor: 'Descriptor'): + + def __new__(cls, value: str, descriptor: "Descriptor"): return super().__new__(cls, value.upper(), descriptor) def __eq__(self, x: object) -> bool: @@ -98,11 +121,10 @@ def __eq__(self, x: object) -> bool: class IntParam(int): - - descriptor: 'Descriptor' + descriptor: "Descriptor" _str: str - def __new__(cls, value, descriptor: 'Descriptor'): + def __new__(cls, value, descriptor: "Descriptor"): _self = int.__new__(cls, value) _self.descriptor = descriptor _self._str = str(value) @@ -122,17 +144,14 @@ def validator(self): class BoolParam(Param): - boolvalue: bool - def __new__(cls, value, descriptor: 'Descriptor'): + def __new__(cls, value: str | bool, descriptor: "Descriptor"): isstr = isinstance(value, str) boolvalue = (value == VALUE_ON) if isstr else bool(value) _self = super().__new__( - cls, - value if isstr else VALUE_ON if boolvalue else VALUE_OFF, - descriptor - ) + cls, value if isstr else VALUE_ON if boolvalue else VALUE_OFF, descriptor + ) _self.boolvalue = boolvalue return _self @@ -144,34 +163,51 @@ def __eq__(self, x: object) -> bool: def __bool__(self) -> bool: return self.boolvalue + +AnyParam: typing.TypeAlias = Param | UpperStringParam | IntParam | BoolParam | None + # # static 'descriptors' helper class to describe motion params properties # class Descriptor: - - def __init__(self, _builder: Param, _validator: Any = None, _set: frozenset = None): - self.builder: Param = _builder - self.set: frozenset = _set + def __init__( + self, + _builder: type[Param] | type[IntParam], + _validator: Any = None, + _set: Container | None = None, + ): + self.builder = _builder + self.set = _set self.validator = _validator or (vol.In(_set) if _set else str) DESCRIPTOR_STR = Descriptor(Param, _validator=str) DESCRIPTOR_BOOL = Descriptor(BoolParam, _set=BOOL_SET) DESCRIPTOR_INT = Descriptor(IntParam, _validator=int) -def DESCRIPTOR_INT_RANGE(min: int = None, max: int = None) -> Descriptor: + + +def DESCRIPTOR_INT_RANGE(min: int | None = None, max: int | None = None) -> Descriptor: return Descriptor(IntParam, _validator=vol.Range(min=min, max=max)) -def DESCRIPTOR_INT_ENUM(_set: frozenset) -> Descriptor: + + +def DESCRIPTOR_INT_ENUM(_set: Container) -> Descriptor: return Descriptor(IntParam, _set=_set) + + DESCRIPTOR_INT_POSITIVE = DESCRIPTOR_INT_RANGE(min=0) DESCRIPTOR_INT_PERCENT = DESCRIPTOR_INT_RANGE(min=0, max=100) DESCRIPTOR_BYTE = DESCRIPTOR_INT_RANGE(min=0, max=255) -def DESCRIPTOR_STRING_ENUM(_set: frozenset) -> Descriptor: + + +def DESCRIPTOR_STRING_ENUM(_set: Container) -> Descriptor: return Descriptor(Param, _set=_set) -def DESCRIPTOR_UPPERSTRING_ENUM(_set: frozenset) -> Descriptor: + + +def DESCRIPTOR_UPPERSTRING_ENUM(_set: Container) -> Descriptor: return Descriptor(UpperStringParam, _set=_set) -def build_value(key: str, value: Any) -> Param: +def build_value(key: str, value: Any): """ Polimorfic factory for typed params: key specifies a well-defined type usually so we parse and cast value @@ -196,22 +232,23 @@ def build_value(key: str, value: Any) -> Param: return Param(value, DESCRIPTOR_STR) + # # List (almost exhaustive) of motion params and corresponding descriptors # # SYSTEM PROCESSING SECTION_SYSTEM = "system" # -SETUP_MODE = 'setup_mode' -TARGET_DIR = 'target_dir' -LOG_FILE = 'log_file' -LOGFILE = 'logfile' # -> 4.1.1 log_file -LOG_LEVEL = 'log_level' -LOG_TYPE = 'log_type' -QUIET = 'quiet' -NATIVE_LANGUAGE = 'native_language' -CAMERA_NAME = 'camera_name' -CAMERA_ID = 'camera_id' +SETUP_MODE = "setup_mode" +TARGET_DIR = "target_dir" +LOG_FILE = "log_file" +LOGFILE = "logfile" # -> 4.1.1 log_file +LOG_LEVEL = "log_level" +LOG_TYPE = "log_type" +QUIET = "quiet" +NATIVE_LANGUAGE = "native_language" +CAMERA_NAME = "camera_name" +CAMERA_ID = "camera_id" SECTION_SYSTEM_MAP = { SETUP_MODE: DESCRIPTOR_BOOL, TARGET_DIR: DESCRIPTOR_STR, @@ -222,84 +259,82 @@ def build_value(key: str, value: Any) -> Param: NATIVE_LANGUAGE: DESCRIPTOR_BOOL, CAMERA_NAME: DESCRIPTOR_STR, CAMERA_ID: DESCRIPTOR_INT_POSITIVE, - LOGFILE: DESCRIPTOR_STR # deprecated + LOGFILE: DESCRIPTOR_STR, # deprecated } # V4L2 CAMERAS -SECTION_V4L2 = 'v4l2' +SECTION_V4L2 = "v4l2" # -VIDEO_DEVICE = 'video_device' -VIDEODEVICE = 'videodevice' # deprecated -VIDEO_PARAMS = 'video_params' -VID_CONTROL_PARAMS = 'vid_control_params' # deprecated -V4L2_PALETTE = 'v4l2_palette' -BRIGHTNESS = 'brightness' # deprecated -CONTRAST = 'contrast' # deprecated -HUE = 'hue' # deprecated -POWER_LINE_FREQUENCY = 'power_line_frequency' # deprecated -SATURATION = 'saturation' # deprecated -AUTO_BRIGHTNESS = 'auto_brightness' -TUNER_DEVICE = 'tuner_device' -TUNERDEVICE = 'tunerdevice' # deprecated +VIDEO_DEVICE = "video_device" +VIDEODEVICE = "videodevice" # deprecated +VIDEO_PARAMS = "video_params" +VID_CONTROL_PARAMS = "vid_control_params" # deprecated +V4L2_PALETTE = "v4l2_palette" +BRIGHTNESS = "brightness" # deprecated +CONTRAST = "contrast" # deprecated +HUE = "hue" # deprecated +POWER_LINE_FREQUENCY = "power_line_frequency" # deprecated +SATURATION = "saturation" # deprecated +AUTO_BRIGHTNESS = "auto_brightness" +TUNER_DEVICE = "tuner_device" +TUNERDEVICE = "tunerdevice" # deprecated SECTION_V4L2_MAP = { VIDEO_DEVICE: DESCRIPTOR_STR, - VIDEODEVICE: DESCRIPTOR_STR, # deprecated + VIDEODEVICE: DESCRIPTOR_STR, # deprecated VIDEO_PARAMS: DESCRIPTOR_STR, - VID_CONTROL_PARAMS: DESCRIPTOR_STR, # deprecated + VID_CONTROL_PARAMS: DESCRIPTOR_STR, # deprecated V4L2_PALETTE: DESCRIPTOR_INT_RANGE(min=0, max=21), - BRIGHTNESS: DESCRIPTOR_BYTE, # deprecated - CONTRAST: DESCRIPTOR_BYTE, # deprecated - HUE: DESCRIPTOR_BYTE, # deprecated + BRIGHTNESS: DESCRIPTOR_BYTE, # deprecated + CONTRAST: DESCRIPTOR_BYTE, # deprecated + HUE: DESCRIPTOR_BYTE, # deprecated POWER_LINE_FREQUENCY: DESCRIPTOR_INT_RANGE(min=-1, max=3), - SATURATION: DESCRIPTOR_BYTE, # deprecated + SATURATION: DESCRIPTOR_BYTE, # deprecated AUTO_BRIGHTNESS: DESCRIPTOR_INT_RANGE(min=0, max=3), TUNER_DEVICE: DESCRIPTOR_STR, - TUNERDEVICE: DESCRIPTOR_STR # deprecated + TUNERDEVICE: DESCRIPTOR_STR, # deprecated } # NETWORK CAMERAS SECTION_NETCAM = "netcam" # -NETCAM_URL = 'netcam_url' -NETCAM_HIGHRES = 'netcam_highres' -NETCAM_USERPASS = 'netcam_userpass' -NETCAM_DECODER = 'netcam_decoder' -NETCAM_KEEPALIVE = 'netcam_keepalive' -NETCAM_USE_TCP = 'netcam_use_tcp' +NETCAM_URL = "netcam_url" +NETCAM_HIGHRES = "netcam_highres" +NETCAM_USERPASS = "netcam_userpass" +NETCAM_DECODER = "netcam_decoder" +NETCAM_KEEPALIVE = "netcam_keepalive" +NETCAM_USE_TCP = "netcam_use_tcp" SECTION_NETCAM_MAP = { NETCAM_URL: DESCRIPTOR_STR, NETCAM_HIGHRES: DESCRIPTOR_STR, NETCAM_DECODER: DESCRIPTOR_STR, - NETCAM_USERPASS: DESCRIPTOR_STR, #Descriptor(str, _validator=validate_userpass), + NETCAM_USERPASS: DESCRIPTOR_STR, # Descriptor(str, _validator=validate_userpass), NETCAM_USE_TCP: DESCRIPTOR_STRING_ENUM((VALUE_ON, VALUE_OFF, VALUE_FORCE)), - NETCAM_KEEPALIVE: DESCRIPTOR_BOOL + NETCAM_KEEPALIVE: DESCRIPTOR_BOOL, } # RASPI CAMERA -SECTION_MMALCAM = 'mmalcam' +SECTION_MMALCAM = "mmalcam" # -MMALCAM_NAME = 'mmalcam_name' -MMALCAM_CONTROL_PARAMS = 'mmalcam_control_params' +MMALCAM_NAME = "mmalcam_name" +MMALCAM_CONTROL_PARAMS = "mmalcam_control_params" SECTION_MMALCAM_MAP = { MMALCAM_NAME: DESCRIPTOR_STR, - MMALCAM_CONTROL_PARAMS: DESCRIPTOR_STR + MMALCAM_CONTROL_PARAMS: DESCRIPTOR_STR, } # -SECTION_WEBCONTROL = 'webcontrol' +SECTION_WEBCONTROL = "webcontrol" # -WEBCONTROL_TLS = 'webcontrol_tls' -SECTION_WEBCONTROL_MAP = { - WEBCONTROL_TLS: DESCRIPTOR_BOOL -} +WEBCONTROL_TLS = "webcontrol_tls" +SECTION_WEBCONTROL_MAP = {WEBCONTROL_TLS: DESCRIPTOR_BOOL} # SECTION_STREAM = "stream" # -STREAM_PORT = 'stream_port' -STREAM_AUTH_METHOD = 'stream_auth_method' -STREAM_AUTHENTICATION = 'stream_authentication' -STREAM_TLS = 'stream_tls' -STREAM_GREY = 'stream_grey' -STREAM_MAXRATE = 'stream_maxrate' -STREAM_MOTION = 'stream_motion' -STREAM_QUALITY = 'stream_quality' -STREAM_PREVIEW_METHOD = 'stream_preview_method' +STREAM_PORT = "stream_port" +STREAM_AUTH_METHOD = "stream_auth_method" +STREAM_AUTHENTICATION = "stream_authentication" +STREAM_TLS = "stream_tls" +STREAM_GREY = "stream_grey" +STREAM_MAXRATE = "stream_maxrate" +STREAM_MOTION = "stream_motion" +STREAM_QUALITY = "stream_quality" +STREAM_PREVIEW_METHOD = "stream_preview_method" SECTION_STREAM_MAP = { STREAM_PORT: DESCRIPTOR_INT_POSITIVE, STREAM_AUTH_METHOD: DESCRIPTOR_INT_ENUM(AUTH_MODE_SET), @@ -309,24 +344,24 @@ def build_value(key: str, value: Any) -> Param: STREAM_MAXRATE: DESCRIPTOR_INT_POSITIVE, STREAM_MOTION: DESCRIPTOR_BOOL, STREAM_QUALITY: DESCRIPTOR_INT_RANGE(min=1, max=100), - STREAM_PREVIEW_METHOD: DESCRIPTOR_INT_ENUM((0, 1, 2, 3, 4)) + STREAM_PREVIEW_METHOD: DESCRIPTOR_INT_ENUM((0, 1, 2, 3, 4)), } # IMAGE PROCESSING SECTION_IMAGE = "image" # -WIDTH = 'width' -HEIGHT = 'height' -FRAMERATE = 'framerate' -MINIMUM_FRAME_TIME = 'minimum_frame_time' -ROTATE = 'rotate' -FLIP_AXIS = 'flip_axis' -LOCATE_MOTION_MODE = 'locate_motion_mode' -LOCATE_MOTION_STYLE = 'locate_motion_style' -TEXT_LEFT = 'text_left' -TEXT_RIGHT = 'text_right' -TEXT_CHANGES = 'text_changes' -TEXT_SCALE = 'text_scale' -TEXT_EVENT = 'text_event' +WIDTH = "width" +HEIGHT = "height" +FRAMERATE = "framerate" +MINIMUM_FRAME_TIME = "minimum_frame_time" +ROTATE = "rotate" +FLIP_AXIS = "flip_axis" +LOCATE_MOTION_MODE = "locate_motion_mode" +LOCATE_MOTION_STYLE = "locate_motion_style" +TEXT_LEFT = "text_left" +TEXT_RIGHT = "text_right" +TEXT_CHANGES = "text_changes" +TEXT_SCALE = "text_scale" +TEXT_EVENT = "text_event" SECTION_IMAGE_MAP = { WIDTH: DESCRIPTOR_INT_POSITIVE, HEIGHT: DESCRIPTOR_INT_POSITIVE, @@ -335,34 +370,36 @@ def build_value(key: str, value: Any) -> Param: ROTATE: DESCRIPTOR_INT_ENUM((0, 90, 180, 270)), FLIP_AXIS: DESCRIPTOR_STRING_ENUM((VALUE_NONE, VALUE_V, VALUE_H)), LOCATE_MOTION_MODE: DESCRIPTOR_STRING_ENUM((VALUE_OFF, VALUE_ON, VALUE_PREVIEW)), - LOCATE_MOTION_STYLE: DESCRIPTOR_STRING_ENUM((VALUE_BOX, VALUE_REDBOX, VALUE_CROSS, VALUE_REDCROSS)), + LOCATE_MOTION_STYLE: DESCRIPTOR_STRING_ENUM( + (VALUE_BOX, VALUE_REDBOX, VALUE_CROSS, VALUE_REDCROSS) + ), TEXT_LEFT: DESCRIPTOR_STR, TEXT_RIGHT: DESCRIPTOR_STR, TEXT_CHANGES: DESCRIPTOR_BOOL, TEXT_SCALE: DESCRIPTOR_INT_RANGE(min=1, max=10), - TEXT_EVENT: DESCRIPTOR_STR + TEXT_EVENT: DESCRIPTOR_STR, } # SECTION_MOTION = "motion" # -EMULATE_MOTION = 'emulate_motion' -THRESHOLD = 'threshold' -THRESHOLD_MAXIMUM = 'threshold_maximum' -THRESHOLD_TUNE = 'threshold_tune' -NOISE_LEVEL = 'noise_level' -NOISE_TUNE = 'noise_tune' -DESPECKLE_FILTER = 'despeckle_filter' -AREA_DETECT = 'area_detect' -MASK_FILE = 'mask_file' -MASK_PRIVACY = 'mask_privacy' -SMART_MASK_SPEED = 'smart_mask_speed' -LIGHTSWITCH_PERCENT = 'lightswitch_percent' -LIGHTSWITCH = 'lightswitch' # -> 4.1.1 -LIGHTSWITCH_FRAMES = 'lightswitch_frames' -MINIMUM_MOTION_FRAMES = 'minimum_motion_frames' -EVENT_GAP = 'event_gap' -PRE_CAPTURE = 'pre_capture' -POST_CAPTURE = 'post_capture' +EMULATE_MOTION = "emulate_motion" +THRESHOLD = "threshold" +THRESHOLD_MAXIMUM = "threshold_maximum" +THRESHOLD_TUNE = "threshold_tune" +NOISE_LEVEL = "noise_level" +NOISE_TUNE = "noise_tune" +DESPECKLE_FILTER = "despeckle_filter" +AREA_DETECT = "area_detect" +MASK_FILE = "mask_file" +MASK_PRIVACY = "mask_privacy" +SMART_MASK_SPEED = "smart_mask_speed" +LIGHTSWITCH_PERCENT = "lightswitch_percent" +LIGHTSWITCH = "lightswitch" # -> 4.1.1 +LIGHTSWITCH_FRAMES = "lightswitch_frames" +MINIMUM_MOTION_FRAMES = "minimum_motion_frames" +EVENT_GAP = "event_gap" +PRE_CAPTURE = "pre_capture" +POST_CAPTURE = "post_capture" SECTION_MOTION_MAP = { EMULATE_MOTION: DESCRIPTOR_BOOL, THRESHOLD: DESCRIPTOR_INT_RANGE(min=1), @@ -381,20 +418,20 @@ def build_value(key: str, value: Any) -> Param: MINIMUM_MOTION_FRAMES: DESCRIPTOR_INT_RANGE(min=1, max=1000), EVENT_GAP: DESCRIPTOR_INT_POSITIVE, PRE_CAPTURE: DESCRIPTOR_INT_RANGE(min=0, max=100), - POST_CAPTURE: DESCRIPTOR_INT_POSITIVE + POST_CAPTURE: DESCRIPTOR_INT_POSITIVE, } # -SECTION_SCRIPT = 'script' +SECTION_SCRIPT = "script" # -ON_EVENT_START = 'on_event_start' -ON_EVENT_END = 'on_event_end' -ON_PICTURE_SAVE = 'on_picture_save' -ON_MOTION_DETECTED = 'on_motion_detected' -ON_AREA_DETECTED = 'on_area_detected' -ON_MOVIE_START = 'on_movie_start' -ON_MOVIE_END = 'on_movie_end' -ON_CAMERA_LOST = 'on_camera_lost' -ON_CAMERA_FOUND = 'on_camera_found' +ON_EVENT_START = "on_event_start" +ON_EVENT_END = "on_event_end" +ON_PICTURE_SAVE = "on_picture_save" +ON_MOTION_DETECTED = "on_motion_detected" +ON_AREA_DETECTED = "on_area_detected" +ON_MOVIE_START = "on_movie_start" +ON_MOVIE_END = "on_movie_end" +ON_CAMERA_LOST = "on_camera_lost" +ON_CAMERA_FOUND = "on_camera_found" SECTION_SCRIPT_MAP = { ON_EVENT_START: DESCRIPTOR_STR, ON_EVENT_END: DESCRIPTOR_STR, @@ -404,25 +441,27 @@ def build_value(key: str, value: Any) -> Param: ON_MOVIE_START: DESCRIPTOR_STR, ON_MOVIE_END: DESCRIPTOR_STR, ON_CAMERA_LOST: DESCRIPTOR_STR, - ON_CAMERA_FOUND: DESCRIPTOR_STR + ON_CAMERA_FOUND: DESCRIPTOR_STR, } # -SECTION_PICTURE = 'picture' +SECTION_PICTURE = "picture" # -PICTURE_OUTPUT = 'picture_output' -OUTPUT_PICTURES = 'output_pictures' # -> 4.1.1 -PICTURE_OUTPUT_MOTION = 'picture_output_motion' -OUTPUT_DEBUG_PICTURES = 'output_debug_pictures' # -> 4.1.1 -PICTURE_TYPE = 'picture_type' -PICTURE_QUALITY = 'picture_quality' -QUALITY = 'quality' # -> 4.1.1 -PICTURE_EXIF = 'picture_exif' -EXIF_TEXT = 'exif_text' # -> 4.1.1 -PICTURE_FILENAME = 'picture_filename' -SNAPSHOT_INTERVAL = 'snapshot_interval' -SNAPSHOT_FILENAME = 'snapshot_filename' +PICTURE_OUTPUT = "picture_output" +OUTPUT_PICTURES = "output_pictures" # -> 4.1.1 +PICTURE_OUTPUT_MOTION = "picture_output_motion" +OUTPUT_DEBUG_PICTURES = "output_debug_pictures" # -> 4.1.1 +PICTURE_TYPE = "picture_type" +PICTURE_QUALITY = "picture_quality" +QUALITY = "quality" # -> 4.1.1 +PICTURE_EXIF = "picture_exif" +EXIF_TEXT = "exif_text" # -> 4.1.1 +PICTURE_FILENAME = "picture_filename" +SNAPSHOT_INTERVAL = "snapshot_interval" +SNAPSHOT_FILENAME = "snapshot_filename" SECTION_PICTURE_MAP = { - PICTURE_OUTPUT: DESCRIPTOR_STRING_ENUM((VALUE_OFF, VALUE_ON, VALUE_FIRST, VALUE_BEST)), + PICTURE_OUTPUT: DESCRIPTOR_STRING_ENUM( + (VALUE_OFF, VALUE_ON, VALUE_FIRST, VALUE_BEST) + ), PICTURE_OUTPUT_MOTION: DESCRIPTOR_BOOL, PICTURE_TYPE: DESCRIPTOR_STRING_ENUM(PICTURE_TYPE_SET), PICTURE_QUALITY: DESCRIPTOR_INT_PERCENT, @@ -430,35 +469,39 @@ def build_value(key: str, value: Any) -> Param: PICTURE_FILENAME: DESCRIPTOR_STR, SNAPSHOT_INTERVAL: DESCRIPTOR_INT_POSITIVE, SNAPSHOT_FILENAME: DESCRIPTOR_STR, - OUTPUT_PICTURES: DESCRIPTOR_STRING_ENUM((VALUE_OFF, VALUE_ON, VALUE_FIRST, VALUE_BEST)), + OUTPUT_PICTURES: DESCRIPTOR_STRING_ENUM( + (VALUE_OFF, VALUE_ON, VALUE_FIRST, VALUE_BEST) + ), OUTPUT_DEBUG_PICTURES: DESCRIPTOR_BOOL, QUALITY: DESCRIPTOR_INT_PERCENT, - EXIF_TEXT: DESCRIPTOR_STR + EXIF_TEXT: DESCRIPTOR_STR, } # -SECTION_MOVIE = 'movie' +SECTION_MOVIE = "movie" # -MOVIE_OUTPUT = 'movie_output' -MOVIE_OUTPUT_MOTION = 'movie_output_motion' -MOVIE_MAX_TIME = 'movie_max_time' -MOVIE_BPS = 'movie_bps' -MOVIE_QUALITY = 'movie_quality' -MOVIE_CODEC = 'movie_codec' -MOVIE_DUPLICATE_FRAMES = 'movie_duplicate_frames' -MOVIE_PASSTHROUGH = 'movie_passthrough' -MOVIE_FILENAME = 'movie_filename' -MOVIE_EXTPIPE_USE = 'movie_extpipe_use' -MOVIE_EXTPIPE = 'movie_extpipe' +MOVIE_OUTPUT = "movie_output" +MOVIE_OUTPUT_MOTION = "movie_output_motion" +MOVIE_MAX_TIME = "movie_max_time" +MOVIE_BPS = "movie_bps" +MOVIE_QUALITY = "movie_quality" +MOVIE_CODEC = "movie_codec" +MOVIE_DUPLICATE_FRAMES = "movie_duplicate_frames" +MOVIE_PASSTHROUGH = "movie_passthrough" +MOVIE_FILENAME = "movie_filename" +MOVIE_EXTPIPE_USE = "movie_extpipe_use" +MOVIE_EXTPIPE = "movie_extpipe" # deprecated -FFMPEG_OUTPUT_MOVIES = 'ffmpeg_output_movies' # -> 4.1.1 movie_output -FFMPEG_OUTPUT_DEBUG_MOVIES = 'ffmpeg_output_debug_movies' # -> 4.1.1 movie_output_motion -MAX_MOVIE_TIME = 'max_movie_time' # -> 4.1.1 movie_max_time -FFMPEG_BPS = 'ffmpeg_bps' # -> 4.1.1 movie_bps -FFMPEG_VARIABLE_BITRATE = 'ffmpeg_variable_bitrate' # -> 4.1.1 movie_quality -FFMPEG_VIDEO_CODEC= 'ffmpeg_video_codec' # -> 4.1.1 movie_codec -FFMPEG_DUPLICATE_FRAMES = 'ffmpeg_duplicate_frames' # -> 4.1.1 movie_duplicate_frames -USE_EXTPIPE = 'use_extpipe' # -> 4.1.1 movie_extpipe_use -EXTPIPE = 'extpipe' # -> 4.1.1 movie_extpipe +FFMPEG_OUTPUT_MOVIES = "ffmpeg_output_movies" # -> 4.1.1 movie_output +FFMPEG_OUTPUT_DEBUG_MOVIES = ( + "ffmpeg_output_debug_movies" # -> 4.1.1 movie_output_motion +) +MAX_MOVIE_TIME = "max_movie_time" # -> 4.1.1 movie_max_time +FFMPEG_BPS = "ffmpeg_bps" # -> 4.1.1 movie_bps +FFMPEG_VARIABLE_BITRATE = "ffmpeg_variable_bitrate" # -> 4.1.1 movie_quality +FFMPEG_VIDEO_CODEC = "ffmpeg_video_codec" # -> 4.1.1 movie_codec +FFMPEG_DUPLICATE_FRAMES = "ffmpeg_duplicate_frames" # -> 4.1.1 movie_duplicate_frames +USE_EXTPIPE = "use_extpipe" # -> 4.1.1 movie_extpipe_use +EXTPIPE = "extpipe" # -> 4.1.1 movie_extpipe SECTION_MOVIE_MAP = { MOVIE_OUTPUT: DESCRIPTOR_BOOL, MOVIE_OUTPUT_MOTION: DESCRIPTOR_BOOL, @@ -479,40 +522,40 @@ def build_value(key: str, value: Any) -> Param: FFMPEG_VIDEO_CODEC: DESCRIPTOR_STRING_ENUM(MOVIE_CODEC_SET), FFMPEG_DUPLICATE_FRAMES: DESCRIPTOR_BOOL, USE_EXTPIPE: DESCRIPTOR_BOOL, - EXTPIPE: DESCRIPTOR_STR + EXTPIPE: DESCRIPTOR_STR, } # -SECTION_TIMELAPSE = 'timelapse' +SECTION_TIMELAPSE = "timelapse" # -TIMELAPSE_INTERVAL = 'timelapse_interval' -TIMELAPSE_MODE = 'timelapse_mode' -TIMELAPSE_FPS = 'timelapse_fps' -TIMELAPSE_CODEC = 'timelapse_codec' -TIMELAPSE_FILENAME = 'timelapse_filename' +TIMELAPSE_INTERVAL = "timelapse_interval" +TIMELAPSE_MODE = "timelapse_mode" +TIMELAPSE_FPS = "timelapse_fps" +TIMELAPSE_CODEC = "timelapse_codec" +TIMELAPSE_FILENAME = "timelapse_filename" SECTION_TIMELAPSE_MAP = { TIMELAPSE_INTERVAL: DESCRIPTOR_INT_POSITIVE, TIMELAPSE_MODE: DESCRIPTOR_STRING_ENUM(TIMELAPSE_MODE_SET), TIMELAPSE_FPS: DESCRIPTOR_INT_RANGE(min=0, max=100), TIMELAPSE_CODEC: DESCRIPTOR_STRING_ENUM(TIMELAPSE_CODEC_SET), - TIMELAPSE_FILENAME: DESCRIPTOR_STR + TIMELAPSE_FILENAME: DESCRIPTOR_STR, } # -SECTION_DATABASE = 'database' +SECTION_DATABASE = "database" # -DATABASE_TYPE = 'database_type' -DATABASE_DBNAME = 'database_dbname' -DATABASE_HOST = 'database_host' -DATABASE_PORT = 'database_port' -DATABASE_USER = 'database_user' -DATABASE_PASSWORD = 'database_password' -DATABASE_BUSY_TIMEOUT = 'database_busy_timeout' -SQL_LOG_PICTURE = 'sql_log_picture' -SQL_LOG_SNAPSHOT = 'sql_log_snapshot' -SQL_LOG_MOVIE = 'sql_log_movie' -SQL_LOG_TIMELAPSE = 'sql_log_timelapse' -SQL_QUERY = 'sql_query' -SQL_QUERY_START = 'sql_query_start' -SQL_QUERY_STOP = 'sql_query_stop' +DATABASE_TYPE = "database_type" +DATABASE_DBNAME = "database_dbname" +DATABASE_HOST = "database_host" +DATABASE_PORT = "database_port" +DATABASE_USER = "database_user" +DATABASE_PASSWORD = "database_password" +DATABASE_BUSY_TIMEOUT = "database_busy_timeout" +SQL_LOG_PICTURE = "sql_log_picture" +SQL_LOG_SNAPSHOT = "sql_log_snapshot" +SQL_LOG_MOVIE = "sql_log_movie" +SQL_LOG_TIMELAPSE = "sql_log_timelapse" +SQL_QUERY = "sql_query" +SQL_QUERY_START = "sql_query_start" +SQL_QUERY_STOP = "sql_query_stop" SECTION_DATABASE_MAP = { DATABASE_TYPE: DESCRIPTOR_STR, DATABASE_DBNAME: DESCRIPTOR_STR, @@ -527,31 +570,31 @@ def build_value(key: str, value: Any) -> Param: SQL_LOG_TIMELAPSE: DESCRIPTOR_BOOL, SQL_QUERY: DESCRIPTOR_STR, SQL_QUERY_START: DESCRIPTOR_STR, - SQL_QUERY_STOP: DESCRIPTOR_STR + SQL_QUERY_STOP: DESCRIPTOR_STR, } # -SECTION_TRACK = 'track' +SECTION_TRACK = "track" # -TRACK_TYPE = 'track_type' -TRACK_AUTO = 'track_auto' -TRACK_PORT = 'track_port' -TRACK_MOTORX = 'track_motorx' -TRACK_MOTORX_REVERSE = 'track_motorx_reverse' -TRACK_MOTORY = 'track_motory' -TRACK_MOTORY_REVERSE = 'track_motory_reverse' -TRACK_MAXX = 'track_maxx' -TRACK_MINX = 'track_minx' -TRACK_MAXY = 'track_maxy' -TRACK_MINY = 'track_miny' -TRACK_HOMEX = 'track_homex' -TRACK_HOMEY = 'track_homey' -TRACK_IOMOJO_ID = 'track_iomojo_id' -TRACK_STEP_ANGLE_X = 'track_step_angle_x' -TRACK_STEP_ANGLE_Y = 'track_step_angle_y' -TRACK_MOVE_WAIT = 'track_move_wait' -TRACK_SPEED = 'track_speed' -TRACK_STEPSIZE = 'track_stepsize' -TRACK_GENERIC_MOVE = 'track_generic_move' +TRACK_TYPE = "track_type" +TRACK_AUTO = "track_auto" +TRACK_PORT = "track_port" +TRACK_MOTORX = "track_motorx" +TRACK_MOTORX_REVERSE = "track_motorx_reverse" +TRACK_MOTORY = "track_motory" +TRACK_MOTORY_REVERSE = "track_motory_reverse" +TRACK_MAXX = "track_maxx" +TRACK_MINX = "track_minx" +TRACK_MAXY = "track_maxy" +TRACK_MINY = "track_miny" +TRACK_HOMEX = "track_homex" +TRACK_HOMEY = "track_homey" +TRACK_IOMOJO_ID = "track_iomojo_id" +TRACK_STEP_ANGLE_X = "track_step_angle_x" +TRACK_STEP_ANGLE_Y = "track_step_angle_y" +TRACK_MOVE_WAIT = "track_move_wait" +TRACK_SPEED = "track_speed" +TRACK_STEPSIZE = "track_stepsize" +TRACK_GENERIC_MOVE = "track_generic_move" SECTION_TRACK_MAP = { TRACK_TYPE: DESCRIPTOR_INT, TRACK_AUTO: DESCRIPTOR_BOOL, @@ -572,10 +615,10 @@ def build_value(key: str, value: Any) -> Param: TRACK_MOVE_WAIT: DESCRIPTOR_INT_POSITIVE, TRACK_SPEED: DESCRIPTOR_INT_POSITIVE, TRACK_STEPSIZE: DESCRIPTOR_INT_POSITIVE, - TRACK_GENERIC_MOVE: DESCRIPTOR_STR + TRACK_GENERIC_MOVE: DESCRIPTOR_STR, } -SECTION_SET_MAP: MappingProxyType[str, MappingProxyType[str, Descriptor]] = { +SECTION_SET_MAP: typing.MutableMapping[str, dict[str, Descriptor]] = { SECTION_SYSTEM: SECTION_SYSTEM_MAP, SECTION_V4L2: SECTION_V4L2_MAP, SECTION_NETCAM: SECTION_NETCAM_MAP, @@ -589,7 +632,7 @@ def build_value(key: str, value: Any) -> Param: SECTION_MOVIE: SECTION_MOVIE_MAP, SECTION_TIMELAPSE: SECTION_TIMELAPSE_MAP, SECTION_DATABASE: SECTION_DATABASE_MAP, - SECTION_TRACK: SECTION_TRACK_MAP + SECTION_TRACK: SECTION_TRACK_MAP, } """ @@ -600,27 +643,41 @@ def build_value(key: str, value: Any) -> Param: Some are only valid at the global level (see motion daemon implementation) """ # list options which should be available and meaningful only at the camera config level -CAMERACONFIG_SET = ( - CAMERA_ID, CAMERA_NAME, - VIDEO_DEVICE, VIDEODEVICE, - NETCAM_URL, NETCAM_HIGHRES, - MMALCAM_NAME, MMALCAM_CONTROL_PARAMS, -) +CAMERACONFIG_SET = { + CAMERA_ID, + CAMERA_NAME, + VIDEO_DEVICE, + VIDEODEVICE, + NETCAM_URL, + NETCAM_HIGHRES, + MMALCAM_NAME, + MMALCAM_CONTROL_PARAMS, +} # list options which are valid and meaningful only at the global motion.conf -GLOBALCONFIG_SET = ( - LOG_FILE, LOG_LEVEL, LOG_TYPE, LOGFILE, +GLOBALCONFIG_SET = { + LOG_FILE, + LOG_LEVEL, + LOG_TYPE, + LOGFILE, NATIVE_LANGUAGE, - WEBCONTROL_TLS, STREAM_AUTHENTICATION -) + WEBCONTROL_TLS, + STREAM_AUTHENTICATION, +} # list options which requires a thread restart to be in place -RESTARTCONFIG_SET = ( - WIDTH, HEIGHT, ROTATE, FLIP_AXIS, - VIDEO_DEVICE, VIDEODEVICE, NETCAM_URL, - MMALCAM_NAME, MMALCAM_CONTROL_PARAMS, -) +RESTARTCONFIG_SET = { + WIDTH, + HEIGHT, + ROTATE, + FLIP_AXIS, + VIDEO_DEVICE, + VIDEODEVICE, + NETCAM_URL, + MMALCAM_NAME, + MMALCAM_CONTROL_PARAMS, +} -SCHEMA: MappingProxyType[str, Descriptor] = ChainMap( +SCHEMA: typing.MutableMapping[str, Descriptor] = ChainMap( SECTION_SYSTEM_MAP, SECTION_V4L2_MAP, SECTION_NETCAM_MAP, @@ -634,6 +691,5 @@ def build_value(key: str, value: Any) -> Param: SECTION_MOVIE_MAP, SECTION_TIMELAPSE_MAP, SECTION_DATABASE_MAP, - SECTION_TRACK_MAP + SECTION_TRACK_MAP, ) - From e94a4c5f02cc40aa8d1044ebe4dbeba06c83d130 Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:13:50 +0000 Subject: [PATCH 3/6] bump version to 2024.1.0 --- .devcontainer/configuration.yaml | 2 +- .../motion_frontend/manifest.json | 38 +++++++++++-------- hacs.json | 9 +---- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index da1f19c..5949230 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -3,7 +3,7 @@ default_config: stream: logger: - default: info + default: warning logs: custom_components.motion_frontend: debug diff --git a/custom_components/motion_frontend/manifest.json b/custom_components/motion_frontend/manifest.json index ecec32f..db73dbf 100644 --- a/custom_components/motion_frontend/manifest.json +++ b/custom_components/motion_frontend/manifest.json @@ -1,17 +1,23 @@ { - "domain": "motion_frontend", - "name": "Motion Frontend", - "config_flow": true, - "iot_class": "local_push", - "documentation": "https://github.com/krahabb/motion_frontend", - "issue_tracker": "https://github.com/krahabb/motion_frontend/issues", - "requirements": [], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "mqtt": [], - "dependencies": [], - "after_dependencies": ["webhook","camera","media_source"], - "codeowners": ["@krahabb"], - "version": "0.0.3" - } + "domain": "motion_frontend", + "name": "Motion Frontend", + "config_flow": true, + "integration_type": "hub", + "iot_class": "local_push", + "documentation": "https://github.com/krahabb/motion_frontend", + "issue_tracker": "https://github.com/krahabb/motion_frontend/issues", + "requirements": [], + "dependencies": [], + "after_dependencies": [ + "webhook", + "camera", + "media_source" + ], + "loggers": [ + "custom_components.motion_frontend" + ], + "codeowners": [ + "@krahabb" + ], + "version": "2024.1.0" +} \ No newline at end of file diff --git a/hacs.json b/hacs.json index 25d6609..364f350 100644 --- a/hacs.json +++ b/hacs.json @@ -1,10 +1,5 @@ { "name": "Motion Frontend", - "domains": [ - "camera", "alarm_control_panel" - ], - "iot_class": "local_push", - "homeassistant": "2021.0.0", - "hacs": "1.6.0", + "homeassistant": "2024.1.0", "render_readme": true -} +} \ No newline at end of file From 4afde967b8e95663018b868219b7d19f137bfacb Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:52:48 +0000 Subject: [PATCH 4/6] update environment tools cfg --- pyproject.toml | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 21 +------------------ 2 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e6e4a99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +################################################################################ +[tool.black] +################################################################################ +target-version = ["py311"] +extend-exclude = "/generated/" + +################################################################################ +[tool.isort] +################################################################################ +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "custom_components.motion_frontend", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true + +################################################################################ +[tool.pylint."MESSAGES CONTROL"] +################################################################################ +disable = [ + "broad-except", + "broad-exception-raised", + "global-statement", + "import-outside-toplevel", + "invalid-name", + "missing-class-docstring", + "missing-function-docstring", + "protected-access", +] + +################################################################################ +[tool.pytest.ini_options] +################################################################################ +python_files = [ + "test_*.py", +] +python_functions = [ + "test_*", +] +testpaths = [ + "tests", +] +norecursedirs = [ + ".git", + "testing_config", +] +log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" +asyncio_mode = "auto" diff --git a/setup.cfg b/setup.cfg index 4ee3655..36d6d2a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,23 +13,4 @@ ignore = W503, E203, D202, - W504 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# by default isort don't check module indexes -not_skip = __init__.py -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = custom_components.integration_blueprint, tests -combine_as_imports = true + W504 \ No newline at end of file From 05a6ab5ec9aa5a7e54e87932c785a5923798ec99 Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:53:14 +0000 Subject: [PATCH 5/6] fix imports --- custom_components/motion_frontend/__init__.py | 32 +++++++------------ custom_components/motion_frontend/camera.py | 11 ++----- custom_components/motion_frontend/helpers.py | 1 + .../motion_frontend/motionclient/__init__.py | 2 +- .../motionclient/config_schema.py | 3 +- 5 files changed, 18 insertions(+), 31 deletions(-) diff --git a/custom_components/motion_frontend/__init__.py b/custom_components/motion_frontend/__init__.py index 31b2c3f..e742485 100644 --- a/custom_components/motion_frontend/__init__.py +++ b/custom_components/motion_frontend/__init__.py @@ -1,25 +1,16 @@ """The Motion Frontend integration.""" from __future__ import annotations + import asyncio -from logging import INFO, WARNING import os from pathlib import Path import types import typing import aiohttp - -from homeassistant.components import mqtt -from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady -from homeassistant.const import ( - ATTR_AREA_ID, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) -from homeassistant.core import HomeAssistant, callback, CALLBACK_TYPE -from homeassistant.helpers import device_registry +from homeassistant.config_entries import ConfigEntryNotReady +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import raise_if_invalid_path @@ -42,10 +33,9 @@ MAP_TLS_MODE, PLATFORMS, ) -from .helpers import LOGGER, LOGGER_trap +from .helpers import LOGGER from .motionclient import ( MotionHttpClient, - MotionHttpClientConnectionError, MotionHttpClientError, TlsMode, config_schema as cs, @@ -53,11 +43,10 @@ if typing.TYPE_CHECKING: import aiohttp.web - from .alarm_control_panel import MotionFrontendAlarmControlPanel - + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import CALLBACK_TYPE, HomeAssistant -async def async_setup(hass: HomeAssistant, config: dict): - return True + from .alarm_control_panel import MotionFrontendAlarmControlPanel async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): @@ -208,7 +197,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): class MotionFrontendApi(MotionHttpClient): - cameras: dict[str, MotionFrontendCamera] def __init__( @@ -253,7 +241,9 @@ async def async_handle_webhook( LOGGER.debug("Received webhook - (%s)", data) - camera = typing.cast(MotionFrontendCamera, self.getcamera(str(data["camera_id"]))) + camera = typing.cast( + MotionFrontendCamera, self.getcamera(str(data["camera_id"])) + ) if self.media_dir_id: try: # fix the path as a media_source compatible url filename = data.get(EXTRA_ATTR_FILENAME) diff --git a/custom_components/motion_frontend/camera.py b/custom_components/motion_frontend/camera.py index 25b300b..8d28b16 100644 --- a/custom_components/motion_frontend/camera.py +++ b/custom_components/motion_frontend/camera.py @@ -3,13 +3,12 @@ import asyncio from contextlib import closing from functools import partial -from typing import Any, Mapping import typing +from typing import Any, Mapping import aiohttp - -from homeassistant.components import camera from homeassistant import const as hac +from homeassistant.components import camera from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform @@ -41,11 +40,7 @@ ON_PICTURE_SAVE, ) from .helpers import LOGGER -from .motionclient import ( - MotionCamera, - TlsMode, - config_schema as cs, -) +from .motionclient import MotionCamera, TlsMode, config_schema as cs if typing.TYPE_CHECKING: from . import MotionFrontendApi diff --git a/custom_components/motion_frontend/helpers.py b/custom_components/motion_frontend/helpers.py index 1841980..a49f2c2 100644 --- a/custom_components/motion_frontend/helpers.py +++ b/custom_components/motion_frontend/helpers.py @@ -1,4 +1,5 @@ from __future__ import annotations + import logging from time import time diff --git a/custom_components/motion_frontend/motionclient/__init__.py b/custom_components/motion_frontend/motionclient/__init__.py index acc71b3..5fbd0f1 100644 --- a/custom_components/motion_frontend/motionclient/__init__.py +++ b/custom_components/motion_frontend/motionclient/__init__.py @@ -1,5 +1,6 @@ """An Http API Client to interact with motion server""" from __future__ import annotations + import asyncio from datetime import datetime from enum import Enum @@ -10,7 +11,6 @@ from typing import Any, Callable import aiohttp - from yarl import URL from . import config_schema as cs diff --git a/custom_components/motion_frontend/motionclient/config_schema.py b/custom_components/motion_frontend/motionclient/config_schema.py index b43742b..76f5d1e 100644 --- a/custom_components/motion_frontend/motionclient/config_schema.py +++ b/custom_components/motion_frontend/motionclient/config_schema.py @@ -2,9 +2,10 @@ Motion daemon config parameters definitions """ from __future__ import annotations + from collections import ChainMap -from typing import Any, Container import typing +from typing import Any, Container import voluptuous as vol From 7a5e656e2374aa14cf8888e94d7b8714f23fc78c Mon Sep 17 00:00:00 2001 From: krahabb <13969600+krahabb@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:57:28 +0000 Subject: [PATCH 6/6] fix keys order in manifest.json --- .../motion_frontend/manifest.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/motion_frontend/manifest.json b/custom_components/motion_frontend/manifest.json index db73dbf..9f5a796 100644 --- a/custom_components/motion_frontend/manifest.json +++ b/custom_components/motion_frontend/manifest.json @@ -1,23 +1,23 @@ { "domain": "motion_frontend", "name": "Motion Frontend", - "config_flow": true, - "integration_type": "hub", - "iot_class": "local_push", - "documentation": "https://github.com/krahabb/motion_frontend", - "issue_tracker": "https://github.com/krahabb/motion_frontend/issues", - "requirements": [], - "dependencies": [], "after_dependencies": [ "webhook", "camera", "media_source" ], - "loggers": [ - "custom_components.motion_frontend" - ], "codeowners": [ "@krahabb" ], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/krahabb/motion_frontend", + "integration_type": "hub", + "iot_class": "local_push", + "issue_tracker": "https://github.com/krahabb/motion_frontend/issues", + "loggers": [ + "custom_components.motion_frontend" + ], + "requirements": [], "version": "2024.1.0" } \ No newline at end of file