diff --git a/.github/workflows/python-code-format.yml b/.github/workflows/python-code-format.yml new file mode 100644 index 0000000..fcd7179 --- /dev/null +++ b/.github/workflows/python-code-format.yml @@ -0,0 +1,50 @@ +name: Check Python code formatting + +on: + push: + paths: + - 'ucapi/**' + - 'requirements.txt' + - 'test-requirements.txt' + - 'tests/**' + - '.github/**/*.yml' + - '.pylintrc' + - 'pyproject.toml' + - 'tox.ini' + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + + name: Check Python code formatting + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi + - name: Analyzing the code with pylint + run: | + python -m pylint ucapi + - name: Lint with flake8 + run: | + python -m flake8 ucapi --count --show-source --statistics + - name: Check code formatting with isort + run: | + python -m isort ucapi/. --check --verbose + - name: Check code formatting with black + run: | + python -m black ucapi --check --verbose --line-length 120 diff --git a/.gitignore b/.gitignore index e00d8d3..592ae9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,102 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.pylint.d/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# Local development settings +.settings/ +.project +.pydevproject +.pypirc +.pytest_cache + +# Visual Studio Code +.vscode/ .DS_Store -.vscode/settings.json -build -dist -*.egg-info \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..1facecf --- /dev/null +++ b/.pylintrc @@ -0,0 +1,44 @@ +[MAIN] +# Specify a score threshold to be exceeded before program exits with error. +fail-under=9.5 + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + logging-fstring-interpolation, + logging-not-lazy, + unspecified-encoding, + consider-using-from-import, + consider-using-with, + invalid-name + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +[MESSAGES CONTROL] + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" + +disable= + too-many-instance-attributes, + global-statement, + too-many-arguments, + unused-argument, + too-few-public-methods + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes diff --git a/README.md b/README.md index 98975de..4192f9a 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ Not supported: - secure WebSocket +Requires Python 3.10 or newer + --- ### Local testing: +```console python3 setup.py bdist_wheel -pip3 install /path/to/wheelfile.whl +pip3 install dist/ucapi-$VERSION-py3-none-any.whl +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5cf74a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +# TODO migrate from setup.py: this is work in progress +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "ucapi" +version = "0.0.11" +authors = [ + {name = "Unfolded Circle ApS", email = "hello@unfoldedcircle.com"} +] +license = {text = "MPL-2.0"} +description = "Python wrapper for the Unfolded Circle Integration API" +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MPL-2.0 License", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries", + "Topic :: Home Automation", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +requires-python = ">=3.10" +dependencies = [ + "asyncio==3.4.3", + "pyee==9.0.4", + "websockets==11.0.3", + "zeroconf==0.119.0", +] + +[project.readme] +file = "README.md" +content-type = "text/markdown; charset=UTF-8" + +[project.optional-dependencies] +testing = [ + "pylint", + "flake8-docstrings", + "flake8", + "black", + "isort", +] + +[tool.setuptools] +zip-safe = false +platforms = ["any"] +license-files = ["LICENSE"] +# TODO is this correct? Set to True in old setup.py +include-package-data = true + +[tool.setuptools.packages.find] +exclude = ["tests"] +namespaces = false + +[tool.isort] +profile = "black" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3812b95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +asyncio==3.4.3 +pyee==9.0.4 +websockets==11.0.3 +zeroconf==0.119.0 diff --git a/setup.py b/setup.py index d231587..8586c90 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,15 @@ +# TODO remove and use pyproject.toml: https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html + from setuptools import setup, find_packages from codecs import open from os import path -PACKAGE_NAME = 'ucapi' +PACKAGE_NAME = "ucapi" HERE = path.abspath(path.dirname(__file__)) -VERSION = '0.0.10' +VERSION = "0.0.11" -with open(path.join(HERE, 'README.md'), encoding='utf-8') as f: +with open(path.join(HERE, "README.md"), encoding="utf-8") as f: long_description = f.read() setup( @@ -17,8 +19,8 @@ url="https://unfoldedcircle.com", author="Unfolded Circle ApS", author_email="hello@unfoldedcircle.com", - license="MIT", - packages=['ucapi'], + license="MPL-2.0", + packages=["ucapi"], include_package_data=True, - install_requires=find_packages() - ) + install_requires=find_packages(), +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..251bc19 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +pylint +flake8-docstrings +flake8 +black +isort diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8692bcc --- /dev/null +++ b/tox.ini @@ -0,0 +1,36 @@ +[flake8] +max_line_length = 120 + +[tox] +envlist = py310,py311,pylint,lint,format +skip_missing_interpreters = True + +[testenv:format] +basepython = python3.11 +deps = + -r{toxinidir}/test-requirements.txt +commands = + python -m isort ucapi/. --check --verbose + python -m black ucapi --check --verbose + +[testenv:pylint] +basepython = python3.11 +deps = + -r{toxinidir}/test-requirements.txt +commands=python -m pylint ucapi + +[testenv:lint] +basepython = python3.11 +deps = + -r{toxinidir}/test-requirements.txt +commands = + python -m flake8 ucapi +; python -m pydocstyle ucapi + +;[testenv] +;setenv = +; LANG=en_US.UTF-8 +; PYTHONPATH = {toxinidir} +;deps = +; -r{toxinidir}/test-requirements.txt +;commands=python -m pytest tests --timeout=30 --durations=10 --cov=denonavr --cov-report html {posargs} diff --git a/ucapi/__init__.py b/ucapi/__init__.py index e69de29..92dc6f7 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Integration driver library for Remote Two. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" diff --git a/ucapi/api.py b/ucapi/api.py index 33df3e9..d3708ee 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1,17 +1,24 @@ +""" +Integration driver API for Remote Two. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import asyncio -import websockets import json import logging import os import socket +from asyncio import AbstractEventLoop +import websockets +from pyee import AsyncIOEventEmitter from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf -from pyee import AsyncIOEventEmitter - import ucapi.api_definitions as uc -import ucapi.entities as entities +from ucapi import entities logging.basicConfig() LOG = logging.getLogger(__name__) @@ -19,22 +26,29 @@ class IntegrationAPI: - def __init__(self, loop, type="default"): + """Integration API to communicate with Remote Two.""" + + def __init__(self, loop: AbstractEventLoop): + """ + Create an integration driver API instance. + + :param loop: event loop + """ self._loop = loop self.events = AsyncIOEventEmitter(self._loop) self.driverInfo = {} - self._driverPath = None + self._driver_path = None self.state = uc.DEVICE_STATES.DISCONNECTED - self._serverTask = None + self._server_task = None self._clients = set() - - self._interface = os.getenv('UC_INTEGRATION_INTERFACE') - self._port = os.getenv('UC_INTEGRATION_HTTP_PORT') + + self._interface = os.getenv("UC_INTEGRATION_INTERFACE") + self._port = os.getenv("UC_INTEGRATION_HTTP_PORT") # TODO: add support for secured - self._httpsEnabled = os.getenv('UC_INTEGRATION_HTTPS_ENABLED', 'False').lower() in ('true', '1', 't') - self._disableMdnsPublish = os.getenv('UC_DISABLE_MDNS_PUBLISH', 'False').lower() in ('true', '1', 't') + self._https_enabled = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "False").lower() in ("true", "1", "t") + self._disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "False").lower() in ("true", "1", "t") - self.configDirPath = os.getenv('UC_CONFIG_HOME') + self.configDirPath = os.getenv("UC_CONFIG_HOME") self.availableEntities = entities.Entities("available", self._loop) self.configuredEntities = entities.Entities("configured", self._loop) @@ -42,40 +56,44 @@ def __init__(self, loop, type="default"): # Setup event loop asyncio.set_event_loop(self._loop) - async def init(self, driverPath): - self._driverPath = driverPath + async def init(self, driver_path): + """ + Load driver configuration and start integration-API WebSocket server. + + :param driver_path: path to configuration file + """ + self._driver_path = driver_path self._port = self.driverInfo["port"] if "port" in self.driverInfo else self._port @self.configuredEntities.events.on(uc.EVENTS.ENTITY_ATTRIBUTES_UPDATED) - async def event_handler(id, entityType, attributes): + async def event_handler(entity_id, entity_type, attributes): data = { - "entity_id": id, - "entity_type": entityType, + "entity_id": entity_id, + "entity_type": entity_type, "attributes": attributes, } - await self._broadcastEvent( - uc.MSG_EVENTS.ENTITY_CHANGE, data, uc.EVENT_CATEGORY.ENTITY - ) + await self._broadcast_event(uc.MSG_EVENTS.ENTITY_CHANGE, data, uc.EVENT_CATEGORY.ENTITY) # Load driver config - file = open(self._driverPath) - self.driverInfo = json.load(file) - file.close() + with open(self._driver_path, "r", encoding="utf-8") as file: + self.driverInfo = json.load(file) # Set driver URL - self.driverInfo["driver_url"] = self.getDriverUrl( - self.driverInfo["driver_url"] if "driver_url" in self.driverInfo else self._interface, - self._port + # TODO verify _get_driver_url: logic might not be correct, + # move all parameter logic inside method to better understand what this does + self.driverInfo["driver_url"] = self._get_driver_url( + self.driverInfo["driver_url"] if "driver_url" in self.driverInfo else self._interface, self._port ) # Set driver name - name = self._getDefaultLanguageString(self.driverInfo["name"], "Unknown driver") - url = self._interface + name = _get_default_language_string(self.driverInfo["name"], "Unknown driver") + # TODO there seems to be missing something with `url` + # url = self._interface addr = socket.gethostbyname(socket.gethostname()) if self.driverInfo["driver_url"] is None else self._interface - if self._disableMdnsPublish is False: + if self._disable_mdns_publish is False: # Setup zeroconf service info info = AsyncServiceInfo( "_uc-integration._tcp.local.", @@ -91,53 +109,29 @@ async def event_handler(id, entityType, attributes): zeroconf = AsyncZeroconf(ip_version=IPVersion.V4Only) await zeroconf.async_register_service(info) - self._serverTask = self._loop.create_task(self._startWebSocketServer()) + self._server_task = self._loop.create_task(self._start_web_socket_server()) LOG.info( "Driver is up: %s, version: %s, listening on: %s", self.driverInfo["driver_id"], self.driverInfo["version"], - self.driverInfo["driver_url"] + self.driverInfo["driver_url"], ) - def getDriverUrl(self, driverUrl, port): - if driverUrl is not None: - if driverUrl.startswith("ws://") or driverUrl.startswith("wss://"): - return driverUrl + def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None: + if driver_url is not None: + if driver_url.startswith("ws://") or driver_url.startswith("wss://"): + return driver_url return "ws://" + self._interface + ":" + port return None - def _getDefaultLanguageString(self, text, defaultText="Undefined"): - if text is None: - return defaultText - - if "en" in text: - return text["en"] - - for index, key, value in text.items(): - if index == 0: - defaultText = value - - if key.startswith("en-"): - return text[key] - - return defaultText - - def _toLanguageObject(self, text): - if text is None: - return None - elif isinstance(text, str): - return {"en": text} - else: - return text - - async def _startWebSocketServer(self): - async with websockets.serve(self._handleWs, self._interface, int(self._port)): + async def _start_web_socket_server(self): + async with websockets.serve(self._handle_ws, self._interface, int(self._port)): await asyncio.Future() - async def _handleWs(self, websocket): + async def _handle_ws(self, websocket): try: self._clients.add(websocket) LOG.info("WS: Client added") @@ -147,7 +141,7 @@ async def _handleWs(self, websocket): async for message in websocket: # process message - await self._processWsMessage(websocket, message) + await self._process_ws_message(websocket, message) except websockets.ConnectionClosedOK: LOG.info("WS: Connection Closed") @@ -160,98 +154,90 @@ async def _handleWs(self, websocket): LOG.info("WS: Client removed") self.events.emit(uc.EVENTS.DISCONNECT) - async def _sendOkResult(self, websocket, id, msgData={}): - await self._sendResponse(websocket, id, "result", msgData, 200) + async def _send_ok_result(self, websocket, req_id, msg_data={}): + await self._send_response(websocket, req_id, "result", msg_data, 200) - async def _sendErrorResult(self, websocket, id, statusCode=500, msgData={}): - await self._sendResponse(websocket, id, "result", msgData, statusCode) + async def _send_error_result(self, websocket, req_id, status_code=500, msg_data={}): + await self._send_response(websocket, req_id, "result", msg_data, status_code) - async def _sendResponse( - self, websocket, id, msg, msgData, statusCode=uc.STATUS_CODES.OK - ): + async def _send_response(self, websocket, req_id, msg, msg_data, status_code=uc.STATUS_CODES.OK): data = { "kind": "resp", - "req_id": id, - "code": int(statusCode), + "req_id": req_id, + "code": int(status_code), "msg": msg, - "msg_data": msgData, + "msg_data": msg_data, } if websocket in self._clients: - dataDump = json.dumps(data) - LOG.debug("->: " + dataDump) - await websocket.send(dataDump) + data_dump = json.dumps(data) + LOG.debug("->: %s", data_dump) + await websocket.send(data_dump) else: LOG.error("Error sending response: connection no longer established") - async def _broadcastEvent(self, msg, msgData, category): - data = {"kind": "event", "msg": msg, "msg_data": msgData, "cat": category} + async def _broadcast_event(self, msg, msg_data, category): + data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} for websocket in self._clients: - dataDump = json.dumps(data) - LOG.debug("->: " + dataDump) - await websocket.send(dataDump) + data_dump = json.dumps(data) + LOG.debug("->: %s", data_dump) + await websocket.send(data_dump) - async def _sendEvent(self, websocket, msg, msgData, category): - data = {"kind": "event", "msg": msg, "msg_data": msgData, "cat": category} + async def _send_event(self, websocket, msg, msg_data, category): + data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} if websocket in self._clients: - dataDump = json.dumps(data) - LOG.debug("->: " + dataDump) - await websocket.send(dataDump) + data_dump = json.dumps(data) + LOG.debug("->: %s", data_dump) + await websocket.send(data_dump) else: LOG.error("Error sending event: connection no longer established") - async def _processWsMessage(self, websocket, message): - LOG.debug("<-: " + message) + async def _process_ws_message(self, websocket, message): + LOG.debug("<-: %s", message) data = json.loads(message) kind = data["kind"] - id = data["id"] if "id" in data else None + req_id = data["id"] if "id" in data else None msg = data["msg"] - msgData = data["msg_data"] if "msg_data" in data else None + msg_data = data["msg_data"] if "msg_data" in data else None if kind == "req": if msg == uc.MESSAGES.GET_DRIVER_VERSION: - await self._sendResponse( - websocket, id, uc.MSG_EVENTS.DRIVER_VERSION, self.getDriverVersion() - ) + await self._send_response(websocket, req_id, uc.MSG_EVENTS.DRIVER_VERSION, self.getDriverVersion()) elif msg == uc.MESSAGES.GET_DEVICE_STATE: - await self._sendResponse( - websocket, id, uc.MSG_EVENTS.DEVICE_STATE, self.state - ) + await self._send_response(websocket, req_id, uc.MSG_EVENTS.DEVICE_STATE, self.state) elif msg == uc.MESSAGES.GET_AVAILABLE_ENTITIES: - availableEntities = self.availableEntities.getEntities() - await self._sendResponse( + available_entities = self.availableEntities.getEntities() + await self._send_response( websocket, - id, + req_id, uc.MSG_EVENTS.AVAILABLE_ENTITIES, - {"available_entities": availableEntities}, + {"available_entities": available_entities}, ) elif msg == uc.MESSAGES.GET_ENTITY_STATES: - entityStates = await self.configuredEntities.getStates() - await self._sendResponse( + entity_states = await self.configuredEntities.getStates() + await self._send_response( websocket, - id, + req_id, uc.MSG_EVENTS.ENTITY_STATES, - entityStates, + entity_states, ) elif msg == uc.MESSAGES.ENTITY_COMMAND: - await self._entityCommand(websocket, id, msgData) + await self._entity_command(websocket, req_id, msg_data) elif msg == uc.MESSAGES.SUBSCRIBE_EVENTS: - await self._subscribeEvents(msgData) - await self._sendOkResult(websocket, id) + await self._subscribe_events(msg_data) + await self._send_ok_result(websocket, req_id) elif msg == uc.MESSAGES.UNSUBSCRIBE_EVENTS: - await self._unsubscribeEvents(msgData) - await self._sendOkResult(websocket, id) + await self._unsubscribe_events(msg_data) + await self._send_ok_result(websocket, req_id) elif msg == uc.MESSAGES.GET_DRIVER_METADATA: - await self._sendResponse( - websocket, id, uc.MSG_EVENTS.DRIVER_METADATA, self.driverInfo - ) + await self._send_response(websocket, req_id, uc.MSG_EVENTS.DRIVER_METADATA, self.driverInfo) elif msg == uc.MESSAGES.SETUP_DRIVER: - await self._setupDriver(websocket, id, msgData) + await self._setup_driver(websocket, req_id, msg_data) elif msg == uc.MESSAGES.SET_DRIVER_USER_DATA: - await self._setDriverUserData(websocket, id, msgData) + await self._set_driver_user_data(websocket, req_id, msg_data) elif kind == "event": if msg == uc.MSG_EVENTS.CONNECT: @@ -266,7 +252,7 @@ async def _processWsMessage(self, websocket, message): self.events.emit(uc.EVENTS.SETUP_DRIVER_ABORT) async def _authenticate(self, websocket, success): - await self._sendResponse( + await self._send_response( websocket, 0, uc.MESSAGES.AUTHENTICATION, @@ -286,12 +272,10 @@ def getDriverVersion(self): async def setDeviceState(self, state): self.state = state - await self._broadcastEvent( - uc.MSG_EVENTS.DEVICE_STATE, {"state": self.state}, uc.EVENT_CATEGORY.DEVICE - ) + await self._broadcast_event(uc.MSG_EVENTS.DEVICE_STATE, {"state": self.state}, uc.EVENT_CATEGORY.DEVICE) - async def _subscribeEvents(self, entities): - for entityId in entities["entity_ids"]: + async def _subscribe_events(self, subscribe): + for entityId in subscribe["entity_ids"]: entity = self.availableEntities.getEntity(entityId) if entity is not None: self.configuredEntities.addEntity(entity) @@ -301,98 +285,106 @@ async def _subscribeEvents(self, entities): entityId, ) - self.events.emit(uc.EVENTS.SUBSCRIBE_ENTITIES, entities["entity_ids"]) + self.events.emit(uc.EVENTS.SUBSCRIBE_ENTITIES, subscribe["entity_ids"]) - async def _unsubscribeEvents(self, entities): + async def _unsubscribe_events(self, unsubscribe): res = True - for entityId in entities["entity_ids"]: + for entityId in unsubscribe["entity_ids"]: if self.configuredEntities.removeEntity(entityId) is False: res = False - self.events.emit(uc.EVENTS.UNSUBSCRIBE_ENTITIES, entities["entity_ids"]) + self.events.emit(uc.EVENTS.UNSUBSCRIBE_ENTITIES, unsubscribe["entity_ids"]) return res - async def _entityCommand(self, websocket, id, msgData): + async def _entity_command(self, websocket, req_id, msg_data): self.events.emit( uc.EVENTS.ENTITY_COMMAND, websocket, - id, - msgData["entity_id"], - msgData["entity_type"], - msgData["cmd_id"], - msgData["params"] if "params" in msgData else None, + req_id, + msg_data["entity_id"], + msg_data["entity_type"], + msg_data["cmd_id"], + msg_data["params"] if "params" in msg_data else None, ) - async def _setupDriver(self, websocket, id, data): - self.events.emit(uc.EVENTS.SETUP_DRIVER, websocket, id, data["setup_data"]) + async def _setup_driver(self, websocket, req_id, data): + self.events.emit(uc.EVENTS.SETUP_DRIVER, websocket, req_id, data["setup_data"]) - async def _setDriverUserData(self, websocket, id, data): + async def _set_driver_user_data(self, websocket, req_id, data): if "input_values" in data: - self.events.emit( - uc.EVENTS.SETUP_DRIVER_USER_DATA, websocket, id, data["input_values"] - ) + self.events.emit(uc.EVENTS.SETUP_DRIVER_USER_DATA, websocket, req_id, data["input_values"]) elif "confirm" in data: - self.events.emit( - uc.EVENTS.SETUP_DRIVER_USER_CONFIRMATION, websocket, id, data=None - ) + self.events.emit(uc.EVENTS.SETUP_DRIVER_USER_CONFIRMATION, websocket, req_id, data=None) else: LOG.warning("Unsupported set_driver_user_data payload received") - async def acknowledgeCommand(self, websocket, id, statusCode=uc.STATUS_CODES.OK): - await self._sendResponse(websocket, id, "result", {}, statusCode) + async def acknowledgeCommand(self, websocket, req_id, status_code=uc.STATUS_CODES.OK): + await self._send_response(websocket, req_id, "result", {}, status_code) async def driverSetupProgress(self, websocket): data = {"event_type": "SETUP", "state": "SETUP"} - await self._sendEvent( - websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE - ) + await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) - async def requestDriverSetupUserConfirmation( - self, websocket, title, msg1=None, image=None, msg2=None - ): + async def requestDriverSetupUserConfirmation(self, websocket, title, msg1=None, image=None, msg2=None): data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", "require_user_action": { "confirmation": { - "title": self._toLanguageObject(title), - "message1": self._toLanguageObject(msg1), + "title": _to_language_object(title), + "message1": _to_language_object(msg1), "image": image, - "message2": self._toLanguageObject(msg2), + "message2": _to_language_object(msg2), } }, } - await self._sendEvent( - websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE - ) + await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) async def requestDriverSetupUserInput(self, websocket, title, settings): data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": { - "input": {"title": self._toLanguageObject(title), "settings": settings} - }, + "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._sendEvent( - websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE - ) + await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) async def driverSetupComplete(self, websocket): data = {"event_type": "STOP", "state": "OK"} - await self._sendEvent( - websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE - ) + await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) async def driverSetupError(self, websocket, error="OTHER"): data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._sendEvent( - websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE - ) + await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + + +def _to_language_object(text): + if text is None: + return None + if isinstance(text, str): + return {"en": text} + + return text + + +def _get_default_language_string(text, default_text="Undefined"): + if text is None: + return default_text + + if "en" in text: + return text["en"] + + for index, key, value in text.items(): + if index == 0: + default_text = value + + if key.startswith("en-"): + return text[key] + + return default_text diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3adb341..fac148f 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -1,12 +1,25 @@ -from enum import IntEnum +""" +API definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + +from enum import Enum, IntEnum + + +class DEVICE_STATES(str, Enum): + """Device states.""" + + CONNECTED = "CONNECTED" + CONNECTING = "CONNECTING" + DISCONNECTED = "DISCONNECTED" + ERROR = "ERROR" -class DEVICE_STATES(): - CONNECTED = 'CONNECTED' - CONNECTING = 'CONNECTING' - DISCONNECTED = 'DISCONNECTED' - ERROR = 'ERROR' class STATUS_CODES(IntEnum): + """Response status codes.""" + OK = 200 BAD_REQUEST = 400 UNAUTHORIZED = 401 @@ -14,47 +27,59 @@ class STATUS_CODES(IntEnum): SERVER_ERROR = 500 SERVICE_UNAVAILABLE = 503 -class MESSAGES(): - AUTHENTICATION = 'authentication' - GET_DRIVER_VERSION = 'get_driver_version' - GET_DEVICE_STATE = 'get_device_state' - GET_AVAILABLE_ENTITIES = 'get_available_entities' - GET_ENTITY_STATES = 'get_entity_states' - SUBSCRIBE_EVENTS = 'subscribe_events' - UNSUBSCRIBE_EVENTS = 'unsubscribe_events' - ENTITY_COMMAND = 'entity_command' - GET_DRIVER_METADATA = 'get_driver_metadata' - SETUP_DRIVER = 'setup_driver' - SET_DRIVER_USER_DATA = 'set_driver_user_data' - -class MSG_EVENTS(): - CONNECT = 'connect' - DISCONNECT = 'disconnect' - ENTER_STANDBY = 'enter_standby' - EXIT_STANDBY = 'exit_standby' - DRIVER_VERSION = 'driver_version' - DEVICE_STATE = 'device_state' - AVAILABLE_ENTITIES = 'available_entities' - ENTITY_STATES = 'entity_states' - ENTITY_CHANGE = 'entity_change' - DRIVER_METADATA = 'driver_metadata' - DRIVER_SETUP_CHANGE = 'driver_setup_change' - ABORT_DRIVER_SETUP = 'abort_driver_setup' - -class EVENTS(): - ENTITY_COMMAND = 'entity_command' - ENTITY_ATTRIBUTES_UPDATED = 'entity_attributes_updated' - SUBSCRIBE_ENTITIES = 'subscribe_entities' - UNSUBSCRIBE_ENTITIES = 'unsubscribe_entities' - SETUP_DRIVER = 'setup_driver' - SETUP_DRIVER_USER_DATA = 'setup_driver_user_data' - SETUP_DRIVER_USER_CONFIRMATION = 'setup_driver_user_confirmation' - SETUP_DRIVER_ABORT = 'setup_driver_abort' - CONNECT = 'connect' - DISCONNECT = 'disconnect' - ENTER_STANDBY = 'enter_standby' - EXIT_STANDBY = 'exit_standby' - -class EVENT_CATEGORY(): - DEVICE = 'DEVICE' - ENTITY = 'ENTITY' \ No newline at end of file + +class MESSAGES(str, Enum): + """Request messages from Remote Two.""" + + AUTHENTICATION = "authentication" + GET_DRIVER_VERSION = "get_driver_version" + GET_DEVICE_STATE = "get_device_state" + GET_AVAILABLE_ENTITIES = "get_available_entities" + GET_ENTITY_STATES = "get_entity_states" + SUBSCRIBE_EVENTS = "subscribe_events" + UNSUBSCRIBE_EVENTS = "unsubscribe_events" + ENTITY_COMMAND = "entity_command" + GET_DRIVER_METADATA = "get_driver_metadata" + SETUP_DRIVER = "setup_driver" + SET_DRIVER_USER_DATA = "set_driver_user_data" + + +class MSG_EVENTS(str, Enum): + """Event messages from Remote Two.""" + + CONNECT = "connect" + DISCONNECT = "disconnect" + ENTER_STANDBY = "enter_standby" + EXIT_STANDBY = "exit_standby" + DRIVER_VERSION = "driver_version" + DEVICE_STATE = "device_state" + AVAILABLE_ENTITIES = "available_entities" + ENTITY_STATES = "entity_states" + ENTITY_CHANGE = "entity_change" + DRIVER_METADATA = "driver_metadata" + DRIVER_SETUP_CHANGE = "driver_setup_change" + ABORT_DRIVER_SETUP = "abort_driver_setup" + + +class EVENTS(str, Enum): + """Internal events.""" + + ENTITY_COMMAND = "entity_command" + ENTITY_ATTRIBUTES_UPDATED = "entity_attributes_updated" + SUBSCRIBE_ENTITIES = "subscribe_entities" + UNSUBSCRIBE_ENTITIES = "unsubscribe_entities" + SETUP_DRIVER = "setup_driver" + SETUP_DRIVER_USER_DATA = "setup_driver_user_data" + SETUP_DRIVER_USER_CONFIRMATION = "setup_driver_user_confirmation" + SETUP_DRIVER_ABORT = "setup_driver_abort" + CONNECT = "connect" + DISCONNECT = "disconnect" + ENTER_STANDBY = "enter_standby" + EXIT_STANDBY = "exit_standby" + + +class EVENT_CATEGORY(str, Enum): + """Event categories.""" + + DEVICE = "DEVICE" + ENTITY = "ENTITY" diff --git a/ucapi/button.py b/ucapi/button.py index 515f9c8..2db216a 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -1,30 +1,57 @@ +""" +Button entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Button entity states.""" + UNAVAILABLE = "UNAVAILABLE" AVAILABLE = "AVAILABLE" -class ATTRIBUTES: +class ATTRIBUTES(str, Enum): + """Button entity attributes.""" + STATE = "state" -class COMMANDS: +class COMMANDS(str, Enum): + """Button entity commands.""" + PUSH = "push" class Button(Entity): - def __init__(self, id, name, area=None, type="default"): + """ + Button entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_button.md + for more information. + """ + + def __init__(self, identifier: str, name: str | dict, area: str | None = None): + """ + Create button-entity instance. + + :param identifier: entity identifier + :param name: friendly name, either a string or a language dictionary + :param area: optional area name + """ super().__init__( - id, + identifier, name, TYPES.BUTTON, ["press"], diff --git a/ucapi/climate.py b/ucapi/climate.py index 334e82f..c7ef139 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -1,14 +1,23 @@ +""" +Climate entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Climate entity states.""" + UNAVAILABLE = "UNAVAILABLE" UNKNOWN = "UNKNOWN" OFF = "OFF" @@ -19,7 +28,9 @@ class STATES: AUTO = "AUTO" -class FEATURES: +class FEATURES(str, Enum): + """Climate entity features.""" + ON_OFF = "on_off" HEAT = "heat" COOL = "cool" @@ -29,7 +40,9 @@ class FEATURES: FAN = "fan" -class ATTRIBUTES: +class ATTRIBUTES(str, Enum): + """Climate entity attributes.""" + STATE = "state" CURRENT_TEMPERATURE = "current_temperature" TARGET_TEMPERATURE = "target_temperature" @@ -38,7 +51,9 @@ class ATTRIBUTES: FAN_MODE = "fan_mode" -class COMMANDS: +class COMMANDS(str, Enum): + """Climate entity commands.""" + ON = "on" OFF = "off" HVAC_MODE = "hvac_mode" @@ -47,11 +62,13 @@ class COMMANDS: FAN_MODE = "fan_mode" -class DEVICECLASSES: - """""" +class DEVICECLASSES(str, Enum): + """Climate entity device classes.""" + +class OPTIONS(str, Enum): + """Climate entity options.""" -class OPTIONS: TEMPERATURE_UNIT = "temperature_unit" TARGET_TEMPERATURE_STEP = "target_temperature_step" MAX_TEMPERATURE = "max_temperature" @@ -60,19 +77,36 @@ class OPTIONS: class Climate(Entity): + """ + Climate entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_climate.md + for more information. + """ + def __init__( self, - id, - name, - features, - attributes, - deviceClass=None, - options=None, - area=None, - type="default", + identifier: str, + name: str | dict, + features: list[FEATURES], + attributes: dict, + deviceClass: str | None = None, + options: dict | None = None, + area: str | None = None, ): + """ + Create a climate-entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: climate features + :param attributes: climate attributes + :param deviceClass: optional climate device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.CLIMATE, features, diff --git a/ucapi/cover.py b/ucapi/cover.py index 1433354..f520ab4 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -1,77 +1,111 @@ +""" +Cover entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: - UNAVAILABLE = 'UNAVAILABLE' - UNKNOWN = 'UNKNOWN' - OPENING = 'OPENING' - OPEN = 'OPEN' - CLOSING = 'CLOSING' - CLOSED = 'CLOSED' +class STATES(str, Enum): + """Cover entity states.""" + + UNAVAILABLE = "UNAVAILABLE" + UNKNOWN = "UNKNOWN" + OPENING = "OPENING" + OPEN = "OPEN" + CLOSING = "CLOSING" + CLOSED = "CLOSED" + + +class FEATURES(str, Enum): + """Cover entity features.""" + OPEN = "open" + CLOSE = "close" + STOP = "stop" + POSITION = "position" + TILT = "tilt" + TILT_STOP = "tilt_stop" + TILT_POSITION = "tilt_position" -class FEATURES: - OPEN = 'open' - CLOSE = 'close' - STOP = 'stop' - POSITION = 'position' - TILT = 'tilt' - TILT_STOP = 'tilt_stop' - TILT_POSITION = 'tilt_position' +class ATTRIBUTES(str, Enum): + """Cover entity attributes.""" -class ATTRIBUTES: - STATE = 'state' - POSITION = 'position' - TILT_POSITION = 'tilt_position' + STATE = "state" + POSITION = "position" + TILT_POSITION = "tilt_position" -class COMMANDS: - OPEN = 'open' - CLOSE = 'close' - STOP = 'stop' - POSITION = 'position' - TILT = 'tilt' - TILT_UP = 'tilt_up' - TILT_DOWN = 'tilt_down' - TILT_STOP = 'tilt_stop' +class COMMANDS(str, Enum): + """Cover entity commands.""" + OPEN = "open" + CLOSE = "close" + STOP = "stop" + POSITION = "position" + TILT = "tilt" + TILT_UP = "tilt_up" + TILT_DOWN = "tilt_down" + TILT_STOP = "tilt_stop" -class DEVICECLASSES: - BLIND = 'blind' - CURTAIN = 'curtain' - GARAGE = 'garage' - SHADE = 'shade' - DOOR = 'door' - GATE = 'gate' - WINDOW = 'window' +class DEVICECLASSES(str, Enum): + """Cover entity device classes.""" -class OPTIONS: - "" + BLIND = "blind" + CURTAIN = "curtain" + GARAGE = "garage" + SHADE = "shade" + DOOR = "door" + GATE = "gate" + WINDOW = "window" + + +class OPTIONS(str, Enum): + """Cover entity options.""" class Cover(Entity): + """ + Cover entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_cover.md + for more information. + """ + def __init__( self, - id, - name, - features, - attributes, - deviceClass=None, - options=None, - area=None, - type="default", + identifier: str, + name: str | dict, + features: list[FEATURES], + attributes: dict, + deviceClass: DEVICECLASSES | None = None, + options: dict | None = None, + area: str | None = None, ): + """ + Create cover-entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: cover features + :param attributes: cover attributes + :param deviceClass: optional cover device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.COVER, features, diff --git a/ucapi/entities.py b/ucapi/entities.py index 53cec07..21957a2 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -1,14 +1,17 @@ -import json -import logging +""" +Entity store. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" -import ucapi.button as button -import ucapi.media_player as media_player +import logging +from asyncio import AbstractEventLoop from pyee import AsyncIOEventEmitter -from ucapi.entity import TYPES -from ucapi.entity import Entity from ucapi.api_definitions import EVENTS +from ucapi.entity import Entity logging.basicConfig() LOG = logging.getLogger(__name__) @@ -16,87 +19,105 @@ class Entities: - def __init__(self, id, loop, type="default"): - self.id = id + """Simple entity storage.""" + + def __init__(self, identifier: str, loop: AbstractEventLoop): + """ + Create entity storage instance with the given identifier. + + :param identifier: storage identifier. + :param loop: event loop + """ + self.id = identifier self._loop = loop self._storage = {} self.events = AsyncIOEventEmitter(self._loop) - def contains(self, id): - return id in self._storage + def contains(self, entity_id: str) -> bool: + """Check if storage contains an entity with given identifier.""" + return entity_id in self._storage - def getEntity(self, id): - if id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, id) + def getEntity(self, entity_id: str) -> Entity | None: + """Retrieve entity with given identifier.""" + if entity_id not in self._storage: + LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) return None - return self._storage[id] + return self._storage[entity_id] - def addEntity(self, entity: Entity): + def addEntity(self, entity: Entity) -> bool: + """Add entity to storage.""" if entity.id in self._storage: - LOG.debug( - "ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id - ) + LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id) return False self._storage[entity.id] = entity LOG.debug("ENTITIES(%s): Entity added with id: %s", self.id, entity.id) return True - def removeEntity(self, id): - if id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, id) + def removeEntity(self, entity_id: str) -> bool: + """Remove entity from storage.""" + if entity_id not in self._storage: + LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) return True - del self._storage[id] - LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, id) + del self._storage[entity_id] + LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, entity_id) return True - def updateEntityAttributes(self, id, attributes): - if id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, id) + def updateEntityAttributes(self, entity_id: str, attributes: dict) -> bool: + """Update entity attributes.""" + if entity_id not in self._storage: + LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + # TODO why return True here? return True - for key in attributes: - self._storage[id].attributes[key] = attributes[key] + for key in attributes: + self._storage[entity_id].attributes[key] = attributes[key] self.events.emit( EVENTS.ENTITY_ATTRIBUTES_UPDATED, - id, - self._storage[id].entityType, + entity_id, + self._storage[entity_id].entityType, attributes, ) - LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, id) + LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, entity_id) return True - def getEntities(self): + def getEntities(self) -> list[dict[str, any]]: + """ + Get all entity information in storage. + + Attributes are not returned. + """ entities = [] - for entity in self._storage: + for entity in self._storage.values(): res = { - "entity_id": self._storage[entity].id, - "entity_type": self._storage[entity].entityType, - "device_id": self._storage[entity].deviceId, - "features": self._storage[entity].features, - "name": self._storage[entity].name, - "area": self._storage[entity].area, - "device_class": self._storage[entity].deviceClass, + "entity_id": entity.id, + "entity_type": entity.entityType, + "device_id": entity.deviceId, + "features": entity.features, + "name": entity.name, + "area": entity.area, + "device_class": entity.deviceClass, } entities.append(res) return entities - async def getStates(self): + async def getStates(self) -> list[dict[str, any]]: + """Get all entity information with entity_id, entity_type, device_id, attributes.""" entities = [] - for entity in self._storage: + for entity in self._storage.values(): res = { - "entity_id": self._storage[entity].id, - "entity_type": self._storage[entity].entityType, - "device_id": self._storage[entity].deviceId, - "attributes": self._storage[entity].attributes, + "entity_id": entity.id, + "entity_type": entity.entityType, + "device_id": entity.deviceId, + "attributes": entity.attributes, } entities.append(res) @@ -104,4 +125,5 @@ async def getStates(self): return entities def clear(self): + """Remove all entities from storage.""" self._storage = {} diff --git a/ucapi/entity.py b/ucapi/entity.py index 66e06a9..0aae614 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -1,4 +1,16 @@ -class TYPES: +""" +Entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + +from enum import Enum + + +class TYPES(str, Enum): + """Entity types.""" + COVER = "cover" BUTTON = "button" CLIMATE = "climate" @@ -9,24 +21,42 @@ class TYPES: class Entity: + """ + Entity base class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/ + for more information. + """ + def __init__( self, - id, - name, - entityType: TYPES, - features, - attributes, - deviceClass, - options, - area, - type="default", + identifier: str, + name: str | dict, + entity_type: TYPES, + features: list[str], + attributes: dict, + device_class: str | None, + options: dict | None, + area: str | None = None, ): - self.id = id + """ + Initialize entity. + + :param identifier: entity identifier + :param name: friendly name, either a string or a language dictionary + :param entity_type: entity type + :param features: entity feature array + :param attributes: entity attributes + :param device_class: entity device class + :param options: entity options + :param area: optional area name + """ + self.id = identifier self.name = {"en": name} if isinstance(name, str) else name - self.entityType = entityType + self.entityType = entity_type self.deviceId = None self.features = features self.attributes = attributes - self.deviceClass = deviceClass + self.deviceClass = device_class self.options = options self.area = area diff --git a/ucapi/light.py b/ucapi/light.py index 279fc05..7f15383 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -1,21 +1,32 @@ +""" +Light entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Light entity states.""" + UNAVAILABLE = "UNAVAILABLE" UNKNOWN = "UNKNOWN" ON = "ON" OFF = "OFF" -class FEATURES: +class FEATURES(str, Enum): + """Light entity features.""" + ON_OFF = "on_off" TOGGLE = "toggle" DIM = "dim" @@ -23,7 +34,9 @@ class FEATURES: COLOR_TEMPERATURE = "color_temperature" -class ATTRIBUTES: +class ATTRIBUTES(str, Enum): + """Light entity attributes.""" + STATE = "state" HUE = "hue" SATURATION = "saturation" @@ -31,34 +44,55 @@ class ATTRIBUTES: COLOR_TEMPERATURE = "color_temperature" -class COMMANDS: +class COMMANDS(str, Enum): + """Light entity commands.""" + ON = "on" OFF = "off" TOGGLE = "toggle" -class DEVICECLASSES: - """""" +class DEVICECLASSES(str, Enum): + """Light entity device classes.""" + +class OPTIONS(str, Enum): + """Light entity options.""" -class OPTIONS: COLOR_TEMPERATURE_STEPS = "color_temperature_steps" class Light(Entity): + """ + Switch entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_light.md + for more information. + """ + def __init__( self, - id, - name, - features, - attributes, - deviceClass=None, - options=None, - area=None, - type="default", + identifier: str, + name: str | dict, + features: list[FEATURES], + attributes: dict, + deviceClass: DEVICECLASSES | None = None, + options: dict | None = None, + area: str | None = None, ): + """ + Create light-entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: light features + :param attributes: light attributes + :param deviceClass: optional light device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.LIGHT, features, diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 0d5e6ac..0bc86fa 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -1,14 +1,23 @@ +""" +Media-player entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Media-player entity states.""" + UNAVAILABLE = "UNAVAILABLE" UNKNOWN = "UNKNOWN" ON = "ON" @@ -19,7 +28,9 @@ class STATES: BUFFERING = "BUFFERING" -class FEATURES: +class FEATURES(str, Enum): + """Media-player entity features.""" + ON_OFF = "on_off" TOGGLE = "toggle" VOLUME = "volume" @@ -52,7 +63,9 @@ class FEATURES: SELECT_SOUND_MODE = "select_sound_mode" -class ATTRIBUTES: +class ATTRIBUTES(str, Enum): + """Media-player entity attributes.""" + STATE = "state" VOLUME = "volume" MUTED = "muted" @@ -71,7 +84,9 @@ class ATTRIBUTES: SOUND_MODE_LIST = "sound_mode_list" -class COMMANDS: +class COMMANDS(str, Enum): + """Media-player entity commands.""" + ON = "on" OFF = "off" TOGGLE = "toggle" @@ -109,7 +124,9 @@ class COMMANDS: SEARCH = "search" -class DEVICECLASSES: +class DEVICECLASSES(str, Enum): + """Media-player entity device classes.""" + RECEIVER = "receiver" SET_TOP_BOX = "set_top_box" SPEAKER = "speaker" @@ -117,21 +134,53 @@ class DEVICECLASSES: TV = "tv" -class OPTIONS: +class OPTIONS(str, Enum): + """Media-player entity options.""" + VOLUME_STEPS = "volume_steps" -class MEDIA_TYPE: +class MEDIA_TYPE(str, Enum): + """Media types.""" + MUSIC = "MUSIC" RADIO = "RADIO" TVSHOW = "TVSHOW" MOVIE = "MOVIE" VIDEO = "VIDEO" + class MediaPlayer(Entity): - def __init__(self, id, name, features, attributes, deviceClass = None, options = None, area=None, type="default"): + """ + Media-player entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md + for more information. + """ + + def __init__( + self, + identifier: str, + name: str | dict, + features: set[FEATURES], + attributes: dict, + deviceClass: DEVICECLASSES | None = None, + options: dict | None = None, + area: str | None = None, + ): + """ + Create media-player entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: media-player features + :param attributes: media-player attributes + :param deviceClass: optional media-player device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.MEDIA_PLAYER, features, diff --git a/ucapi/sensor.py b/ucapi/sensor.py index 186dea2..256e088 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -1,34 +1,47 @@ +""" +Sensor entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Sensor entity states.""" + UNAVAILABLE = "UNAVAILABLE" UNKNOWN = "UNKNOWN" ON = "ON" -class FEATURES: - """""" +class FEATURES(str, Enum): + """Sensor entity features.""" + +class ATTRIBUTES(str, Enum): + """Sensor entity attributes.""" -class ATTRIBUTES: STATE = "state" VALUE = "value" UNIT = "unit" -class COMMANDS: - """""" +class COMMANDS(str, Enum): + """Sensor entity commands.""" -class DEVICECLASSES: +class DEVICECLASSES(str, Enum): + """Sensor entity device classes.""" + CUSTOM = "custom" BATTERY = "battery" CURRENT = "current" @@ -39,7 +52,9 @@ class DEVICECLASSES: VOLTAGE = "voltage" -class OPTIONS: +class OPTIONS(str, Enum): + """Sensor entity options.""" + CUSTOM_UNIT = "custom_unit" NATIVE_UNIT = "native_unit" DECIMALS = "decimals" @@ -48,19 +63,36 @@ class OPTIONS: class Sensor(Entity): + """ + Sensor entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_sensor.md + for more information. + """ + def __init__( self, - id, - name, - features, - attributes, - deviceClass=None, - options=None, - area=None, - type="default", + identifier: str, + name: str | dict, + features: list[FEATURES], + attributes: dict, + deviceClass: DEVICECLASSES | None = None, + options: dict | None = None, + area: str | None = None, ): + """ + Create sensor-entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: sensor features + :param attributes: sensor attributes + :param deviceClass: optional sensor device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.SENSOR, features, diff --git a/ucapi/switch.py b/ucapi/switch.py index 0343193..5305021 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -1,58 +1,94 @@ +""" +Switch entity definitions. + +:copyright: (c) 2023 by Unfolded Circle ApS. +:license: MPL 2.0, see LICENSE for more details. +""" + import logging +from enum import Enum -from ucapi.entity import TYPES -from ucapi.entity import Entity +from ucapi.entity import TYPES, Entity logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES: +class STATES(str, Enum): + """Switch entity states.""" + UNAVAILABLE = "UNAVAILABLE" UNKNOWN = "UNKNOWN" ON = "ON" OFF = "OFF" -class FEATURES: +class FEATURES(str, Enum): + """Switch entity features.""" + ON_OFF = "on_off" TOGGLE = "toggle" -class ATTRIBUTES: +class ATTRIBUTES(str, Enum): + """Switch entity attributes.""" + STATE = "state" -class COMMANDS: +class COMMANDS(str, Enum): + """Switch entity commands.""" + ON = "on" OFF = "off" TOGGLE = "toggle" -class DEVICECLASSES: +class DEVICECLASSES(str, Enum): + """Switch entity device classes.""" + OUTLET = "outlet" SWITCH = "switch" -class OPTIONS: +class OPTIONS(str, Enum): + """Switch entity options.""" + READABLE = "readable" class Switch(Entity): + """ + Switch entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_switch.md + for more information. + """ + def __init__( self, - id, - name, - features, - attributes, - deviceClass=None, - options=None, - area=None, - type="default", + identifier: str, + name: str | dict, + features: list[FEATURES], + attributes: dict, + deviceClass: DEVICECLASSES | None = None, + options: dict | None = None, + area: str | None = None, ): + """ + Create switch-entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: switch features + :param attributes: switch attributes + :param deviceClass: optional switch device class + :param options: options + :param area: optional area + """ super().__init__( - id, + identifier, name, TYPES.SWITCH, features,