├── tools ├── templates │ ├── licenses-footer.md │ └── licenses-header.md ├── transform-pip-licenses.js └── scan_uc.py ├── setup.cfg ├── kodi.png ├── test-requirements.txt ├── requirements.txt ├── .dockerignore ├── docker ├── docker-compose.yml └── Dockerfile ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── python-code-format.yml │ └── build.yml ├── Makefile ├── docs ├── licenses.md └── code_guidelines.md ├── CHANGELOG.md ├── .pylintrc ├── pyproject.toml ├── driver.json ├── .gitignore ├── src ├── discover.py ├── test_connection.py ├── remote.py ├── media_player.py ├── config.py ├── pykodi │ └── kodi.py ├── driver.py ├── const.py └── setup_flow.py ├── CONTRIBUTING.md ├── LICENSE └── README.md /tools/templates/licenses-footer.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /kodi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albaintor/integration-kodi/HEAD/kodi.png -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pylint 2 | flake8-docstrings 3 | flake8 4 | black 5 | isort 6 | rich -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyee~=13.0.0 2 | ucapi~=0.4.0 3 | httpx~=0.28.1 4 | defusedxml~=0.7.1 5 | jsonrpc-async>=2.1.3 6 | jsonrpc-websocket>=3.1.6 7 | jsonrpc_base>=2.2.0 8 | aiohttp~=3.13.2 9 | zeroconf~=0.148.0 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | docs 4 | tools 5 | *.tar.gz 6 | dist 7 | build 8 | *.pyc 9 | __pycache__ 10 | .pytest_cache 11 | .venv 12 | venv 13 | config 14 | .gitignore 15 | .pylintrc 16 | CHANGELOG.md 17 | CONTRIBUTING.md 18 | LICENSE 19 | setup.cfg 20 | test-requirements.txt 21 | Dockerfile 22 | docker-compose.yml 23 | Makefile 24 | .dockerignore -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | kodi-integration: 3 | image: albator78/kodi-integration 4 | container_name: kodi-integration 5 | restart: unless-stopped 6 | network_mode: host # Required for network discovery and magic packets 7 | environment: 8 | - UC_INTEGRATION_HTTP_PORT=9090 9 | - UC_CONFIG_HOME=/app/config 10 | volumes: 11 | - ./config:/app/config 12 | -------------------------------------------------------------------------------- /tools/templates/licenses-header.md: -------------------------------------------------------------------------------- 1 | # Denon integration for Remote Two 2 | The Unfolded Circle Denon integration for Remote Two is part of the firmware. 3 | 4 | ## Licenses 5 | These are the licenses for the libraries we use in the shipped product as well as for development. 6 | ### Licenses for OSS used in Denon integration are reproduced below 7 | THE FOLLOWING SETS FORTH ATTRIBUTION NOTICES FOR THIRD PARTY SOFTWARE THAT MAY BE CONTAINED IN PORTIONS OF THE PRODUCT. 8 | 9 | --- 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Unfolded Circle Community Forum 4 | url: https://unfolded.community/ 5 | about: Please ask and answer questions here. 6 | - name: Unfolded Circle Discord Channel 7 | url: https://unfolded.chat/ 8 | about: Chat with the community and for asking questions. 9 | - name: Unfolded Circle Contact Form 10 | url: https://unfoldedcircle.com/contact 11 | about: Write us a message on our website. 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build up down logs restart clean 2 | 3 | # Build the Docker image 4 | build: 5 | docker-compose build 6 | 7 | # Start the service 8 | up: 9 | docker-compose up -d 10 | 11 | # Stop the service 12 | down: 13 | docker-compose down 14 | 15 | # Show logs 16 | logs: 17 | docker-compose logs -f 18 | 19 | # Restart the service 20 | restart: 21 | docker-compose restart 22 | 23 | # Clean up everything 24 | clean: 25 | docker-compose down -v 26 | docker image prune -f 27 | 28 | # Build and start 29 | start: build up 30 | 31 | # Development mode with live logs 32 | dev: 33 | docker-compose up --build -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | # Install system dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | gcc \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | # Set working directory 9 | WORKDIR /app 10 | 11 | # Copy requirements first for better caching 12 | COPY requirements.txt . 13 | 14 | # Install Python dependencies 15 | RUN pip install --no-cache-dir -r requirements.txt 16 | 17 | # Copy source code 18 | COPY src/ ./src/ 19 | COPY driver.json . 20 | COPY kodi.png . 21 | 22 | # Create non-root user for security 23 | RUN useradd -m -u 1000 kodi && chown -R kodi:kodi /app 24 | USER kodi 25 | 26 | # Expose the integration port (default 9090) 27 | EXPOSE 9090 28 | 29 | # Run the driver 30 | CMD ["python", "src/driver.py"] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: '' 4 | labels: [enhancement] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please check the existing issues first to see if your feature request has already been recorded. 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of what the feature request is about. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Additional context 20 | description: | 21 | Add any other context or screenshots about the feature request here. 22 | 23 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /docs/licenses.md: -------------------------------------------------------------------------------- 1 | # Licenses 2 | 3 | To generate the license overview file for remote-ui, [pip-licenses](https://pypi.org/project/pip-licenses/) is used 4 | to extract the license information in JSON format. The output JSON is then transformed in a Markdown file with a 5 | custom script. 6 | 7 | Create a virtual environment for pip-licenses, since it operates on the packages installed with pip: 8 | ```shell 9 | python3 -m venv env 10 | source env/bin/activate 11 | pip3 install -r requirements.txt 12 | ``` 13 | Exit `venv` with `deactivate`. 14 | 15 | Gather licenses: 16 | ```shell 17 | pip-licenses --python ./env/bin/python \ 18 | --with-description --with-urls \ 19 | --with-license-file --no-license-path \ 20 | --with-notice-file \ 21 | --format=json > licenses.json 22 | ``` 23 | 24 | Transform: 25 | ```shell 26 | cd tools 27 | node transform-pip-licenses.js ../licenses.json licenses.md 28 | ``` 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Kodi integration for Remote Two Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | - Rename the predefined simple commands to match [expected UC name patterns](https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md#command-name-patterns) 11 | - Brings support for the different keymaps (e.g by bringing a separator in the command name to set the keymap name) 12 | 13 | --- 14 | 15 | ## v1.0.2 - 2024-04-25 16 | ### Added remote entity and default mapping 17 | - New remote entity (firmware >= 1.7.10) with support for custom commands and command sequences with repeat, delay, holding time 18 | - Default buttons mapping when raising entity page 19 | - Default interface mapping when raising entity page 20 | 21 | ## v1.0.0 - 2024-03-16 22 | ### Initial release 23 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | 3 | # Maximum number of characters on a single line. 4 | max-line-length=120 5 | 6 | [MESSAGES CONTROL] 7 | 8 | # Disable the message, report, category or checker with the given id(s). You 9 | # can either give multiple identifiers separated by comma (,) or put this 10 | # option multiple times (only on the command line, not in the configuration 11 | # file where it should appear only once).You can also use "--disable=all" to 12 | # disable everything first and then re-enable specific checks. For example, if 13 | # you want to run only the similarities checker, you can use "--disable=all 14 | # --enable=similarities". If you want to run only the classes checker, but have 15 | # no Warning level messages displayed, use"--disable=all --enable=classes 16 | # --disable=W" 17 | 18 | disable= 19 | too-many-arguments, 20 | too-many-branches, 21 | too-many-instance-attributes, 22 | too-many-public-methods, 23 | global-statement, 24 | fixme 25 | 26 | [STRING] 27 | 28 | # This flag controls whether inconsistent-quotes generates a warning when the 29 | # character used as a quote delimiter is used inconsistently within a module. 30 | check-quote-consistency=yes -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # TODO this is work in progress 2 | [build-system] 3 | requires = ["setuptools>=61.2"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | [project] 7 | name = "intg-kodi" 8 | version = "0.2.4" 9 | authors = [ 10 | { name = "Albator", email = "albaintor@github.com" } 11 | ] 12 | license = { text = "MPL-2.0" } 13 | description = "Remote Two integration for Sony AVRs" 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 18 | "Operating System :: OS Independent", 19 | "Topic :: Home Automation", 20 | "Programming Language :: Python :: 3.11", 21 | ] 22 | requires-python = ">=3.11" 23 | dependencies = [ 24 | "pyee>=12.0.0", 25 | "ucapi==0.3.1", 26 | ] 27 | 28 | [project.readme] 29 | file = "README.md" 30 | content-type = "text/markdown; charset=UTF-8" 31 | 32 | [project.optional-dependencies] 33 | testing = [ 34 | "pylint", 35 | "flake8-docstrings", 36 | "flake8", 37 | "black", 38 | "isort", 39 | ] 40 | 41 | [tool.setuptools] 42 | platforms = ["any"] 43 | license-files = ["LICENSE"] 44 | include-package-data = false 45 | 46 | [tool.setuptools.packages.find] 47 | exclude = ["tests"] 48 | namespaces = false 49 | 50 | [tool.isort] 51 | profile = "black" 52 | -------------------------------------------------------------------------------- /driver.json: -------------------------------------------------------------------------------- 1 | { 2 | "driver_id": "kodi_driver", 3 | "version": "1.13.0", 4 | "min_core_api": "0.20.0", 5 | "name": { 6 | "en": "Kodi" 7 | }, 8 | "icon":"custom:kodi.png", 9 | "description": { 10 | "en": "Control your Kodi instances with Remote Two/3.", 11 | "de": "Steuere Kodi instances mit Remote Two/3.", 12 | "fr": "Contrôler vos instances Kodi avec Remote Two/3." 13 | }, 14 | "developer": { 15 | "name": "Albaintor", 16 | "email": "albaintor@github.com", 17 | "url": "https://github.com/albaintor" 18 | }, 19 | "home_page": "https://github.com/albaintor/integration-kodi", 20 | "setup_data_schema": { 21 | "title": { 22 | "en": "Integration setup", 23 | "de": "Integrations setup", 24 | "fr": "Configuration de l'intégration" 25 | }, 26 | "settings": [ 27 | { 28 | "id": "info", 29 | "label": { 30 | "en": "Setup process", 31 | "de": "Setup Fortschritt", 32 | "fr": "Avancement de la configuration" 33 | }, 34 | "field": { 35 | "label": { 36 | "value": { 37 | "en": "The integration will let you configure your Kodi instances on your network.", 38 | "fr": "Cette intégration permet de configurer vos instances Kodi dans le réseau." 39 | } 40 | } 41 | } 42 | } 43 | ] 44 | }, 45 | "release_date": "2025-12-07" 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/python-code-format.yml: -------------------------------------------------------------------------------- 1 | name: Check Python code formatting 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**' 7 | - 'requirements.txt' 8 | - 'test-requirements.txt' 9 | - 'tests/**' 10 | - '.github/**/*.yml' 11 | - '.pylintrc' 12 | - 'pyproject.toml' 13 | pull_request: 14 | branches: [main] 15 | types: [opened, synchronize, reopened] 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-22.04 23 | 24 | name: Check Python code formatting 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.11" 32 | 33 | - name: Install pip 34 | run: | 35 | python -m pip install --upgrade pip 36 | 37 | - name: Install dependencies 38 | run: | 39 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 40 | if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi 41 | - name: Analyzing the code with pylint 42 | run: | 43 | python -m pylint src 44 | - name: Lint with flake8 45 | run: | 46 | python -m flake8 src --count --show-source --statistics 47 | - name: Check code formatting with isort 48 | run: | 49 | python -m isort src/. --check --verbose 50 | - name: Check code formatting with black 51 | run: | 52 | python -m black src --check --diff --verbose --line-length 120 53 | -------------------------------------------------------------------------------- /docs/code_guidelines.md: -------------------------------------------------------------------------------- 1 | # Code Style 2 | 3 | - Code line length: 120 4 | - Use double quotes as default (don't mix and match for simple quoting, checked with pylint). 5 | - Configuration: 6 | - `.pylint.rc` for pylint. 7 | - `pyproject.toml` for isort. 8 | _TBD if pyproject.toml is the right approach. This is not a library and we are new to Python build systems..._ 9 | - `setup.cfg` for flake8. 10 | 11 | ## Tooling 12 | 13 | Install all code linting tools: 14 | 15 | ```shell 16 | pip3 install -r test-requirements.txt 17 | ``` 18 | 19 | ### Verify 20 | 21 | The following tests are run as GitHub action for each push on the main branch and for pull requests. 22 | They can also be run anytime on a local developer machine: 23 | 24 | ```shell 25 | python -m pylint intg-sonyavr 26 | python -m flake8 intg-sonyavr --count --show-source --statistics 27 | python -m isort intg-denonavr/. --check --verbose 28 | python -m black intg-sonyavr --check --verbose --line-length 120 29 | ``` 30 | 31 | Linting integration in PyCharm/IntelliJ IDEA: 32 | 33 | 1. Install plugin [Pylint](https://plugins.jetbrains.com/plugin/11084-pylint) 34 | 2. Open Pylint window and run a scan: `Check Module` or `Check Current File` 35 | 36 | ### Format Code 37 | 38 | ```shell 39 | python -m black intg-sonyavr --line-length 120 40 | ``` 41 | 42 | PyCharm/IntelliJ IDEA integration: 43 | 44 | 1. Go to `Preferences or Settings -> Tools -> Black` 45 | 2. Configure: 46 | 47 | - Python interpreter 48 | - Use Black formatter: `On code reformat` & optionally `On save` 49 | - Arguments: `--line-length 120` 50 | 51 | ### Sort Imports 52 | 53 | ```shell 54 | python -m isort intg-denonavr/. 55 | ``` 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .pylint.d/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | # Local development settings 93 | .settings/ 94 | .project 95 | .pydevproject 96 | .pypirc 97 | .pytest_cache 98 | 99 | # Visual Studio Code 100 | .vscode/ 101 | 102 | .DS_Store 103 | config.json 104 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: '<title>' 4 | labels: [bug] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Please check the existing issues first to see if the bug has already been recorded. 11 | If you have more information about an existing bug, please add it as a comment and don't open a new issue. 12 | Thank you! 13 | - type: textarea 14 | attributes: 15 | label: Description 16 | description: A clear and concise description of what the bug is. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: How to Reproduce 22 | description: Steps to reproduce the behavior. 23 | placeholder: | 24 | 1. ... 25 | 2. ... 26 | 3. See error 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: Expected behavior 32 | description: A clear and concise description of what you expected to happen. 33 | validations: 34 | required: true 35 | - type: input 36 | id: intg_version 37 | attributes: 38 | label: Integration version 39 | description: You can find the integration version in driver.json or in the UI under Settings/Integrations 40 | placeholder: ex. v0.4.5 41 | validations: 42 | required: false 43 | - type: textarea 44 | attributes: 45 | label: Additional context 46 | description: | 47 | Add any other context about the problem here. Otherwise you can ignore this section. 48 | How has this issue affected you? What are you trying to accomplish? 49 | Providing context helps us come up with a solution that is most useful in the real world 50 | 51 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 52 | validations: 53 | required: false 54 | -------------------------------------------------------------------------------- /tools/transform-pip-licenses.js: -------------------------------------------------------------------------------- 1 | // Quick and dirty helper script to create the license overview page in markdown for the remote-ui. 2 | // 3 | // Usage: 4 | // pip-licenses --with-description --with-urls \ 5 | // --with-license-file --no-license-path \ 6 | // --with-notice-file \ 7 | // --format=json > licenses.json 8 | // node transform-pip-licenses.js licenses.json licenses.md 9 | 10 | const fs = require("fs"); 11 | 12 | function ensureFileExists(file) { 13 | if (!fs.existsSync(file)) { 14 | console.error(`File does not exist: ${file}`); 15 | process.exit(1); 16 | } 17 | } 18 | 19 | if (process.argv.length < 4) { 20 | console.error("Expected two argument: <licenses.json> <output.md>"); 21 | process.exit(1); 22 | } 23 | 24 | const licenseFile = process.argv[2]; 25 | const outputFile = process.argv[3]; 26 | 27 | ensureFileExists(licenseFile); 28 | 29 | const licenses = JSON.parse(fs.readFileSync(licenseFile, "utf-8")); 30 | 31 | fs.writeFileSync(outputFile, fs.readFileSync("templates/licenses-header.md", "utf-8"), "utf-8"); 32 | 33 | for (const index in licenses) { 34 | let package = licenses[index]; 35 | let name = package.Name; 36 | let repository = package.URL; 37 | let license = package.License; 38 | let version = package.Version; 39 | 40 | console.log(`${name} ${version}: ${license}`); 41 | 42 | fs.appendFileSync(outputFile, `#### ${name} ${version}\n`, "utf-8"); 43 | fs.appendFileSync(outputFile, `${package.Description} \n`, "utf-8"); 44 | fs.appendFileSync(outputFile, `License: ${license} \n`, "utf-8"); 45 | if (repository) { 46 | fs.appendFileSync(outputFile, `This software may be included in this product and a copy of the source code may be downloaded from: ${repository}.\n`, "utf-8"); 47 | } 48 | 49 | fs.appendFileSync(outputFile, "```\n", "utf-8"); 50 | fs.appendFileSync(outputFile, `${package.LicenseText.trim()}`, "utf-8"); 51 | fs.appendFileSync(outputFile, "\n```\n\n", "utf-8"); 52 | } 53 | 54 | fs.appendFileSync(outputFile, fs.readFileSync("templates/licenses-footer.md", "utf-8"), "utf-8"); 55 | -------------------------------------------------------------------------------- /src/discover.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements a Remote Two integration driver for Kodi receivers. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | from typing import Any 11 | 12 | from zeroconf import ServiceBrowser, ServiceListener, Zeroconf 13 | 14 | # pylint: disable = W1405 15 | ZERO_CONF_LOOKUP_STRING = "_xbmc-jsonrpc-h._tcp.local." 16 | LOOKUP_TIMEOUT = 5 17 | LOOKUP_DURATION = 10 18 | _LOG = logging.getLogger(__name__) 19 | 20 | 21 | class KodiDiscover(ServiceListener): 22 | """Kodi instance discovery.""" 23 | 24 | _services_found = [] 25 | 26 | async def discover(self) -> list[Any]: 27 | """Discover instances.""" 28 | self._services_found = [] 29 | zeroconf = Zeroconf() 30 | ServiceBrowser(zeroconf, ZERO_CONF_LOOKUP_STRING, self) 31 | await asyncio.sleep(LOOKUP_DURATION) 32 | zeroconf.close() 33 | _LOG.debug("Discovery services found %s", self._services_found) 34 | return self._services_found 35 | 36 | def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: 37 | """Update entry.""" 38 | # print(f"Service {name} updated") 39 | 40 | def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: 41 | """Remove entry.""" 42 | # print(f"Service {name} removed") 43 | 44 | def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: 45 | """Add entry.""" 46 | info = zc.get_service_info(type_, name, LOOKUP_TIMEOUT) 47 | ip = info.parsed_addresses()[0] 48 | server = info.server 49 | name = info.name 50 | port = info.port 51 | _id = server 52 | try: 53 | _id = info.properties[b"uuid"].decode("ascii") 54 | # pylint: disable = W0718 55 | except Exception: 56 | pass 57 | self._services_found.append({"server": server, "ip": ip, "port": port, "name": name, "id": _id, "info": info}) 58 | _LOG.debug("Discovered service %s : %s", name, info) 59 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thanks for taking the time to contribute! 4 | 5 | Found a bug, typo, missing feature or a description that doesn't make sense or needs clarification? 6 | Great, please let us know! 7 | 8 | ### Bug Reports :bug: 9 | 10 | If you find a bug, please search for it first in the [Issues of this repository](https://github.com/unfoldedcircle/integration-denonavr/issues), 11 | and also in our common [feature and bugtracker repository for Remote Two](https://github.com/unfoldedcircle/feature-and-bug-tracker/issues). 12 | If it isn't already tracked, [create a new issue](https://github.com/unfoldedcircle/integration-denonavr/issues/new). 13 | 14 | ### New Features :bulb: 15 | 16 | If you'd like to see or add new functionality to the library, describe the problem you want to solve in a 17 | [new Issue](https://github.com/unfoldedcircle/integration-denonavr/issues/new). 18 | 19 | ### Pull Requests 20 | 21 | **Any pull request needs to be reviewed and approved by the Unfolded Circle development team.** 22 | 23 | We love contributions from everyone. 24 | 25 | ⚠️ If you plan to make substantial changes, we kindly ask you, that you please reach out to us first. 26 | Either by opening a feature request describing your proposed changes before submitting code, or by contacting us on 27 | one of the other [feedback channels](#feedback-speech_balloon). 28 | 29 | Since this software is being used on the embedded Remote Two device, we have to make sure it remains 30 | compatible with the embedded runtime environment and runs smoothly. 31 | 32 | Submitting pull requests for typos, formatting issues etc. are happily accepted and usually approved relatively quick. 33 | 34 | With that out of the way, here's the process of creating a pull request and making sure it passes the automated tests: 35 | 36 | ### Contributing Code :bulb: 37 | 38 | 1. Fork the repo. 39 | 40 | 2. Make your changes or enhancements (preferably on a feature-branch). 41 | 42 | Contributed code must be licensed under the [Mozilla Public License 2.0](https://choosealicense.com/licenses/mpl-2.0/), 43 | or a compatible license, if existing parts of other projects are reused (e.g. MIT licensed code). 44 | It is required to add a boilerplate copyright notice to the top of each file: 45 | 46 | ``` 47 | """ 48 | {fileheader} 49 | 50 | :copyright: (c) {year} {person OR org} <{email}> 51 | :license: MPL-2.0, see LICENSE for more details. 52 | """ 53 | ``` 54 | 55 | 3. Make sure your changes follow the project's code style and the lints pass described in [Code Style](docs/code_guidelines.md). 56 | 57 | 4. Push to your fork. 58 | 59 | 5. Submit a pull request. 60 | 61 | At this point we will review the PR and give constructive feedback. 62 | This is a time for discussion and improvements, and making the necessary changes will be required before we can 63 | merge the contribution. 64 | 65 | ### Feedback :speech_balloon: 66 | 67 | There are a few different ways to provide feedback: 68 | 69 | - [Create a new issue](https://github.com/unfoldedcircle/integration-denonavr/issues/new) 70 | - [Reach out to us on Twitter](https://twitter.com/unfoldedcircle) 71 | - [Visit our community forum](http://unfolded.community/) 72 | - [Chat with us in our Discord channel](http://unfolded.chat/) 73 | - [Send us a message on our website](https://unfoldedcircle.com/contact) 74 | -------------------------------------------------------------------------------- /tools/scan_uc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test connection script for Kodi integration driver. 3 | 4 | :copyright: (c) 2025 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | import argparse 8 | # pylint: disable=all 9 | # flake8: noqa 10 | 11 | import asyncio 12 | import logging 13 | import sys 14 | from typing import Any 15 | 16 | import jsonrpc_base 17 | from rich import print_json 18 | 19 | if sys.platform == "win32": 20 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 21 | _LOOP = asyncio.new_event_loop() 22 | asyncio.set_event_loop(_LOOP) 23 | 24 | from zeroconf import ServiceBrowser, ServiceListener, Zeroconf 25 | 26 | # pylint: disable = W1405 27 | LOOKUP_TIMEOUT = 5 28 | LOOKUP_DURATION = 10 29 | _LOG = logging.getLogger(__name__) 30 | 31 | 32 | class UCDiscover(ServiceListener): 33 | """Kodi instance discovery.""" 34 | 35 | _services_found = [] 36 | 37 | async def discover(self, zero_conf_lookup_string: str) -> list[Any]: 38 | """Discover instances.""" 39 | self._services_found:list[dict[str, any]] = [] 40 | zeroconf = Zeroconf() 41 | ServiceBrowser(zeroconf, zero_conf_lookup_string, self) 42 | await asyncio.sleep(LOOKUP_DURATION) 43 | zeroconf.close() 44 | _LOG.debug("Discovery services found %s", self._services_found) 45 | return self._services_found 46 | 47 | def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: 48 | """Update entry.""" 49 | # print(f"Service {name} updated") 50 | 51 | def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: 52 | """Remove entry.""" 53 | # print(f"Service {name} removed") 54 | 55 | def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: 56 | """Add entry.""" 57 | info = zc.get_service_info(type_, name, LOOKUP_TIMEOUT) 58 | ip = info.parsed_addresses()[0] 59 | server = info.server 60 | name = info.name 61 | port = info.port 62 | _id = server 63 | try: 64 | _id = info.properties[b"uuid"].decode("ascii") 65 | # pylint: disable = W0718 66 | except Exception: 67 | pass 68 | item = {"server": server, "ip": ip, "port": port, "name": name, "id": _id, "info": info} 69 | self._services_found.append(item) 70 | print_json(data={"server": server, "ip": ip, "port": port, "name": name, "id": _id}) 71 | # _LOG.debug("Discovered service %s : %s", name, info) 72 | 73 | 74 | async def main(): 75 | _LOG.debug("Start scan") 76 | parser = argparse.ArgumentParser( 77 | prog='scan_uc.py', 78 | description='Scan network for UC remotes or docks') 79 | parser.add_argument('-d', '--dock', 80 | action='store_true', help="Scan for docks") 81 | parser.add_argument('-r', '--remote', 82 | action='store_true', help="Scan for remotes") 83 | args = parser.parse_args() 84 | print(args) 85 | if args.dock is False and args.remote is False: 86 | parser.print_help() 87 | exit(0) 88 | if args.dock is True and args.remote is True: 89 | parser.print_help() 90 | exit(0) 91 | 92 | zero_conf_lookup_string = "_uc-dock._tcp.local." if args.dock else "_uc-remote._tcp.local." 93 | try: 94 | discovery = UCDiscover() 95 | _discovered_ucs = await discovery.discover(zero_conf_lookup_string) 96 | _LOG.debug("Discovered UC devices : %s", _discovered_ucs) 97 | # pylint: disable = W0718 98 | except Exception as ex: 99 | _LOG.error("Error during devices discovery %s", ex) 100 | # await pair() 101 | # exit(0) 102 | 103 | exit(0) 104 | 105 | 106 | def register_rpc(self, method_name, callback): 107 | _LOG.debug("Register %s", method_name) 108 | self._server_request_handlers[method_name] = callback 109 | 110 | 111 | if __name__ == "__main__": 112 | _LOG = logging.getLogger(__name__) 113 | formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 114 | ch = logging.StreamHandler() 115 | ch.setFormatter(formatter) 116 | logging.basicConfig(handlers=[ch]) 117 | logging.getLogger(__name__).setLevel(logging.DEBUG) 118 | jsonrpc_base.Server.__register = register_rpc 119 | 120 | logging.getLogger(__name__).setLevel(logging.DEBUG) 121 | _LOOP.run_until_complete(main()) 122 | _LOOP.run_forever() 123 | -------------------------------------------------------------------------------- /src/test_connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test connection script for Kodi integration driver. 3 | 4 | :copyright: (c) 2025 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | # pylint: disable=all 9 | # flake8: noqa 10 | 11 | import asyncio 12 | import logging 13 | import sys 14 | from typing import Any 15 | 16 | import jsonrpc_base 17 | from rich import print_json 18 | 19 | import kodi 20 | from config import KodiConfigDevice 21 | from kodi import KodiDevice 22 | from media_player import KodiMediaPlayer 23 | 24 | if sys.platform == "win32": 25 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 26 | _LOOP = asyncio.new_event_loop() 27 | asyncio.set_event_loop(_LOOP) 28 | 29 | address = "192.168.1.60" # PC 30 | # address = "192.168.1.45" # Mac 31 | # address = "192.168.1.20" # Shield 32 | username = "kodi" 33 | password = "ludi" 34 | 35 | 36 | async def on_device_update(device_id: str, update: dict[str, Any] | None) -> None: 37 | print_json(data=update) 38 | 39 | 40 | async def main(): 41 | _LOG.debug("Start connection") 42 | # await pair() 43 | # exit(0) 44 | client = KodiDevice( 45 | device_config=KodiConfigDevice( 46 | id="kodi", 47 | name="Kodi", 48 | address=address, 49 | port="8080", 50 | ws_port="9090", 51 | username=username, 52 | ssl=False, 53 | password=password, 54 | artwork_type="fanart", 55 | artwork_type_tvshows="season.banner", 56 | media_update_task=True, 57 | download_artwork=False, 58 | disable_keyboard_map=True, 59 | show_stream_name=True, 60 | show_stream_language_name=True, 61 | ) 62 | ) 63 | # await client.power_on() 64 | client.events.on(kodi.Events.UPDATE, on_device_update) 65 | await client.connect() 66 | 67 | await asyncio.sleep(2) 68 | properties = client._item 69 | print("Properties :") 70 | print_json(data=properties) 71 | 72 | # await asyncio.sleep(4) 73 | # await client.select_audio_track("French FR (VFF Remix Surround 5.1 (tonalité correcte), DVD PAL FRA) DTS-HD MA 5.1") 74 | # properties = await client.get_chapters() 75 | # print_json(data=properties) 76 | 77 | await asyncio.sleep(600) 78 | 79 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "key yellow KB 0") 80 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "action nextchannelgroup") 81 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "action nextchannelgroup") 82 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "System.Shutdown") 83 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "Input.ExecuteAction {\"action\":\"subtitledelayminus\"}") 84 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "audiodelay 0.1") 85 | # await KodiMediaPlayer.mediaplayer_command( 86 | # "entityid", client, 'Player.SetAudioDelay {"playerid":PID,"offset":"increment"}' 87 | # ) 88 | # await KodiMediaPlayer.mediaplayer_command("entity.media_player", client, "activatewindow shutdownmenu") 89 | # await client.call_command("GUI.ActivateWindow", **{"window": "settings"}) 90 | # await client.command_action("dialogselectsubtitle") 91 | # await client.command_action("dialogselectaudio") 92 | # await client.call_command("GUI.ActivateWindow", **{"window": "dialogselectaudio"}) 93 | # await client.call_command("GUI.ActivateWindow", **{"window": "dialogselectaudio"}) 94 | # await KodiMediaPlayer.mediaplayer_command("entityid", client, "audio_track") 95 | # await client.play_pause() 96 | # await asyncio.sleep(4) 97 | # await client.play_pause() 98 | 99 | # Examples : 100 | await client._kodi.call_method("Input.Down") 101 | # await client._kodi._server.Input.Down() 102 | # command = KODI_ALTERNATIVE_BUTTONS_KEYMAP[Commands.CURSOR_DOWN] 103 | # await client.call_command(command["method"], **command["params"]) 104 | 105 | # command = KODI_ALTERNATIVE_BUTTONS_KEYMAP[Commands.CHANNEL_DOWN] 106 | # await client.call_command(command["method"], **command["params"]) 107 | 108 | # await client.command_action(KODI_SIMPLE_COMMANDS["MODE_FULLSCREEN"]) 109 | # await client.call_command("GUI.SetFullscreen", **{"fullscreen": "toggle"}) 110 | # await client.call_command("GUI.ActivateWindow", **{"window": "osdsubtitlesettings"}) 111 | 112 | exit(0) 113 | 114 | 115 | def register_rpc(self, method_name, callback): 116 | _LOG.debug("Register %s", method_name) 117 | self._server_request_handlers[method_name] = callback 118 | 119 | 120 | if __name__ == "__main__": 121 | _LOG = logging.getLogger(__name__) 122 | formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 123 | ch = logging.StreamHandler() 124 | ch.setFormatter(formatter) 125 | logging.basicConfig(handlers=[ch]) 126 | logging.getLogger(__name__).setLevel(logging.DEBUG) 127 | logging.getLogger("client").setLevel(logging.DEBUG) 128 | logging.getLogger("media_player").setLevel(logging.DEBUG) 129 | logging.getLogger("remote").setLevel(logging.DEBUG) 130 | logging.getLogger("kodi").setLevel(logging.DEBUG) 131 | logging.getLogger("pykodi.kodi").setLevel(logging.DEBUG) 132 | jsonrpc_base.Server.__register = register_rpc 133 | 134 | logging.getLogger(__name__).setLevel(logging.DEBUG) 135 | _LOOP.run_until_complete(main()) 136 | _LOOP.run_forever() 137 | -------------------------------------------------------------------------------- /src/remote.py: -------------------------------------------------------------------------------- 1 | """ 2 | Media-player entity functions. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | from asyncio import shield 11 | from typing import Any 12 | 13 | from ucapi import EntityTypes, Remote, StatusCodes 14 | from ucapi.media_player import States as MediaStates 15 | from ucapi.remote import Attributes, Commands, Features 16 | from ucapi.remote import States as RemoteStates 17 | 18 | import kodi 19 | from config import KodiConfigDevice, create_entity_id 20 | from const import ( 21 | KODI_REMOTE_BUTTONS_MAPPING, 22 | KODI_REMOTE_SIMPLE_COMMANDS, 23 | KODI_REMOTE_UI_PAGES, 24 | key_update_helper, 25 | ) 26 | from media_player import KodiMediaPlayer 27 | 28 | _LOG = logging.getLogger(__name__) 29 | 30 | # TODO to improve : the media states are calculated for media player entity, 31 | # then they have to be converted to remote states 32 | # A device state map should be defined and then mapped to both entity types 33 | KODI_REMOTE_STATE_MAPPING = { 34 | MediaStates.OFF: RemoteStates.OFF, 35 | MediaStates.ON: RemoteStates.ON, 36 | MediaStates.STANDBY: RemoteStates.ON, 37 | MediaStates.PLAYING: RemoteStates.ON, 38 | MediaStates.PAUSED: RemoteStates.ON, 39 | } 40 | 41 | COMMAND_TIMEOUT = 4.5 42 | 43 | 44 | def get_int_param(param: str, params: dict[str, Any], default: int): 45 | """Get parameter in integer format.""" 46 | # TODO bug to be fixed on UC Core : some params are sent as (empty) strings by remote (hold == "") 47 | value = params.get(param, default) 48 | if isinstance(value, str) and value == "": 49 | return default 50 | if isinstance(value, str) and len(value) > 0: 51 | return int(float(value)) 52 | return value 53 | 54 | 55 | class KodiRemote(Remote): 56 | """Representation of a Kodi Media Player entity.""" 57 | 58 | def __init__(self, config_device: KodiConfigDevice, device: kodi.KodiDevice): 59 | """Initialize the class.""" 60 | # pylint: disable = R0801 61 | self._device: kodi.KodiDevice = device 62 | _LOG.debug("KodiRemote init") 63 | entity_id = create_entity_id(config_device.id, EntityTypes.REMOTE) 64 | features = [Features.SEND_CMD, Features.ON_OFF] 65 | attributes = { 66 | Attributes.STATE: KODI_REMOTE_STATE_MAPPING.get(device.get_state()), 67 | } 68 | KODI_REMOTE_SIMPLE_COMMANDS.sort() 69 | super().__init__( 70 | entity_id, 71 | config_device.name, 72 | features, 73 | attributes, 74 | simple_commands=KODI_REMOTE_SIMPLE_COMMANDS, 75 | button_mapping=KODI_REMOTE_BUTTONS_MAPPING, 76 | ui_pages=KODI_REMOTE_UI_PAGES, 77 | ) 78 | 79 | async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: 80 | """ 81 | Media-player entity command handler. 82 | 83 | Called by the integration-API if a command is sent to a configured media-player entity. 84 | 85 | :param cmd_id: command 86 | :param params: optional command parameters 87 | :return: status code of the command request 88 | """ 89 | _LOG.info("Got %s command request: %s %s", self.id, cmd_id, params) 90 | 91 | if self._device is None: 92 | _LOG.warning("No Kodi instance for entity: %s", self.id) 93 | return StatusCodes.SERVICE_UNAVAILABLE 94 | 95 | # Occurs when the user press a button after wake up from standby and 96 | # the driver reconnection is not triggered yet 97 | if not self._device.kodi_connection or not self._device.kodi_connection.connected: 98 | await self._device.connect() 99 | 100 | res = StatusCodes.OK 101 | if cmd_id == Commands.ON: 102 | res = await self._device.power_on() 103 | elif cmd_id == Commands.OFF: 104 | res = await self._device.power_off() 105 | elif cmd_id == Commands.TOGGLE: 106 | if self._device.available: 107 | res = await self._device.power_off() 108 | else: 109 | res = await self._device.power_on() 110 | elif cmd_id in [Commands.SEND_CMD, Commands.SEND_CMD_SEQUENCE]: 111 | # If the duration exceeds the remote timeout, keep it running and return immediately 112 | try: 113 | async with asyncio.timeout(COMMAND_TIMEOUT): 114 | res = await shield(self.send_commands(cmd_id, params)) 115 | except asyncio.TimeoutError: 116 | _LOG.info("[%s] Command request timeout, keep running: %s %s", self.id, cmd_id, params) 117 | else: 118 | return StatusCodes.NOT_IMPLEMENTED 119 | return res 120 | 121 | async def send_commands(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: 122 | """Handle custom command or commands sequence.""" 123 | hold = get_int_param("hold", params, 0) 124 | delay = get_int_param("delay", params, 0) 125 | repeat = get_int_param("repeat", params, 1) 126 | command = params.get("command", "") 127 | res = StatusCodes.OK 128 | for _i in range(0, repeat): 129 | if cmd_id == Commands.SEND_CMD: 130 | result = await KodiMediaPlayer.mediaplayer_command(self.id, self._device, command, params) 131 | if result == StatusCodes.NOT_IMPLEMENTED: 132 | result = await self._device.command_button({"button": command, "keymap": "KB", "holdtime": hold}) 133 | if result != StatusCodes.OK: 134 | res = result 135 | if delay > 0: 136 | await asyncio.sleep(delay / 1000) 137 | else: 138 | commands = params.get("sequence", []) 139 | for command in commands: 140 | result = KodiMediaPlayer.mediaplayer_command(self.id, self._device, command, params) 141 | if result == StatusCodes.NOT_IMPLEMENTED: 142 | result = await self._device.command_button( 143 | {"button": command, "keymap": "KB", "holdtime": hold} 144 | ) 145 | if result != StatusCodes.OK: 146 | res = result 147 | if delay > 0: 148 | await asyncio.sleep(delay / 1000) 149 | return res 150 | 151 | def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: 152 | """ 153 | Filter the given attributes and return only the changed values. 154 | 155 | :param update: dictionary with attributes. 156 | :return: filtered entity attributes containing changed attributes only. 157 | """ 158 | attributes = {} 159 | 160 | if Attributes.STATE in update: 161 | state = KODI_REMOTE_STATE_MAPPING.get(update[Attributes.STATE]) 162 | attributes = key_update_helper(self.attributes, Attributes.STATE, state, attributes) 163 | 164 | _LOG.debug("KodiRemote update attributes %s", attributes) 165 | return attributes 166 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Action to build a self-contained binary of the Kodi Python driver and Docker images 2 | --- 3 | name: "Build & Release" 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | tags: 9 | - v[0-9]+.[0-9]+.[0-9]+* 10 | pull_request: 11 | branches: [main] 12 | types: [opened, synchronize, reopened] 13 | 14 | env: 15 | INTG_NAME: kodi 16 | HASH_FILENAME: uc-src.hash 17 | # Python version to use in the builder image. See https://hub.docker.com/r/unfoldedcircle/r2-pyinstaller for possible versions. 18 | PYTHON_VER: 3.11.13-0.4.0 19 | # Docker configuration 20 | REGISTRY: docker.io 21 | IMAGE_NAME: ${{ github.repository_owner }}/kodi-integration 22 | 23 | jobs: 24 | build: 25 | # using ubuntu-24.04: Package 'qemu' has no installation candidate 26 | runs-on: ubuntu-22.04 27 | environment: Ubuntu 28 | strategy: 29 | matrix: 30 | platform: [aarch64, x86_64] 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | with: 35 | # History of 200 should be more than enough to calculate commit count since last release tag. 36 | fetch-depth: 200 37 | 38 | - name: Fetch all tags to determine version 39 | run: | 40 | git fetch origin +refs/tags/*:refs/tags/* 41 | echo VERSION="v$(jq .version -r driver.json)" >> $GITHUB_ENV 42 | # echo "VERSION=$(git describe --match "v[0-9]*" --tags HEAD --always)" >> $GITHUB_ENV 43 | 44 | - name: Verify driver.json version for release build 45 | if: contains(github.ref, 'tags/v') 46 | run: | 47 | DRIVER_VERSION="v$(jq .version -r driver.json)" 48 | if [ "${{ env.VERSION }}" != "$DRIVER_VERSION" ]; then 49 | echo "Version in driver.json ($DRIVER_VERSION) doesn't match git version tag (${{ env.VERSION }})!" 50 | exit 1 51 | fi 52 | 53 | - name: Prepare for aarch64 build 54 | if: matrix.platform == 'aarch64' 55 | run: | 56 | sudo apt-get update && sudo apt-get install -y qemu binfmt-support qemu-user-static 57 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 58 | 59 | - name: Build for aarch64 60 | if: matrix.platform == 'aarch64' 61 | run: | 62 | echo "Starting pyinstaller build for aarch64" 63 | docker run --rm --name builder \ 64 | --platform=aarch64 \ 65 | --user=$(id -u):$(id -g) \ 66 | -v ${GITHUB_WORKSPACE}:/workspace \ 67 | docker.io/unfoldedcircle/r2-pyinstaller:${PYTHON_VER} \ 68 | bash -c \ 69 | "cd /workspace && \ 70 | python -m pip install -r requirements.txt && \ 71 | pyinstaller --collect-submodules zeroconf --clean --onedir --name driver src/driver.py" 72 | 73 | - name: Build for x86_64 74 | if: matrix.platform == 'x86_64' 75 | run: | 76 | echo "Starting pyinstaller build for x86_64" 77 | # Use native x86_64 Python environment 78 | sudo apt-get update && sudo apt-get install -y python3-pip python3-venv 79 | python3 -m venv build_env 80 | source build_env/bin/activate 81 | pip install -r requirements.txt 82 | pip install pyinstaller 83 | pyinstaller --collect-submodules zeroconf --clean --onedir --name driver src/driver.py 84 | 85 | - name: Add version 86 | run: | 87 | DRIVER_VERSION="v$(jq .version -r driver.json)" 88 | mkdir -p artifacts/bin 89 | cd artifacts 90 | # echo ${{ env.VERSION }} > version.txt 91 | echo $DRIVER_VERSION > version.txt 92 | 93 | - name: Prepare artifacts 94 | shell: bash 95 | run: | 96 | cp -r dist/driver/* artifacts/bin 97 | cp driver.json artifacts/ 98 | cp kodi.png artifacts/ 99 | echo "ARTIFACT_NAME=uc-intg-${{ env.INTG_NAME }}-${{ env.VERSION }}-${{ matrix.platform }}" >> $GITHUB_ENV 100 | 101 | - name: Create upload artifact 102 | shell: bash 103 | run: | 104 | tar czvf ${{ env.ARTIFACT_NAME }}.tar.gz -C ${GITHUB_WORKSPACE}/artifacts . 105 | ls -lah 106 | 107 | - uses: actions/upload-artifact@v4 108 | id: upload_artifact 109 | with: 110 | name: ${{ env.ARTIFACT_NAME }} 111 | path: ${{ env.ARTIFACT_NAME }}.tar.gz 112 | if-no-files-found: error 113 | retention-days: 3 114 | 115 | docker: 116 | runs-on: ubuntu-latest 117 | environment: Ubuntu 118 | needs: [build] 119 | steps: 120 | - name: Checkout 121 | uses: actions/checkout@v4 122 | 123 | - name: Set up Docker Buildx 124 | uses: docker/setup-buildx-action@v3 125 | 126 | - name: Log in to Docker Hub 127 | if: github.event_name != 'pull_request' 128 | uses: docker/login-action@v3 129 | with: 130 | registry: ${{ env.REGISTRY }} 131 | username: ${{ secrets.DOCKERHUB_USERNAME }} 132 | password: ${{ secrets.DOCKERHUB_TOKEN }} 133 | 134 | #- name: Extract metadata 135 | # id: meta 136 | # uses: docker/metadata-action@v5 137 | # with: 138 | # images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 139 | # tags: | 140 | # type=ref,event=branch 141 | # type=ref,event=pr 142 | # type=semver,pattern={{version}} 143 | # type=semver,pattern={{major}}.{{minor}} 144 | # type=semver,pattern={{major}} 145 | # type=raw,value=latest,enable={{is_default_branch}} 146 | 147 | 148 | - name: Build and push Docker to Docker Hub 149 | uses: docker/build-push-action@v6 150 | with: 151 | context: . 152 | file: docker/Dockerfile 153 | push: true 154 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/kodi-integration:latest 155 | 156 | #- name: Build and push Docker image 157 | # uses: docker/build-push-action@v5 158 | # with: 159 | # context: . 160 | # file: docker/Dockerfile 161 | # platforms: linux/amd64 162 | # push: ${{ github.event_name != 'pull_request' }} 163 | # tags: ${{ steps.meta.outputs.tags }} 164 | # labels: ${{ steps.meta.outputs.labels }} 165 | # cache-from: type=gha 166 | # cache-to: type=gha,mode=max 167 | 168 | release: 169 | name: Create Release 170 | if: github.ref == 'refs/heads/main' || contains(github.ref, 'tags/v') 171 | # using ubuntu-24.04: Package 'qemu' has no installation candidate 172 | runs-on: ubuntu-22.04 173 | needs: [build] 174 | 175 | steps: 176 | - name: Download build artifacts 177 | uses: actions/download-artifact@v4 178 | 179 | - name: Extract build archives from downloaded files 180 | run: | 181 | ls -R 182 | # extract tar.gz build archives from downloaded artifacts 183 | # (wrapped in tar from actions/upload-artifact, then extracted into a directory by actions/download-artifact) 184 | for D in * 185 | do if [ -d "${D}" ]; then 186 | mv $D/* ./ 187 | fi 188 | done; 189 | 190 | # Use a common timestamp for all matrix build artifacts 191 | - name: Get timestamp 192 | run: | 193 | echo "TIMESTAMP=$(date +"%Y%m%d_%H%M%S")" >> $GITHUB_ENV 194 | 195 | # Add timestamp to development builds 196 | - name: Create GitHub development build archives 197 | if: "!contains(github.ref, 'tags/v')" 198 | run: | 199 | # append timestamp 200 | for filename in *.tar.gz; do mv $filename "$(basename $filename .tar.gz)-${{ env.TIMESTAMP }}.tar.gz"; done; 201 | for filename in *.tar.gz; do echo "sha256 `sha256sum $filename`" >> ${{ env.HASH_FILENAME }}; done; 202 | 203 | - name: Create Pre-Release 204 | uses: "marvinpinto/action-automatic-releases@latest" 205 | if: "!contains(github.ref, 'tags/v')" 206 | with: 207 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 208 | automatic_release_tag: "latest" 209 | prerelease: true 210 | title: "Development Build" 211 | files: | 212 | *.tar.gz 213 | ${{ env.HASH_FILENAME }} 214 | 215 | - name: Create GitHub release archives 216 | if: "contains(github.ref, 'tags/v')" 217 | run: | 218 | for filename in *.tar.gz; do echo "sha256 `sha256sum $filename`" >> ${{ env.HASH_FILENAME }}; done; 219 | 220 | - name: Create Release 221 | uses: "marvinpinto/action-automatic-releases@latest" 222 | if: "contains(github.ref, 'tags/v')" 223 | with: 224 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 225 | prerelease: false 226 | files: | 227 | *.tar.gz 228 | ${{ env.HASH_FILENAME }} 229 | -------------------------------------------------------------------------------- /src/media_player.py: -------------------------------------------------------------------------------- 1 | """ 2 | Media-player entity functions. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import logging 9 | from typing import Any 10 | 11 | from ucapi import EntityTypes, MediaPlayer, StatusCodes 12 | from ucapi.media_player import Commands, DeviceClasses, Options 13 | 14 | import kodi 15 | from config import KodiConfigDevice, create_entity_id 16 | from const import ( 17 | KODI_ACTIONS_KEYMAP, 18 | KODI_ADVANCED_SIMPLE_COMMANDS, 19 | KODI_ALTERNATIVE_BUTTONS_KEYMAP, 20 | KODI_BUTTONS_KEYMAP, 21 | KODI_SIMPLE_COMMANDS, 22 | KODI_SIMPLE_COMMANDS_DIRECT, 23 | ButtonKeymap, 24 | MethodCall, 25 | ) 26 | 27 | _LOG = logging.getLogger(__name__) 28 | 29 | 30 | class KodiMediaPlayer(MediaPlayer): 31 | """Representation of a Kodi Media Player entity.""" 32 | 33 | def __init__(self, config_device: KodiConfigDevice, device: kodi.KodiDevice): 34 | """Initialize the class.""" 35 | # pylint: disable = R0801 36 | self._device: kodi.KodiDevice = device 37 | _LOG.debug("KodiMediaPlayer init") 38 | entity_id = create_entity_id(config_device.id, EntityTypes.MEDIA_PLAYER) 39 | features = device.supported_features 40 | attributes = device.attributes 41 | 42 | # # use sound mode support & name from configuration: receiver might not yet be connected 43 | # if device.support_sound_mode: 44 | # features.append(Features.SELECT_SOUND_MODE) 45 | # attributes[Attributes.SOUND_MODE] = "" 46 | # attributes[Attributes.SOUND_MODE_LIST] = [] 47 | simple_commands = [*list(KODI_SIMPLE_COMMANDS.keys()), *list(KODI_ADVANCED_SIMPLE_COMMANDS.keys())] 48 | simple_commands.sort() 49 | options = {Options.SIMPLE_COMMANDS: simple_commands} 50 | super().__init__( 51 | entity_id, config_device.name, features, attributes, device_class=DeviceClasses.RECEIVER, options=options 52 | ) 53 | 54 | @staticmethod 55 | async def mediaplayer_command( 56 | entity_id: str, device: kodi.KodiDevice, cmd_id: str, params: dict[str, Any] | None = None 57 | ) -> StatusCodes: 58 | """Handle any command for Media Player and Remote entities.""" 59 | # pylint: disable=R0915 60 | if device is None: 61 | _LOG.warning("No Kodi instance for entity: %s", entity_id) 62 | return StatusCodes.SERVICE_UNAVAILABLE 63 | if params is None: 64 | params = {} 65 | 66 | # Occurs when the user press a button after wake up from standby and 67 | # the driver reconnection is not triggered yet 68 | if not device.kodi_connection or not device.kodi_connection.connected: 69 | await device.connect() 70 | 71 | if cmd_id == Commands.VOLUME: 72 | res = await device.set_volume_level() 73 | elif cmd_id == Commands.VOLUME_UP: 74 | res = await device.volume_up() 75 | elif cmd_id == Commands.VOLUME_DOWN: 76 | res = await device.volume_down() 77 | elif cmd_id == Commands.MUTE_TOGGLE: 78 | res = await device.mute(not device.is_volume_muted) 79 | elif cmd_id == Commands.MUTE: 80 | res = await device.mute(True) 81 | elif cmd_id == Commands.UNMUTE: 82 | res = await device.mute(False) 83 | elif cmd_id == Commands.ON: 84 | res = await device.power_on() 85 | elif cmd_id == Commands.OFF: 86 | res = await device.power_off() 87 | elif cmd_id == Commands.NEXT: 88 | res = await device.next() 89 | elif cmd_id == Commands.PREVIOUS: 90 | res = await device.previous() 91 | elif cmd_id == Commands.PLAY_PAUSE: 92 | res = await device.play_pause() 93 | elif cmd_id == Commands.STOP: 94 | res = await device.stop() 95 | elif cmd_id == Commands.HOME: 96 | res = await device.home() 97 | elif cmd_id == Commands.SETTINGS: 98 | res = await device.call_command("GUI.ActivateWindow", **{"window": "settings"}) 99 | elif cmd_id == Commands.CONTEXT_MENU: 100 | res = await device.context_menu() 101 | elif cmd_id == Commands.SEEK: 102 | res = await device.seek(params.get("media_position", 0)) 103 | elif cmd_id == Commands.SETTINGS: 104 | res = await device.call_command("GUI.ActivateWindow", **{"window": "screensaver"}) 105 | elif cmd_id == Commands.SELECT_SOURCE: 106 | res = await device.select_chapter(params.get("source")) 107 | elif cmd_id == Commands.SELECT_SOUND_MODE: 108 | res = await device.select_audio_track(params.get("mode")) 109 | elif not device.device_config.disable_keyboard_map and cmd_id in KODI_BUTTONS_KEYMAP: 110 | command: ButtonKeymap | MethodCall = KODI_BUTTONS_KEYMAP[cmd_id] 111 | if "button" in command.keys(): 112 | command: ButtonKeymap = command.copy() 113 | hold = params.get("hold", 0) 114 | if hold != "" and hold > 0: 115 | command["holdtime"] = hold 116 | res = await device.command_button(command) 117 | else: 118 | command: MethodCall = command 119 | res = await device.call_command(command["method"], **command["params"]) 120 | elif device.device_config.disable_keyboard_map and cmd_id in KODI_ALTERNATIVE_BUTTONS_KEYMAP: 121 | command: MethodCall = KODI_ALTERNATIVE_BUTTONS_KEYMAP[cmd_id] 122 | res = await device.call_command(command["method"], **command["params"]) 123 | elif cmd_id in KODI_ACTIONS_KEYMAP: 124 | res = await device.command_action(KODI_ACTIONS_KEYMAP[cmd_id]) 125 | elif cmd_id in KODI_SIMPLE_COMMANDS: 126 | command = KODI_SIMPLE_COMMANDS[cmd_id] 127 | if command in KODI_SIMPLE_COMMANDS_DIRECT: 128 | res = await device.call_command(command) 129 | else: 130 | res = await device.command_action(command) 131 | elif cmd_id in KODI_ADVANCED_SIMPLE_COMMANDS: 132 | command: MethodCall | str = KODI_ADVANCED_SIMPLE_COMMANDS[cmd_id] 133 | if isinstance(command, str): 134 | command: str = command 135 | res = await device.command_action(command) 136 | else: 137 | command: MethodCall = command 138 | res = await device.call_command(command["method"], **command["params"]) 139 | else: 140 | return await KodiMediaPlayer.custom_command(device, cmd_id) 141 | return res 142 | 143 | @staticmethod 144 | async def custom_command(device: kodi.KodiDevice, command: str) -> StatusCodes: 145 | """Handle custom commands for Media Player and Remote entities.""" 146 | # pylint: disable=R0911,R0915 147 | arguments = command.split(" ", 1) 148 | command_key = arguments[0].lower() 149 | if command_key == "activatewindow" and len(arguments) == 2: 150 | arguments = {"window": arguments[1]} 151 | _LOG.debug("[%s] Custom command GUI.ActivateWindow %s", device.device_config.address, arguments) 152 | return await device.call_command("GUI.ActivateWindow", **arguments) 153 | if command_key == "stereoscopimode" and len(arguments) == 2: 154 | arguments = {"mode": arguments[1]} 155 | _LOG.debug("[%s] Custom command GUI.SetStereoscopicMode %s", device.device_config.address, arguments) 156 | return await device.call_command("GUI.SetStereoscopicMode", **arguments) 157 | if command_key == "viewmode" and len(arguments) == 2: 158 | return await device.view_mode(arguments[1]) 159 | if command_key == "zoom" and len(arguments) == 2: 160 | mode = arguments[1] 161 | if mode not in ["in", "out"]: 162 | try: 163 | mode = int(mode) 164 | except ValueError: 165 | pass 166 | return await device.zoom(mode) 167 | if command_key == "speed" and len(arguments) == 2: 168 | value = arguments[1] 169 | if value not in ["increment", "decrement"]: 170 | try: 171 | value = int(value) 172 | except ValueError: 173 | pass 174 | return await device.speed(value) 175 | if command_key == "audiodelay" and len(arguments) == 2: 176 | value = arguments[1] 177 | try: 178 | value = float(value) 179 | except ValueError: 180 | pass 181 | 182 | return await device.audio_delay(value) 183 | if command_key == "key" and len(arguments) == 2: 184 | value = arguments[1] 185 | value = value.split(" ") 186 | button: ButtonKeymap = ButtonKeymap(button=value[0], keymap="KB", holdtime=0) 187 | if len(value) >= 2: 188 | button["keymap"] = value[1] 189 | if len(value) == 3: 190 | try: 191 | button["holdtime"] = int(value[2]) 192 | except ValueError: 193 | pass 194 | _LOG.debug( 195 | "[%s] Keyboard command Input.ButtonEvent %s %s %s", 196 | device.device_config.address, 197 | button["button"], 198 | button["keymap"], 199 | button["holdtime"], 200 | ) 201 | return await device.command_button(button) 202 | if command_key == "action" and len(arguments) == 2: 203 | value = arguments[1] 204 | _LOG.debug("[%s] Action command Input.ExecuteAction %s", device.device_config.address, value) 205 | return await device.call_command_args("Input.ExecuteAction", value) 206 | params = {} 207 | try: 208 | # Evaluate arguments from custom command and create necessary variables (PID) 209 | if len(arguments) == 2: 210 | # pylint: disable=C0103,W0123,W0612 211 | PID = 1 # noqa: F841 212 | if "PID" in arguments[1]: 213 | PID = device.player_id # noqa: F841 214 | params = eval(arguments[1]) 215 | # pylint: disable = W0718 216 | except Exception as ex: 217 | _LOG.error("[%s] Custom command bad arguments : %s %s", device.device_config.address, arguments[1], ex) 218 | _LOG.debug("[%s] Custom command : %s %s", device.device_config.address, command, params) 219 | return await device.call_command(command_key, **params) 220 | 221 | async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: 222 | """ 223 | Media-player entity command handler. 224 | 225 | Called by the integration-API if a command is sent to a configured media-player entity. 226 | 227 | :param cmd_id: command 228 | :param params: optional command parameters 229 | :return: status code of the command request 230 | """ 231 | _LOG.info("Got %s command request: %s %s", self.id, cmd_id, params) 232 | return await KodiMediaPlayer.mediaplayer_command(self.id, self._device, cmd_id, params) 233 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration handling of the integration driver. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import dataclasses 9 | import json 10 | import logging 11 | import os 12 | from dataclasses import dataclass, field, fields 13 | from enum import Enum 14 | from typing import Callable, Iterator 15 | 16 | from ucapi import EntityTypes 17 | 18 | _LOG = logging.getLogger(__name__) 19 | 20 | _CFG_FILENAME = "config.json" 21 | 22 | 23 | def create_entity_id(avr_id: str, entity_type: EntityTypes) -> str: 24 | """Create a unique entity identifier for the given receiver and entity type.""" 25 | return f"{entity_type.value}.{avr_id}" 26 | 27 | 28 | def device_from_entity_id(entity_id: str) -> str | None: 29 | """ 30 | Return the avr_id prefix of an entity_id. 31 | 32 | The prefix is the part before the first dot in the name and refers to the AVR device identifier. 33 | 34 | :param entity_id: the entity identifier 35 | :return: the device prefix, or None if entity_id doesn't contain a dot 36 | """ 37 | return entity_id.split(".", 1)[1] 38 | 39 | 40 | class ConfigImportResult(Enum): 41 | """Result of configuration import.""" 42 | 43 | SUCCESS = 1 44 | WARNINGS = 2 45 | ERROR = 3 46 | 47 | 48 | @dataclass 49 | class KodiConfigDevice: 50 | """Sony device configuration.""" 51 | 52 | id: str 53 | name: str 54 | address: str 55 | port: str = field(default="8080") 56 | ws_port: str = field(default="9090") 57 | username: str = field(default="kodi") 58 | ssl: bool = field(default=False) 59 | password: str = field(default="password") 60 | artwork_type: str = field(default="thumb") 61 | artwork_type_tvshows: str = field(default="tvshow.poster") 62 | media_update_task: bool = field(default=False) 63 | download_artwork: bool = field(default=False) 64 | disable_keyboard_map: bool = field(default=False) 65 | show_stream_name: bool = field(default=True) 66 | show_stream_language_name: bool = field(default=True) 67 | 68 | def __post_init__(self): 69 | """Apply default values on missing fields.""" 70 | for attribute in fields(self): 71 | # If there is a default and the value of the field is none we can assign a value 72 | if ( 73 | not isinstance(attribute.default, dataclasses.MISSING.__class__) 74 | and getattr(self, attribute.name) is None 75 | ): 76 | setattr(self, attribute.name, attribute.default) 77 | 78 | 79 | class _EnhancedJSONEncoder(json.JSONEncoder): 80 | """Python dataclass json encoder.""" 81 | 82 | def default(self, o): 83 | if dataclasses.is_dataclass(o): 84 | return dataclasses.asdict(o) 85 | return super().default(o) 86 | 87 | 88 | class Devices: 89 | """Integration driver configuration class. Manages all configured Sony devices.""" 90 | 91 | def __init__( 92 | self, 93 | data_path: str, 94 | add_handler: Callable[[KodiConfigDevice], None], 95 | remove_handler: Callable[[KodiConfigDevice | None], None], 96 | update_handler: Callable[[KodiConfigDevice], None], 97 | ): 98 | """ 99 | Create a configuration instance for the given configuration path. 100 | 101 | :param data_path: configuration path for the configuration file and client device certificates. 102 | """ 103 | self._data_path: str = data_path 104 | self._cfg_file_path: str = os.path.join(data_path, _CFG_FILENAME) 105 | self._config: list[KodiConfigDevice] = [] 106 | self._add_handler = add_handler 107 | self._remove_handler = remove_handler 108 | self._update_handler = update_handler 109 | 110 | self.load() 111 | 112 | @property 113 | def data_path(self) -> str: 114 | """Return the configuration path.""" 115 | return self._data_path 116 | 117 | def all(self) -> Iterator[KodiConfigDevice]: 118 | """Get an iterator for all device configurations.""" 119 | return iter(self._config) 120 | 121 | def contains(self, device_id: str) -> bool: 122 | """Check if there's a device with the given device identifier.""" 123 | for item in self._config: 124 | if item.id == device_id: 125 | return True 126 | return False 127 | 128 | def add(self, atv: KodiConfigDevice) -> None: 129 | """Add a new configured Sony device.""" 130 | existing = self.get_by_id_or_address(atv.id, atv.address) 131 | if existing: 132 | _LOG.debug("Replacing existing device %s => %s", existing, atv) 133 | self._config.remove(existing) 134 | 135 | self._config.append(atv) 136 | if self._add_handler is not None: 137 | self._add_handler(atv) 138 | 139 | def add_or_update(self, atv: KodiConfigDevice) -> None: 140 | """Add a new configured device.""" 141 | if self.contains(atv.id): 142 | _LOG.debug("Existing config %s, updating it %s", atv.id, atv) 143 | self.update(atv) 144 | if self._update_handler is not None: 145 | self._update_handler(atv) 146 | else: 147 | _LOG.debug("Adding new config %s", atv) 148 | self._config.append(atv) 149 | self.store() 150 | if self._add_handler is not None: 151 | self._add_handler(atv) 152 | 153 | def get(self, avr_id: str) -> KodiConfigDevice | None: 154 | """Get device configuration for given identifier.""" 155 | for item in self._config: 156 | if item.id == avr_id: 157 | # return a copy 158 | return dataclasses.replace(item) 159 | return None 160 | 161 | def update(self, device: KodiConfigDevice) -> bool: 162 | """Update a configured Sony device and persist configuration.""" 163 | for item in self._config: 164 | if item.id == device.id: 165 | item.name = device.name 166 | item.address = device.address 167 | item.port = device.port 168 | item.ws_port = device.ws_port 169 | item.username = device.username 170 | item.password = device.password 171 | item.ssl = device.ssl 172 | item.artwork_type = device.artwork_type 173 | item.artwork_type_tvshows = device.artwork_type_tvshows 174 | item.media_update_task = device.media_update_task 175 | item.download_artwork = device.download_artwork 176 | item.disable_keyboard_map = device.disable_keyboard_map 177 | item.show_stream_name = device.show_stream_name 178 | item.show_stream_language_name = device.show_stream_language_name 179 | return self.store() 180 | return False 181 | 182 | def remove(self, avr_id: str) -> bool: 183 | """Remove the given device configuration.""" 184 | device = self.get(avr_id) 185 | if device is None: 186 | return False 187 | try: 188 | self._config.remove(device) 189 | if self._remove_handler is not None: 190 | self._remove_handler(device) 191 | return True 192 | except ValueError: 193 | pass 194 | return False 195 | 196 | def clear(self) -> None: 197 | """Remove the configuration file.""" 198 | self._config = [] 199 | 200 | if os.path.exists(self._cfg_file_path): 201 | os.remove(self._cfg_file_path) 202 | 203 | if self._remove_handler is not None: 204 | self._remove_handler(None) 205 | 206 | def store(self) -> bool: 207 | """ 208 | Store the configuration file. 209 | 210 | :return: True if the configuration could be saved. 211 | """ 212 | try: 213 | with open(self._cfg_file_path, "w+", encoding="utf-8") as f: 214 | json.dump(self._config, f, ensure_ascii=False, cls=_EnhancedJSONEncoder) 215 | return True 216 | except OSError: 217 | _LOG.error("Cannot write the config file") 218 | 219 | return False 220 | 221 | def export(self) -> str: 222 | """Export the configuration file to a string. 223 | 224 | :return: JSON formatted string of the current configuration 225 | """ 226 | return json.dumps(self._config, ensure_ascii=False, cls=_EnhancedJSONEncoder) 227 | 228 | def import_config(self, updated_config: str) -> ConfigImportResult: 229 | """Import the updated configuration.""" 230 | config_backup = self._config.copy() 231 | result = ConfigImportResult.SUCCESS 232 | try: 233 | data = json.loads(updated_config) 234 | self._config.clear() 235 | for item in data: 236 | try: 237 | self._config.append(KodiConfigDevice(**item)) 238 | except TypeError as ex: 239 | _LOG.warning("Invalid configuration entry will be ignored: %s", ex) 240 | result = ConfigImportResult.WARNINGS 241 | 242 | _LOG.debug("Configuration to import : %s", self._config) 243 | 244 | # Now trigger events add/update/removal of devices based on old / updated list 245 | for device in self._config: 246 | found = False 247 | for old_device in config_backup: 248 | if old_device.id == device.id: 249 | if self._update_handler is not None: 250 | self._update_handler(device) 251 | found = True 252 | break 253 | if not found and self._add_handler is not None: 254 | self._add_handler(device) 255 | for old_device in config_backup: 256 | found = False 257 | for device in self._config: 258 | if old_device.id == device.id: 259 | found = True 260 | break 261 | if not found and self._remove_handler is not None: 262 | self._remove_handler(old_device) 263 | 264 | with open(self._cfg_file_path, "w+", encoding="utf-8") as f: 265 | json.dump(self._config, f, ensure_ascii=False, cls=_EnhancedJSONEncoder) 266 | return result 267 | # pylint: disable = W0718 268 | except Exception as ex: 269 | result = ConfigImportResult.ERROR 270 | _LOG.error( 271 | "Cannot import the updated configuration %s, keeping existing configuration : %s", updated_config, ex 272 | ) 273 | try: 274 | # Restore current configuration 275 | self._config = config_backup 276 | self.store() 277 | # pylint: disable = W0718 278 | except Exception: 279 | pass 280 | return result 281 | 282 | def load(self) -> bool: 283 | """Load the config into the config global variable. 284 | 285 | :return: True if the configuration could be loaded. 286 | """ 287 | try: 288 | with open(self._cfg_file_path, "r", encoding="utf-8") as f: 289 | data = json.load(f) 290 | for item in data: 291 | try: 292 | self._config.append(KodiConfigDevice(**item)) 293 | except TypeError as ex: 294 | _LOG.warning("Invalid configuration entry will be ignored: %s", ex) 295 | return True 296 | except OSError: 297 | _LOG.error("Cannot open the config file") 298 | except ValueError: 299 | _LOG.error("Empty or invalid config file") 300 | 301 | return False 302 | 303 | def get_by_id_or_address(self, unique_id: str, address: str) -> KodiConfigDevice | None: 304 | """Get device configuration for a matching id or address. 305 | 306 | :return: A copy of the device configuration or None if not found. 307 | """ 308 | for item in self._config: 309 | if item.id == unique_id or item.address == address: 310 | # return a copy 311 | return dataclasses.replace(item) 312 | return None 313 | 314 | 315 | # pylint: disable=C0103 316 | devices: Devices | None = None 317 | -------------------------------------------------------------------------------- /src/pykodi/kodi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of a Kodi interface. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | import urllib 11 | 12 | import aiohttp 13 | import jsonrpc_async 14 | import jsonrpc_base # pylint: disable = E0401 15 | import jsonrpc_websocket # pylint: disable = E0401 16 | from aiohttp import ServerTimeoutError 17 | 18 | _LOG = logging.getLogger(__name__) 19 | 20 | 21 | # pylint: disable=R0917 22 | def get_kodi_connection(host, port, ws_port, username, password, ssl=False, timeout=5, session=None): 23 | """Return a Kodi connection.""" 24 | if ws_port is None: 25 | return KodiHTTPConnection(host, port, username, password, ssl, timeout, session) 26 | return KodiWSConnection(host, port, ws_port, username, password, ssl, timeout, session) 27 | 28 | 29 | class KodiConnection: 30 | """A connection to Kodi interface.""" 31 | 32 | def __init__(self, host, port, username, password, ssl, timeout, session): 33 | """Initialize the object.""" 34 | self._session = session 35 | self._created_session = False 36 | if self._session is None: 37 | self._session = aiohttp.ClientSession() 38 | self._created_session = True 39 | 40 | self._kwargs = {"timeout": timeout, "session": self._session} 41 | 42 | if username is not None: 43 | self._kwargs["auth"] = aiohttp.BasicAuth(username, password) 44 | image_auth_string = f"{username}:{password}@" 45 | else: 46 | image_auth_string = "" 47 | 48 | http_protocol = "https" if ssl else "http" 49 | 50 | self._image_url = f"{http_protocol}://{image_auth_string}{host}:{port}/image" 51 | 52 | async def connect(self): 53 | """Connect to kodi.""" 54 | 55 | async def close(self): 56 | """Close the connection.""" 57 | if self._created_session and self._session is not None: 58 | await self._session.close() 59 | self._session = None 60 | self._created_session = False 61 | 62 | @property 63 | def server(self) -> jsonrpc_base.Server: 64 | """Return server.""" 65 | raise NotImplementedError 66 | 67 | @property 68 | def connected(self) -> bool: 69 | """Is the server connected.""" 70 | raise NotImplementedError 71 | 72 | @property 73 | def can_subscribe(self) -> bool: 74 | """Can subscribe.""" 75 | return False 76 | 77 | def thumbnail_url(self, thumbnail) -> str | None: 78 | """Get the URL for a thumbnail.""" 79 | if thumbnail is None: 80 | return None 81 | 82 | url_components = urllib.parse.urlparse(thumbnail) 83 | if url_components.scheme == "image": 84 | return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}" 85 | return None 86 | 87 | 88 | class KodiHTTPConnection(KodiConnection): 89 | """An HTTP connection to Kodi.""" 90 | 91 | def __init__(self, host, port, username, password, ssl, timeout, session): 92 | """Initialize the object.""" 93 | super().__init__(host, port, username, password, ssl, timeout, session) 94 | 95 | http_protocol = "https" if ssl else "http" 96 | 97 | http_url = f"{http_protocol}://{host}:{port}/jsonrpc" 98 | 99 | self._http_server: jsonrpc_async.Server = jsonrpc_async.Server(http_url, **self._kwargs) 100 | 101 | @property 102 | def connected(self) -> bool: 103 | """Is the server connected.""" 104 | return True 105 | 106 | async def close(self): 107 | """Close the connection.""" 108 | self._http_server = None 109 | await super().close() 110 | 111 | @property 112 | def server(self) -> jsonrpc_async.Server: 113 | """Active server for json-rpc requests.""" 114 | return self._http_server 115 | 116 | 117 | class KodiWSConnection(KodiConnection): 118 | """A WS connection to Kodi.""" 119 | 120 | _connect_task = None 121 | 122 | def __init__(self, host, port, ws_port, username, password, ssl, timeout, session): 123 | """Initialize the object.""" 124 | super().__init__(host, port, username, password, ssl, timeout, session) 125 | 126 | ws_protocol = "wss" if ssl else "ws" 127 | ws_url = f"{ws_protocol}://{host}:{ws_port}/jsonrpc" 128 | 129 | self._ws_server: jsonrpc_websocket.Server = jsonrpc_websocket.Server(ws_url, **self._kwargs) 130 | 131 | @property 132 | def connected(self): 133 | """Return whether websocket is connected.""" 134 | return self._ws_server.connected 135 | 136 | @property 137 | def can_subscribe(self): 138 | """Can subscribe to vents.""" 139 | return True 140 | 141 | async def connect(self): 142 | """Connect to kodi over websocket.""" 143 | if self.connected: 144 | return 145 | try: 146 | if self._connect_task: 147 | try: 148 | self._connect_task.cancel() 149 | await self.close() 150 | # pylint: disable = W0718 151 | except Exception: 152 | pass 153 | self._connect_task = None 154 | 155 | self._connect_task = await self._ws_server.ws_connect() 156 | except (jsonrpc_base.jsonrpc.TransportError, asyncio.exceptions.CancelledError, ServerTimeoutError) as error: 157 | _LOG.error("Kodi connection error %s", error) 158 | raise CannotConnectError(error) from error 159 | 160 | async def close(self): 161 | """Close the connection.""" 162 | await self._ws_server.close() 163 | await super().close() 164 | 165 | @property 166 | def server(self): 167 | """Active server for json-rpc requests.""" 168 | return self._ws_server 169 | 170 | 171 | class Kodi: 172 | """A high level Kodi interface.""" 173 | 174 | def __init__(self, connection: KodiConnection): 175 | """Initialize the object.""" 176 | self._conn: KodiConnection = connection 177 | self._server: jsonrpc_base.Server = connection.server 178 | 179 | @property 180 | def server(self) -> jsonrpc_base.Server | None: 181 | """Return Kodi server.""" 182 | return self._server 183 | 184 | async def ping(self): 185 | """Ping the server.""" 186 | try: 187 | response = await self._server.JSONRPC.Ping() 188 | return response == "pong" 189 | except jsonrpc_base.jsonrpc.TransportError as error: 190 | if "401" in str(error): 191 | raise InvalidAuthError from error 192 | raise CannotConnectError from error 193 | 194 | async def get_application_properties(self, properties): 195 | """Get value of given properties.""" 196 | return await self._server.Application.GetProperties(properties) 197 | 198 | async def get_player_properties(self, player, properties): 199 | """Get value of given properties.""" 200 | return await self._server.Player.GetProperties(player["playerid"], properties) 201 | 202 | async def get_playing_item_properties(self, player, properties): 203 | """Get value of given properties.""" 204 | return (await self._server.Player.GetItem(player["playerid"], properties))["item"] 205 | 206 | async def get_playlist(self, playlist_id=0): 207 | """Get playlist properties.""" 208 | return await self._server.Playlist.GetItems(playlist_id) 209 | 210 | async def get_player_chapters(self, player): 211 | """Get video chapters.""" 212 | return await self._server.Player.GetChapters(player["playerid"]) 213 | 214 | async def volume_up(self): 215 | """Send volume up command.""" 216 | await self._server.Input.ExecuteAction("volumeup") 217 | 218 | async def volume_down(self): 219 | """Send volume down command.""" 220 | await self._server.Input.ExecuteAction("volumedown") 221 | 222 | async def set_volume_level(self, volume): 223 | """Set volume level, range 0-100.""" 224 | await self._server.Application.SetVolume(volume) 225 | 226 | async def mute(self, mute): 227 | """Send (un)mute command.""" 228 | await self._server.Application.SetMute(mute) 229 | 230 | async def _set_play_state(self, state): 231 | players = await self.get_players() 232 | 233 | if players: 234 | await self._server.Player.PlayPause(players[0]["playerid"], state) 235 | 236 | async def play_pause(self): 237 | """Send toggle command command.""" 238 | await self._set_play_state("toggle") 239 | 240 | async def play(self): 241 | """Send play command.""" 242 | await self._set_play_state(True) 243 | 244 | async def pause(self): 245 | """Send pause command.""" 246 | await self._set_play_state(False) 247 | 248 | async def stop(self): 249 | """Send stop command.""" 250 | players = await self.get_players() 251 | 252 | if players: 253 | await self._server.Player.Stop(players[0]["playerid"]) 254 | 255 | async def _goto(self, direction): 256 | players = await self.get_players() 257 | 258 | if players: 259 | if direction == "previous": 260 | # First seek to position 0. Kodi goes to the beginning of the 261 | # current track if the current track is not at the beginning. 262 | await self._server.Player.Seek(players[0]["playerid"], {"percentage": 0}) 263 | 264 | await self._server.Player.GoTo(players[0]["playerid"], direction) 265 | 266 | async def next_track(self): 267 | """Send next track command.""" 268 | await self._goto("next") 269 | 270 | async def previous_track(self): 271 | """Send previous track command.""" 272 | await self._goto("previous") 273 | 274 | async def media_seek(self, position): 275 | """Send seek command.""" 276 | players = await self.get_players() 277 | 278 | time = {"milliseconds": int((position % 1) * 1000)} 279 | 280 | position = int(position) 281 | 282 | time["seconds"] = int(position % 60) 283 | position /= 60 284 | 285 | time["minutes"] = int(position % 60) 286 | position /= 60 287 | 288 | time["hours"] = int(position) 289 | 290 | if players: 291 | await self._server.Player.Seek(players[0]["playerid"], {"time": time}) 292 | 293 | async def play_item(self, item): 294 | """Play given item.""" 295 | await self._server.Player.Open(**{"item": item}) 296 | 297 | async def play_channel(self, channel_id): 298 | """Play the given channel.""" 299 | await self.play_item({"channelid": channel_id}) 300 | 301 | async def play_playlist(self, playlist_id): 302 | """Play the given playlist.""" 303 | await self.play_item({"playlistid": playlist_id}) 304 | 305 | async def play_directory(self, directory): 306 | """Play the given directory.""" 307 | await self.play_item({"directory": directory}) 308 | 309 | async def play_file(self, file): 310 | """Play the given file.""" 311 | await self.play_item({"file": file}) 312 | 313 | async def set_shuffle(self, shuffle): 314 | """Set shuffle mode, for the first player.""" 315 | players = await self.get_players() 316 | if players: 317 | await self._server.Player.SetShuffle(**{"playerid": players[0]["playerid"], "shuffle": shuffle}) 318 | 319 | async def call_method(self, method, **kwargs): 320 | """Run Kodi JSONRPC API method with params.""" 321 | if "." not in method or len(method.split(".")) != 2: 322 | raise ValueError(f"Invalid method: {method}") 323 | return await getattr(self._server, method)(**kwargs) 324 | 325 | async def call_method_args(self, method, *args): 326 | """Run Kodi JSONRPC API method with params.""" 327 | if "." not in method or len(method.split(".")) != 2: 328 | raise ValueError(f"Invalid method: {method}") 329 | return await getattr(self._server, method)(*args) 330 | 331 | async def _add_item_to_playlist(self, item): 332 | await self._server.Playlist.Add(**{"playlistid": 0, "item": item}) 333 | 334 | async def add_song_to_playlist(self, song_id): 335 | """Add song to default playlist (i.e. playlistid=0).""" 336 | await self._add_item_to_playlist({"songid": song_id}) 337 | 338 | async def add_album_to_playlist(self, album_id): 339 | """Add album to default playlist (i.e. playlistid=0).""" 340 | await self._add_item_to_playlist({"albumid": album_id}) 341 | 342 | async def add_artist_to_playlist(self, artist_id): 343 | """Add album to default playlist (i.e. playlistid=0).""" 344 | await self._add_item_to_playlist({"artistid": artist_id}) 345 | 346 | async def clear_playlist(self): 347 | """Clear default playlist (i.e. playlistid=0).""" 348 | await self._server.Playlist.Clear(**{"playlistid": 0}) 349 | 350 | async def get_artists(self, properties=None): 351 | """Get artists list.""" 352 | return await self._server.AudioLibrary.GetArtists(**_build_query(properties=properties)) 353 | 354 | async def get_artist_details(self, artist_id=None, properties=None): 355 | """Get artist details.""" 356 | return await self._server.AudioLibrary.GetArtistDetails( 357 | **_build_query(artistid=artist_id, properties=properties) 358 | ) 359 | 360 | async def get_albums(self, artist_id=None, album_id=None, properties=None): 361 | """Get albums list.""" 362 | _filter = {} 363 | if artist_id: 364 | _filter["artistid"] = artist_id 365 | if album_id: 366 | _filter["albumid"] = album_id 367 | 368 | return await self._server.AudioLibrary.GetAlbums(**_build_query(filter=_filter, properties=properties)) 369 | 370 | async def get_album_details(self, album_id, properties=None): 371 | """Get album details.""" 372 | return await self._server.AudioLibrary.GetAlbumDetails(**_build_query(albumid=album_id, properties=properties)) 373 | 374 | async def get_songs(self, artist_id=None, album_id=None, properties=None): 375 | """Get songs list.""" 376 | _filter = {} 377 | if artist_id: 378 | _filter["artistid"] = artist_id 379 | if album_id: 380 | _filter["albumid"] = album_id 381 | 382 | return await self._server.AudioLibrary.GetSongs(**_build_query(filter=_filter, properties=properties)) 383 | 384 | async def get_movies(self, properties=None): 385 | """Get movies list.""" 386 | return await self._server.VideoLibrary.GetMovies(**_build_query(properties=properties)) 387 | 388 | async def get_movie_details(self, movie_id, properties=None): 389 | """Get movie details.""" 390 | return await self._server.VideoLibrary.GetMovieDetails(**_build_query(movieid=movie_id, properties=properties)) 391 | 392 | async def get_seasons(self, tv_show_id, properties=None): 393 | """Get seasons list.""" 394 | return await self._server.VideoLibrary.GetSeasons(**_build_query(tvshowid=tv_show_id, properties=properties)) 395 | 396 | async def get_season_details(self, season_id, properties=None): 397 | """Get songs list.""" 398 | return await self._server.VideoLibrary.GetSeasonDetails( 399 | **_build_query(seasonid=season_id, properties=properties) 400 | ) 401 | 402 | async def get_episodes(self, tv_show_id, season_id, properties=None): 403 | """Get episodes list.""" 404 | return await self._server.VideoLibrary.GetEpisodes( 405 | **_build_query(tvshowid=tv_show_id, season=season_id, properties=properties) 406 | ) 407 | 408 | async def get_tv_shows(self, properties=None): 409 | """Get tv shows list.""" 410 | return await self._server.VideoLibrary.GetTVShows(**_build_query(properties=properties)) 411 | 412 | async def get_tv_show_details(self, tv_show_id=None, properties=None): 413 | """Get songs list.""" 414 | return await self._server.VideoLibrary.GetTVShowDetails( 415 | **_build_query(tvshowid=tv_show_id, properties=properties) 416 | ) 417 | 418 | async def get_channels(self, channel_group_id, properties=None): 419 | """Get channels list.""" 420 | return await self._server.PVR.GetChannels( 421 | **_build_query(channelgroupid=channel_group_id, properties=properties) 422 | ) 423 | 424 | async def get_players(self): 425 | """Return the active player objects.""" 426 | return await self._server.Player.GetActivePlayers() 427 | 428 | async def send_notification(self, title, message, icon="info", displaytime=10000): 429 | """Display on-screen message.""" 430 | await self._server.GUI.ShowNotification(title, message, icon, displaytime) 431 | 432 | def thumbnail_url(self, thumbnail): 433 | """Get the URL for a thumbnail.""" 434 | return self._conn.thumbnail_url(thumbnail) 435 | 436 | async def set_audio_stream(self, stream_index: int): 437 | """Set the audio stream of the running player.""" 438 | players = await self.get_players() 439 | if players: 440 | await self._server.Player.SetAudioStream(**{"playerid": players[0]["playerid"], "stream": stream_index}) 441 | 442 | 443 | def _build_query(**kwargs): 444 | """Build query.""" 445 | query = {} 446 | for key, val in kwargs.items(): 447 | if val: 448 | query.update({key: val}) 449 | 450 | return query 451 | 452 | 453 | class CannotConnectError(Exception): 454 | """Exception to indicate an error in connection.""" 455 | 456 | 457 | class InvalidAuthError(Exception): 458 | """Exception to indicate an error in authentication.""" 459 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/driver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This module implements a Remote Two integration driver for Kodi receivers. 4 | 5 | :copyright: (c) 2023 by Unfolded Circle ApS. 6 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 7 | """ 8 | 9 | import asyncio 10 | import logging 11 | import os 12 | import sys 13 | from typing import Any 14 | 15 | import ucapi 16 | 17 | import config 18 | import kodi 19 | import media_player 20 | import remote 21 | import setup_flow 22 | from config import device_from_entity_id 23 | 24 | _LOG = logging.getLogger("driver") # avoid having __main__ in log messages 25 | if sys.platform == "win32": 26 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 27 | _LOOP = asyncio.new_event_loop() 28 | asyncio.set_event_loop(_LOOP) 29 | 30 | # Global variables 31 | api = ucapi.IntegrationAPI(_LOOP) 32 | # Map of id -> device instance 33 | _configured_kodis: dict[str, kodi.KodiDevice] = {} 34 | _remote_in_standby = False # pylint: disable=C0103 35 | 36 | 37 | @api.listens_to(ucapi.Events.CONNECT) 38 | async def on_r2_connect_cmd() -> None: 39 | """Connect all configured TVs when the Remote Two sends the connect command.""" 40 | await api.set_device_state(ucapi.DeviceStates.CONNECTED) 41 | # TODO check if we were in standby and ignore the call? We'll also get an EXIT_STANDBY 42 | _LOG.debug("R2 connect command: connecting device(s)") 43 | for device in _configured_kodis.values(): 44 | # start background task 45 | # TODO ? what is the connect event for (against exit from standby) 46 | # await _LOOP.create_task(device.power_on()) 47 | try: 48 | await _LOOP.create_task(device.connect()) 49 | except RuntimeError as ex: 50 | _LOG.debug("Could not connect to device %s : %s", device.device_config.address, ex) 51 | 52 | 53 | @api.listens_to(ucapi.Events.DISCONNECT) 54 | async def on_r2_disconnect_cmd(): 55 | """Disconnect all configured TVs when the Remote Two sends the disconnect command.""" 56 | # pylint: disable = W0212 57 | if len(api._clients) == 0: 58 | _LOG.debug("Disconnect requested") 59 | for device in _configured_kodis.values(): 60 | # start background task 61 | await _LOOP.create_task(device.disconnect()) 62 | else: 63 | _LOG.debug("Disconnect requested but 1 client is connected %s", api._clients) 64 | 65 | 66 | @api.listens_to(ucapi.Events.ENTER_STANDBY) 67 | async def on_r2_enter_standby() -> None: 68 | """Enter standby notification from Remote Two. 69 | 70 | Disconnect every Kodi instances. 71 | """ 72 | global _remote_in_standby 73 | 74 | _remote_in_standby = True 75 | _LOG.debug("Enter standby event: disconnecting device(s)") 76 | for configured in _configured_kodis.values(): 77 | await configured.disconnect() 78 | 79 | 80 | async def connect_device(device: kodi.KodiDevice): 81 | """Connect device and send state.""" 82 | try: 83 | _LOG.debug("Connecting device %s...", device.id) 84 | await device.connect() 85 | _LOG.debug("Device %s connected, sending attributes for subscribed entities", device.id) 86 | state = device.state 87 | for entity in api.configured_entities.get_all(): 88 | entity_id = entity.get("entity_id", "") 89 | device_id = device_from_entity_id(entity_id) 90 | if device_id != device.id: 91 | continue 92 | if isinstance(entity, media_player.KodiMediaPlayer): 93 | _LOG.debug("Sending attributes %s : %s", entity_id, device.attributes) 94 | api.configured_entities.update_attributes(entity_id, device.attributes) 95 | if isinstance(entity, remote.KodiRemote): 96 | api.configured_entities.update_attributes( 97 | entity_id, {ucapi.remote.Attributes.STATE: remote.KODI_REMOTE_STATE_MAPPING.get(state)} 98 | ) 99 | except RuntimeError as ex: 100 | _LOG.error("Error while reconnecting to Kodi %s", ex) 101 | 102 | 103 | @api.listens_to(ucapi.Events.EXIT_STANDBY) 104 | async def on_r2_exit_standby() -> None: 105 | """ 106 | Exit standby notification from Remote Two. 107 | 108 | Connect all Kodi instances. 109 | """ 110 | global _remote_in_standby 111 | 112 | _remote_in_standby = False 113 | _LOG.debug("Exit standby event: connecting Kodi device(s) %s", _configured_kodis) 114 | 115 | for configured in _configured_kodis.values(): 116 | # start background task 117 | try: 118 | await _LOOP.create_task(connect_device(configured)) 119 | except RuntimeError as ex: 120 | _LOG.error("Error while reconnecting to Kodi %s", ex) 121 | # _LOOP.create_task(configured.connect()) 122 | 123 | 124 | @api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES) 125 | async def on_subscribe_entities(entity_ids: list[str]) -> None: 126 | """ 127 | Subscribe to given entities. 128 | 129 | :param entity_ids: entity identifiers. 130 | """ 131 | global _remote_in_standby 132 | 133 | _remote_in_standby = False 134 | _LOG.debug("Subscribe entities event: %s", entity_ids) 135 | for entity_id in entity_ids: 136 | entity = api.configured_entities.get(entity_id) 137 | device_id = device_from_entity_id(entity_id) 138 | if device_id in _configured_kodis: 139 | device = _configured_kodis[device_id] 140 | state = device.get_state() 141 | if isinstance(entity, media_player.KodiMediaPlayer): 142 | api.configured_entities.update_attributes(entity_id, {ucapi.media_player.Attributes.STATE: state}) 143 | if isinstance(entity, remote.KodiRemote): 144 | api.configured_entities.update_attributes( 145 | entity_id, {ucapi.remote.Attributes.STATE: remote.KODI_REMOTE_STATE_MAPPING.get(state)} 146 | ) 147 | continue 148 | 149 | device = config.devices.get(device_id) 150 | if device: 151 | _configure_new_device(device, connect=True) 152 | _LOOP.create_task(_configured_kodis.get(device_id).connect()) 153 | else: 154 | _LOG.error("Failed to subscribe entity %s: no Kodi configuration found", entity_id) 155 | 156 | 157 | @api.listens_to(ucapi.Events.UNSUBSCRIBE_ENTITIES) 158 | async def on_unsubscribe_entities(entity_ids: list[str]) -> None: 159 | """On unsubscribe, we disconnect the objects and remove listeners for events.""" 160 | _LOG.debug("Unsubscribe entities event: %s", entity_ids) 161 | devices_to_remove = set() 162 | for entity_id in entity_ids: 163 | device_id = device_from_entity_id(entity_id) 164 | if device_id is None: 165 | continue 166 | devices_to_remove.add(device_id) 167 | 168 | # Keep devices that are used by other configured entities not in this list 169 | for entity in api.configured_entities.get_all(): 170 | entity_id = entity.get("entity_id", "") 171 | if entity_id in entity_ids: 172 | continue 173 | device_id = device_from_entity_id(entity_id) 174 | if device_id is None: 175 | continue 176 | if device_id in devices_to_remove: 177 | devices_to_remove.remove(device_id) 178 | 179 | for device_id in devices_to_remove: 180 | if device_id in _configured_kodis: 181 | await _configured_kodis[device_id].disconnect() 182 | _configured_kodis[device_id].events.remove_all_listeners() 183 | 184 | 185 | async def on_device_connected(device_id: str): 186 | """Handle device connection.""" 187 | _LOG.debug("Kodi connected: %s", device_id) 188 | 189 | if device_id not in _configured_kodis: 190 | _LOG.warning("Kodi %s is not configured", device_id) 191 | return 192 | 193 | # TODO #20 when multiple devices are supported, the device state logic isn't that simple anymore! 194 | await api.set_device_state(ucapi.DeviceStates.CONNECTED) 195 | 196 | for entity_id in _entities_from_device_id(device_id): 197 | configured_entity = api.configured_entities.get(entity_id) 198 | if configured_entity is None: 199 | continue 200 | 201 | if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: 202 | if ( 203 | configured_entity.attributes[ucapi.media_player.Attributes.STATE] 204 | == ucapi.media_player.States.UNAVAILABLE 205 | ): 206 | # TODO why STANDBY? 207 | api.configured_entities.update_attributes( 208 | entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.STANDBY} 209 | ) 210 | else: 211 | api.configured_entities.update_attributes( 212 | entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.ON} 213 | ) 214 | elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: 215 | if configured_entity.attributes[ucapi.remote.Attributes.STATE] == ucapi.remote.States.UNAVAILABLE: 216 | api.configured_entities.update_attributes( 217 | entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.OFF} 218 | ) 219 | 220 | 221 | async def on_device_disconnected(device_id: str): 222 | """Handle device disconnection.""" 223 | _LOG.debug("Kodi disconnected: %s", device_id) 224 | 225 | for entity_id in _entities_from_device_id(device_id): 226 | configured_entity = api.configured_entities.get(entity_id) 227 | if configured_entity is None: 228 | continue 229 | 230 | if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: 231 | api.configured_entities.update_attributes( 232 | entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNAVAILABLE} 233 | ) 234 | elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: 235 | api.configured_entities.update_attributes( 236 | entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.UNAVAILABLE} 237 | ) 238 | 239 | # TODO #20 when multiple devices are supported, the device state logic isn't that simple anymore! 240 | await api.set_device_state(ucapi.DeviceStates.DISCONNECTED) 241 | 242 | 243 | async def on_device_connection_error(device_id: str, message): 244 | """Set entities of Kodi to state UNAVAILABLE if device connection error occurred.""" 245 | _LOG.error(message) 246 | 247 | for entity_id in _entities_from_device_id(device_id): 248 | configured_entity = api.configured_entities.get(entity_id) 249 | if configured_entity is None: 250 | continue 251 | 252 | if configured_entity.entity_type == ucapi.EntityTypes.MEDIA_PLAYER: 253 | api.configured_entities.update_attributes( 254 | entity_id, {ucapi.media_player.Attributes.STATE: ucapi.media_player.States.UNAVAILABLE} 255 | ) 256 | elif configured_entity.entity_type == ucapi.EntityTypes.REMOTE: 257 | api.configured_entities.update_attributes( 258 | entity_id, {ucapi.remote.Attributes.STATE: ucapi.remote.States.UNAVAILABLE} 259 | ) 260 | 261 | # TODO #20 when multiple devices are supported, the device state logic isn't that simple anymore! 262 | await api.set_device_state(ucapi.DeviceStates.ERROR) 263 | 264 | 265 | async def handle_device_address_change(device_id: str, address: str) -> None: 266 | """Update device configuration with changed IP address.""" 267 | # TODO discover 268 | device = config.devices.get(device_id) 269 | if device and device.address != address: 270 | _LOG.info("Updating IP address of configured Kodi %s: %s -> %s", device_id, device.address, address) 271 | device.address = address 272 | config.devices.update(device) 273 | 274 | 275 | async def on_device_update(device_id: str, update: dict[str, Any] | None) -> None: 276 | """Update attributes of configured media-player entity if device properties changed. 277 | 278 | :param device_id: device identifier 279 | :param update: dictionary containing the updated properties or None if 280 | """ 281 | if update is None: 282 | if device_id not in _configured_kodis: 283 | return 284 | device = _configured_kodis[device_id] 285 | update = device.attributes 286 | else: 287 | _LOG.info("[%s] Kodi update: %s", device_id, update) 288 | 289 | attributes = None 290 | 291 | # TODO awkward logic: this needs better support from the integration library 292 | for entity_id in _entities_from_device_id(device_id): 293 | _LOG.info("Update device %s for configured entity %s", device_id, entity_id) 294 | configured_entity = api.configured_entities.get(entity_id) 295 | if configured_entity is None: 296 | continue 297 | 298 | if isinstance(configured_entity, media_player.KodiMediaPlayer): 299 | attributes = update 300 | elif isinstance(configured_entity, remote.KodiRemote): 301 | attributes = configured_entity.filter_changed_attributes(update) 302 | 303 | if attributes: 304 | api.configured_entities.update_attributes(entity_id, attributes) 305 | 306 | 307 | def _entities_from_device_id(device_id: str) -> list[str]: 308 | """ 309 | Return all associated entity identifiers of the given device. 310 | 311 | :param device_id: the device identifier 312 | :return: list of entity identifiers 313 | """ 314 | # dead simple for now: one media_player entity per device! 315 | # TODO #21 support multiple zones: one media-player per zone 316 | return [f"media_player.{device_id}", f"remote.{device_id}"] 317 | 318 | 319 | def _configure_new_device(device_config: config.KodiConfigDevice, connect: bool = True) -> None: 320 | """ 321 | Create and configure a new device. 322 | 323 | Supported entities of the device are created and registered in the integration library as available entities. 324 | 325 | :param device_config: the receiver configuration. 326 | :param connect: True: start connection to receiver. 327 | """ 328 | # the device should not yet be configured, but better be safe 329 | if device_config.id in _configured_kodis: 330 | device = _configured_kodis[device_config.id] 331 | asyncio.create_task(device.disconnect()) 332 | else: 333 | device = kodi.KodiDevice(device_config, loop=_LOOP) 334 | 335 | asyncio.create_task(on_device_connected(device.id)) 336 | # asyncio.rundevice.events.on(lg.Events.CONNECTED, on_device_connected) 337 | # device.events.on(lg.Events.DISCONNECTED, on_device_disconnected) 338 | device.events.on(kodi.Events.ERROR, on_device_connection_error) 339 | device.events.on(kodi.Events.UPDATE, on_device_update) 340 | # TODO event change address 341 | # receiver.events.on(lg.Events.IP_ADDRESS_CHANGED, handle_lg_address_change) 342 | # receiver.connect() 343 | _configured_kodis[device.id] = device 344 | 345 | _register_available_entities(device_config, device) 346 | 347 | if connect: 348 | # start background connection task 349 | try: 350 | _LOOP.create_task(device.connect()) 351 | except RuntimeError as ex: 352 | _LOG.debug("Could not connect to device, probably because it is starting with magic packet %s", ex) 353 | 354 | 355 | def _register_available_entities(device_config: config.KodiConfigDevice, device: kodi.KodiDevice) -> None: 356 | """ 357 | Create entities for given device and register them as available entities. 358 | 359 | :param device_config: Receiver 360 | """ 361 | # plain and simple for now: only one media_player per device 362 | # entity = media_player.create_entity(device) 363 | entities = [media_player.KodiMediaPlayer(device_config, device), remote.KodiRemote(device_config, device)] 364 | for entity in entities: 365 | if api.available_entities.contains(entity.id): 366 | api.available_entities.remove(entity.id) 367 | api.available_entities.add(entity) 368 | 369 | 370 | def on_device_added(device: config.KodiConfigDevice) -> None: 371 | """Handle a newly added device in the configuration.""" 372 | _LOG.debug("New device added: %s", device) 373 | _configure_new_device(device, connect=False) 374 | 375 | 376 | def on_device_updated(device: config.KodiConfigDevice) -> None: 377 | """Handle an updated device in the configuration.""" 378 | _LOG.debug("Device config updated: %s, reconnect with new configuration", device) 379 | _configure_new_device(device, connect=True) 380 | 381 | 382 | def on_device_removed(device: config.KodiConfigDevice | None) -> None: 383 | """Handle a removed device in the configuration.""" 384 | if device is None: 385 | _LOG.debug("Configuration cleared, disconnecting & removing all configured Kodi instances") 386 | for configured in _configured_kodis.values(): 387 | _LOOP.create_task(_async_remove(configured)) 388 | _configured_kodis.clear() 389 | api.configured_entities.clear() 390 | api.available_entities.clear() 391 | else: 392 | if device.id in _configured_kodis: 393 | _LOG.debug("Disconnecting from removed Kodi %s", device.id) 394 | configured = _configured_kodis.pop(device.id) 395 | _LOOP.create_task(_async_remove(configured)) 396 | for entity_id in _entities_from_device_id(configured.id): 397 | api.configured_entities.remove(entity_id) 398 | api.available_entities.remove(entity_id) 399 | 400 | 401 | async def _async_remove(device: kodi.KodiDevice) -> None: 402 | """Disconnect from receiver and remove all listeners.""" 403 | await device.disconnect() 404 | device.events.remove_all_listeners() 405 | 406 | 407 | async def main(): 408 | """Start the Remote Two integration driver.""" 409 | logging.basicConfig() 410 | 411 | level = os.getenv("UC_LOG_LEVEL", "DEBUG").upper() 412 | logging.getLogger("lg").setLevel(level) 413 | logging.getLogger("discover").setLevel(level) 414 | logging.getLogger("driver").setLevel(level) 415 | logging.getLogger("media_player").setLevel(level) 416 | logging.getLogger("remote").setLevel(level) 417 | logging.getLogger("kodi").setLevel(level) 418 | logging.getLogger("setup_flow").setLevel(level) 419 | logging.getLogger("config").setLevel(level) 420 | logging.getLogger("pykodi.kodi").setLevel(level) 421 | 422 | # Load driver config 423 | config.devices = config.Devices(api.config_dir_path, on_device_added, on_device_removed, on_device_updated) 424 | for device_config in config.devices.all(): 425 | _configure_new_device(device_config, connect=False) 426 | 427 | await api.init("driver.json", setup_flow.driver_setup_handler) 428 | 429 | 430 | if __name__ == "__main__": 431 | _LOOP.run_until_complete(main()) 432 | _LOOP.run_forever() 433 | -------------------------------------------------------------------------------- /src/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants used for Kodi integration. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | from typing import Any, TypedDict 9 | 10 | from ucapi.media_player import Commands, Features, MediaType 11 | from ucapi.ui import Buttons, DeviceButtonMapping, UiPage 12 | 13 | 14 | class ButtonKeymap(TypedDict): 15 | """Kodi keymap.""" 16 | 17 | button: str 18 | keymap: str | None 19 | holdtime: int | None 20 | 21 | 22 | class MethodCall(TypedDict): 23 | """Kodi method call.""" 24 | 25 | method: str 26 | params: dict[str, Any] 27 | 28 | 29 | KODI_MEDIA_TYPES = { 30 | "music": MediaType.MUSIC, 31 | "artist": MediaType.MUSIC, 32 | "album": MediaType.MUSIC, 33 | "song": MediaType.MUSIC, 34 | "video": MediaType.VIDEO, 35 | "set": MediaType.MUSIC, 36 | "musicvideo": MediaType.VIDEO, 37 | "movie": MediaType.MOVIE, 38 | "tvshow": MediaType.TVSHOW, 39 | "season": MediaType.TVSHOW, 40 | "episode": MediaType.TVSHOW, 41 | # Type 'channel' is used for radio or tv streams from pvr 42 | "channel": MediaType.TVSHOW, 43 | # Type 'audio' is used for audio media, that Kodi couldn't scroblle 44 | "audio": MediaType.MUSIC, 45 | } 46 | 47 | KODI_ARTWORK_LABELS = [ 48 | {"id": "thumb", "label": {"en": "Thumbnail", "fr": "Standard"}}, 49 | {"id": "fanart", "label": {"en": "Fan art", "fr": "Fan art"}}, 50 | {"id": "poster", "label": {"en": "Poster", "fr": "Poster"}}, 51 | {"id": "landscape", "label": {"en": "Landscape", "fr": "Paysage"}}, 52 | {"id": "keyart", "label": {"en": "Key art", "fr": "Key art"}}, 53 | {"id": "banner", "label": {"en": "Banner", "fr": "Affiche"}}, 54 | {"id": "clearart", "label": {"en": "Clear art", "fr": "Clear art"}}, 55 | {"id": "clearlogo", "label": {"en": "Clear logo", "fr": "Clear logo"}}, 56 | {"id": "discart", "label": {"en": "Disc art", "fr": "Disc art"}}, 57 | {"id": "icon", "label": {"en": "Icon", "fr": "Icône"}}, 58 | {"id": "set.fanart", "label": {"en": "Fanart set", "fr": "Jeu de fanart"}}, 59 | {"id": "set.poster", "label": {"en": "Poster set", "fr": "Jeu de poster"}}, 60 | ] 61 | 62 | KODI_ARTWORK_TVSHOWS_LABELS = [ 63 | {"id": "thumb", "label": {"en": "Thumbnail", "fr": "Standard"}}, 64 | {"id": "season.banner", "label": {"en": "Season banner", "fr": "Affiche de la saison"}}, 65 | {"id": "season.landscape", "label": {"en": "Season landscape", "fr": "Saison en paysage"}}, 66 | {"id": "season.poster", "label": {"en": "Season poster", "fr": "Affiche de la saison"}}, 67 | {"id": "tvshow.banner", "label": {"en": "TV show banner", "fr": "Affiche de la série"}}, 68 | {"id": "tvshow.characterart", "label": {"en": "TV show character art", "fr": "Personnages de la série"}}, 69 | {"id": "tvshow.clearart", "label": {"en": "TV show clear art", "fr": "Affiche sans fond de la série"}}, 70 | {"id": "tvshow.clearlogo", "label": {"en": "TV show clear logo", "fr": "Logo sans fond de la série"}}, 71 | {"id": "tvshow.fanart", "label": {"en": "TV show fan art", "fr": "Fan art de la série"}}, 72 | {"id": "tvshow.landscape", "label": {"en": "TV show landscape", "fr": "Affiche en paysage"}}, 73 | {"id": "tvshow.poster", "label": {"en": "TV show poster", "fr": "Affiche de la série"}}, 74 | {"id": "icon", "label": {"en": "Icon", "fr": "Icône"}}, 75 | ] 76 | 77 | KODI_DEFAULT_ARTWORK = "thumb" 78 | KODI_DEFAULT_TVSHOW_ARTWORK = "tvshow.poster" 79 | 80 | KODI_FEATURES = [ 81 | Features.ON_OFF, 82 | # Features.TOGGLE, 83 | Features.VOLUME, 84 | Features.VOLUME_UP_DOWN, 85 | Features.MUTE_TOGGLE, 86 | Features.MUTE, 87 | Features.UNMUTE, 88 | Features.PLAY_PAUSE, 89 | Features.STOP, 90 | Features.NEXT, 91 | Features.PREVIOUS, 92 | Features.FAST_FORWARD, 93 | Features.REWIND, 94 | Features.MEDIA_TITLE, 95 | Features.MEDIA_IMAGE_URL, 96 | Features.MEDIA_TYPE, 97 | Features.MEDIA_DURATION, 98 | Features.MEDIA_POSITION, 99 | Features.DPAD, 100 | Features.NUMPAD, 101 | Features.HOME, 102 | Features.MENU, 103 | Features.CONTEXT_MENU, 104 | Features.GUIDE, 105 | Features.INFO, 106 | Features.COLOR_BUTTONS, 107 | Features.CHANNEL_SWITCHER, 108 | Features.SELECT_SOURCE, 109 | Features.SELECT_SOUND_MODE, 110 | Features.AUDIO_TRACK, 111 | Features.SUBTITLE, 112 | Features.RECORD, 113 | Features.SEEK, 114 | Features.SETTINGS, 115 | ] 116 | 117 | # Taken from https://kodi.wiki/view/JSON-RPC_API/v10#Input.Action 118 | KODI_SIMPLE_COMMANDS = { 119 | "MENU_VIDEO": "showvideomenu", # TODO : showvideomenu not working ? 120 | "MODE_FULLSCREEN": "togglefullscreen", 121 | "MODE_ZOOM_IN": "zoomin", 122 | "MODE_ZOOM_OUT": "zoomout", 123 | "MODE_INCREASE_PAR": "increasepar", 124 | "MODE_DECREASE_PAR": "decreasepar", 125 | "MODE_SHOW_SUBTITLES": "showsubtitles", 126 | "MODE_SUBTITLES_DELAY_MINUS": "subtitledelayminus", 127 | "MODE_SUBTITLES_DELAY_PLUS": "subtitledelayplus", 128 | "MODE_AUDIO_DELAY_MINUS": "audiodelayminus", 129 | "MODE_AUDIO_DELAY_PLUS": "audiodelayplus", 130 | "MODE_DELETE": "delete", 131 | "APP_SHUTDOWN": "System.Shutdown", 132 | "APP_REBOOT": "System.Reboot", 133 | "APP_HIBERNATE": "System.Hibernate", 134 | "APP_SUSPEND": "System.Suspend", 135 | "ACTION_BLUE": "blue", 136 | "ACTION_GREEN": "green", 137 | "ACTION_RED": "red", 138 | "ACTION_YELLOW": "yellow", 139 | } 140 | 141 | KODI_SIMPLE_COMMANDS_DIRECT = ["System.Hibernate", "System.Reboot", "System.Shutdown", "System.Suspend"] 142 | 143 | # Taken from https://kodi.wiki/view/JSON-RPC_API/v10#Input.Action 144 | # (expand schema description), 145 | # more info also on https://forum.kodi.tv/showthread.php?tid=349151 which explains the logic 146 | KODI_ACTIONS_KEYMAP = { 147 | Commands.SUBTITLE: "nextsubtitle", 148 | Commands.AUDIO_TRACK: "audionextlanguage", 149 | Commands.FAST_FORWARD: "fastforward", 150 | Commands.REWIND: "rewind", 151 | Commands.MENU: "menu", 152 | Commands.INFO: "info", 153 | } 154 | 155 | 156 | # Taken from https://kodi.wiki/view/List_of_keynames, 157 | # For remote buttons : 158 | # see https://github.com/xbmc/xbmc/blob/master/system/keymaps/remote.xml for R1 keymap or 159 | # see https://github.com/xbmc/xbmc/blob/master/system/keymaps/keyboard.xml for KB keymap 160 | KODI_BUTTONS_KEYMAP: dict[str, ButtonKeymap | MethodCall] = { 161 | Commands.CHANNEL_UP: ButtonKeymap(**{"button": "pageplus", "keymap": "R1"}), # channelup or pageup 162 | Commands.CHANNEL_DOWN: ButtonKeymap(**{"button": "pageminus", "keymap": "R1"}), # channeldown or pagedown 163 | Commands.CURSOR_UP: ButtonKeymap(**{"button": "up", "keymap": "R1"}), 164 | Commands.CURSOR_DOWN: ButtonKeymap(**{"button": "down", "keymap": "R1"}), 165 | Commands.CURSOR_LEFT: ButtonKeymap(**{"button": "left", "keymap": "R1"}), 166 | Commands.CURSOR_RIGHT: ButtonKeymap(**{"button": "right", "keymap": "R1"}), 167 | Commands.CURSOR_ENTER: ButtonKeymap(**{"button": "enter"}), 168 | Commands.BACK: ButtonKeymap(**{"button": "backspace"}), 169 | # Send numbers through "R1" keymap so they can be used for character input (like on old phones) 170 | Commands.DIGIT_0: ButtonKeymap(**{"button": "zero", "keymap": "R1"}), 171 | Commands.DIGIT_1: ButtonKeymap(**{"button": "one", "keymap": "R1"}), 172 | Commands.DIGIT_2: ButtonKeymap(**{"button": "two", "keymap": "R1"}), 173 | Commands.DIGIT_3: ButtonKeymap(**{"button": "three", "keymap": "R1"}), 174 | Commands.DIGIT_4: ButtonKeymap(**{"button": "four", "keymap": "R1"}), 175 | Commands.DIGIT_5: ButtonKeymap(**{"button": "five", "keymap": "R1"}), 176 | Commands.DIGIT_6: ButtonKeymap(**{"button": "six", "keymap": "R1"}), 177 | Commands.DIGIT_7: ButtonKeymap(**{"button": "seven", "keymap": "R1"}), 178 | Commands.DIGIT_8: ButtonKeymap(**{"button": "eight", "keymap": "R1"}), 179 | Commands.DIGIT_9: ButtonKeymap(**{"button": "nine", "keymap": "R1"}), 180 | Commands.RECORD: ButtonKeymap(**{"button": "record", "keymap": "R1"}), 181 | Commands.GUIDE: ButtonKeymap(**{"button": "guide", "keymap": "R1"}), 182 | Commands.FUNCTION_GREEN: ButtonKeymap(**{"button": "green", "keymap": "R1"}), 183 | Commands.FUNCTION_BLUE: ButtonKeymap(**{"button": "blue", "keymap": "R1"}), 184 | Commands.FUNCTION_RED: ButtonKeymap(**{"button": "red", "keymap": "R1"}), 185 | Commands.FUNCTION_YELLOW: ButtonKeymap(**{"button": "yellow", "keymap": "R1"}), 186 | Commands.SETTINGS: MethodCall(method="GUI.ActivateWindow", params={"window": "settings"}), 187 | # Commands.STOP: ButtonKeymap(**{"button": "stop", "keymap": "R1"}), 188 | } 189 | 190 | KODI_ADVANCED_SIMPLE_COMMANDS: dict[str, MethodCall | str] = { 191 | "MODE_TOGGLE_GUI": {"method": "GUI.SetFullscreen", "params": {"fullscreen": "toggle"}, "holdtime": None}, 192 | "MODE_SHOW_SUBTITLES_STREAM": "dialogselectsubtitle", 193 | "MODE_SHOW_AUDIO_STREAM": "dialogselectaudio", 194 | "MODE_SHOW_SUBTITLES_MENU": { 195 | "method": "GUI.ActivateWindow", 196 | "params": {"window": "osdsubtitlesettings"}, 197 | "holdtime": None, 198 | }, 199 | "MODE_SHOW_AUDIO_MENU": { 200 | "method": "GUI.ActivateWindow", 201 | "params": {"window": "osdaudiosettings"}, 202 | "holdtime": None, 203 | }, 204 | "MODE_SHOW_VIDEO_MENU": { 205 | "method": "GUI.ActivateWindow", 206 | "params": {"window": "osdvideosettings"}, 207 | "holdtime": None, 208 | }, 209 | "MODE_SHOW_BOOKMARKS_MENU": { 210 | "method": "GUI.ActivateWindow", 211 | "params": {"window": "videobookmarks"}, 212 | "holdtime": None, 213 | }, 214 | "MODE_SHOW_SUBTITLE_SEARCH_MENU": { 215 | "method": "GUI.ActivateWindow", 216 | "params": {"window": "subtitlesearch"}, 217 | "holdtime": None, 218 | }, 219 | "MODE_SCREENSAVER": {"method": "GUI.ActivateWindow", "params": {"window": "screensaver"}}, 220 | } 221 | 222 | KODI_ALTERNATIVE_BUTTONS_KEYMAP: dict[str, MethodCall] = { 223 | Commands.CHANNEL_UP: { 224 | "method": "Input.ExecuteAction", 225 | "params": {"action": "pageup"}, 226 | }, # channelup or pageup 227 | Commands.CHANNEL_DOWN: { 228 | "method": "Input.ExecuteAction", 229 | "params": {"action": "pagedown"}, 230 | }, # channeldown or pagedown 231 | Commands.CURSOR_UP: {"method": "Input.Up", "params": {}}, 232 | Commands.CURSOR_DOWN: {"method": "Input.Down", "params": {}}, 233 | Commands.CURSOR_LEFT: {"method": "Input.Left", "params": {}}, 234 | Commands.CURSOR_RIGHT: {"method": "Input.Right", "params": {}}, 235 | Commands.CURSOR_ENTER: {"method": "Input.Select", "params": {}}, 236 | Commands.BACK: {"method": "Input.Back", "params": {}}, 237 | } 238 | 239 | KODI_REMOTE_BUTTONS_MAPPING: list[DeviceButtonMapping] = [ 240 | DeviceButtonMapping(**{"button": Buttons.BACK, "short_press": {"cmd_id": Commands.BACK}}), 241 | DeviceButtonMapping(**{"button": Buttons.HOME, "short_press": {"cmd_id": Commands.HOME}}), 242 | DeviceButtonMapping(**{"button": Buttons.CHANNEL_DOWN, "short_press": {"cmd_id": Commands.CHANNEL_DOWN}}), 243 | DeviceButtonMapping(**{"button": Buttons.CHANNEL_UP, "short_press": {"cmd_id": Commands.CHANNEL_UP}}), 244 | DeviceButtonMapping(**{"button": Buttons.DPAD_UP, "short_press": {"cmd_id": Commands.CURSOR_UP}}), 245 | DeviceButtonMapping(**{"button": Buttons.DPAD_DOWN, "short_press": {"cmd_id": Commands.CURSOR_DOWN}}), 246 | DeviceButtonMapping(**{"button": Buttons.DPAD_LEFT, "short_press": {"cmd_id": Commands.CURSOR_LEFT}}), 247 | DeviceButtonMapping(**{"button": Buttons.DPAD_RIGHT, "short_press": {"cmd_id": Commands.CURSOR_RIGHT}}), 248 | DeviceButtonMapping(**{"button": Buttons.DPAD_MIDDLE, "short_press": {"cmd_id": Commands.CURSOR_ENTER}}), 249 | DeviceButtonMapping(**{"button": Buttons.PLAY, "short_press": {"cmd_id": Commands.PLAY_PAUSE}}), 250 | DeviceButtonMapping(**{"button": Buttons.PREV, "short_press": {"cmd_id": Commands.PREVIOUS}}), 251 | DeviceButtonMapping(**{"button": Buttons.NEXT, "short_press": {"cmd_id": Commands.NEXT}}), 252 | DeviceButtonMapping(**{"button": Buttons.VOLUME_UP, "short_press": {"cmd_id": Commands.VOLUME_UP}}), 253 | DeviceButtonMapping(**{"button": Buttons.VOLUME_DOWN, "short_press": {"cmd_id": Commands.VOLUME_DOWN}}), 254 | DeviceButtonMapping(**{"button": Buttons.MUTE, "short_press": {"cmd_id": Commands.MUTE_TOGGLE}}), 255 | DeviceButtonMapping(**{"button": Buttons.STOP, "short_press": {"cmd_id": Commands.STOP}}), 256 | DeviceButtonMapping(**{"button": Buttons.MENU, "short_press": {"cmd_id": Commands.CONTEXT_MENU}}), 257 | ] 258 | 259 | # All defined commands for remote entity 260 | # TODO rename simple commands to be compliant to expected names in R2 261 | KODI_REMOTE_SIMPLE_COMMANDS = [ 262 | *list(KODI_SIMPLE_COMMANDS.keys()), 263 | *list(KODI_ADVANCED_SIMPLE_COMMANDS.keys()), 264 | *list(KODI_ACTIONS_KEYMAP.keys()), 265 | *list(KODI_BUTTONS_KEYMAP.keys()), 266 | Commands.CONTEXT_MENU, 267 | Commands.VOLUME_UP, 268 | Commands.VOLUME_DOWN, 269 | Commands.MUTE_TOGGLE, 270 | Commands.MUTE, 271 | Commands.UNMUTE, 272 | Commands.PLAY_PAUSE, 273 | Commands.STOP, 274 | Commands.HOME, 275 | ] 276 | 277 | KODI_REMOTE_UI_PAGES: list[UiPage] = [ 278 | UiPage( 279 | **{ 280 | "page_id": "Kodi commands", 281 | "name": "Kodi commands", 282 | "grid": {"width": 4, "height": 6}, 283 | "items": [ 284 | { 285 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.INFO, "repeat": 1}}, 286 | "icon": "uc:info", 287 | "location": {"x": 0, "y": 0}, 288 | "type": "icon", 289 | }, 290 | { 291 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.AUDIO_TRACK, "repeat": 1}}, 292 | "icon": "uc:language", 293 | "location": {"x": 1, "y": 0}, 294 | "type": "icon", 295 | }, 296 | { 297 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.SUBTITLE, "repeat": 1}}, 298 | "icon": "uc:cc", 299 | "location": {"x": 2, "y": 0}, 300 | "type": "icon", 301 | }, 302 | { 303 | "command": {"cmd_id": "MODE_SHOW_SUBTITLES"}, 304 | "text": "Toggle subtitles", 305 | "location": {"x": 3, "y": 0}, 306 | "type": "text", 307 | }, 308 | { 309 | "command": {"cmd_id": "MODE_FULLSCREEN"}, 310 | "text": "Full screen", 311 | "location": {"x": 0, "y": 1}, 312 | "type": "text", 313 | }, 314 | { 315 | "command": {"cmd_id": "MODE_ZOOM_IN"}, 316 | "text": "Zoom in", 317 | "location": {"x": 1, "y": 1}, 318 | "type": "text", 319 | }, 320 | { 321 | "command": {"cmd_id": "MODE_ZOOM_OUT"}, 322 | "text": "Zoom out", 323 | "location": {"x": 2, "y": 1}, 324 | "type": "text", 325 | }, 326 | { 327 | "command": {"cmd_id": Commands.CONTEXT_MENU}, 328 | "icon": "uc:menu", 329 | "location": {"x": 3, "y": 5}, 330 | "type": "icon", 331 | }, 332 | ], 333 | } 334 | ), 335 | UiPage( 336 | **{ 337 | "page_id": "Kodi numbers", 338 | "name": "Kodi numbers", 339 | "grid": {"height": 4, "width": 3}, 340 | "items": [ 341 | { 342 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_1, "repeat": 1}}, 343 | "location": {"x": 0, "y": 0}, 344 | "text": "1", 345 | "type": "text", 346 | }, 347 | { 348 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_2, "repeat": 1}}, 349 | "location": {"x": 1, "y": 0}, 350 | "text": "2", 351 | "type": "text", 352 | }, 353 | { 354 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_3, "repeat": 1}}, 355 | "location": {"x": 2, "y": 0}, 356 | "text": "3", 357 | "type": "text", 358 | }, 359 | { 360 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_4, "repeat": 1}}, 361 | "location": {"x": 0, "y": 1}, 362 | "text": "4", 363 | "type": "text", 364 | }, 365 | { 366 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_5, "repeat": 1}}, 367 | "location": {"x": 1, "y": 1}, 368 | "text": "5", 369 | "type": "text", 370 | }, 371 | { 372 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_6, "repeat": 1}}, 373 | "location": {"x": 2, "y": 1}, 374 | "text": "6", 375 | "type": "text", 376 | }, 377 | { 378 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_7, "repeat": 1}}, 379 | "location": {"x": 0, "y": 2}, 380 | "text": "7", 381 | "type": "text", 382 | }, 383 | { 384 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_8, "repeat": 1}}, 385 | "location": {"x": 1, "y": 2}, 386 | "text": "8", 387 | "type": "text", 388 | }, 389 | { 390 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_9, "repeat": 1}}, 391 | "location": {"x": 2, "y": 2}, 392 | "text": "9", 393 | "type": "text", 394 | }, 395 | { 396 | "command": {"cmd_id": "remote.send", "params": {"command": Commands.DIGIT_0, "repeat": 1}}, 397 | "location": {"x": 1, "y": 3}, 398 | "text": "0", 399 | "type": "text", 400 | }, 401 | ], 402 | } 403 | ), 404 | ] 405 | 406 | 407 | def key_update_helper(input_attributes, key: str, value: str | None, attributes): 408 | """Return modified attributes only.""" 409 | if value is None: 410 | return attributes 411 | 412 | if key in input_attributes: 413 | if input_attributes[key] != value: 414 | attributes[key] = value 415 | else: 416 | attributes[key] = value 417 | 418 | return attributes 419 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kodi integration for Remote Two and 3 2 | 3 | Using (modified) [pykodi](https://github.com/OnFreund/PyKodi) 4 | and [uc-integration-api](https://github.com/aitatoi/integration-python-library) 5 | 6 | The driver lets discover and configure your Kodi instances. A media player and a remote entity are exposed to the core. 7 | 8 | Note : this release requires remote firmware `>= 1.7.10` 9 | 10 | - [Installation](#installation) 11 | * [Kodi Keymap](#kodi-keymap) 12 | * [Hint for saving battery life](#hint-for-saving-battery-life) 13 | * [Installation on the Remote](#installation-on-the-remote) 14 | * [Backup or restore configuration](#backup-or-restore-configuration) 15 | - [Additional commands](#additional-commands) 16 | - [Note about Kodi keymap and UC Remotes](#note-about-kodi-keymap-and-uc-remotes) 17 | - [Installation as external integration](#installation-as-external-integration) 18 | - [Build self-contained binary for Remote Two](#build-self-contained-binary-for-remote-two) 19 | 20 | 21 | 22 | ### Supported attributes 23 | - State (on, off, playing, paused, unknown) 24 | - Title 25 | - Album 26 | - Artist 27 | - Artwork 28 | - Media position / duration 29 | - Volume (level and up/down) and mute 30 | - Sources : corresponds to the list of video chapters (Kodi >=22 only) 31 | - Remote entity : predefined buttons mapping and interface buttons (to be completed) 32 | 33 | 34 | ### Supported commands for Media Player entity 35 | - Turn off (turn on is not supported) 36 | - Direction pad and enter 37 | - Numeric pad 38 | - Back 39 | - Next 40 | - Previous 41 | - Volume up 42 | - Volume down 43 | - Pause / Play 44 | - Channels Up/Down 45 | - Menus (home, context) 46 | - Colored buttons 47 | - Subtitle/audio language switching 48 | - Fast forward / rewind 49 | - Stop 50 | - Source selection : corresponds to chapters selection (Kodi >=22 only) 51 | - Simple commands (more can be added) : see [list of simple commands](#list-of-simple-commands) 52 | 53 | 54 | ### Supported commands for Remote entity 55 | - Send command : custom commands or keyboard commands which sent as KB keymap commands in JSON RPC (see [Kodi keyboard map](https://github.com/xbmc/xbmc/blob/master/system/keymaps/keyboard.xml) and more specifically [Action Ids](https://kodi.wiki/view/Action_IDs) for the list of available keyboard commands) 56 | Example : type in `togglefullscreen` in the command field of the remote entity to toggle full screen 57 | - Send command sequence (same commands as above) 58 | - Support for the repeat, hold, delay parameters 59 | - List of commands (simple or custom) : see [the list here](#additional-commands) 60 | 61 | 62 | ## Installation 63 | 64 | - First [go to the release section](https://github.com/albaintor/integration-kodi/releases) and download the `xxx_aarch64-xxx.tar.gz` file 65 | - On the Web configurator of your remote, go to the `Integrations` tab, click on `Add new` and select `Install custom` 66 | - Select the downloaded file in first step and wait for the upload to finish 67 | - A new integration will appear in the list : click on it and start setup 68 | - Kodi must be running for setup, and control enabled from Settings > Services > Control section. Set the username, password and enable HTTP control. 69 | <img width="588" alt="image" src="https://github.com/user-attachments/assets/7809d1c7-0be6-4b44-ab9a-73539b58a3f0"> 70 | 71 | - Port numbers shouldn't be modified normally (8080 for HTTP and 9090 for websocket) : websocket port is not configurable from the GUI (in advanced settings file) 72 | - There is no turn on command : Kodi has to be started some other way 73 | 74 | ### Kodi Keymap 75 | 76 | General info : if the direction pad doesn't work, enable the Joystick extension in Kodi settings then exit (not just minimize) Kodi and relaunch it. 77 | 78 | **On Kodi 22** : 79 | - in fullscreen video the `OK` button will trigger play/pause instead of the default behavior which should trigger OSD menu 80 | - in the navigation menus the `Back` button will go back to the home instead of the previous menu 81 | I raised a [ticket](https://github.com/xbmc/xbmc/issues/27523) for that. 82 | In the meantime to restore this default behavior, you will have to create a `joystick.xml` file with the following content : 83 | ```xml 84 | <keymap> 85 | <global> 86 | <joystick profile="game.controller.default"> 87 | <back>Back</back> 88 | </joystick> 89 | </global> 90 | <fullscreenvideo> 91 | <joystick profile="game.controller.default"> 92 | <a>OSD</a> 93 | </joystick> 94 | </fullscreenvideo> 95 | </keymap> 96 | ``` 97 | Then you will have to transfer this file through Kodi Settings > File manager into the `Profile directory` then `keymaps` directory : mount a network share or use a USB stick to grab it 98 | <img width="600" alt="image" src="https://github.com/user-attachments/assets/fd27601d-ef6e-4597-b881-089180e51154" /> 99 | 100 | See the target path in the bottom like this : 101 | <img width="150" alt="image" src="https://github.com/user-attachments/assets/498b35a1-aa30-4894-bb7b-bf803ccb4492" /> 102 | 103 | Then on the right view, from your server (NAS, PC...) right click (or long press OK) to raise context menu and select `Copy` on the `joystick.xml` file to transfer it to the server. 104 | 105 | Lastly, exit Kodi (not just minimize) and relaunch it. 106 | <br><br> 107 | 108 | **On Kodi 21 and earlier**, this is easier : within Kodi, click settings, then go to `Apps`/`Add-on Browser`, `My Add-ons` and scroll down and click on `Peripheral Libraries` : click on `Joystick Support` and click `Disable`. THEN : kill and restart Kodi in order to take effect and then all the remote commands will work fine. 109 | 110 | 111 | ### Hint for saving battery life 112 | 113 | To save battery life, the integration will stop reconnecting if Kodi is off (which is the case on most devices when you switch from Kodi to another app). 114 | But if any Kodi command is sent (cursor pad, turn on, play/pause...), a reconnection will be automatically triggered. 115 | 116 | So if you start Kodi (ex : from Nvidia Shield), but you mapped all cursors pad and enter to Nvidia Shield device (through AndroidTV integration or bluetooth), Kodi reconnection won't be triggered. 117 | So here is the trick to make Kodi integration reconnect : create a macro with your devices (e.g. Nvidia Shield, and Kodi media player) with the following commands : 118 | 1. Nvidia Shield : `Input Source` command to start app `Kodi` 119 | 2. Kodi media player : `Switch On` command (which does nothing except triggering reconnection) 120 | 121 | And add the macro to your activity, mapped to the screen or to a button. In that way, it will both launch Kodi and trigger the reconnection. 122 | 123 | 124 | ### Installation on the Remote 125 | 126 | - Download the release from the release section : file ending with `.tar.gz` 127 | - Navigate into the Web Configurator of the remote, go into the `Integrations` tab, click on `Add new` and select : `Install custom` 128 | - Select the downloaded `.tar.gz` file and click on upload 129 | - Once uploaded, the new integration should appear in the list : click on it and select `Start setup` 130 | - Your Kodi instance must be running and connected to the network before proceed 131 | 132 | ### Backup or restore configuration 133 | 134 | The integration lets backup or restore the devices configuration (in JSON format). 135 | To use this functionality, select the "Backup or restore" option in the setup flow, then you will have a text field which will be empty if no devices are configured. 136 | - Backup : just save the content of the text field in a file for later restore and abort the setup flow (clicking next will apply this configuration) 137 | - Restore : just replace the content by the previously saved configuration and click on next to apply it. Beware while using this functionality : the expected format should be respected and could change in the future. 138 | If the format is not recognized, the import will be aborted and existing configuration will remain unchanged. 139 | 140 | 141 | ## Additional commands 142 | 143 | First don't mix up with entities : when registering the integration, you will get 2 entities : `Media Player` and `Remote` entities. 144 | 145 | The media player entity should cover most needs, however if you want to use custom commands and use additional parameters such as repeating the same command, you can use the remote entity. 146 | 147 | This entity exposes 2 specific commands : `Send command` and `Command sequence` 148 | 149 | Here is an example of setting a `Send command` command from the remote entity : 150 | 151 | <img width="335" height="451" alt="image" src="https://github.com/user-attachments/assets/d3e2e011-7a5d-42fa-bcfe-66e722c6d025" /> 152 | 153 | 154 | ### List of simple commands 155 | 156 | These are exposed by both media & remote entities : 157 | 158 | | Simple command | Description | 159 | |--------------------------------|--------------------------------------------------------| 160 | | MENU_VIDEO | Show video menu (showvideomenu) | 161 | | MODE_TOGGLE_GUI | Toggle GUI while playing | 162 | | MODE_FULLSCREEN | Toggle full screen (togglefullscreen) | 163 | | MODE_SHOW_AUDIO_STREAM | Show audio streams menu while playing (Kodi >=22) | 164 | | MODE_SHOW_SUBTITLES_STREAM | Show subtitles streams menu while playing (Kodi >=22) | 165 | | MODE_SHOW_AUDIO_MENU | Show audio context menu while playing | 166 | | MODE_SHOW_SUBTITLES_MENU | Show subtitles context menu while playing | 167 | | MODE_SHOW_VIDEO_MENU | Show video settings menu while playing | 168 | | MODE_SHOW_BOOKMARKS_MENU | Show bookmarks menu while playing | 169 | | MODE_SHOW_SUBTITLE_SEARCH_MENU | Show subtitles search menu while playing | 170 | | MODE_SCREENSAVER | Show screensaver | 171 | | MODE_ZOOM_IN | Zoom in (zoomin) | 172 | | MODE_ZOOM_OUT | Zoom out (zoomout) | 173 | | MODE_INCREASE_PAR | Increase aspect ratio (increasepar) | 174 | | MODE_DECREASE_PAR | Decrease aspect ratio (decreasepar) | 175 | | MODE_SHOW_SUBTITLES | Toggle subtitles (showsubtitles) | 176 | | MODE_SUBTITLES_DELAY_MINUS | Decrease subtitles delay (subtitledelayminus) | 177 | | MODE_SUBTITLES_DELAY_PLUS | Increase subtitles delay (subtitledelayplus) | 178 | | MODE_AUDIO_DELAY_MINUS | Decrease audio delay (audiodelayminus) | 179 | | MODE_AUDIO_DELAY_PLUS | Increase audio delay (audiodelayplus) | 180 | | MODE_DELETE | Delete (delete) | 181 | | APP_HIBERNATE | Hibernate the device (System.Hibernate) | 182 | | APP_REBOOT | Reboot the device (System.Reboot) | 183 | | APP_SHUTDOWN | Shutdown the device (System.Shutdown) | 184 | | APP_SUSPEND | Suspend the device (System.Suspend) | 185 | | ACTION_BLUE | Blue command | 186 | | ACTION_GREEN | Green command | 187 | | ACTION_RED | Red command | 188 | | ACTION_YELLOW | Yellow command | 189 | | System.Hibernate | Hibernate the device | 190 | | System.Reboot | Reboot the device | 191 | | System.Shutdown | Shutdown the device | 192 | | System.Suspend | Suspend the device | 193 | 194 | 195 | 196 | ### List of standard commands (remote entity only) 197 | 198 | The following commands are standard commands available for the remote entity in addition of simple commands. These are already exposed by the `Media Player` entity through a predefined mappping but can also be used in the remote entity (to build commands sequence for example) : 199 | 200 | `on, off, toggle, play_pause, stop, previous, next, fast_forward, rewind, seek, volume, volume_up, volume_down, mute_toggle, mute, unmute, repeat, shuffle, channel_up, channel_down, cursor_up, cursor_down, cursor_left, cursor_right, cursor_enter, digit_0, digit_1, digit_2, digit_3, digit_4, digit_5, digit_6, digit_7, digit_8, digit_9, function_red, function_green, function_yellow, function_blue, home, menu, context_menu, guide, info, back, select_source, select_sound_mode, record, my_recordings, live, eject, open_close, audio_track, subtitle, settings, search` 201 | 202 | ### List of custom commands (remote entity only) 203 | 204 | Additionally, the following custom commands can be set in the `Send command` or `Command sequence` commands of the `Remote` entity. 205 | Some can have parameters 206 | 207 | 208 | | Custom command | Description | Example | 209 | |----------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------| 210 | | key `button` `[keymap (default KB)]` `[hold time (default 0)]` | Trigger keyboard command | `key f KB 0`<br>`key a`<br>`key livetv R1` | 211 | | activatewindow `windowId` | Show the given window ID, [see this link](https://kodi.wiki/view/Window_IDs) | `activatewindow movieinformation` | 212 | | action `action name` | Execute given action [see the list here](https://kodi.wiki/view/JSON-RPC_API/v13#Input.Action) | `action fastforward` | 213 | | stereoscopimode `mode` | Set the given stereoscopic mode, [see here](https://kodi.wiki/view/JSON-RPC_API/v13#GUI.SetStereoscopicMode) | `stereoscopimode split_horizontal` | 214 | | viewmode `mode` | Set view mode : normal,zoom,stretch4x3,widezoom,stretch16x9,original, stretch16x9nonlin,zoom120width,zoom110width | `viewmode stretch16x9` | 215 | | zoom `mode` | Set zoom to given mode : in, out or level from 1 to 10 | `zoom in` | 216 | | speed `speed` | Set playback speed : increment, decrement or integer from -32, -16, -8,... to 32 | `speed 32` | 217 | | audiodelay `offset` | Set audio delay in seconds relatively | `audiodelay -0.1` | 218 | | _<JSON RPC Command> `{parameters}`_ | Any JSON RPC command [complete list here](https://kodi.wiki/view/JSON-RPC_API/v13)<br>_Length is limited to 64 characters_ | _See examples below_ | 219 | 220 | #### **Examples of custom commands** 221 | 222 | **Execute action : [list of actions here](https://kodi.wiki/view/JSON-RPC_API/v13#Input.Action)** 223 | - Show video menu : `Input.ExecuteAction {"action":"showvideomenu"}` 224 | - Increase subtitles delay : `Input.ExecuteAction {"action":"subtitledelayplus"}` 225 | - Decrease subtitles delay : `Input.ExecuteAction {"action":"subtitledelayminus"}` 226 | 227 | **Shutdown the system :** 228 | `System.Shutdown` 229 | 230 | **Restart the system :** 231 | `System.Restart` 232 | 233 | **Increase audio delay :** 234 | `Player.SetAudioDelay {"playerid":PID,"offset":"increment"}` 235 | 236 | **Decrease audio delay :** 237 | `Player.SetAudioDelay {"playerid":PID,"offset":"decrement"}` 238 | 239 | **Set audio delay to +0.5 seconds :** 240 | `Player.SetAudioDelay {"playerid":PID,"offset":0.5}` 241 | 242 | 243 | Notes : 244 | - Some commands require a player Id parameter, just submit `PID` value that will be evaluated on runtime 245 | - Commands length if limited to 64 characters 246 | 247 | 248 | ## Note about Kodi keymap and UC Remotes 249 | 250 | With the UC remote, the button mapping is not Kodi's default with other devices (eg original AndroidTV remote). This is very obscure but in the meantime it is possible to catch the button IDs and remap them. 251 | 1. First, in Kodi settings, go to System / Logs and enable debug mode 252 | 2. Press the buttons you want to catch from the remote 253 | 3. Disable debug log when finished 254 | 4. Go to Settings / File explorer, go to logpath and you will find `kodi.log` 255 | 5. Transfer `kodi.log` to your external drive (NAS, USB flashdrive...) 256 | 257 | You will find lines like these : 258 | ``` 259 | FEATURE [ back ] on game.controller.default pressed (handled) 260 | FEATURE [ up ] on game.controller.default pressed (handled) 261 | FEATURE [ a ] on game.controller.default pressed (handled) 262 | ``` 263 | The name between square brackets correspond to the button ID : here `back` for back, `up` for DPAD up, `a` for OK button. 264 | 265 | Then you will be able to modify the Keyboard mapping as explained at the beginning of the document : define a custom keymap file `joystick.xml` and upload it to profile folder / keymaps. 266 | A little explanation of how it works : 267 | - `global` section defines the default mapping in navigation 268 | - `fullscreenvideo` section defines the mapping applied while playing a video in fullscreen 269 | - Subsections define the device type to apply mapping to : `keyboard`, `mouse`, `gamepad` and `joystick`. Note that the UC remote is detected as a joystick so other types won't be applied 270 | - Lastly, in the `joystick` subsection, define the custom mapping : the button ID in the tag (eg `<a>...</a>` for OK button), and the command to apply. See [this link](https://kodi.wiki/view/Keymap) to get the list of commands 271 | ```xml 272 | <keymap> 273 | <global> 274 | <joystick profile="game.controller.default"> 275 | <back>Back</back> 276 | </joystick> 277 | </global> 278 | <fullscreenvideo> 279 | <joystick profile="game.controller.default"> 280 | <a>OSD</a> 281 | </joystick> 282 | </fullscreenvideo> 283 | </keymap> 284 | ``` 285 | 286 | 287 | 288 | ## Installation as external integration 289 | 290 | - Requires Python 3.11 291 | - Under a virtual environment : the driver has to be run in host mode and not bridge mode, otherwise the turn on function won't work (a magic packet has to be sent through network and it won't reach it under bridge mode) 292 | - Your Kodi instance has to be started in order to run the setup flow and process commands. When configured, the integration will detect automatically when it will be started and process commands. 293 | - Install required libraries: 294 | (using a [virtual environment](https://docs.python.org/3/library/venv.html) is highly recommended) 295 | 296 | ```shell 297 | pip3 install -r requirements.txt 298 | ``` 299 | 300 | For running a separate integration driver on your network for Remote Two, the configuration in file 301 | [driver.json](driver.json) needs to be changed: 302 | 303 | - Set `driver_id` to a unique value, `uc_kodi_driver` is already used for the embedded driver in the firmware. 304 | - Change `name` to easily identify the driver for discovery & setup with Remote Two or the web-configurator. 305 | - Optionally add a `"port": 8090` field for the WebSocket server listening port. 306 | - Default port: `9090` 307 | - Also overrideable with environment variable `UC_INTEGRATION_HTTP_PORT` 308 | 309 | ### Custom installation 310 | 311 | ```shell 312 | python3 src/driver.py 313 | ``` 314 | 315 | See 316 | available [environment variables](https://github.com/unfoldedcircle/integration-python-library#environment-variables) 317 | in the Python integration library to control certain runtime features like listening interface and configuration 318 | directory. 319 | 320 | ## Build self-contained binary for Remote Two 321 | 322 | After some tests, turns out python stuff on embedded is a nightmare. So we're better off creating a single binary file 323 | that has everything in it. 324 | 325 | To do that, we need to compile it on the target architecture as `pyinstaller` does not support cross compilation. 326 | 327 | ### x86-64 Linux 328 | 329 | On x86-64 Linux we need Qemu to emulate the aarch64 target platform: 330 | 331 | ```bash 332 | sudo apt install qemu binfmt-support qemu-user-static 333 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 334 | ``` 335 | 336 | Run pyinstaller: 337 | 338 | ```shell 339 | docker run --rm --name builder \ 340 | --platform=aarch64 \ 341 | --user=$(id -u):$(id -g) \ 342 | -v "$PWD":/workspace \ 343 | docker.io/unfoldedcircle/r2-pyinstaller:3.11.6 \ 344 | bash -c \ 345 | "python -m pip install -r requirements.txt && \ 346 | pyinstaller --clean --onefile --name driver src/driver.py" 347 | ``` 348 | 349 | ### aarch64 Linux / Mac 350 | 351 | On an aarch64 host platform, the build image can be run directly (and much faster): 352 | 353 | ```shell 354 | docker run --rm --name builder \ 355 | --user=$(id -u):$(id -g) \ 356 | -v "$PWD":/workspace \ 357 | docker.io/unfoldedcircle/r2-pyinstaller:3.11.6 \ 358 | bash -c \ 359 | "python -m pip install -r requirements.txt && \ 360 | pyinstaller --clean --onefile --name driver src/driver.py" 361 | ``` 362 | 363 | ## Docker Setup (x86-64 & ARM64) 364 | 365 | For easy installation on x86-64 and ARM64 systems using Docker: 366 | 367 | ### Quick Start 368 | 369 | ```bash 370 | # Clone repository 371 | git clone https://github.com/albaintor/integration-kodi.git 372 | cd integration-kodi 373 | 374 | # Start with Docker Compose 375 | docker-compose up -d 376 | 377 | # View logs 378 | docker-compose logs -f 379 | ``` 380 | 381 | ### Using Makefile (recommended) 382 | 383 | ```bash 384 | # Build and start 385 | make start 386 | 387 | # View logs 388 | make logs 389 | 390 | # Stop 391 | make down 392 | 393 | # Restart 394 | make restart 395 | ``` 396 | 397 | ### Using Pre-built Docker Images 398 | 399 | ```bash 400 | # Pull and run from Docker Hub 401 | docker run -d \ 402 | --name kodi-integration \ 403 | --network host \ 404 | -v $(pwd)/config:/app/config \ 405 | -e UC_INTEGRATION_HTTP_PORT=9090 \ 406 | docker.io/your-username/kodi-integration:latest 407 | ``` 408 | 409 | ### Manual Docker Commands 410 | 411 | ```bash 412 | # Build image locally 413 | docker build -t kodi-integration . 414 | 415 | # Run container 416 | docker run -d \ 417 | --name kodi-integration \ 418 | --network host \ 419 | -v $(pwd)/config:/app/config \ 420 | -e UC_INTEGRATION_HTTP_PORT=9090 \ 421 | kodi-integration 422 | ``` 423 | 424 | ### Configuration 425 | 426 | - Integration runs on port `9090` (configurable via `UC_INTEGRATION_HTTP_PORT`) 427 | - Configuration data is stored in `./config` directory 428 | - `network_mode: host` is required for network discovery and magic packets 429 | - Supports both x86-64 and ARM64 architectures 430 | 431 | ### Access 432 | 433 | After startup, the integration is available at `http://localhost:9090` and can be configured in Remote Two/Three. 434 | 435 | ### Available Docker Tags 436 | 437 | - `latest` - Latest development build from main branch 438 | - `v1.x.x` - Specific version releases 439 | - `main` - Latest commit from main branch 440 | 441 | ### Docker Hub 442 | 443 | Pre-built images are available on Docker Hub with multi-architecture support (x86-64 and ARM64). 444 | 445 | ## Versioning 446 | 447 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the 448 | [tags and releases in this repository](https://github.com/albaintor/integration-kodi/releases). 449 | 450 | ## Changelog 451 | 452 | The major changes found in each new release are listed in the [changelog](CHANGELOG.md) 453 | and under the GitHub [releases](https://github.com/albaintor/integration-kodi/releases). 454 | 455 | ## Contributions 456 | 457 | Please read our [contribution guidelines](CONTRIBUTING.md) before opening a pull request. 458 | 459 | ## License 460 | 461 | This project is licensed under the [**Mozilla Public License 2.0**](https://choosealicense.com/licenses/mpl-2.0/). 462 | See the [LICENSE](LICENSE) file for details. 463 | 464 | 465 | -------------------------------------------------------------------------------- /src/setup_flow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup flow for LG TV integration. 3 | 4 | :copyright: (c) 2023 by Unfolded Circle ApS. 5 | :license: Mozilla Public License Version 2.0, see LICENSE for more details. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | from enum import IntEnum 11 | 12 | from aiohttp import ClientSession 13 | from ucapi import ( 14 | AbortDriverSetup, 15 | DriverSetupRequest, 16 | IntegrationSetupError, 17 | RequestUserInput, 18 | SetupAction, 19 | SetupComplete, 20 | SetupDriver, 21 | SetupError, 22 | UserDataResponse, 23 | ) 24 | 25 | import config 26 | from config import ConfigImportResult, KodiConfigDevice 27 | from const import ( 28 | KODI_ARTWORK_LABELS, 29 | KODI_ARTWORK_TVSHOWS_LABELS, 30 | KODI_DEFAULT_ARTWORK, 31 | KODI_DEFAULT_TVSHOW_ARTWORK, 32 | ) 33 | from discover import KodiDiscover 34 | from pykodi.kodi import ( 35 | CannotConnectError, 36 | InvalidAuthError, 37 | Kodi, 38 | KodiConnection, 39 | KodiHTTPConnection, 40 | KodiWSConnection, 41 | ) 42 | 43 | _LOG = logging.getLogger(__name__) 44 | 45 | 46 | # pylint: disable = C0301,W1405,C0302,C0103 47 | # flake8: noqa 48 | 49 | 50 | # TODO to be confirmed : Home assistant configured zeroconf url "_xbmc-jsonrpc-h._tcp.local." 51 | # but it was not advertised at all on my network so I didn't code discovery 52 | class SetupSteps(IntEnum): 53 | """Enumeration of setup steps to keep track of user data responses.""" 54 | 55 | INIT = 0 56 | WORKFLOW_MODE = 1 57 | DEVICE_CONFIGURATION_MODE = 2 58 | DISCOVER = 3 59 | DEVICE_CHOICE = 4 60 | RECONFIGURE = 5 61 | BACKUP_RESTORE = 6 62 | 63 | 64 | _setup_step = SetupSteps.INIT 65 | _cfg_add_device: bool = False 66 | _discovered_kodis: list[dict[str, str]] = [] 67 | _pairing_device: KodiConnection | None = None 68 | _pairing_device_ws: KodiWSConnection | None = None 69 | _reconfigured_device: KodiConfigDevice | None = None 70 | 71 | # pylint: disable = R0911 72 | _user_input_manual = RequestUserInput( 73 | {"en": "Setup mode", "de": "Setup Modus", "fr": "Installation"}, 74 | [ 75 | { 76 | "id": "info", 77 | "label": { 78 | "en": "Discover or connect to Kodi instances. Leave address blank for automatic discovery.", 79 | "fr": "Découverte ou connexion à vos instances Kodi. Laisser le champ adresse vide pour la découverte automatique.", 80 | # noqa: E501 81 | }, 82 | "field": { 83 | "label": { 84 | "value": { 85 | "en": "Kodi must be running, and control enabled from Settings > Services > Control section. Port numbers shouldn't be modified.", 86 | # noqa: E501 87 | "fr": "Kodi doit être lancé et le contrôle activé depuis les Paramètres > Services > Contrôle. Laisser les numéros des ports inchangés.", 88 | # noqa: E501 89 | } 90 | } 91 | }, 92 | }, 93 | { 94 | "field": {"text": {"value": ""}}, 95 | "id": "address", 96 | "label": {"en": "IP address", "de": "IP-Adresse", "fr": "Adresse IP"}, 97 | }, 98 | { 99 | "field": {"text": {"value": ""}}, 100 | "id": "username", 101 | "label": {"en": "Username", "fr": "Utilisateur"}, 102 | }, 103 | { 104 | "field": {"text": {"value": ""}}, 105 | "id": "password", 106 | "label": {"en": "Password", "fr": "Mot de passe"}, 107 | }, 108 | { 109 | "field": {"text": {"value": "9090"}}, 110 | "id": "ws_port", 111 | "label": {"en": "Websocket port", "fr": "Port websocket"}, 112 | }, 113 | { 114 | "field": {"text": {"value": "8080"}}, 115 | "id": "port", 116 | "label": {"en": "HTTP port", "fr": "Port HTTP"}, 117 | }, 118 | { 119 | "field": {"checkbox": {"value": False}}, 120 | "id": "ssl", 121 | "label": {"en": "Use SSL", "fr": "Utiliser SSL"}, 122 | }, 123 | { 124 | "field": {"dropdown": {"value": KODI_DEFAULT_ARTWORK, "items": KODI_ARTWORK_LABELS}}, 125 | "id": "artwork_type", 126 | "label": { 127 | "en": "Artwork type to display", 128 | "fr": "Type d'image média à afficher", 129 | }, 130 | }, 131 | { 132 | "field": {"dropdown": {"value": KODI_DEFAULT_TVSHOW_ARTWORK, "items": KODI_ARTWORK_TVSHOWS_LABELS}}, 133 | "id": "artwork_type_tvshows", 134 | "label": { 135 | "en": "Artwork type to display for TV Shows", 136 | "fr": "Type d'image média à afficher pour les séries", 137 | }, 138 | }, 139 | { 140 | "field": {"checkbox": {"value": True}}, 141 | "id": "show_stream_name", 142 | "label": { 143 | "en": "Show audio/subtitle track name", 144 | "fr": "Afficher le nom de la piste audio/sous-titres", 145 | }, 146 | }, 147 | { 148 | "field": {"checkbox": {"value": True}}, 149 | "id": "show_stream_language_name", 150 | "label": { 151 | "en": "Show language name instead of track name", 152 | "fr": "Afficher le nom de la langue au lieu du nom de la piste", 153 | }, 154 | }, 155 | { 156 | "field": {"checkbox": {"value": True}}, 157 | "id": "media_update_task", 158 | "label": {"en": "Enable media update task", "fr": "Activer la tâche de mise à jour du média"}, 159 | }, 160 | { 161 | "field": {"checkbox": {"value": False}}, 162 | "id": "download_artwork", 163 | "label": { 164 | "en": "Download artwork instead of transmitting URL to the remote", 165 | "fr": "Télécharger l'image au lieu de transmettre l'URL à la télécommande", 166 | }, 167 | }, 168 | { 169 | "field": {"checkbox": {"value": False}}, 170 | "id": "disable_keyboard_map", 171 | "label": { 172 | "en": "Disable keyboard map : check only if some commands fail (eg arrow keys)", 173 | "fr": "Désactiver les commandes clavier : cocher uniquement si certaines commandes échouent " 174 | "(ex : commandes de direction)", 175 | }, 176 | }, 177 | ], 178 | ) 179 | 180 | 181 | # pylint: disable=R0911 182 | async def driver_setup_handler(msg: SetupDriver) -> SetupAction: 183 | """ 184 | Dispatch driver setup requests to corresponding handlers. 185 | 186 | Either start the setup process or handle the selected LG TV device. 187 | 188 | :param msg: the setup driver request object, either DriverSetupRequest or UserDataResponse 189 | :return: the setup action on how to continue 190 | """ 191 | global _setup_step 192 | global _cfg_add_device 193 | global _pairing_device 194 | global _pairing_device_ws 195 | 196 | if isinstance(msg, DriverSetupRequest): 197 | _setup_step = SetupSteps.INIT 198 | _cfg_add_device = False 199 | return await handle_driver_setup(msg) 200 | 201 | if isinstance(msg, UserDataResponse): 202 | _LOG.debug("Setup handler message : step %s, message : %s", _setup_step, msg) 203 | manual_config = False 204 | if _setup_step == SetupSteps.WORKFLOW_MODE: 205 | if msg.input_values.get("configuration_mode", "") == "normal": 206 | _setup_step = SetupSteps.DEVICE_CONFIGURATION_MODE 207 | _LOG.debug("Starting normal setup workflow") 208 | return _user_input_manual 209 | _LOG.debug("User requested backup/restore of configuration") 210 | return await _handle_backup_restore_step() 211 | if "address" in msg.input_values and len(msg.input_values["address"]) > 0: 212 | manual_config = True 213 | if _setup_step == SetupSteps.DEVICE_CONFIGURATION_MODE: 214 | if "action" in msg.input_values: 215 | _LOG.debug("Setup flow starts with existing configuration") 216 | return await handle_configuration_mode(msg) 217 | if not manual_config: 218 | _LOG.debug("Setup flow in discovery mode") 219 | _setup_step = SetupSteps.DISCOVER 220 | return await handle_discovery(msg) 221 | _LOG.debug("Setup flow configuration mode") 222 | return await _handle_configuration(msg) 223 | # When user types an address at start (manual configuration) 224 | if _setup_step == SetupSteps.DISCOVER and manual_config: 225 | return await _handle_configuration(msg) 226 | # No address typed, discovery mode then 227 | if _setup_step == SetupSteps.DISCOVER: 228 | return await handle_discovery(msg) 229 | if _setup_step == SetupSteps.RECONFIGURE: 230 | return await _handle_device_reconfigure(msg) 231 | if _setup_step == SetupSteps.DEVICE_CHOICE and "choice" in msg.input_values: 232 | return await _handle_configuration(msg) 233 | if _setup_step == SetupSteps.BACKUP_RESTORE: 234 | return await _handle_backup_restore(msg) 235 | _LOG.error("No or invalid user response was received: %s (step %s)", msg, _setup_step) 236 | elif isinstance(msg, AbortDriverSetup): 237 | _LOG.info("Setup was aborted with code: %s", msg.error) 238 | # pylint: disable = W0718 239 | if _pairing_device: 240 | try: 241 | await _pairing_device.close() 242 | except Exception: 243 | pass 244 | _pairing_device = None 245 | if _pairing_device_ws: 246 | try: 247 | await _pairing_device_ws.close() 248 | except Exception: 249 | pass 250 | _pairing_device_ws = None 251 | _setup_step = SetupSteps.INIT 252 | 253 | return SetupError() 254 | 255 | 256 | async def handle_driver_setup(msg: DriverSetupRequest) -> RequestUserInput | SetupError: 257 | """ 258 | Start driver setup. 259 | 260 | Initiated by Remote Two to set up the driver. 261 | Ask user to enter ip-address for manual configuration, otherwise auto-discovery is used. 262 | 263 | :param msg: not used, we don't have any input fields in the first setup screen. 264 | :return: the setup action on how to continue 265 | """ 266 | global _setup_step 267 | 268 | # workaround for web-configurator not picking up first response 269 | await asyncio.sleep(1) 270 | 271 | reconfigure = msg.reconfigure 272 | _LOG.debug("Handle driver setup, reconfigure=%s", reconfigure) 273 | if reconfigure: 274 | _setup_step = SetupSteps.DEVICE_CONFIGURATION_MODE 275 | 276 | # get all configured devices for the user to choose from 277 | dropdown_devices = [] 278 | for device in config.devices.all(): 279 | dropdown_devices.append({"id": device.id, "label": {"en": f"{device.name} ({device.id})"}}) 280 | 281 | # TODO #12 externalize language texts 282 | # build user actions, based on available devices 283 | dropdown_actions = [ 284 | { 285 | "id": "add", 286 | "label": { 287 | "en": "Add a new device", 288 | "de": "Neues Gerät hinzufügen", 289 | "fr": "Ajouter un nouvel appareil", 290 | }, 291 | }, 292 | ] 293 | 294 | # add remove & reset actions if there's at least one configured device 295 | if dropdown_devices: 296 | dropdown_actions.append( 297 | { 298 | "id": "configure", 299 | "label": { 300 | "en": "Configure selected device", 301 | "fr": "Configurer l'appareil sélectionné", 302 | }, 303 | }, 304 | ) 305 | dropdown_actions.append( 306 | { 307 | "id": "remove", 308 | "label": { 309 | "en": "Delete selected device", 310 | "de": "Selektiertes Gerät löschen", 311 | "fr": "Supprimer l'appareil sélectionné", 312 | }, 313 | }, 314 | ) 315 | dropdown_actions.append( 316 | { 317 | "id": "reset", 318 | "label": { 319 | "en": "Reset configuration and reconfigure", 320 | "de": "Konfiguration zurücksetzen und neu konfigurieren", 321 | "fr": "Réinitialiser la configuration et reconfigurer", 322 | }, 323 | }, 324 | ) 325 | else: 326 | # dummy entry if no devices are available 327 | dropdown_devices.append({"id": "", "label": {"en": "---"}}) 328 | 329 | dropdown_actions.append( 330 | { 331 | "id": "backup_restore", 332 | "label": { 333 | "en": "Backup or restore devices configuration", 334 | "fr": "Sauvegarder ou restaurer la configuration des appareils", 335 | }, 336 | }, 337 | ) 338 | 339 | return RequestUserInput( 340 | {"en": "Configuration mode", "de": "Konfigurations-Modus"}, 341 | [ 342 | { 343 | "field": {"dropdown": {"value": dropdown_devices[0]["id"], "items": dropdown_devices}}, 344 | "id": "choice", 345 | "label": { 346 | "en": "Configured devices", 347 | "de": "Konfigurierte Geräte", 348 | "fr": "Appareils configurés", 349 | }, 350 | }, 351 | { 352 | "field": {"dropdown": {"value": dropdown_actions[0]["id"], "items": dropdown_actions}}, 353 | "id": "action", 354 | "label": { 355 | "en": "Action", 356 | "de": "Aktion", 357 | "fr": "Appareils configurés", 358 | }, 359 | }, 360 | ], 361 | ) 362 | 363 | # Initial setup, make sure we have a clean configuration 364 | config.devices.clear() # triggers device instance removal 365 | _setup_step = SetupSteps.WORKFLOW_MODE 366 | return RequestUserInput( 367 | {"en": "Configuration mode", "de": "Konfigurations-Modus"}, 368 | [ 369 | { 370 | "field": { 371 | "dropdown": { 372 | "value": "normal", 373 | "items": [ 374 | { 375 | "id": "normal", 376 | "label": { 377 | "en": "Start the configuration of the integration", 378 | "fr": "Démarrer la configuration de l'intégration", 379 | }, 380 | }, 381 | { 382 | "id": "backup_restore", 383 | "label": { 384 | "en": "Backup or restore devices configuration", 385 | "fr": "Sauvegarder ou restaurer la configuration des appareils", 386 | }, 387 | }, 388 | ], 389 | } 390 | }, 391 | "id": "configuration_mode", 392 | "label": { 393 | "en": "Configuration mode", 394 | "fr": "Mode de configuration", 395 | }, 396 | } 397 | ], 398 | ) 399 | 400 | 401 | async def handle_configuration_mode(msg: UserDataResponse) -> RequestUserInput | SetupComplete | SetupError: 402 | """ 403 | Process user data response in a setup process. 404 | 405 | If ``address`` field is set by the user: try connecting to device and retrieve model information. 406 | Otherwise, start instances discovery and present the found devices to the user to choose from. 407 | 408 | :param msg: response data from the requested user data 409 | :return: the setup action on how to continue 410 | """ 411 | global _setup_step 412 | global _cfg_add_device 413 | global _reconfigured_device 414 | 415 | action = msg.input_values["action"] 416 | 417 | _LOG.debug("Handle configuration mode") 418 | 419 | # workaround for web-configurator not picking up first response 420 | await asyncio.sleep(1) 421 | 422 | match action: 423 | case "add": 424 | _cfg_add_device = True 425 | case "remove": 426 | choice = msg.input_values["choice"] 427 | if not config.devices.remove(choice): 428 | _LOG.warning("Could not remove device from configuration: %s", choice) 429 | return SetupError(error_type=IntegrationSetupError.OTHER) 430 | config.devices.store() 431 | return SetupComplete() 432 | case "configure": 433 | # Reconfigure device if the identifier has changed 434 | choice = msg.input_values["choice"] 435 | selected_device = config.devices.get(choice) 436 | if not selected_device: 437 | _LOG.warning("Can not configure device from configuration: %s", choice) 438 | return SetupError(error_type=IntegrationSetupError.OTHER) 439 | 440 | _setup_step = SetupSteps.RECONFIGURE 441 | _reconfigured_device = selected_device 442 | 443 | return RequestUserInput( 444 | { 445 | "en": "Configure your Kodi device", 446 | "fr": "Configurez votre appareil Kodi", 447 | }, 448 | [ 449 | { 450 | "field": {"text": {"value": _reconfigured_device.address}}, 451 | "id": "address", 452 | "label": {"en": "IP address", "de": "IP-Adresse", "fr": "Adresse IP"}, 453 | }, 454 | { 455 | "field": {"text": {"value": _reconfigured_device.username}}, 456 | "id": "username", 457 | "label": {"en": "Username", "fr": "Utilisateur"}, 458 | }, 459 | { 460 | "field": {"text": {"value": _reconfigured_device.password}}, 461 | "id": "password", 462 | "label": {"en": "Password", "fr": "Mot de passe"}, 463 | }, 464 | { 465 | "field": {"text": {"value": str(_reconfigured_device.ws_port)}}, 466 | "id": "ws_port", 467 | "label": {"en": "Websocket port", "fr": "Port websocket"}, 468 | }, 469 | { 470 | "field": {"text": {"value": str(_reconfigured_device.port)}}, 471 | "id": "port", 472 | "label": {"en": "HTTP port", "fr": "Port HTTP"}, 473 | }, 474 | { 475 | "field": {"checkbox": {"value": _reconfigured_device.ssl}}, 476 | "id": "ssl", 477 | "label": {"en": "Use SSL", "fr": "Utiliser SSL"}, 478 | }, 479 | { 480 | "field": { 481 | "dropdown": {"value": _reconfigured_device.artwork_type, "items": KODI_ARTWORK_LABELS} 482 | }, 483 | "id": "artwork_type", 484 | "label": { 485 | "en": "Artwork type to display", 486 | "fr": "Type d'image média à afficher", 487 | }, 488 | }, 489 | { 490 | "field": { 491 | "dropdown": { 492 | "value": _reconfigured_device.artwork_type_tvshows, 493 | "items": KODI_ARTWORK_TVSHOWS_LABELS, 494 | } 495 | }, 496 | "id": "artwork_type_tvshows", 497 | "label": { 498 | "en": "Artwork type to display for TV Shows", 499 | "fr": "Type d'image média à afficher pour les séries", 500 | }, 501 | }, 502 | { 503 | "field": {"checkbox": {"value": _reconfigured_device.show_stream_name}}, 504 | "id": "show_stream_name", 505 | "label": { 506 | "en": "Show audio/subtitle track name", 507 | "fr": "Afficher le nom de la piste audio/sous-titres", 508 | }, 509 | }, 510 | { 511 | "field": {"checkbox": {"value": _reconfigured_device.show_stream_language_name}}, 512 | "id": "show_stream_language_name", 513 | "label": { 514 | "en": "Show language name instead of track name", 515 | "fr": "Afficher le nom de la langue au lieu du nom de la piste", 516 | }, 517 | }, 518 | { 519 | "field": {"checkbox": {"value": _reconfigured_device.media_update_task}}, 520 | "id": "media_update_task", 521 | "label": {"en": "Enable media update task", "fr": "Activer la tâche de mise à jour du média"}, 522 | }, 523 | { 524 | "field": {"checkbox": {"value": _reconfigured_device.download_artwork}}, 525 | "id": "download_artwork", 526 | "label": { 527 | "en": "Download artwork instead of transmitting URL to the remote", 528 | "fr": "Télécharger l'image au lieu de transmettre l'URL à la télécommande", 529 | }, 530 | }, 531 | { 532 | "field": {"checkbox": {"value": _reconfigured_device.disable_keyboard_map}}, 533 | "id": "disable_keyboard_map", 534 | "label": { 535 | "en": "Disable keyboard map : check only if some commands fail (eg arrow keys)", 536 | "fr": "Désactiver les commandes clavier : cocher uniquement si certaines commandes" 537 | " échouent (ex : commandes de direction)", 538 | }, 539 | }, 540 | ], 541 | ) 542 | case "reset": 543 | config.devices.clear() # triggers device instance removal 544 | case "backup_restore": 545 | return await _handle_backup_restore_step() 546 | case _: 547 | _LOG.error("Invalid configuration action: %s", action) 548 | return SetupError(error_type=IntegrationSetupError.OTHER) 549 | 550 | _setup_step = SetupSteps.DISCOVER 551 | return _user_input_manual 552 | 553 | 554 | async def handle_discovery(_msg: UserDataResponse) -> RequestUserInput | SetupError: 555 | """ 556 | Process user data response from the first setup process screen. 557 | 558 | If ``address`` field is set by the user: try connecting to device and retrieve device information. 559 | Otherwise, start Apple TV discovery and present the found devices to the user to choose from. 560 | 561 | :param _msg: response data from the requested user data 562 | :return: the setup action on how to continue 563 | """ 564 | global _discovered_kodis 565 | global _setup_step 566 | 567 | dropdown_items = [] 568 | 569 | _LOG.debug("Handle driver setup with discovery") 570 | # start discovery 571 | try: 572 | discovery = KodiDiscover() 573 | _discovered_kodis = await discovery.discover() 574 | _LOG.debug("Discovered Kodi devices : %s", _discovered_kodis) 575 | # pylint: disable = W0718 576 | except Exception as ex: 577 | _LOG.error("Error during devices discovery %s", ex) 578 | return SetupError(error_type=IntegrationSetupError.NOT_FOUND) 579 | 580 | # only add new devices or configured devices requiring new pairing 581 | for discovered_kodi in _discovered_kodis: 582 | kodi_data = {"id": discovered_kodi["ip"], "label": {"en": f"Kodi {discovered_kodi['ip']}"}} 583 | existing = config.devices.get_by_id_or_address(discovered_kodi["id"], discovered_kodi["ip"]) 584 | if _cfg_add_device and existing: 585 | _LOG.info("Skipping found device '%s': already configured", discovered_kodi["id"]) 586 | continue 587 | dropdown_items.append(kodi_data) 588 | 589 | if not dropdown_items: 590 | _LOG.warning("No Kodi instance found") 591 | return SetupError(error_type=IntegrationSetupError.NOT_FOUND) 592 | 593 | _setup_step = SetupSteps.DEVICE_CHOICE 594 | # TODO #9 externalize language texts 595 | return RequestUserInput( 596 | { 597 | "en": "Please choose and configure your Kodi instance", 598 | "fr": "Sélectionnez et configurez votre instance Kodi", 599 | }, 600 | [ 601 | { 602 | "field": {"dropdown": {"value": dropdown_items[0]["id"], "items": dropdown_items}}, 603 | "id": "choice", 604 | "label": { 605 | "en": "Choose your Kodi instance", 606 | "de": "Wähle deinen Kodi", 607 | "fr": "Choisir votre instance Kodi", 608 | }, 609 | }, 610 | { 611 | "id": "info", 612 | "label": { 613 | "en": "Configure your Kodi devices", 614 | "de": "Verbinde auf Kodi Gerät", 615 | "fr": "Connexion à votre instance Kodi", 616 | }, 617 | "field": { 618 | "label": { 619 | "value": { 620 | "en": "Kodi must be running, and control enabled from Settings > " 621 | "Services > Control section. Port numbers shouldn't be modified." 622 | " Leave blank for automatic discovery.", 623 | "fr": "Kodi doit être lancé et le contrôle activé depuis les " 624 | "Paramètres > Services > Contrôle. Laisser les numéros des ports " 625 | "inchangés.Laisser vide pour la découverte automatique.", 626 | } 627 | } 628 | }, 629 | }, 630 | { 631 | "field": {"text": {"value": ""}}, 632 | "id": "username", 633 | "label": {"en": "Username", "fr": "Utilisateur"}, 634 | }, 635 | { 636 | "field": {"text": {"value": ""}}, 637 | "id": "password", 638 | "label": {"en": "Password", "fr": "Mot de passe"}, 639 | }, 640 | { 641 | "field": {"text": {"value": "9090"}}, 642 | "id": "ws_port", 643 | "label": {"en": "Websocket port", "fr": "Port websocket"}, 644 | }, 645 | { 646 | "field": {"text": {"value": "8080"}}, 647 | "id": "port", 648 | "label": {"en": "HTTP port", "fr": "Port HTTP"}, 649 | }, 650 | { 651 | "field": {"checkbox": {"value": False}}, 652 | "id": "ssl", 653 | "label": {"en": "Use SSL", "fr": "Utiliser SSL"}, 654 | }, 655 | { 656 | "field": {"dropdown": {"value": KODI_DEFAULT_ARTWORK, "items": KODI_ARTWORK_LABELS}}, 657 | "id": "artwork_type", 658 | "label": { 659 | "en": "Artwork type to display", 660 | "fr": "Type d'image média à afficher", 661 | }, 662 | }, 663 | { 664 | "field": {"dropdown": {"value": KODI_DEFAULT_TVSHOW_ARTWORK, "items": KODI_ARTWORK_TVSHOWS_LABELS}}, 665 | "id": "artwork_type_tvshows", 666 | "label": { 667 | "en": "Artwork type to display for TV Shows", 668 | "fr": "Type d'image média à afficher pour les séries", 669 | }, 670 | }, 671 | { 672 | "field": {"checkbox": {"value": True}}, 673 | "id": "show_stream_name", 674 | "label": { 675 | "en": "Show audio/subtitle track name", 676 | "fr": "Afficher le nom de la piste audio/sous-titres", 677 | }, 678 | }, 679 | { 680 | "field": {"checkbox": {"value": True}}, 681 | "id": "show_stream_language_name", 682 | "label": { 683 | "en": "Show language name instead of track name", 684 | "fr": "Afficher le nom de la langue au lieu du nom de la piste", 685 | }, 686 | }, 687 | { 688 | "field": {"checkbox": {"value": True}}, 689 | "id": "media_update_task", 690 | "label": {"en": "Enable media update task", "fr": "Activer la tâche de mise à jour du média"}, 691 | }, 692 | { 693 | "field": {"checkbox": {"value": False}}, 694 | "id": "download_artwork", 695 | "label": { 696 | "en": "Download artwork instead of transmitting URL to the remote", 697 | "fr": "Télécharger l'image au lieu de transmettre l'URL à la télécommande", 698 | }, 699 | }, 700 | { 701 | "field": {"checkbox": {"value": False}}, 702 | "id": "disable_keyboard_map", 703 | "label": { 704 | "en": "Disable keyboard map : check only if some commands fail (eg arrow keys)", 705 | "fr": "Désactiver les commandes clavier : cocher uniquement si certaines commandes échouent " 706 | "(ex : commandes de direction)", 707 | }, 708 | }, 709 | ], 710 | ) 711 | 712 | 713 | async def _handle_backup_restore_step() -> RequestUserInput: 714 | global _setup_step 715 | 716 | _setup_step = SetupSteps.BACKUP_RESTORE 717 | current_config = config.devices.export() 718 | 719 | _LOG.debug("Handle backup/restore step") 720 | 721 | return RequestUserInput( 722 | { 723 | "en": "Backup or restore devices configuration (all existing devices will be removed)", 724 | "fr": "Sauvegarder ou restaurer la configuration des appareils (tous les appareils existants seront supprimés)", 725 | }, 726 | [ 727 | { 728 | "field": { 729 | "textarea": { 730 | "value": current_config, 731 | } 732 | }, 733 | "id": "config", 734 | "label": { 735 | "en": "Devices configuration", 736 | "fr": "Configuration des appareils", 737 | }, 738 | }, 739 | ], 740 | ) 741 | 742 | 743 | async def _handle_configuration(msg: UserDataResponse) -> SetupComplete | SetupError: 744 | """ 745 | Process user data response in a setup process. 746 | 747 | If ``address`` field is set by the user: try connecting to device and retrieve model information. 748 | Otherwise, start LG TV discovery and present the found devices to the user to choose from. 749 | 750 | :param msg: response data from the requested user data 751 | :return: the setup action on how to continue 752 | """ 753 | # pylint: disable=W0602,W0718,R0915,R0914 754 | global _pairing_device 755 | global _pairing_device_ws 756 | global _setup_step 757 | global _discovered_kodis 758 | 759 | _LOG.debug("Handle configuration") 760 | 761 | # clear all configured devices and any previous pairing attempt 762 | if _pairing_device: 763 | try: 764 | await _pairing_device.close() 765 | except Exception: 766 | pass 767 | _pairing_device = None 768 | if _pairing_device_ws: 769 | try: 770 | await _pairing_device_ws.close() 771 | except Exception: 772 | pass 773 | _pairing_device_ws = None 774 | 775 | dropdown_items = [] 776 | address = msg.input_values.get("address", None) 777 | device_choice = msg.input_values.get("choice", None) 778 | port = msg.input_values["port"] 779 | ws_port = msg.input_values["ws_port"] 780 | username = msg.input_values["username"] 781 | password = msg.input_values["password"] 782 | ssl = msg.input_values["ssl"] 783 | artwork_type = msg.input_values.get("artwork_type", KODI_DEFAULT_ARTWORK) 784 | artwork_type_tvshows = msg.input_values.get("artwork_type_tvshows", KODI_DEFAULT_TVSHOW_ARTWORK) 785 | media_update_task = msg.input_values["media_update_task"] 786 | download_artwork = msg.input_values["download_artwork"] 787 | disable_keyboard_map = msg.input_values["disable_keyboard_map"] 788 | show_stream_name = msg.input_values["show_stream_name"] 789 | show_stream_language_name = msg.input_values["show_stream_language_name"] 790 | 791 | if ssl == "false": 792 | ssl = False 793 | else: 794 | ssl = True 795 | 796 | if media_update_task == "false": 797 | media_update_task = False 798 | else: 799 | media_update_task = True 800 | 801 | if download_artwork == "false": 802 | download_artwork = False 803 | else: 804 | download_artwork = True 805 | 806 | if disable_keyboard_map == "false": 807 | disable_keyboard_map = False 808 | else: 809 | disable_keyboard_map = True 810 | 811 | if show_stream_name == "false": 812 | show_stream_name = False 813 | else: 814 | show_stream_name = True 815 | 816 | if show_stream_language_name == "false": 817 | show_stream_language_name = False 818 | else: 819 | show_stream_language_name = True 820 | 821 | if device_choice: 822 | _LOG.debug("Configure device following discovery : %s %s", device_choice, _discovered_kodis) 823 | for discovered_kodi in _discovered_kodis: 824 | if device_choice == discovered_kodi["ip"]: 825 | address = discovered_kodi["ip"] 826 | 827 | _LOG.debug( 828 | "Starting driver setup for %s, port %s, websocket port %s, username %s, ssl %s", 829 | address, 830 | port, 831 | ws_port, 832 | username, 833 | ssl, 834 | ) 835 | try: 836 | # simple connection check 837 | async with ClientSession(raise_for_status=True) as session: 838 | device = KodiHTTPConnection( 839 | host=address, port=port, username=username, password=password, timeout=5, session=session, ssl=ssl 840 | ) 841 | kodi = Kodi(device) 842 | try: 843 | await kodi.ping() 844 | _LOG.debug("Connection %s:%s succeeded over HTTP", address, port) 845 | except CannotConnectError as ex: 846 | _LOG.warning("Cannot connect to %s:%s over HTTP [%s]", address, port, ex) 847 | return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) 848 | except InvalidAuthError: 849 | _LOG.warning("Authentication refused to %s:%s over HTTP", address, port) 850 | return SetupError(error_type=IntegrationSetupError.AUTHORIZATION_ERROR) 851 | device = KodiWSConnection( 852 | host=address, 853 | port=port, 854 | ws_port=ws_port, 855 | username=username, 856 | password=password, 857 | ssl=ssl, 858 | timeout=5, 859 | session=session, 860 | ) 861 | try: 862 | await device.connect() 863 | if not device.connected: 864 | _LOG.warning("Cannot connect to %s:%s over WebSocket", address, ws_port) 865 | return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) 866 | kodi = Kodi(device) 867 | await kodi.ping() 868 | await device.close() 869 | _LOG.debug("Connection %s:%s succeeded over websocket", address, port) 870 | except CannotConnectError: 871 | _LOG.warning("Cannot connect to %s:%s over WebSocket", address, ws_port) 872 | return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) 873 | 874 | dropdown_items.append({"id": address, "label": {"en": f"Kodi [{address}]"}}) 875 | except Exception as ex: 876 | _LOG.error("Cannot connect to manually entered address %s: %s", address, ex) 877 | return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) 878 | 879 | # TODO improve device ID (IP actually) 880 | config.devices.add( 881 | KodiConfigDevice( 882 | id=address, 883 | name="Kodi", 884 | address=address, 885 | username=username, 886 | password=password, 887 | port=port, 888 | ws_port=ws_port, 889 | ssl=ssl, 890 | artwork_type=artwork_type, 891 | artwork_type_tvshows=artwork_type_tvshows, 892 | media_update_task=media_update_task, 893 | download_artwork=download_artwork, 894 | disable_keyboard_map=disable_keyboard_map, 895 | show_stream_name=show_stream_name, 896 | show_stream_language_name=show_stream_language_name, 897 | ) 898 | ) # triggers SonyLG TV instance creation 899 | config.devices.store() 900 | 901 | await asyncio.sleep(1) 902 | _LOG.info("Setup successfully completed for %s", address) 903 | return SetupComplete() 904 | 905 | 906 | async def _handle_device_reconfigure(msg: UserDataResponse) -> SetupComplete | SetupError: 907 | """ 908 | Process reconfiguration of a registered Android TV device. 909 | 910 | :param msg: response data from the requested user data 911 | :return: the setup action on how to continue: SetupComplete after updating configuration 912 | """ 913 | # flake8: noqa:F824 914 | # pylint: disable=W0602, R0915 915 | global _reconfigured_device 916 | 917 | _LOG.debug("Handle device reconfigure") 918 | 919 | if _reconfigured_device is None: 920 | return SetupError() 921 | 922 | address = msg.input_values["address"] 923 | port = msg.input_values["port"] 924 | ws_port = msg.input_values["ws_port"] 925 | username = msg.input_values["username"] 926 | password = msg.input_values["password"] 927 | ssl = msg.input_values["ssl"] 928 | artwork_type = msg.input_values.get("artwork_type", KODI_DEFAULT_ARTWORK) 929 | artwork_type_tvshows = msg.input_values.get("artwork_type_tvshows", KODI_DEFAULT_TVSHOW_ARTWORK) 930 | media_update_task = msg.input_values["media_update_task"] 931 | download_artwork = msg.input_values["download_artwork"] 932 | disable_keyboard_map = msg.input_values["disable_keyboard_map"] 933 | show_stream_name = msg.input_values["show_stream_name"] 934 | show_stream_language_name = msg.input_values["show_stream_language_name"] 935 | 936 | if ssl == "false": 937 | ssl = False 938 | else: 939 | ssl = True 940 | 941 | if media_update_task == "false": 942 | media_update_task = False 943 | else: 944 | media_update_task = True 945 | 946 | if download_artwork == "false": 947 | download_artwork = False 948 | else: 949 | download_artwork = True 950 | 951 | if disable_keyboard_map == "false": 952 | disable_keyboard_map = False 953 | else: 954 | disable_keyboard_map = True 955 | 956 | if show_stream_name == "false": 957 | show_stream_name = False 958 | else: 959 | show_stream_name = True 960 | 961 | if show_stream_language_name == "false": 962 | show_stream_language_name = False 963 | else: 964 | show_stream_language_name = True 965 | 966 | _LOG.debug("User has changed configuration") 967 | _reconfigured_device.address = address 968 | _reconfigured_device.username = username 969 | _reconfigured_device.password = password 970 | _reconfigured_device.port = port 971 | _reconfigured_device.ws_port = ws_port 972 | _reconfigured_device.ssl = ssl 973 | _reconfigured_device.artwork_type = artwork_type 974 | _reconfigured_device.artwork_type_tvshows = artwork_type_tvshows 975 | _reconfigured_device.media_update_task = media_update_task 976 | _reconfigured_device.download_artwork = download_artwork 977 | _reconfigured_device.disable_keyboard_map = disable_keyboard_map 978 | _reconfigured_device.show_stream_name = show_stream_name 979 | _reconfigured_device.show_stream_language_name = show_stream_language_name 980 | 981 | config.devices.add_or_update(_reconfigured_device) # triggers ATV instance update 982 | await asyncio.sleep(1) 983 | _LOG.info("Setup successfully completed for %s", _reconfigured_device.name) 984 | 985 | return SetupComplete() 986 | 987 | 988 | async def _handle_backup_restore(msg: UserDataResponse) -> SetupComplete | SetupError: 989 | """ 990 | Process import of configuration 991 | 992 | :param msg: response data from the requested user data 993 | :return: the setup action on how to continue: SetupComplete after updating configuration 994 | """ 995 | # flake8: noqa:F824 996 | # pylint: disable=W0602 997 | global _reconfigured_device 998 | 999 | _LOG.debug("Handle backup/restore") 1000 | updated_config = msg.input_values["config"] 1001 | _LOG.info("Replacing configuration with : %s", updated_config) 1002 | res = config.devices.import_config(updated_config) 1003 | if res == ConfigImportResult.ERROR: 1004 | _LOG.error("Setup error, unable to import updated configuration : %s", updated_config) 1005 | return SetupError(error_type=IntegrationSetupError.OTHER) 1006 | if res == ConfigImportResult.WARNINGS: 1007 | _LOG.error("Setup warning, configuration imported with warnings : %s", config.devices) 1008 | _LOG.debug("Configuration imported successfully") 1009 | 1010 | await asyncio.sleep(1) 1011 | return SetupComplete() 1012 | --------------------------------------------------------------------------------