diff --git a/CHANGELOG.md b/CHANGELOG.md index a503869..514705c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _Changes in the next release_ +### Added +- New remote-entity type. Requires remote-core / Core Simulator version 0.43.0 or newer. + --- ## v0.1.7 - 2024-03-13 diff --git a/examples/remote.json b/examples/remote.json new file mode 100644 index 0000000..459461d --- /dev/null +++ b/examples/remote.json @@ -0,0 +1,18 @@ +{ + "driver_id": "remote_test", + "version": "0.0.1", + "min_core_api": "0.20.0", + "name": { "en": "Remote test" }, + "icon": "uc:integration", + "description": { + "en": "Minimal Python integration driver example with a remote entity." + }, + "port": 9084, + "developer": { + "name": "Unfolded Circle ApS", + "email": "hello@unfoldedcircle.com", + "url": "https://www.unfoldedcircle.com" + }, + "home_page": "https://www.unfoldedcircle.com", + "release_date": "2024-04-08" +} diff --git a/examples/remote.py b/examples/remote.py new file mode 100644 index 0000000..b00096b --- /dev/null +++ b/examples/remote.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Remote entity integration example. Bare minimum of an integration driver.""" +import asyncio +import logging +import sys +from typing import Any + +import ucapi +from ucapi import remote +from ucapi.remote import * + +loop = asyncio.get_event_loop() +api = ucapi.IntegrationAPI(loop) + +supported_commands = [ + "VOLUME_UP", + "VOLUME_DOWN", + "HOME", + "GUIDE", + "CONTEXT_MENU", + "CURSOR_UP", + "CURSOR_DOWN", + "CURSOR_LEFT", + "CURSOR_RIGHT", + "CURSOR_ENTER", +] + + +async def cmd_handler( + entity: ucapi.Remote, cmd_id: str, params: dict[str, Any] | None +) -> ucapi.StatusCodes: + """ + Remote command handler. + + Called by the integration-API if a command is sent to a configured remote-entity. + + :param entity: remote entity + :param cmd_id: command + :param params: optional command parameters + :return: status of the command + """ + print(f"Got {entity.id} command request: {cmd_id}") + + state = None + match cmd_id: + case remote.Commands.ON: + state = remote.States.ON + case remote.Commands.OFF: + state = remote.States.OFF + case remote.Commands.TOGGLE: + if entity.attributes[remote.Attributes.STATE] == remote.States.OFF: + state = remote.States.ON + else: + state = remote.States.OFF + case remote.Commands.SEND_CMD: + command = params.get("command") + if command not in supported_commands: + print(f"Unknown command: {command}", file=sys.stderr) + return ucapi.StatusCodes.BAD_REQUEST + + repeat = params.get("repeat", 1) + delay = params.get("delay", 0) + hold = params.get("hold", 0) + print(f"Command: {command} (repeat={repeat}, delay={delay}, hold={hold})") + case remote.Commands.SEND_CMD_SEQUENCE: + sequence = params.get("sequence") + repeat = params.get("repeat", 1) + delay = params.get("delay", 0) + hold = params.get("hold", 0) + print( + f"Command sequence: {sequence} (repeat={repeat}, delay={delay}, hold={hold})" + ) + case _: + print(f"Unsupported command: {cmd_id}", file=sys.stderr) + return ucapi.StatusCodes.BAD_REQUEST + + if state: + api.configured_entities.update_attributes( + entity.id, {remote.Attributes.STATE: state} + ) + + return ucapi.StatusCodes.OK + + +@api.listens_to(ucapi.Events.CONNECT) +async def on_connect() -> None: + # When the remote connects, we just set the device state. We are ready all the time! + await api.set_device_state(ucapi.DeviceStates.CONNECTED) + + +def create_button_mappings() -> list[DeviceButtonMapping]: + return [ + # simple short- and long-press mapping + create_btn_mapping(Buttons.HOME, "HOME", "GUIDE"), + # use channel buttons for volume control + create_btn_mapping(Buttons.CHANNEL_DOWN, "VOLUME_DOWN"), + create_btn_mapping(Buttons.CHANNEL_UP, "VOLUME_UP"), + create_btn_mapping(Buttons.DPAD_UP, "CURSOR_UP"), + create_btn_mapping(Buttons.DPAD_DOWN, "CURSOR_DOWN"), + create_btn_mapping(Buttons.DPAD_LEFT, "CURSOR_LEFT"), + create_btn_mapping(Buttons.DPAD_RIGHT, "CURSOR_RIGHT"), + # use a send command + create_btn_mapping( + Buttons.DPAD_MIDDLE, create_send_cmd("CONTEXT_MENU", hold=1000) + ), + # use a sequence command + create_btn_mapping( + Buttons.BLUE, + create_sequence_cmd( + [ + "CURSOR_UP", + "CURSOR_RIGHT", + "CURSOR_DOWN", + "CURSOR_LEFT", + ], + delay=200, + ), + ), + ] + + +def create_ui() -> list[UiPage]: + ui_page1 = UiPage("page1", "Main") + ui_page1.add(create_ui_text("Hello remote entity", 0, 0, size=Size(4, 1))) + ui_page1.add(create_ui_icon("uc:home", 0, 2, cmd="HOME")) + ui_page1.add(create_ui_icon("uc:up-arrow-bold", 2, 2, cmd="CURSOR_UP")) + ui_page1.add(create_ui_icon("uc:down-arrow-bold", 2, 4, cmd="CURSOR_DOWN")) + ui_page1.add(create_ui_icon("uc:left-arrow", 1, 3, cmd="CURSOR_LEFT")) + ui_page1.add(create_ui_icon("uc:right-arrow", 3, 3, cmd="CURSOR_RIGHT")) + ui_page1.add(create_ui_text("Ok", 2, 3, cmd="CURSOR_ENTER")) + + ui_page2 = UiPage("page2", "Page 2") + ui_page2.add( + create_ui_text( + "Pump up the volume!", + 0, + 0, + size=Size(4, 2), + cmd=create_send_cmd("VOLUME_UP", repeat=5), + ) + ) + ui_page2.add( + create_ui_text( + "Test sequence", + 0, + 4, + size=Size(4, 1), + cmd=create_sequence_cmd( + [ + "CURSOR_UP", + "CURSOR_RIGHT", + "CURSOR_DOWN", + "CURSOR_LEFT", + ], + delay=200, + ), + ) + ) + ui_page2.add(create_ui_text("On", 0, 5, cmd="on")) + ui_page2.add(create_ui_text("Off", 1, 5, cmd="off")) + + return [ui_page1, ui_page2] + + +if __name__ == "__main__": + logging.basicConfig() + + entity = ucapi.Remote( + "remote1", + "Demo remote", + [remote.Features.ON_OFF, remote.Features.TOGGLE], + {remote.Attributes.STATE: remote.States.OFF}, + simple_commands=supported_commands, + button_mapping=create_button_mappings(), + ui_pages=create_ui(), + cmd_handler=cmd_handler, + ) + api.available_entities.add(entity) + + loop.run_until_complete(api.init("remote.json")) + loop.run_forever() diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 0915374..1223168 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -35,6 +35,7 @@ from .cover import Cover # noqa: F401 from .light import Light # noqa: F401 from .media_player import MediaPlayer # noqa: F401 +from .remote import Remote # noqa: F401 from .sensor import Sensor # noqa: F401 from .switch import Switch # noqa: F401 diff --git a/ucapi/api.py b/ucapi/api.py index 3d19ae9..c36f081 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -6,6 +6,7 @@ """ import asyncio +import dataclasses import json import logging import os @@ -32,6 +33,15 @@ _LOG.setLevel(logging.DEBUG) +class _EnhancedJSONEncoder(json.JSONEncoder): + """Python dataclass json encoder.""" + + def default(self, o): + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) + return super().default(o) + + class IntegrationAPI: """Integration API to communicate with Remote Two.""" @@ -239,7 +249,7 @@ async def _send_ws_response( } if websocket in self._clients: - data_dump = json.dumps(data) + data_dump = json.dumps(data, cls=_EnhancedJSONEncoder) _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump) await websocket.send(data_dump) else: @@ -259,10 +269,14 @@ async def _broadcast_ws_event( :param category: event category """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} - data_dump = json.dumps(data) + data_dump = json.dumps(data, cls=_EnhancedJSONEncoder) # filter fields if _LOG.isEnabledFor(logging.DEBUG): - data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump + data_log = ( + json.dumps(data, cls=_EnhancedJSONEncoder) + if filter_log_msg_data(data) + else data_dump + ) for websocket in self._clients: if _LOG.isEnabledFor(logging.DEBUG): @@ -287,11 +301,15 @@ async def _send_ws_event( websockets.ConnectionClosed: When the connection is closed. """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} - data_dump = json.dumps(data) + data_dump = json.dumps(data, cls=_EnhancedJSONEncoder) if websocket in self._clients: if _LOG.isEnabledFor(logging.DEBUG): - data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump + data_log = ( + json.dumps(data, cls=_EnhancedJSONEncoder) + if filter_log_msg_data(data) + else data_dump + ) _LOG.debug("[%s] ->: %s", websocket.remote_address, data_log) await websocket.send(data_dump) else: diff --git a/ucapi/button.py b/ucapi/button.py index f78d8ad..4f2b83e 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -59,8 +59,6 @@ def __init__( EntityTypes.BUTTON, ["press"], {Attributes.STATE: States.AVAILABLE}, - None, - None, - area, - cmd_handler, + area=area, + cmd_handler=cmd_handler, ) diff --git a/ucapi/entity.py b/ucapi/entity.py index 67c9976..1da63df 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -23,6 +23,7 @@ class EntityTypes(str, Enum): CLIMATE = "climate" LIGHT = "light" MEDIA_PLAYER = "media_player" + REMOTE = "remote" SENSOR = "sensor" SWITCH = "switch" @@ -42,8 +43,8 @@ def __init__( entity_type: EntityTypes, features: list[str], attributes: dict[str, Any], - device_class: str | None, - options: dict[str, Any] | None, + device_class: str | None = None, + options: dict[str, Any] | None = None, area: str | None = None, cmd_handler: CommandHandler = None, ): diff --git a/ucapi/remote.py b/ucapi/remote.py new file mode 100644 index 0000000..8c383e8 --- /dev/null +++ b/ucapi/remote.py @@ -0,0 +1,331 @@ +""" +Remote entity definitions. + +:copyright: (c) 2024 by Unfolded Circle ApS. +:license: MPL-2.0, see LICENSE for more details. +""" + +from dataclasses import KW_ONLY, dataclass +from enum import Enum +from typing import Any + +from ucapi.api_definitions import CommandHandler +from ucapi.entity import Entity, EntityTypes + + +class States(str, Enum): + """Remote entity states.""" + + UNAVAILABLE = "UNAVAILABLE" + UNKNOWN = "UNKNOWN" + ON = "ON" + OFF = "OFF" + + +class Features(str, Enum): + """Remote entity features.""" + + ON_OFF = "on_off" + TOGGLE = "toggle" + SEND_CMD = "send_cmd" + + +class Attributes(str, Enum): + """Remote entity attributes.""" + + STATE = "state" + + +class Commands(str, Enum): + """Remote entity commands.""" + + ON = "on" + OFF = "off" + TOGGLE = "toggle" + SEND_CMD = "send_cmd" + SEND_CMD_SEQUENCE = "send_cmd_sequence" + + +class Options(str, Enum): + """Remote entity options.""" + + SIMPLE_COMMANDS = "simple_commands" + BUTTON_MAPPING = "button_mapping" + USER_INTERFACE = "user_interface" + + +@dataclass +class EntityCommand: + """Remote command definition for a button mapping or UI page definition.""" + + cmd_id: str + params: dict[str, str | int | list[str]] | None = None + + +def create_send_cmd( + command: str, + delay: int | None = None, + repeat: int | None = None, + hold: int | None = None, +) -> EntityCommand: + """ + Create a send command. + + :param command: command to send. + :param delay: optional delay in milliseconds after the command or between repeats. + :param repeat: optional repeat count of the command. + :param hold: optional hold time in milliseconds. + :return: the created EntityCommand. + """ + params = {"command": command} + if delay: + params["delay"] = delay + if repeat: + params["repeat"] = repeat + if hold: + params["hold"] = hold + return EntityCommand(Commands.SEND_CMD.value, params) + + +def create_sequence_cmd( + sequence: list[str], + delay: int | None = None, + repeat: int | None = None, +) -> EntityCommand: + """ + Create a sequence command. + + :param sequence: list of simple commands. + :param delay: optional delay in milliseconds between the commands in the sequence. + :param repeat: optional repeat count of the sequence. + :return: the created EntityCommand. + """ + params = {"sequence": sequence} + if delay: + params["delay"] = delay + if repeat: + params["repeat"] = repeat + return EntityCommand(Commands.SEND_CMD_SEQUENCE.value, params) + + +class Buttons(str, Enum): + """Physical buttons.""" + + BACK = "BACK" + HOME = "HOME" + VOICE = "VOICE" + VOLUME_UP = "VOLUME_UP" + VOLUME_DOWN = "VOLUME_DOWN" + MUTE = "MUTE" + DPAD_UP = "DPAD_UP" + DPAD_DOWN = "DPAD_DOWN" + DPAD_LEFT = "DPAD_LEFT" + DPAD_RIGHT = "DPAD_RIGHT" + DPAD_MIDDLE = "DPAD_MIDDLE" + GREEN = "GREEN" + YELLOW = "YELLOW" + RED = "RED" + BLUE = "BLUE" + CHANNEL_UP = "CHANNEL_UP" + CHANNEL_DOWN = "CHANNEL_DOWN" + PREV = "PREV" + PLAY = "PLAY" + NEXT = "NEXT" + POWER = "POWER" + + +@dataclass +class DeviceButtonMapping: + """Physical button mapping.""" + + button: str + short_press: EntityCommand | None = None + long_press: EntityCommand | None = None + + +def create_btn_mapping( + button: Buttons, + short: str | EntityCommand | None = None, + long: str | EntityCommand | None = None, +) -> DeviceButtonMapping: + """ + Create a physical button command mapping. + + :param button: physical button identifier. + :param short: associated short-press command to the physical button. + A string parameter corresponds to a simple command, whereas an + ``EntityCommand`` allows to customize the command. + :param long: associated long-press command to the physical button + :return: the created DeviceButtonMapping + """ + if isinstance(short, str): + short = EntityCommand(short) + if isinstance(long, str): + long = EntityCommand(long) + return DeviceButtonMapping(button.value, short_press=short, long_press=long) + + +@dataclass +class Size: + """Item size in the button grid. Default size if not specified: 1x1.""" + + width: int = 1 + height: int = 1 + + +@dataclass +class Location: + """Button placement in the grid with 0-based coordinates.""" + + x: int + y: int + + +@dataclass +class UiItem: + """ + A user interface item is either an icon or text. + + - Icon and text items can be static or linked to a command specified in the + `command` field. + - Default size is 1x1 if not specified. + """ + + type: str + location: Location + size: Size | None = None + icon: str | None = None + text: str | None = None + command: EntityCommand | None = None + + +def create_ui_text( + text: str, + x: int, + y: int, + size: Size | None = None, + cmd: str | EntityCommand | None = None, +) -> UiItem: + """ + Create a text UI item. + + :param text: the text to show in the UI item. + :param x: x-position, 0-based. + :param y: y-position, 0-based. + :param size: item size, defaults to 1 x 1 if not specified. + :param cmd: associated command to the text item. A string parameter corresponds to + a simple command, whereas an ``EntityCommand`` allows to customize the + command for example with number of repeats. + :return: the created UiItem + """ + if isinstance(cmd, str): + cmd = EntityCommand(cmd) + return UiItem("text", Location(x, y), size=size, text=text, command=cmd) + + +def create_ui_icon( + icon: str, + x: int, + y: int, + size: Size | None = None, + cmd: str | EntityCommand | None = None, +) -> UiItem: + """ + Create an icon UI item. + + The icon identifier consists of a prefix and a resource identifier, + separated by `:`. Available prefixes: + - `uc:` - integrated icon font + - `custom:` - custom resource + + :param icon: the icon identifier of the icon to show in the UI item. + :param x: x-position, 0-based. + :param y: y-position, 0-based. + :param size: item size, defaults to 1 x 1 if not specified. + :param cmd: associated command to the text item. A string parameter corresponds to + a simple command, whereas an ``EntityCommand`` allows to customize the + command for example with number of repeats. + :return: the created UiItem + """ + if isinstance(cmd, str): + cmd = EntityCommand(cmd) + return UiItem("icon", Location(x, y), size=size, icon=icon, command=cmd) + + +@dataclass +class UiPage: + """ + Definition of a complete user interface page. + + Default grid size is 4x6 if not specified. + """ + + page_id: str + name: str + _: KW_ONLY + grid: Size = None + items: list[UiItem] = None + + def __post_init__(self): + """Post initialization to set required fields.""" + # grid and items are required Integration-API fields + if self.grid is None: + self.grid = Size(4, 6) + if self.items is None: + self.items = [] + + def add(self, item: UiItem): + """Append the given UiItem to the page items.""" + self.items.append(item) + + +class Remote(Entity): + """ + Remote entity class. + + See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_remote.md + for more information. + """ # noqa + + def __init__( + self, + identifier: str, + name: str | dict[str, str], + features: list[Features], + attributes: dict[str, Any], + simple_commands: list[str] | None = None, + button_mapping: list[DeviceButtonMapping] | None = None, + ui_pages: list[UiPage] | None = None, + area: str | None = None, + cmd_handler: CommandHandler = None, + ): + """ + Create remote entity instance. + + :param identifier: entity identifier + :param name: friendly name + :param features: remote features + :param attributes: remote attributes + :param simple_commands: optional list of supported remote command identifiers + :param button_mapping: optional command mapping of physical buttons + :param ui_pages: optional user interface page definitions + :param area: optional area + :param cmd_handler: handler for entity commands + """ + options: dict[str, Any] = {} + if simple_commands: + options["simple_commands"] = simple_commands + if button_mapping: + options["button_mapping"] = button_mapping + if ui_pages: + options["user_interface"] = {"pages": ui_pages} + super().__init__( + identifier, + name, + EntityTypes.REMOTE, + features, + attributes, + options=options, + area=area, + cmd_handler=cmd_handler, + )