├── 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: ''
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: ");
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 |
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 |
85 |
86 |
87 | Back
88 |
89 |
90 |
91 |
92 | OSD
93 |
94 |
95 |
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 |
99 |
100 | See the target path in the bottom like this :
101 |
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 |
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 |
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`
`key a`
`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)
_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 `...` 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 |
273 |
274 |
275 | Back
276 |
277 |
278 |
279 |
280 | OSD
281 |
282 |
283 |
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 |
--------------------------------------------------------------------------------