diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c26562..4232a78 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _Changes in the next release_ -## [0.7.0-beta] - 2024-09-20 +## [0.8.0] - 2024-11-03 + +### Added + +- Added a lamp timer sensor entity + - The sensor value will be updated every time the projector is powered on or off by the remote and automatically every 30 minutes by default while the projector is powered on and the remote is not in sleep/standby mode or the integration is disconnected +- Added a remote entity + - Advanced button mappings compared to the media player entity (see [Default remote entity button mappings](/README.md#default-remote-entity-button-mappings)) and remote ui pages with all available commands. Both can be customized in the web configurator under Remotes/External + - Use command sequences in the activity editor instead of creating a macro for each sequence and map it to a button or add it to the ui grid i. All command names have to be in upper case and separated by a comma + - Support for repeat, delay and hold + - Hold just repeats the command continuously for the given hold time. There is no native hold function for the SDCP protocol as with some ir devices to activate additional functions +- Added 3 new simple commands: + - INPUT_HDMI_1 + - INPUT_HDMI_2 + - MODE_HDR_TOGGLE (toggles between On and Off) + +## [0.7.0] - 2024-09-20 ### Breaking changes @@ -19,7 +35,7 @@ _Changes in the next release_ ### Added -- Add build.yml Github action to automatically build a self-contained binary of the integration and create a release draft with the current driver version as a tag/name +- Added build.yml Github action to automatically build a self-contained binary of the integration and create a release draft with the current driver version as a tag/name ### Changed diff --git a/README.md b/README.md index 816fbe5..b8e7086 100755 --- a/README.md +++ b/README.md @@ -1,17 +1,80 @@ -# Sony Projector integration for Unfolded Circle Remote Devices +# Sony Projector integration for Unfolded Circle Remote Devices -## ⚠️ Disclaimer ⚠️ +## ⚠️ Disclaimer ⚠️ This software may contain bugs that could affect system stability. Please use it at your own risk! -## - Integration for Unfolded Circle Remote Devices running [Unfolded OS](https://www.unfoldedcircle.com/unfolded-os) (currently [Remote Two](https://www.unfoldedcircle.com/remote-two) and the upcoming [Remote 3](https://www.unfoldedcircle.com)) to control Sony projectors that support the SDCP/PJ Talk protocol. Using [uc-integration-api](https://github.com/aitatoi/integration-python-library) and a modified and extended version of [pySDCP](https://github.com/Galala7/pySDCP) that is included in this repository. -### Supported commands +## Table of Contents + +- [Entities](#entities) + - [Planned features](#planned-features) +- [Commands \& attributes](#commands--attributes) + - [Supported media player commands](#supported-media-player-commands) + - [Supported media player attributes](#supported-media-player-attributes) + - [Supported simple commands (media player \& remote entity)](#supported-simple-commands-media-player--remote-entity) + - [Supported remote entity commands](#supported-remote-entity-commands) + - [Default remote entity button mappings](#default-remote-entity-button-mappings) + - [Attributes poller](#attributes-poller) + - [Media player](#media-player) + - [Lamp timer sensor](#lamp-timer-sensor) +- [Usage](#usage) + - [Limitations](#limitations) + - [Known supported projectors](#known-supported-projectors) + - [Projector Setup](#projector-setup) + - [Activate SDCP/PJTalk](#activate-sdcppjtalk) + - [Change SDAP Interval (optional)](#change-sdap-interval-optional) +- [Installation](#installation) + - [Run on the remote as a custom integration driver](#run-on-the-remote-as-a-custom-integration-driver) + - [Limitations / Disclaimer](#limitations--disclaimer) + - [Missing firmware features](#missing-firmware-features) + - [Download integration driver](#download-integration-driver) + - [Install the custom integration driver on the remote](#install-the-custom-integration-driver-on-the-remote) + - [Run on a separate device as an external integration](#run-on-a-separate-device-as-an-external-integration) + - [Requirements](#requirements) + - [Bare metal/VM](#bare-metalvm) + - [Requirements](#requirements-1) + - [Start the integration](#start-the-integration) + - [Docker container](#docker-container) +- [Build](#build) + - [Build distribution binary](#build-distribution-binary) + - [x86-64 Linux](#x86-64-linux) + - [aarch64 Linux / Mac](#aarch64-linux--mac) + - [Create tar.gz archive](#create-targz-archive) +- [Versioning](#versioning) +- [Changelog](#changelog) +- [Contributions](#contributions) +- [License](#license) + +## Entities + +- Media player + - Source select feature to choose the input from a list +- Remote + - Pre-defined buttons mappings and ui pages with all available commands that can be customized in the web configurator + - Use command sequences in the activity editor instead of creating a macro for each sequence. All command names have to be in upper case and separated by a comma + - Support for repeat, delay and hold + - Hold just repeats the command continuously for the given hold time. There is no native hold function for the SDCP protocol as with some ir devices to activate additional functions +- Sensor + - Lamp timer + - Lamp hours will be updated every time the projector is powered on or off by the remote and automatically every 30 minutes (can be changed in config.py) while the projector is powered on and the remote is not in sleep/standby mode or the integration is disconnected + +### Planned features + +- Picture position and advanced iris commands + - Needs testers as I only own a VPL-VW-270 that doesn't support lens memory and iris control +- Configure poller interval, SDCP & SDAP ports and PJTalk community in an advanced setup +- Power/error status sensor entity + +Additional smaller planned improvements are labeled with #TODO in the code + +## Commands & attributes + +### Supported media player commands - Turn On/Off/Toggle - Mute/Unmute/Toggle @@ -22,29 +85,40 @@ and a modified and extended version of [pySDCP](https://github.com/Galala7/pySDC - Opens the setup menu. Used instead of the menu feature because of the hard mapped home button when opening the entity from a profile page - Source Select - HDMI 1, HDMI 2 -- Simple Commands - - Calibration Presets* - - Cinema Film 1, Cinema Film 2, Reference, TV, Photo, Game, Bright Cinema, Bright TV, User - - Aspect Ratios* - - Normal, Stretch**, V Stretch, Ratio Squeeze, Zoom 1:85, Zoom 2:35 - - Motionfow* - - Off, Smoth High, Smoth Low, Impulse\*\*\*, Combination\***, True Cinema - - HDR* - - On, Off, Auto - - 2D/3D Display Select** - - 2D, 3D, Auto - - 3D Format** - - Simulated 3D, Side-by-Side, Over-Under - - Lamp Control* - - High, Low - - Input Lag Reduction* - - On, Off - - Menu Position - - Bottom Left, Center - - Lens Control - - Lens Shift Up/Down/Left/Right - - Lens Focus Far/Near - - Lens Zoom Large/Small + +### Supported media player attributes + +- State (On, Off, Unknown) +- Muted (True, False) +- Source +- Source List (HDMI 1, HDMI 2) + +### Supported simple commands (media player & remote entity) + +- Input HDMI 1 & 2 + - Intended for the remote entity in addition to the source select feature of the media player entity +- Calibration Presets* + - Cinema Film 1, Cinema Film 2, Reference, TV, Photo, Game, Bright Cinema, Bright TV, User +- Aspect Ratios* + - Normal, Squeeze, Stretch**, V Stretch, Zoom 1:85, Zoom 2:35 +- Motionfow* + - Off, Smoth High, Smoth Low, Impulse\*\*\*, Combination\***, True Cinema +- HDR* + - On, Off, Auto, Toggle +- 2D/3D Display Select** + - 2D, 3D, Auto +- 3D Format** + - Simulated 3D, Side-by-Side, Over-Under +- Lamp Control* + - High, Low +- Input Lag Reduction* + - On, Off +- Menu Position + - Bottom Left, Center +- Lens Control + - Lens Shift Up/Down/Left/Right + - Lens Focus Far/Near + - Lens Zoom Large/Small \* _Only works if a video signal is present at the input_ \ \** _May not work work with all video signals. Please refer to Sony's user manual_ \ @@ -52,27 +126,58 @@ and a modified and extended version of [pySDCP](https://github.com/Galala7/pySDC If a command can't be processed or applied by the projector this will result in a bad request error on the remote. The response error message from the projector is shown in the integration log -### Supported attributes +### Supported remote entity commands -- State (On, Off, Unknown) -- Muted (True, False) -- Source -- Source List (HDMI 1, HDMI 2) +- On, Off, Toggle +- Send command + - Command names have to be in upper case and separated by a comma +- Send command sequence + - All command names have to be in upper case and separated by a comma -By default the integration checks the status of all attributes every 20 seconds. The interval can be changed in config.py. Set it to 0 to deactivate this function. +### Default remote entity button mappings -### Planned features +_The default button mappings and ui pages can be customized in the web configurator under Remotes/External._ -- Picture position and advanced iris commands (needs testers as I only own a VPL-VW-270 that doesn't support lens memory and iris control) -- Additional sensor entity to show the lamp time -- Additional remote entity to automatically map all commands to buttons and the ui grid -- Configure poller interval, SDCP & SDAP ports and PJTalk community in an advanced setup +| Button | Short Press command | Long Press command | +|-------------------------|---------------------|--------------------| +| BACK | Cursor Left | | +| HOME | Menu | | +| VOICE | Projector Info (Menu+Cursor Up) | Toggle HDR On/Off | +| VOLUME_UP/DOWN | Lens Zoom Large/Small | Lens Focus Far/Near | +| MUTE | Toggle Picture Muting | | +| DPAD_UP/DOWN/LEFT/RIGHT | Cursor Up/Down/Left/Right | Lens Shift Up/Down/Left/Right | +| DPAD_MIDDLE | Cursor Enter | | +| GREEN | | Mode Preset Cinema Film 1 | +| YELLOW | | Mode Preset Cinema Film 2 | +| RED | | Mode Preset Bright TV | +| BLUE | | Mode Preset Bright Cinema | +| CHANNEL_UP/DOWN | Input HDMI 1/2 | | +| PREV | Mode Preset Ref | Mode Preset Photo | +| PLAY | Mode Preset Game | | +| NEXT | Mode Preset User | Mode Preset TV | +| POWER | Power Toggle | | -Additional smaller planned improvements are labeled with #TODO in the code +### Attributes poller + +#### Media player + +By default the integration checks the status of all media player entity attributes every 20 seconds while the remote is not in standby/sleep mode or disconnected from the integration. The interval can be changed in config.py. Set it to 0 to deactivate this function. When running on the remote as a custom integration the interval will be automatically set to 0 to reduce battery consumption and save cpu/memory usage. + +#### Lamp timer sensor + +The sensor value will be updated every time the projector is powered on or off by the remote and automatically every 30 minutes by default while the projector is powered on and the remote is not in sleep/standby mode or the integration is disconnected. + +## Usage + +### Limitations + +This integration supports one projector per integration instance. Multi device support is currently not planned for this integration but you could run the integration multiple times using different und unique driver IDs. ### Known supported projectors -_According to pySDCP and/or personal testing._ +Usually all Sony projectors that support the PJTalk / SDCP protocol should be supported. + +The following models have been tested with either pySDCP or this integration by personal testing: - VPL-HW65ES - VPL-VW100 @@ -90,8 +195,6 @@ _According to pySDCP and/or personal testing._ Please inform me if you have a projector that is not on this list and it works with pySDCP or this integration -## Usage - ### Projector Setup #### Activate SDCP/PJTalk @@ -100,7 +203,7 @@ Open the projectors web interface and go to _Setup/Advanced Menu (left menu)/PJT ![webinterface](webinterface.png) -#### Optional: Change SDAP Interval +#### Change SDAP Interval (optional) During the initial setup the integration tries to query data from the projector via the SDAP advertisement protocol to generate a unique entity id. The default SDAP interval is 30 seconds. You can shorten the interval to a minimum value of 10 seconds under _Setup/Advanced Menu/Advertisement/Interval_. @@ -110,9 +213,11 @@ During the initial setup the integration tries to query data from the projector ### Run on the remote as a custom integration driver -_⚠️ This feature is currently only available in beta firmware releases and requires version 1.9.2 or newer. Please keep in mind that due to the beta status there are missing firmware features that require workarounds (see below) and that changes in future beta updates may temporarily or permanently break the functionality of this integration as a custom integration. Please wait until custom integrations are available in stable firmware releases if you don't want to take these risks._ +#### Limitations / Disclaimer -#### Missing firmware features +⚠️ This feature is currently only available in beta firmware releases and requires version 1.9.2 or newer. Please keep in mind that due to the beta status there are missing firmware features that require workarounds (see below) and that changes in future beta updates may temporarily or permanently break the functionality of this integration as a custom integration. Please wait until custom integrations are available in stable firmware releases if you don't want to take these risks. + +##### Missing firmware features - The configuration file of custom integrations are not included in backups. - You currently can't update custom integrations. You need to delete the integration from the integrations menu first and then re-upload the new version. Do not edit any activity or macros that includes entities from this integration after you removed the integration and wait until the new version has been uploaded and installed. You also need to add re-add entities to the main pages after the update as they are automatically removed. An update function will probably be added once the custom integrations feature will be available in stable firmware releases. @@ -121,7 +226,7 @@ _⚠️ This feature is currently only available in beta firmware releases and r Download the uc-intg-sonysdcp-x.x.x-aarch64.tar.gz archive in the assets section from the [latest release](https://github.com/kennymc-c/ucr2-integration-sonySDCP/releases/latest) -#### Install custom integration driver on the remote +#### Install the custom integration driver on the remote The custom integration driver installation is currently only possible via the Core API. @@ -131,17 +236,22 @@ curl --location 'http://$IP/api/intg/install' \ --form 'file=@"uc-intg-sonysdcp-$VERSION-aarch64.tar.gz"' ``` -There is also a Core API GUI available at https://[Remote-IP]/doc/core-rest/. Scroll down to POST intg/install and click on Try it out, choose a file and then click on Execute. +There is also a Core API GUI available at https://_Remote-IP_/doc/core-rest. Click on Authorize to log in (username: web-configurator, password: your PIN), scroll down to POST intg/install, click on Try it out, choose a file and then click on Execute. + +Alternatively you can also use the inofficial [UC Remote Toolkit](https://github.com/albaintor/UC-Remote-Two-Toolkit) UC plans to integrate the upload function to the web configurator once they get enough positive feedback from developers (and users). The current status can be tracked in this issue: [#79](https://github.com/unfoldedcircle/feature-and-bug-tracker/issues/79). ### Run on a separate device as an external integration +#### Requirements + +- Firmware 1.7.12 or newer to support simple commands and remote entities + #### Bare metal/VM -##### Requirements +#### Requirements -- Firmware 1.7.4 or newer to support simple commands - Python 3.11 - Install Libraries: (using a [virtual environment](https://docs.python.org/3/library/venv.html) is highly recommended) diff --git a/driver.json b/driver.json index eaafbca..4d8bb2c 100644 --- a/driver.json +++ b/driver.json @@ -1,7 +1,7 @@ { "driver_id": "sonysdcp", - "version": "0.7.0", - "release_date": "2024-09-20", + "version": "0.8.0", + "release_date": "2024-11-03", "min_core_api": "0.24.3", "name": { "en": "Sony Projector", diff --git a/intg-sonysdcp/config.py b/intg-sonysdcp/config.py index 9984311..6910563 100644 --- a/intg-sonysdcp/config.py +++ b/intg-sonysdcp/config.py @@ -7,8 +7,8 @@ _LOG = logging.getLogger(__name__) -#TODO Integrate SDCP and SDAP port and PJTalk community as variables into the command assigner to replace the pySDCP default values -#TODO Make cfg_path, SDCP & SDAP ports and PJTalk community user configurable in an advanced setup option +#TODO Integrate SDCP and SDAP port and PJTalk community as variables into the command handlers to replace the pySDCP default values +#TODO Make SDCP & SDAP ports and PJTalk community user configurable in an advanced setup option #Fixed variables SDCP_PORT = 53484 #Currently only used for port check during setup @@ -16,27 +16,9 @@ -class MpDef: - """Media player entity definition class that includes the device class, features, attributes and options""" - device_class = ucapi.media_player.DeviceClasses.TV - features = [ - ucapi.media_player.Features.ON_OFF, - ucapi.media_player.Features.TOGGLE, - ucapi.media_player.Features.MUTE, - ucapi.media_player.Features.UNMUTE, - ucapi.media_player.Features.MUTE_TOGGLE, - ucapi.media_player.Features.DPAD, - ucapi.media_player.Features.HOME, - ucapi.media_player.Features.SELECT_SOURCE - ] - attributes = { - ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNKNOWN, - ucapi.media_player.Attributes.MUTED: False, - ucapi.media_player.Attributes.SOURCE: "", - ucapi.media_player.Attributes.SOURCE_LIST: ["HDMI 1", "HDMI 2"] - } - options = { - ucapi.media_player.Options.SIMPLE_COMMANDS: [ +simple_commands = [ + "INPUT_HDMI_1", + "INPUT_HDMI_2", "MODE_PRESET_REF", "MODE_PRESET_USER", "MODE_PRESET_TV", @@ -61,6 +43,7 @@ class MpDef: "MODE_HDR_ON", "MODE_HDR_OFF", "MODE_HDR_AUTO", + "MODE_HDR_TOGGLE", "MODE_2D_3D_SELECT_AUTO", "MODE_2D_3D_SELECT_3D", "MODE_2D_3D_SELECT_2D", @@ -82,6 +65,56 @@ class MpDef: "LENS_ZOOM_LARGE", "LENS_ZOOM_SMALL", ] + + + +class MpDef: + """Media player entity definition class that includes the device class, features, attributes and options""" + device_class = ucapi.media_player.DeviceClasses.TV + features = [ + ucapi.media_player.Features.ON_OFF, + ucapi.media_player.Features.TOGGLE, + ucapi.media_player.Features.MUTE, + ucapi.media_player.Features.UNMUTE, + ucapi.media_player.Features.MUTE_TOGGLE, + ucapi.media_player.Features.DPAD, + ucapi.media_player.Features.HOME, + ucapi.media_player.Features.SELECT_SOURCE + ] + attributes = { + ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNKNOWN, + ucapi.media_player.Attributes.MUTED: False, + ucapi.media_player.Attributes.SOURCE: "", + ucapi.media_player.Attributes.SOURCE_LIST: ["HDMI 1", "HDMI 2"] + } + options = { + ucapi.media_player.Options.SIMPLE_COMMANDS: simple_commands + } + + + +class RemoteDef: + """Remote entity definition class that includes the features, attributes and simple commands""" + features = [ + ucapi.remote.Features.ON_OFF, + ucapi.remote.Features.TOGGLE, + ] + attributes = { + ucapi.remote.Attributes.STATE: ucapi.remote.States.UNKNOWN + } + simple_commands = simple_commands + + + +class LTSensorDef: + """Lamp timer sensor entity definition class that includes the device class, attributes and options""" + device_class = ucapi.sensor.DeviceClasses.CUSTOM + attributes = { + ucapi.sensor.Attributes.STATE: ucapi.sensor.States.ON, + ucapi.sensor.Attributes.UNIT: "h" + } + options = { + ucapi.sensor.Options.CUSTOM_UNIT: "h" } @@ -94,14 +127,18 @@ class Setup: "ip": "", "id": "", "name": "", + "rt-id": "", + "lt-id": "", + "lt-name":"", "setup_complete": False, "setup_reconfigure": False, "standby": False, "bundle_mode": False, - "poller_interval": 20, #Use 0 to deactivate; will be automatically set to 0 when running on the remote (bundle_mode: True) + "mp_poller_interval": 20, #Use 0 to deactivate; will be automatically set to 0 when running on the remote (bundle_mode: True) + "lt_poller_interval": 1800, "cfg_path": "config.json" } - __setters = ["ip", "id", "name", "setup_complete", "setup_reconfigure", "standby", "bundle_mode", "poller_interval", "cfg_path"] + __setters = ["ip", "id", "name", "rt-id", "lt-id", "lt-name", "setup_complete", "setup_reconfigure", "standby", "bundle_mode", "mp_poller_interval", "lt_poller_interval", "cfg_path"] __storers = ["setup_complete", "ip", "id", "name"] #Skip runtime only related keys in config file @@ -111,6 +148,21 @@ def get(key): if Setup.__conf[key] == "": raise ValueError("Got empty value for key " + key + " from runtime storage") return Setup.__conf[key] + + @staticmethod + def set_lt_name_id(mp_entity_id: str, mp_entity_name: str): + """Generate lamp timer sensor entity id and name and store it""" + _LOG.info("Generate lamp timer sensor entity id and name") + lt_entity_id = "lamptimer-"+mp_entity_id + lt_entity_name = { + "en": "Lamp Timer "+mp_entity_name, + "de": "Lampen-Timer "+mp_entity_name + } + try: + Setup.set("lt-id", lt_entity_id) + Setup.set("lt-name", lt_entity_name) + except ValueError as v: + raise ValueError(v) from v @staticmethod def set(key, value): diff --git a/intg-sonysdcp/driver.py b/intg-sonysdcp/driver.py index b196dd1..00bc80f 100644 --- a/intg-sonysdcp/driver.py +++ b/intg-sonysdcp/driver.py @@ -7,15 +7,15 @@ import asyncio import logging import logging.handlers -from typing import Any import ucapi - from pysdcp.protocol import * import config import setup import media_player +import sensor +import remote _LOG = logging.getLogger("driver") # avoid having __main__ in log messages @@ -26,7 +26,7 @@ async def startcheck(): """ - Called at the start of the integration driver to load the config file into the runtime storage, add a media player entity and start the attributes poller task + Called at the start of the integration driver to load the config file into the runtime storage and add all needed entities and create attributes poller tasks """ try: config.Setup.load() @@ -36,65 +36,33 @@ async def startcheck(): raise SystemExit(0) from o if config.Setup.get("setup_complete"): - entity_id = config.Setup.get("id") - entity_name = config.Setup.get("name") - if api.available_entities.contains(entity_id): - _LOG.debug("Entity with id " + entity_id + " is already in storage as available entity") + try: + mp_entity_id = config.Setup.get("id") + mp_entity_name = config.Setup.get("name") + rt_entity_id = "remote-"+mp_entity_id + config.Setup.set("rt-id", rt_entity_id) + rt_entity_name = mp_entity_name + config.Setup.set_lt_name_id(mp_entity_id, mp_entity_name) + lt_entity_id = config.Setup.get("lt-id") + lt_entity_name = config.Setup.get("lt-name") + except ValueError as v: + _LOG.error(v) + + if api.available_entities.contains(mp_entity_id): + _LOG.debug("Projector media player entity with id " + mp_entity_id + " is already in storage as available entity") else: - _LOG.info("Add entity with id " + entity_id + " and name " + entity_name + " as available entity") - - await media_player.add_mp(entity_id, entity_name) + await media_player.add_mp(mp_entity_id, mp_entity_name) - poller_interval = config.Setup.get("poller_interval") - if poller_interval == 0: - _LOG.info("Attributes poller interval set to " + str(poller_interval) + ". Skip creation of attributes poller task") + if api.available_entities.contains(rt_entity_id): + _LOG.debug("Projector remote entity with id " + rt_entity_id + " is already in storage as available entity") else: - loop.create_task(attributes_poller(entity_id, poller_interval)) - _LOG.debug("Created attributes poller task with an interval of " + str(poller_interval) + " seconds") - - - -async def attributes_poller(entity_id: str, interval: int) -> None: - """Projector data poller.""" - while True: - await asyncio.sleep(interval) - #TODO Implement check if there are too many timeouts to the projector and automatically deactivate poller and set entity status to unknown - #TODO #WAIT Check if there are configured entities using the get_configured_entities api request once the UC Python library supports this - if config.Setup.get("standby"): - continue - try: - #TODO Add check if network and remote is reachable - await media_player.update_attributes(entity_id) - except Exception as e: - _LOG.warning(e) + await remote.add_remote(rt_entity_id, rt_entity_name) - - -async def mp_cmd_handler(entity: ucapi.MediaPlayer, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: - """ - Media Player command handler. - - Called by the integration-API if a command is sent to a configured media_player-entity. - - :param entity: media_player entity - :param cmd_id: command - :param _params: optional command parameters - :return: status of the command - """ - - if _params is None: - _LOG.info(f"Received {cmd_id} command for {entity.id}") - else: - _LOG.info(f"Received {cmd_id} command with parameter {_params} for {entity.id}") - - try: - ip = config.Setup.get("ip") - except ValueError as v: - _LOG.error(v) - return ucapi.StatusCodes.SERVER_ERROR - - return media_player.mp_cmd_assigner(entity.id, cmd_id, _params, ip) + if api.available_entities.contains(lt_entity_id): + _LOG.debug("Projector lamp timer sensor entity with id " + lt_entity_id + " is already in storage as available entity") + else: + await sensor.add_lt_sensor(lt_entity_id, lt_entity_name) @@ -106,8 +74,20 @@ async def on_r2_connect() -> None: Just reply with connected as there is no permanent connection to the projector that needs to be re-established """ _LOG.info("Received connect event message from remote") + await api.set_device_state(ucapi.DeviceStates.CONNECTED) + if config.Setup.get("setup_complete"): + try: + ip = config.Setup.get("ip") + mp_entity_id = config.Setup.get("id") + lt_entity_id = config.Setup.get("lt-id") + except ValueError as v: + _LOG.error(v) + + await media_player.create_mp_poller(mp_entity_id, ip) + await sensor.create_lt_poller(lt_entity_id, ip) + @api.listens_to(ucapi.Events.DISCONNECT) @@ -118,6 +98,22 @@ async def on_r2_disconnect() -> None: Just reply with disconnected as there is no permanent connection to the projector that needs to be closed """ _LOG.info("Received disconnect event message from remote") + + if config.Setup.get("setup_complete"): + _LOG.info("Stopping all attributes poller tasks") + + tasks = ["mp_poller", "lt_poller"] + for task_name in tasks: + try: + poller_task, = [task for task in asyncio.all_tasks() if task.get_name() == task_name] + poller_task.cancel() + try: + await poller_task + except asyncio.CancelledError: + _LOG.debug("Stopped " + task_name + " task") + except ValueError: + _LOG.debug(task_name + " task is not running") + await api.set_device_state(ucapi.DeviceStates.DISCONNECTED) @@ -160,10 +156,19 @@ async def on_subscribe_entities(entity_ids: list[str]) -> None: _LOG.info("Received subscribe entities event for entity ids: " + str(entity_ids)) config.Setup.set("standby", False) + ip = config.Setup.get("ip") + mp_entity_id = config.Setup.get("id") + rt_entity_id = config.Setup.get("rt-id") + lt_entity_id = config.Setup.get("lt-id") for entity_id in entity_ids: try: - await media_player.update_attributes(entity_id) + if entity_id == mp_entity_id: + await media_player.update_mp(entity_id, ip) + if entity_id == lt_entity_id: + await sensor.update_lt(entity_id, ip) + if entity_id == rt_entity_id: + await remote.update_rt(rt_entity_id, ip) except OSError as o: _LOG.critical(o) except Exception as e: @@ -191,16 +196,19 @@ def setup_logger(): logging.getLogger("ucapi.entities").setLevel(level) logging.getLogger("ucapi.entity").setLevel(level) logging.getLogger("driver").setLevel(level) - logging.getLogger("media_player").setLevel(level) - logging.getLogger("setup").setLevel(level) logging.getLogger("config").setLevel(level) + logging.getLogger("setup").setLevel(level) + logging.getLogger("projector").setLevel(level) + logging.getLogger("media_player").setLevel(level) + logging.getLogger("remote").setLevel(level) + logging.getLogger("sensor").setLevel(level) async def main(): """Main function that gets logging from all sub modules and starts the driver""" - #Check if integration runs in a PyInstaller bundle on the remote and adjust the logging format, config file path and attributes poller interval + #Check if integration runs in a PyInstaller bundle on the remote and adjust the logging format, config file path and projector attributes poller interval if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): logging.basicConfig(format="%(name)-14s %(levelname)-8s %(message)s") @@ -213,10 +221,10 @@ async def main(): config.Setup.set("cfg_path", cfg_path) _LOG.info("The configuration is stored in " + cfg_path) - _LOG.info("Deactivating attributes poller to reduce battery consumption when running on the remote") - config.Setup.set("poller_interval", 0) + _LOG.info("Deactivating projector attributes poller to reduce battery consumption when running on the remote") + config.Setup.set("mp_poller_interval", 0) else: - logging.basicConfig(format="%(asctime)s | %(levelname)-8s | %(name)-14s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + logging.basicConfig(format="%(asctime)s.%(msecs)03d | %(levelname)-8s | %(name)-14s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") setup_logger() _LOG.debug("Starting driver") diff --git a/intg-sonysdcp/media_player.py b/intg-sonysdcp/media_player.py index 658ae06..ebaca14 100644 --- a/intg-sonysdcp/media_player.py +++ b/intg-sonysdcp/media_player.py @@ -1,24 +1,58 @@ #!/usr/bin/env python3 -"""Module that includes functions to add a pre-defined media player entity, logics for a attributes polling function and the media player command handler""" +"""Module that includes functions to add a projector media player entity, poll attributes and the media player command handler""" import logging from typing import Any import ucapi -import pysdcp -from pysdcp.protocol import * import config import driver +import projector _LOG = logging.getLogger(__name__) +async def mp_cmd_handler(entity: ucapi.MediaPlayer, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: + """ + Media Player command handler. + + Called by the integration-API if a command is sent to a configured media_player-entity. + + :param entity: media_player entity + :param cmd_id: command + :param _params: optional command parameters + :return: status of the command + """ + + try: + ip = config.Setup.get("ip") + except ValueError as v: + _LOG.error(v) + return ucapi.StatusCodes.SERVER_ERROR + + try: + if _params is None: + _LOG.info(f"Received {cmd_id} command for {entity.id}") + await projector.send_cmd(entity.id, ip, cmd_id) + else: + _LOG.info(f"Received {cmd_id} command with parameter {_params} for {entity.id}") + await projector.send_cmd(entity.id, ip, cmd_id, _params) + except Exception as e: + if e is None: + return ucapi.StatusCodes.SERVER_ERROR + return ucapi.StatusCodes.BAD_REQUEST + return ucapi.StatusCodes.OK + + + async def add_mp(ent_id: str, name: str): """Function to add a media player entity with the config.MpDef class definition""" + _LOG.info("Add projector media player entity with id " + ent_id + " and name " + name) + definition = ucapi.MediaPlayer( ent_id, name, @@ -26,81 +60,66 @@ async def add_mp(ent_id: str, name: str): attributes=config.MpDef.attributes, device_class=config.MpDef.device_class, options=config.MpDef.options, - cmd_handler=driver.mp_cmd_handler + cmd_handler=mp_cmd_handler ) - _LOG.debug("Entity definition created") + _LOG.debug("Projector media player entity definition created") driver.api.available_entities.add(definition) - _LOG.info("Added media player entity") + _LOG.info("Added projector media player entity") -def get_attr_power(ip: str): - """Get the current power state from the projector and return the corresponding ucapi power state attribute""" - projector = pysdcp.Projector(ip) - try: - if projector.get_power(): - return ucapi.media_player.States.ON - else: - return ucapi.media_player.States.OFF - except (Exception, ConnectionError) as e: - _LOG.error(e) - _LOG.warning("Can't get power status from projector. Set to Unknown") - return ucapi.media_player.States.UNKNOWN - -def get_attr_muted(ip: str): - """Get the current muted state from the projector and return either False or True""" - projector = pysdcp.Projector(ip) - try: - if projector.get_muting(): - return True - else: - return False - except (Exception, ConnectionError) as e: - _LOG.error(e) - _LOG.warning("Can't get mute status from projector. Set to False") - return False - -def get_attr_source(ip: str): - """Get the current input source from the projector and return it as a string""" - projector = pysdcp.Projector(ip) - try: - return projector.get_input() - except (Exception, ConnectionError) as e: - _LOG.error(e) - _LOG.warning("Can't get input from projector. Set to None") - return None +async def create_mp_poller(ent_id: str, ip: str): + """Creates a task to regularly poll attributes from the projector""" + mp_poller_interval = config.Setup.get("mp_poller_interval") + if mp_poller_interval == 0: + _LOG.info("Projector attributes poller interval set to " + str(mp_poller_interval) + ". Task will not be started") + else: + driver.loop.create_task(mp_poller(ent_id, mp_poller_interval, ip), name="mp_poller") + _LOG.debug("Started projector attributes poller task with an interval of " + str(mp_poller_interval) + " seconds") + + + +async def mp_poller(entity_id: str, interval: int, ip: str) -> None: + """Projector attributes poller task""" + while True: + await driver.asyncio.sleep(interval) + #TODO Implement check if there are too many timeouts to the projector and automatically deactivate poller and set entity status to unknown + #TODO #WAIT Check if there are configured entities using the get_configured_entities api request once the UC Python library supports this + if config.Setup.get("standby"): + continue + try: + #TODO Add check if network and remote is reachable + await update_mp(entity_id, ip) + except Exception as e: + _LOG.warning(e) -async def update_attributes(entity_id: str): - """Retrieve input source, power state and muted state from the projector, compare them with the known state on the remote and update them if necessary""" - try: - ip = config.Setup.get("ip") - except ValueError as v: - raise Exception(v) from v + +async def update_mp(entity_id: str, ip: str): + """Retrieve input source, power state and muted state from the projector, compare them with the known state on the remote and update them if necessary""" try: - state = get_attr_power(ip) - muted = get_attr_muted(ip) - source = get_attr_source(ip) + state = projector.get_attr_power(ip) + muted = projector.get_attr_muted(ip) + source = projector.get_attr_source(ip) except Exception as e: raise Exception(e) from e try: - #TODO #WAIT Change to configured_entities once the core supports this feature + #TODO #WAIT Change to configured_entities once the UC Python library supports this stored_states = await driver.api.available_entities.get_states() except Exception as e: raise Exception(e) from e if stored_states != []: - for entity in stored_states: - attributes_stored = entity["attributes"] + attributes_stored = stored_states[0]["attributes"] # [0] = 1st entity that has been added else: - raise Exception("Got empty states from remote. Please make sure to add the entity as a configured entity") + raise Exception("Got empty states from remote. Please make sure to add configured entities") stored_attributes = {"state": attributes_stored["state"], "muted": attributes_stored["muted"], "source": attributes_stored["source"]} current_attributes = {"state": state, "muted": muted, "source": source} @@ -138,267 +157,4 @@ async def update_attributes(entity_id: str): _LOG.info("Updated entity attribute(s) " + str(attributes_to_update) + " for " + entity_id) else: - _LOG.debug("No entity attributes to update") - - - -def mp_cmd_assigner(entity_id: str, cmd_name: str, params: dict[str, Any] | None, ip: str): - """Assign a SDCP command to the passed entity id, command name and parameter""" - - projector = pysdcp.Projector(ip) - - def cmd_error(msg: str = None): - if msg is None: - _LOG.error("Error while executing the command: " + cmd_name) - return ucapi.StatusCodes.SERVER_ERROR - else: - _LOG.error(msg) - return ucapi.StatusCodes.BAD_REQUEST - - match cmd_name: - - case ucapi.media_player.Commands.ON: - try: - if projector.set_power(True): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.OFF: - try: - if projector.set_power(False): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.OFF}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.TOGGLE: - try: - if projector.get_power(): - if projector.set_power(False): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.OFF}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - elif not projector.get_power(): - if projector.set_power(True): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.MUTE_TOGGLE: - try: - if projector.get_muting(): - if projector.set_muting(False): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.MUTED: False}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - elif not projector.get_muting(): - if projector.set_muting(True): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.MUTED: True}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.MUTE: - try: - if projector.set_muting(True): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.MUTED: True}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.UNMUTE: - try: - if projector.set_muting(False): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.MUTED: False}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.HOME: - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS_IR["MENU"]) - return ucapi.StatusCodes.OK - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case \ - ucapi.media_player.Commands.BACK: - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS_IR["CURSOR_LEFT"]) - return ucapi.StatusCodes.OK - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case ucapi.media_player.Commands.SELECT_SOURCE: - source = params["source"] - try: - if source == "HDMI 1": - if projector.set_HDMI_input(1): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.SOURCE: source}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - elif source == "HDMI 2": - if projector.set_HDMI_input(2): - driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.SOURCE: source}) - return ucapi.StatusCodes.OK - else: - return cmd_error() - else: - _LOG.error("Unknown source: " + source) - return ucapi.StatusCodes.BAD_REQUEST - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case \ - "MODE_ASPECT_RATIO_NORMAL" | \ - "MODE_ASPECT_RATIO_V_STRETCH" | \ - "MODE_ASPECT_RATIO_ZOOM_1_85" | \ - "MODE_ASPECT_RATIO_ZOOM_2_35" | \ - "MODE_ASPECT_RATIO_STRETCH" | \ - "MODE_ASPECT_RATIO_SQUEEZE": - aspect = cmd_name.replace("MODE_ASPECT_RATIO_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["ASPECT_RATIO"], data=ASPECT_RATIOS[aspect]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MODE_PRESET_CINEMA_FILM_1" | \ - "MODE_PRESET_CINEMA_FILM_2" | \ - "MODE_PRESET_REF" | \ - "MODE_PRESET_TV" | \ - "MODE_PRESET_PHOTO" | \ - "MODE_PRESET_GAME" | \ - "MODE_PRESET_BRIGHT_CINEMA" | \ - "MODE_PRESET_BRIGHT_TV" | \ - "MODE_PRESET_USER": - preset = cmd_name.replace("MODE_PRESET_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["CALIBRATION_PRESET"], data=CALIBRATION_PRESETS[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MODE_MOTIONFLOW_OFF" | \ - "MODE_MOTIONFLOW_SMOTH_HIGH" | \ - "MODE_MOTIONFLOW_SMOTH_LOW" | \ - "MODE_MOTIONFLOW_IMPULSE" | \ - "MODE_MOTIONFLOW_COMBINATION" | \ - "MODE_MOTIONFLOW_TRUE_CINEMA": - preset = cmd_name.replace("MODE_MOTIONFLOW_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["MOTIONFLOW"], data=MOTIONFLOW[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MODE_HDR_ON" | \ - "MODE_HDR_OFF" | \ - "MODE_HDR_AUTO": - preset = cmd_name.replace("MODE_HDR_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["HDR"], data=HDR[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MODE_2D_3D_SELECT_AUTO" | \ - "MODE_2D_3D_SELECT_3D" | \ - "MODE_2D_3D_SELECT_2D": - preset = cmd_name.replace("MODE_2D_3D_SELECT_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["2D_3D_DISPLAY_SELECT"], data=TWO_D_THREE_D_SELECT[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MODE_3D_FORMAT_SIMULATED_3D" | \ - "MODE_3D_FORMAT_SIDE_BY_SIDE" | \ - "MODE_3D_FORMAT_OVER_UNDER": - preset = cmd_name.replace("MODE_3D_FORMAT_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["3D_FORMAT"], data=THREE_D_FORMATS[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "LAMP_CONTROL_LOW" | \ - "LAMP_CONTROL_HIGH": - preset = cmd_name.replace("LAMP_CONTROL_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["LAMP_CONTROL"], data=LAMP_CONTROL[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "INPUT_LAG_REDUCTION_ON" | \ - "INPUT_LAG_REDUCTION_OFF": - preset = cmd_name.replace("INPUT_LAG_REDUCTION_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["INPUT_LAG_REDUCTION"], data=INPUT_LAG_REDUCTION[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - "MENU_POSITION_BOTTOM_LEFT" | \ - "MENU_POSITION_CENTER": - preset = cmd_name.replace("MENU_POSITION_", "") - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS["MENU_POSITION"], data=MENU_POSITIONS[preset]) - except (Exception, ConnectionError) as e: - return cmd_error(e) - return ucapi.StatusCodes.OK - - case \ - ucapi.media_player.Commands.CURSOR_ENTER | \ - ucapi.media_player.Commands.CURSOR_UP | \ - ucapi.media_player.Commands.CURSOR_DOWN | \ - ucapi.media_player.Commands.CURSOR_LEFT | \ - ucapi.media_player.Commands.CURSOR_RIGHT | \ - "LENS_SHIFT_UP" | \ - "LENS_SHIFT_DOWN" | \ - "LENS_SHIFT_LEFT" | \ - "LENS_SHIFT_RIGHT" | \ - "LENS_FOCUS_FAR" | \ - "LENS_FOCUS_NEAR" | \ - "LENS_ZOOM_LARGE" | \ - "LENS_ZOOM_SMALL": - try: - projector._send_command(action=ACTIONS["SET"], command=COMMANDS_IR[cmd_name.upper()]) - return ucapi.StatusCodes.OK - except (Exception, ConnectionError) as e: - return cmd_error(e) - - case _: - _LOG.error("Command not implemented: " + cmd_name) - return ucapi.StatusCodes.NOT_IMPLEMENTED + _LOG.debug("No projector attributes to update. Skipping update process") diff --git a/intg-sonysdcp/projector.py b/intg-sonysdcp/projector.py new file mode 100644 index 0000000..c0f491d --- /dev/null +++ b/intg-sonysdcp/projector.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 + +"""Module that includes functions to execute pySDCP commands""" + +import logging + +import ucapi +import pysdcp +from pysdcp.protocol import * + +import config +import driver +import sensor + +_LOG = logging.getLogger(__name__) + + + +def get_attr_power(ip: str): + """Get the current power state from the projector and return the corresponding ucapi power state attribute""" + projector = pysdcp.Projector(ip) + + try: + if projector.get_power(): + return {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON} + return {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.OFF} + except (Exception, ConnectionError) as e: + raise Exception(e) from e + +def get_attr_muted(ip: str): + """Get the current muted state from the projector and return either False or True""" + projector = pysdcp.Projector(ip) + try: + if projector.get_muting(): + return True + else: + return False + except (Exception, ConnectionError) as e: + raise Exception(e) from e + +def get_attr_source(ip: str): + """Get the current input source from the projector and return it as a string""" + projector = pysdcp.Projector(ip) + try: + return projector.get_input() + except (Exception, ConnectionError) as e: + raise Exception(e) from e + + + +async def send_cmd(entity_id: str, ip: str, cmd_name:str, params = None): + """Send a command to the projector and raise an exception if it fails""" + + projector_pysdcp = pysdcp.Projector(ip) + mp_id = config.Setup.get("id") + rt_id = config.Setup.get("rt-id") + lt_id = config.Setup.get("lt-id") + + def cmd_error(msg:str = None): + if msg is None: + _LOG.error("Error while executing the command: " + cmd_name) + raise Exception(msg) + _LOG.error(msg) + raise Exception(msg) + + match cmd_name: + + case ucapi.media_player.Commands.ON: + try: + if projector_pysdcp.set_power(True): + driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON}) + driver.api.configured_entities.update_attributes(rt_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.ON}) + sensor.update_lt(lt_id, ip) + else: + cmd_error() + except (Exception, ConnectionError) as e: + cmd_error(e) + + case ucapi.media_player.Commands.OFF: + try: + if projector_pysdcp.set_power(False): + driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.OFF}) + driver.api.configured_entities.update_attributes(rt_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.OFF}) + sensor.update_lt(lt_id, ip) + else: + cmd_error() + except (Exception, ConnectionError) as e: + cmd_error(e) + + case ucapi.media_player.Commands.TOGGLE: + try: + if projector_pysdcp.get_power(): + if projector_pysdcp.set_power(False): + driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.OFF}) + driver.api.configured_entities.update_attributes(rt_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.OFF}) + else: + cmd_error() + elif not projector_pysdcp.get_power(): + if projector_pysdcp.set_power(True): + driver.api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON}) + driver.api.configured_entities.update_attributes(rt_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.ON}) + else: + cmd_error() + else: + cmd_error() + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + ucapi.media_player.Commands.MUTE_TOGGLE | \ + "PICTURE_MUTING_TOGGLE": + try: + if projector_pysdcp.get_muting(): + if projector_pysdcp.set_muting(False): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.MUTED: False}) + else: + cmd_error() + elif not projector_pysdcp.get_muting(): + if projector_pysdcp.set_muting(True): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.MUTED: True}) + else: + cmd_error() + else: + cmd_error() + except (Exception, ConnectionError) as e: + return cmd_error(e) + + case \ + ucapi.media_player.Commands.MUTE | \ + "MUTE": + try: + if projector_pysdcp.set_muting(True): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.MUTED: True}) + else: + cmd_error() + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + ucapi.media_player.Commands.UNMUTE | \ + "UNMUTE": + try: + if projector_pysdcp.set_muting(False): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.MUTED: False}) + else: + cmd_error() + except (Exception, ConnectionError) as e: + return cmd_error(e) + + case \ + ucapi.media_player.Commands.SELECT_SOURCE | \ + "INPUT_HDMI_1" | \ + "INPUT_HDMI_2": + if params: + source = params["source"] + else: + source = cmd_name.replace("INPUT_", "").replace("_", " ") + + try: + if source == "HDMI 1": + if projector_pysdcp.set_HDMI_input(1): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.SOURCE: source}) + else: + cmd_error() + elif source == "HDMI 2": + if projector_pysdcp.set_HDMI_input(2): + driver.api.configured_entities.update_attributes(mp_id, {ucapi.media_player.Attributes.SOURCE: source}) + else: + cmd_error() + else: + cmd_error("Unknown source: " + source) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + ucapi.media_player.Commands.HOME | \ + "HOME" | \ + "MENU": + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS_IR["MENU"]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + ucapi.media_player.Commands.BACK | \ + "BACK": + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS_IR["CURSOR_LEFT"]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + ucapi.media_player.Commands.CURSOR_ENTER | \ + ucapi.media_player.Commands.CURSOR_UP | \ + ucapi.media_player.Commands.CURSOR_DOWN | \ + ucapi.media_player.Commands.CURSOR_LEFT | \ + ucapi.media_player.Commands.CURSOR_RIGHT | \ + "CURSOR_ENTER" | \ + "CURSOR_UP" | \ + "CURSOR_DOWN" | \ + "CURSOR_LEFT" | \ + "CURSOR_RIGHT" | \ + "LENS_SHIFT_UP" | \ + "LENS_SHIFT_DOWN" | \ + "LENS_SHIFT_LEFT" | \ + "LENS_SHIFT_RIGHT" | \ + "LENS_FOCUS_FAR" | \ + "LENS_FOCUS_NEAR" | \ + "LENS_ZOOM_LARGE" | \ + "LENS_ZOOM_SMALL": + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS_IR[cmd_name.upper()]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_ASPECT_RATIO_NORMAL" | \ + "MODE_ASPECT_RATIO_V_STRETCH" | \ + "MODE_ASPECT_RATIO_ZOOM_1_85" | \ + "MODE_ASPECT_RATIO_ZOOM_2_35" | \ + "MODE_ASPECT_RATIO_STRETCH" | \ + "MODE_ASPECT_RATIO_SQUEEZE": + aspect = cmd_name.replace("MODE_ASPECT_RATIO_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["ASPECT_RATIO"], data=ASPECT_RATIOS[aspect]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_PRESET_CINEMA_FILM_1" | \ + "MODE_PRESET_CINEMA_FILM_2" | \ + "MODE_PRESET_REF" | \ + "MODE_PRESET_TV" | \ + "MODE_PRESET_PHOTO" | \ + "MODE_PRESET_GAME" | \ + "MODE_PRESET_BRIGHT_CINEMA" | \ + "MODE_PRESET_BRIGHT_TV" | \ + "MODE_PRESET_USER": + preset = cmd_name.replace("MODE_PRESET_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["CALIBRATION_PRESET"], data=CALIBRATION_PRESETS[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_MOTIONFLOW_OFF" | \ + "MODE_MOTIONFLOW_SMOTH_HIGH" | \ + "MODE_MOTIONFLOW_SMOTH_LOW" | \ + "MODE_MOTIONFLOW_IMPULSE" | \ + "MODE_MOTIONFLOW_COMBINATION" | \ + "MODE_MOTIONFLOW_TRUE_CINEMA": + preset = cmd_name.replace("MODE_MOTIONFLOW_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["MOTIONFLOW"], data=MOTIONFLOW[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_HDR_ON" | \ + "MODE_HDR_OFF" | \ + "MODE_HDR_AUTO" | \ + "MODE_HDR_TOGGLE": + preset = cmd_name.replace("MODE_HDR_", "") + if preset != "TOGGLE": + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["HDR"], data=HDR[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + if preset == "TOGGLE": + try: + data = projector_pysdcp._send_command(action=ACTIONS["GET"], command=COMMANDS["HDR"]) + except (Exception, ConnectionError) as e: + cmd_error(e) + if data == HDR["ON"] or data == HDR["AUTO"]: + try: + _LOG.info("Turn HDR off") + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["HDR"], data=HDR["OFF"]) + except (Exception, ConnectionError) as e: + cmd_error(e) + else: + try: + _LOG.info("Turn HDR on") + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["HDR"], data=HDR["ON"]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_2D_3D_SELECT_AUTO" | \ + "MODE_2D_3D_SELECT_3D" | \ + "MODE_2D_3D_SELECT_2D": + preset = cmd_name.replace("MODE_2D_3D_SELECT_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["2D_3D_DISPLAY_SELECT"], data=TWO_D_THREE_D_SELECT[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MODE_3D_FORMAT_SIMULATED_3D" | \ + "MODE_3D_FORMAT_SIDE_BY_SIDE" | \ + "MODE_3D_FORMAT_OVER_UNDER": + preset = cmd_name.replace("MODE_3D_FORMAT_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["3D_FORMAT"], data=THREE_D_FORMATS[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "LAMP_CONTROL_LOW" | \ + "LAMP_CONTROL_HIGH": + preset = cmd_name.replace("LAMP_CONTROL_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["LAMP_CONTROL"], data=LAMP_CONTROL[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "INPUT_LAG_REDUCTION_ON" | \ + "INPUT_LAG_REDUCTION_OFF": + preset = cmd_name.replace("INPUT_LAG_REDUCTION_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["INPUT_LAG_REDUCTION"], data=INPUT_LAG_REDUCTION[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case \ + "MENU_POSITION_BOTTOM_LEFT" | \ + "MENU_POSITION_CENTER": + preset = cmd_name.replace("MENU_POSITION_", "") + try: + projector_pysdcp._send_command(action=ACTIONS["SET"], command=COMMANDS["MENU_POSITION"], data=MENU_POSITIONS[preset]) + except (Exception, ConnectionError) as e: + cmd_error(e) + + case _: + cmd_error("Command not found or unsupported: " + cmd_name) diff --git a/intg-sonysdcp/pysdcp/__init__.py b/intg-sonysdcp/pysdcp/__init__.py index 51ed2b6..4aa8ba2 100755 --- a/intg-sonysdcp/pysdcp/__init__.py +++ b/intg-sonysdcp/pysdcp/__init__.py @@ -272,6 +272,11 @@ def set_muting(self, on=True): self._send_command(action=ACTIONS["SET"], command=COMMANDS["PICTURE_MUTING"], data=PICTURE_MUTING["ON"] if on else PICTURE_MUTING["OFF"]) return True + + def get_lamp_hours(self): + data = self._send_command(action=ACTIONS["GET"], command=COMMANDS["GET_STATUS_LAMP_TIMER"]) + hours = "{:d}".format(data) + return hours if __name__ == '__main__': diff --git a/intg-sonysdcp/remote.py b/intg-sonysdcp/remote.py new file mode 100644 index 0000000..fdb0434 --- /dev/null +++ b/intg-sonysdcp/remote.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +"""Module that includes functions to add a remote entity with all available commands from the media player entity""" + +import asyncio +import logging +from typing import Any +import time + +import ucapi +import ucapi.ui +from pysdcp.protocol import * + +import driver +import config +import projector + +_LOG = logging.getLogger(__name__) + + + +async def update_rt(entity_id: str, ip: str): + """Retrieve input source, power state and muted state from the projector, compare them with the known state on the remote and update them if necessary""" + + try: + state = projector.get_attr_power(ip) + except Exception as e: + _LOG.error(e) + _LOG.warning("Can't get power status from projector. Set to Unavailable") + state = {ucapi.remote.Attributes.STATE: ucapi.remote.States.UNAVAILABLE} + + try: + api_update_attributes = driver.api.configured_entities.update_attributes(entity_id, state) + except Exception as e: + raise Exception("Error while updating state attribute for entity id " + entity_id) from e + + if not api_update_attributes: + raise Exception("Entity " + entity_id + " not found. Please make sure it's added as a configured entity on the remote") + else: + _LOG.info("Updated remote entity state attribute to " + str(state) + " for " + entity_id) + + + +async def remote_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 + """ + + if params is None: + _LOG.info(f"Received {cmd_id} command for {entity.id}") + else: + _LOG.info(f"Received {cmd_id} command with parameter {params} for {entity.id}") + repeat = params.get("repeat") + delay = params.get("delay") + hold = params.get("hold") + + if hold is None or hold == "": + hold = 0 + if repeat is None: + repeat = 1 + if delay is None: + delay = 0 + else: + delay = delay / 1000 #Convert milliseconds to seconds for sleep + + if repeat == 1 and delay != 0: + _LOG.info(str(delay) + " seconds delay will be ignored as the command will not be repeated (repeat = 1)") + delay = 0 + + try: + ip = config.Setup.get("ip") + except ValueError as v: + _LOG.error(v) + return ucapi.StatusCodes.SERVER_ERROR + + match cmd_id: + + case \ + ucapi.remote.Commands.ON | \ + ucapi.remote.Commands.OFF | \ + ucapi.remote.Commands.TOGGLE: + try: + await projector.send_cmd(entity.id, ip, cmd_id) + except Exception as e: + if e is None: + return ucapi.StatusCodes.SERVER_ERROR + return ucapi.StatusCodes.BAD_REQUEST + return ucapi.StatusCodes.OK + + case \ + ucapi.remote.Commands.SEND_CMD: + + command = params.get("command") + + try: + i = 0 + r = range(repeat) + for i in r: + i = i+1 + if repeat != 1: + _LOG.debug("Round " + str(i) + " for command " + command) + if hold != 0: + cmd_start = time.time()*1000 + while time.time()*1000 - cmd_start < hold: + await projector.send_cmd(entity.id, ip, command) + await asyncio.sleep(0) + else: + await projector.send_cmd(entity.id, ip, command) + await asyncio.sleep(0) + await asyncio.sleep(delay) + except Exception as e: + if repeat != 1: + _LOG.warning("Execution of the command " + command + " failed. Remaining " + str(repeat-1) + " repetitions will no longer be executed") + if e is None: + return ucapi.StatusCodes.SERVER_ERROR + return ucapi.StatusCodes.BAD_REQUEST + + return ucapi.StatusCodes.OK + + case \ + ucapi.remote.Commands.SEND_CMD_SEQUENCE: + + sequence = params.get("sequence") + + _LOG.info(f"Command sequence: {sequence}") + + for command in sequence: + _LOG.debug("Sending command: " + command) + try: + i = 0 + r = range(repeat) + for i in r: + i = i+1 + if repeat != 1: + _LOG.debug("Round " + str(i) + " for command " + command) + if hold != 0: + cmd_start = time.time()*1000 + while time.time()*1000 - cmd_start < hold: + await projector.send_cmd(entity.id, ip, command) + await asyncio.sleep(0) + else: + await projector.send_cmd(entity.id, ip, command) + await asyncio.sleep(0) + await asyncio.sleep(delay) + except Exception as e: + if repeat != 1: + _LOG.warning("Execution of the command " + command + " failed. Remaining " + str(repeat-1) + " repetitions will no longer be executed") + if e is None: + return ucapi.StatusCodes.SERVER_ERROR + return ucapi.StatusCodes.BAD_REQUEST + + return ucapi.StatusCodes.OK + + case _: + + _LOG.info(f"Unsupported command: {cmd_id} for {entity.id}") + return ucapi.StatusCodes.BAD_REQUEST + + + +def create_button_mappings() -> list[ucapi.ui.DeviceButtonMapping | dict[str, Any]]: + """Create the button mapping of the remote entity""" + return [ + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.BACK, "BACK"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.HOME, "MENU"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.VOICE, ucapi.remote.create_sequence_cmd(["MENU","CURSOR_UP"]), "MODE_HDR_TOGGLE"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.VOLUME_UP, "LENS_ZOOM_LARGE", "LENS_FOCUS_NEAR"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.VOLUME_DOWN, "LENS_ZOOM_SMALL", "LENS_FOCUS_FAR"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.MUTE, "PICTURE_MUTING_TOGGLE"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.DPAD_UP, "CURSOR_UP", "LENS_SHIFT_UP"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.DPAD_DOWN, "CURSOR_DOWN", "LENS_SHIFT_DOWN"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.DPAD_LEFT, "CURSOR_LEFT", "LENS_SHIFT_LEFT"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.DPAD_RIGHT, "CURSOR_RIGHT", "LENS_SHIFT_RIGHT"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.DPAD_MIDDLE, "CURSOR_ENTER"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.GREEN, "", "MODE_PRESET_CINEMA_FILM_1"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.YELLOW, "", "MODE_PRESET_CINEMA_FILM_2"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.RED, "", "MODE_PRESET_BRIGHT_TV"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.BLUE, "", "MODE_PRESET_BRIGHT_CINEMA"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.CHANNEL_DOWN, "INPUT_HDMI_1"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.CHANNEL_UP, "INPUT_HDMI_2"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.PREV, "MODE_PRESET_REF", "MODE_PRESET_PHOTO"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.PLAY, "MODE_PRESET_GAME"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.NEXT, "MODE_PRESET_USER", "MODE_PRESET_TV"), + ucapi.ui.create_btn_mapping(ucapi.ui.Buttons.POWER, ucapi.remote.Commands.TOGGLE), + ] + + + +def create_ui_pages() -> list[ucapi.ui.UiPage | dict[str, Any]]: + """Create a user interface with different pages that includes all commands""" + + ui_page1 = ucapi.ui.UiPage("page1", "Power, Inputs & HDR", grid=ucapi.ui.Size(6, 6)) + ui_page1.add(ucapi.ui.create_ui_text("On", 0, 0, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.Commands.ON)) + ui_page1.add(ucapi.ui.create_ui_text("Off", 2, 0, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.Commands.OFF)) + ui_page1.add(ucapi.ui.create_ui_icon("uc:button", 4, 0, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.Commands.TOGGLE)) + ui_page1.add(ucapi.ui.create_ui_icon("uc:info", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_sequence_cmd(["MENU","CURSOR_UP"]))) + ui_page1.add(ucapi.ui.create_ui_text("HDMI 1", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("INPUT_HDMI_1"))) + ui_page1.add(ucapi.ui.create_ui_text("HDMI 2", 4, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("INPUT_HDMI_2"))) + ui_page1.add(ucapi.ui.create_ui_text("-- HDR --", 0, 2, size=ucapi.ui.Size(6, 1))) + ui_page1.add(ucapi.ui.create_ui_text("On", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_HDR_ON"))) + ui_page1.add(ucapi.ui.create_ui_text("Off", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_HDR_OFF"))) + ui_page1.add(ucapi.ui.create_ui_text("Auto", 4, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_HDR_AUTO"))) + + ui_page2 = ucapi.ui.UiPage("page2", "Picture Modes") + ui_page2.add(ucapi.ui.create_ui_text("-- Picture Modes --", 0, 0, size=ucapi.ui.Size(4, 1))) + ui_page2.add(ucapi.ui.create_ui_text("Cinema Film 1", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_CINEMA_FILM_1"))) + ui_page2.add(ucapi.ui.create_ui_text("Cinema Film 2", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_CINEMA_FILM_1"))) + ui_page2.add(ucapi.ui.create_ui_text("Reference", 0, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_REF"))) + ui_page2.add(ucapi.ui.create_ui_text("Game", 2, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_GAME"))) + ui_page2.add(ucapi.ui.create_ui_text("TV", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_TV"))) + ui_page2.add(ucapi.ui.create_ui_text("Bright TV", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_BRIGHT_TV"))) + ui_page2.add(ucapi.ui.create_ui_text("Bright Cinema", 0, 4, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_BRIGHT_CINEMA"))) + ui_page2.add(ucapi.ui.create_ui_text("Photo", 2, 4, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_PHOTO"))) + ui_page2.add(ucapi.ui.create_ui_text("User", 1, 5, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_PRESET_USER"))) + + ui_page3 = ucapi.ui.UiPage("page3", "Aspect Ratios") + ui_page3.add(ucapi.ui.create_ui_text("-- Aspect Ratios --", 0, 0, size=ucapi.ui.Size(4, 1))) + ui_page3.add(ucapi.ui.create_ui_text("Normal", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_NORMAL"))) + ui_page3.add(ucapi.ui.create_ui_text("Squeeze", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_SQUEEZE"))) + ui_page3.add(ucapi.ui.create_ui_text("Stretch", 0, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_STRETCH"))) + ui_page3.add(ucapi.ui.create_ui_text("V Stretch", 2, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_V_STRETCH"))) + ui_page3.add(ucapi.ui.create_ui_text("Zoom 1:85", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_ZOOM_1_85"))) + ui_page3.add(ucapi.ui.create_ui_text("Zoom 2:35", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_ASPECT_RATIO_ZOOM_2_35"))) + + ui_page4 = ucapi.ui.UiPage("page4", "Motionflow") + ui_page4.add(ucapi.ui.create_ui_text("-- Motionflow --", 0, 0, size=ucapi.ui.Size(4, 1))) + ui_page4.add(ucapi.ui.create_ui_text("Off", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_OFF"))) + ui_page4.add(ucapi.ui.create_ui_text("True Cinema", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_TRUE_CINEMA"))) + ui_page4.add(ucapi.ui.create_ui_text("Smoth High", 0, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_SMOTH_HIGH"))) + ui_page4.add(ucapi.ui.create_ui_text("Smoth Low", 2, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_SMOTH_LOW"))) + ui_page4.add(ucapi.ui.create_ui_text("Impulse", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_IMPULSE"))) + ui_page4.add(ucapi.ui.create_ui_text("Combination", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_MOTIONFLOW_COMBINATION"))) + + ui_page5 = ucapi.ui.UiPage("page5", "2D / 3D", grid=ucapi.ui.Size(6, 6)) + ui_page5.add(ucapi.ui.create_ui_text("-- 2D/3D Display Select --", 0, 0, size=ucapi.ui.Size(6, 1))) + ui_page5.add(ucapi.ui.create_ui_text("2D", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_2D_3D_SELECT_2D"))) + ui_page5.add(ucapi.ui.create_ui_text("3D", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_2D_3D_SELECT_3D"))) + ui_page5.add(ucapi.ui.create_ui_text("Auto", 4, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_2D_3D_SELECT_AUTO"))) + ui_page5.add(ucapi.ui.create_ui_text("-- 3D Format --", 0, 2, size=ucapi.ui.Size(6, 1))) + ui_page5.add(ucapi.ui.create_ui_text("Simulated 3D", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_3D_FORMAT_SIMULATED_3D"))) + ui_page5.add(ucapi.ui.create_ui_text("Side-by-Side", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_3D_FORMAT_SIDE_BY_SIDE"))) + ui_page5.add(ucapi.ui.create_ui_text("Over-Under", 4, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MODE_3D_FORMAT_OVER_UNDER"))) + + ui_page6 = ucapi.ui.UiPage("page6", "Lens Control", grid=ucapi.ui.Size(4, 7)) + ui_page6.add(ucapi.ui.create_ui_text("-- Lens Control --", 0, 0, size=ucapi.ui.Size(4, 1))) + ui_page6.add(ucapi.ui.create_ui_text("-- Focus --", 0, 1, size=ucapi.ui.Size(2, 1))) + ui_page6.add(ucapi.ui.create_ui_text("-- Zoom --", 2, 1, size=ucapi.ui.Size(2, 1))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:up-arrow", 0, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_FOCUS_NEAR"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:down-arrow", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_FOCUS_FAR"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:up-arrow-bold", 2, 2, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_ZOOM_LARGE"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:down-arrow-bold", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_ZOOM_SMALL"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:up-arrow-alt", 1, 4, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_SHIFT_UP"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:left-arrow-alt", 0, 5, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_SHIFT_LEFT"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:right-arrow-alt", 2, 5, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_SHIFT_RIGHT"))) + ui_page6.add(ucapi.ui.create_ui_icon("uc:down-arrow-alt", 1, 6, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LENS_SHIFT_DOWN"))) + + ui_page7 = ucapi.ui.UiPage("page7", "Miscellaneous") + ui_page7.add(ucapi.ui.create_ui_text("-- Lamp Control --", 0, 0, size=ucapi.ui.Size(4, 1))) + ui_page7.add(ucapi.ui.create_ui_text("High", 0, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LAMP_CONTROL_HIGH"))) + ui_page7.add(ucapi.ui.create_ui_text("Low", 2, 1, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("LAMP_CONTROL_LOW"))) + ui_page7.add(ucapi.ui.create_ui_text("-- Input Lag Reduction --", 0, 2, size=ucapi.ui.Size(4, 1))) + ui_page7.add(ucapi.ui.create_ui_text("On", 0, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("INPUT_LAG_REDUCTION_ON"))) + ui_page7.add(ucapi.ui.create_ui_text("Off", 2, 3, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("INPUT_LAG_REDUCTION_OFF"))) + ui_page7.add(ucapi.ui.create_ui_text("-- Menu Position --", 0, 4, size=ucapi.ui.Size(4, 1))) + ui_page7.add(ucapi.ui.create_ui_text("Bottom Left", 0, 5, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MENU_POSITION_BOTTOM_LEFT"))) + ui_page7.add(ucapi.ui.create_ui_text("Center", 2, 5, size=ucapi.ui.Size(2, 1), cmd=ucapi.remote.create_send_cmd("MENU_POSITION_CENTER"))) + + return [ui_page1, ui_page2, ui_page3, ui_page4, ui_page5, ui_page6, ui_page7] + + + +async def add_remote(ent_id: str, name: str): + """Function to add a remote entity""" + + _LOG.info("Add projector remote entity with id " + ent_id + " and name " + name) + + definition = ucapi.Remote( + ent_id, + name, + features=config.RemoteDef.features, + attributes=config.RemoteDef.attributes, + simple_commands=config.RemoteDef.simple_commands, + button_mapping=create_button_mappings(), + ui_pages=create_ui_pages(), + cmd_handler=remote_cmd_handler, + ) + + _LOG.debug("Projector remote entity definition created") + + driver.api.available_entities.add(definition) + + _LOG.info("Added projector remote entity") diff --git a/intg-sonysdcp/sensor.py b/intg-sonysdcp/sensor.py new file mode 100644 index 0000000..a012cf9 --- /dev/null +++ b/intg-sonysdcp/sensor.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +"""Module that includes functions to add a lamp timer sensor entity and to poll the sensor data""" + +import logging + +import ucapi +import pysdcp +from pysdcp.protocol import * + +import config +import driver + +_LOG = logging.getLogger(__name__) + + + +async def add_lt_sensor(ent_id: str, name: str): + """Function to add a lamp timer sensor entity with the config.sensorDef class definition and get current lamp hours""" + + definition = ucapi.Sensor( + ent_id, + name, + features=None, #Mandatory although sensor entities have no features + attributes=config.LTSensorDef.attributes, + device_class=config.LTSensorDef.device_class, + options=config.LTSensorDef.options + ) + + _LOG.debug("Projector lamp timer sensor entity definition created") + + driver.api.available_entities.add(definition) + + _LOG.info("Added projector lamp timer sensor entity") + + + +async def create_lt_poller(ent_id: str, ip: str): + """Creates a lamp timer poller task""" + + lt_poller_interval = config.Setup.get("lt_poller_interval") + driver.loop.create_task(lt_poller(ent_id, lt_poller_interval, ip), name="lt_poller") + _LOG.debug("Started lamp timer poller task with an interval of " + str(lt_poller_interval) + " seconds") + + + +async def lt_poller(entity_id: str, interval: int, ip: str) -> None: + """Projector lamp timer poller task. Runs only when the projector is powered on""" + while True: + await driver.asyncio.sleep(interval) + #TODO Implement check if there are too many timeouts to the projector and automatically deactivate poller and set entity status to unknown + #TODO #WAIT Check if there are configured entities using the get_configured_entities api request once the UC Python library supports this + if config.Setup.get("standby"): + continue + try: + projector = pysdcp.Projector(ip) + if not projector.get_power(): + _LOG.debug("Skip updating lamp timer. Projector is powered off") + continue + except ConnectionError: + _LOG.warning("Could not check projector power status. Connection refused") + continue + try: + #TODO Add check if network and remote is reachable + await update_lt(entity_id, ip) + except Exception as e: + _LOG.warning(e) + + + +def get_lamp_hours(ip: str): + """Get the lamp hours from the projector""" + projector = pysdcp.Projector(ip) + try: + hours = projector.get_lamp_hours() + return hours + except (Exception, ConnectionError) as e: + _LOG.error(e) + + + +async def update_lt(entity_id: str, ip: str): + """Update lamp timer sensor. Compare retrieved lamp hours with the last sensor value from the remote and update it if necessary""" + + try: + #TODO #WAIT Remove h string from value when the remote ui actually shows the unit + current_value = get_lamp_hours(ip)+" h" + except Exception as e: + _LOG.warning("Can't get lamp hours from projector. Use empty sensor value") + current_value = "" + raise Exception(e) from e + + try: + #TODO #WAIT Change to configured_entities once the UC Python library supports this + stored_states = await driver.api.available_entities.get_states() + except Exception as e: + raise Exception(e) from e + + if stored_states != []: + attributes_stored = stored_states[1]["attributes"] # [1] = 2nd entity that has been added + else: + raise Exception("Got empty states from remote. Please make sure to add configured entities") + + try: + stored_value = attributes_stored["value"] + except KeyError as e: + _LOG.info("Lamp timer sensor value has not been set yet") + stored_value = "0" + + if current_value == "": + _LOG.warning("Couldn't get lamp hours from projector. Set state to Unknown") + attributes_to_send = {ucapi.sensor.Attributes.STATE: ucapi.sensor.States.UNKNOWN, ucapi.sensor.Attributes.VALUE: current_value, ucapi.sensor.Attributes.UNIT: "h"} + else: + attributes_to_send = {ucapi.sensor.Attributes.STATE: ucapi.sensor.States.ON, ucapi.sensor.Attributes.VALUE: current_value, ucapi.sensor.Attributes.UNIT: "h"} + + if stored_value == current_value: + _LOG.debug("Lamp hours have not changed since the last update. Skipping update process") + else: + try: + api_update_attributes = driver.api.configured_entities.update_attributes(entity_id, attributes_to_send) + except Exception as e: + _LOG.error(e) + raise Exception("Error while updating sensor value for entity id " + entity_id) from e + + if not api_update_attributes: + raise Exception("Sensor entity " + entity_id + " not found. Please make sure it's added as a configured entity on the remote") + + _LOG.info("Updated lamp timer sensor value to " + current_value) diff --git a/intg-sonysdcp/setup.py b/intg-sonysdcp/setup.py index 16a3558..223f1c7 100644 --- a/intg-sonysdcp/setup.py +++ b/intg-sonysdcp/setup.py @@ -14,6 +14,8 @@ import config import driver import media_player +import sensor +import remote _LOG = logging.getLogger(__name__) @@ -112,21 +114,25 @@ async def handle_driver_setup(msg: ucapi.DriverSetupRequest,) -> ucapi.SetupActi return ucapi.SetupError() try: - entity_id = config.Setup.get("id") - entity_name = config.Setup.get("name") + mp_entity_id = config.Setup.get("id") + mp_entity_name = config.Setup.get("name") + rt_entity_id = "remote-"+mp_entity_id + config.Setup.set("rt-id", rt_entity_id) + rt_entity_name = mp_entity_name + config.Setup.set_lt_name_id(mp_entity_id, mp_entity_name) + lt_entity_id = config.Setup.get("lt-id") + lt_entity_name = config.Setup.get("lt-name") except ValueError as v: _LOG.error(v) return ucapi.SetupError() - _LOG.info("Add media player entity with id " + entity_id + " and name " + entity_name) - await media_player.add_mp(entity_id, entity_name) + await media_player.add_mp(mp_entity_id, mp_entity_name) + await media_player.create_mp_poller(mp_entity_id, ip) - poller_interval = config.Setup.get("poller_interval") - if poller_interval == 0: - _LOG.info("Attributes poller interval set to " + str(poller_interval) + ". Skip creation of attributes poller task") - else: - driver.loop.create_task(driver.attributes_poller(entity_id, poller_interval)) - _LOG.debug("Created attributes poller task with an interval of " + str(poller_interval) + " seconds") + await remote.add_remote(rt_entity_id, rt_entity_name) + + await sensor.add_lt_sensor(lt_entity_id, lt_entity_name) + await sensor.create_lt_poller(lt_entity_id, ip) config.Setup.set("setup_complete", True) _LOG.info("Setup complete") @@ -134,6 +140,7 @@ async def handle_driver_setup(msg: ucapi.DriverSetupRequest,) -> ucapi.SetupActi + def set_entity_data(man_ip: str = None): """Retrieves data from the projector (ip, serial number, model name) via the SDAP protocol which can take up to 30 seconds depending on the advertisement interval setting of the projector diff --git a/requirements.txt b/requirements.txt index cd6fd6d..97d30b5 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -ucapi>=0.1.7 \ No newline at end of file +ucapi>=0.2.0 \ No newline at end of file