Repository: ollo69/ha-samsungtv-smart Branch: master Commit: 1336a3557898 Files: 59 Total size: 381.6 KB Directory structure: gitextract_jb3sp0ji/ ├── .devcontainer/ │ └── devcontainer.json ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── hassfest.yaml │ ├── linting.yaml │ ├── release.yml │ ├── stale.yaml │ └── validate.yaml ├── .gitignore ├── .prettierignore ├── .pylintrc ├── .ruff.toml ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── Dockerfile.dev ├── LICENSE ├── README.md ├── config/ │ └── configuration.yaml ├── custom_components/ │ ├── __init__.py │ └── samsungtv_smart/ │ ├── __init__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── samsungcast.py │ │ ├── samsungws.py │ │ ├── shortcuts.py │ │ ├── smartthings.py │ │ └── upnp.py │ ├── config_flow.py │ ├── const.py │ ├── diagnostics.py │ ├── entity.py │ ├── logo.py │ ├── logo_paths.json │ ├── manifest.json │ ├── media_player.py │ ├── remote.py │ ├── services.yaml │ └── translations/ │ ├── en.json │ ├── hu.json │ ├── it.json │ └── pt-BR.json ├── docs/ │ ├── App_list.md │ ├── Key_chaining.md │ ├── Key_codes.md │ └── Smartthings.md ├── hacs.json ├── info.md ├── requirements.txt ├── requirements_test.txt ├── script/ │ └── integration_init ├── scripts/ │ ├── develop │ ├── lint │ └── setup ├── setup.cfg └── tests/ ├── __init__.py └── conftest.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "SamsungTV Smart Integration", "dockerFile": "../Dockerfile.dev", "postCreateCommand": "scripts/setup", "forwardPorts": [8123], "portsAttributes": { "8123": { "label": "Home Assistant", "onAutoForward": "notify" } }, "customizations": { "vscode": { "extensions": [ "ms-python.black-formatter", "ms-python.pylint", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", "esbenp.prettier-vscode", "GitHub.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, "python.pythonPath": "/usr/local/bin/python", "python.testing.pytestArgs": ["--no-cov"], "python.analysis.autoSearchPaths": false, "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" } }, "terminal.integrated.defaultProfile.linux": "zsh", "[python]": { "editor.defaultFormatter": "ms-python.black-formatter" } } } }, "remoteUser": "vscode" } ================================================ FILE: .dockerignore ================================================ # General files .git .github config docs # Development .devcontainer .vscode # Test related files tests # Other virtualization methods venv .vagrant # Temporary files **/__pycache__ ================================================ FILE: .gitattributes ================================================ # Ensure Docker script files uses LF to support Docker for Windows. # Ensure "git config --global core.autocrlf input" before you clone * text eol=lf *.py whitespace=error *.ico binary *.gif binary *.jpg binary *.png binary *.zip binary *.mp3 binary Dockerfile.dev linguist-language=Dockerfile ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **Expected behavior** If applicable, a clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment details:** - Environment (HASSIO, Raspbian, etc): - Home Assistant version installed: - Component version installed: - Last know working version: - TV model: **Output of HA logs** Paste the relavant output of the HA log here. ``` ``` **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: Feature Request assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/hassfest.yaml ================================================ name: Validate with Hassfest on: push: branches: - master pull_request: branches: ["*"] schedule: - cron: "0 0 * * *" jobs: validate_hassfest: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v5" - uses: home-assistant/actions/hassfest@master ================================================ FILE: .github/workflows/linting.yaml ================================================ name: Linting on: push: branches: - master pull_request: branches: ["*"] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.13 - name: Install dependencies run: | pip install -r requirements.txt - name: flake8 run: flake8 . - name: isort run: isort --diff --check . - name: Black run: black --line-length 88 --diff --check . ================================================ FILE: .github/workflows/release.yml ================================================ name: "Release" on: release: types: - "published" permissions: {} jobs: release: name: "Release" runs-on: "ubuntu-latest" permissions: contents: write steps: - name: "Checkout the repository" uses: "actions/checkout@v5" - name: "ZIP the integration directory" shell: "bash" run: | cd "${{ github.workspace }}/custom_components/samsungtv_smart" zip samsungtv_smart.zip -r ./ - name: "Upload the ZIP file to the release" uses: "softprops/action-gh-release@v2.0.8" with: files: ${{ github.workspace }}/custom_components/samsungtv_smart/samsungtv_smart.zip ================================================ FILE: .github/workflows/stale.yaml ================================================ # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. # # You can adjust the behavior by modifying this file. # For more information, see: # https://github.com/actions/stale name: "Close stale issues and PRs" on: schedule: - cron: "0 2 * * *" workflow_dispatch: permissions: contents: read jobs: stale: permissions: issues: write # for actions/stale to close stale issues pull-requests: write # for actions/stale to close stale PRs runs-on: ubuntu-latest steps: - uses: actions/stale@v10 with: stale-issue-message: 'This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 10 days.' close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.' close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' exempt-issue-labels: 'Feature Request,documentation' days-before-issue-stale: 45 days-before-pr-stale: -1 days-before-issue-close: 7 days-before-pr-close: -1 ascending: true operations-per-run: 400 ================================================ FILE: .github/workflows/validate.yaml ================================================ name: Validate with Hacs on: push: branches: - master pull_request: branches: ["*"] schedule: - cron: "0 0 * * *" jobs: validate_hacs: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v5" - name: HACS validation uses: "hacs/action@main" with: category: "integration" ================================================ FILE: .gitignore ================================================ # artifacts __pycache__ .pytest* .cache *.egg-info */build/* */dist/* # pycharm .idea/ # Unit test / coverage reports .coverage coverage.xml # Home Assistant configuration config/* !config/configuration.yaml ================================================ FILE: .prettierignore ================================================ *.md .strict-typing azure-*.yml docs/source/_templates/* ================================================ FILE: .pylintrc ================================================ [MESSAGES CONTROL] # PyLint message control settings # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) # consider-using-assignment-expr (Pylint CodeStyle extension) disable = format, abstract-method, cyclic-import, duplicate-code, inconsistent-return-statements, locally-disabled, not-context-manager, too-few-public-methods, too-many-ancestors, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, too-many-boolean-expressions, unused-argument, wrong-import-order, consider-using-f-string, unexpected-keyword-arg # consider-using-namedtuple-or-dataclass, # consider-using-assignment-expr ================================================ FILE: .ruff.toml ================================================ # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml target-version = "py310" select = [ "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "C", # complexity "D", # docstrings "E", # pycodestyle "F", # pyflakes/autoflake "ICN001", # import concentions; {name} should be imported as {asname} "PGH004", # Use specific rule codes when using noqa "PLC0414", # Useless import alias. Import alias does not rename original package. "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass "SIM117", # Merge with-statements that use the same scope "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() "SIM201", # Use {left} != {right} instead of not {left} == {right} "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. "SIM401", # Use get from dict with default instead of an if block "T20", # flake8-print "TRY004", # Prefer TypeError exception for invalid type "RUF006", # Store a reference to the return value of asyncio.create_task "UP", # pyupgrade "W", # pycodestyle ] ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D404", # First word of the docstring should not be This "D406", # Section name should end with a newline "D407", # Section name underlining "D411", # Missing blank line before section "E501", # line too long "E731", # do not assign a lambda expression, use a def ] [flake8-pytest-style] fixture-parentheses = false [pyupgrade] keep-runtime-typing = true [mccabe] max-complexity = 25 ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] } ================================================ FILE: .vscode/launch.json ================================================ { // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { // Example of attaching to local debug server "name": "Python: Attach Local", "type": "python", "request": "attach", "port": 5678, "host": "localhost", "pathMappings": [ { "localRoot": "${workspaceFolder}", "remoteRoot": "." } ], }, { // Example of attaching to my production server "name": "Python: Attach Remote", "type": "python", "request": "attach", "port": 5678, "host": "homeassistant.local", "pathMappings": [ { "localRoot": "${workspaceFolder}", "remoteRoot": "/usr/src/homeassistant" } ], } ] } ================================================ FILE: .vscode/settings.json ================================================ { //"editor.formatOnSave": true "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings "python.testing.pytestEnabled": false } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "label": "Run Home Assistant on port 8123", "type": "shell", "command": "scripts/develop", "problemMatcher": [] }, { "label": "Install Requirements", "type": "shell", "command": "pip3 install --use-deprecated=legacy-resolver -r requirements.txt", "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Install Test Requirements", "type": "shell", "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test.txt", "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Run PyTest", "detail": "Run pytest for integration.", "type": "shell", "command": "pytest --cov-report term-missing -vv --durations=10", "group": { "kind": "test", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] } ] } ================================================ FILE: Dockerfile.dev ================================================ FROM mcr.microsoft.com/devcontainers/python:1-3.13 SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN \ pipx uninstall pydocstyle \ && pipx uninstall pycodestyle \ && pipx uninstall mypy \ && pipx uninstall pylint RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery bluez \ ffmpeg \ libudev-dev \ libavformat-dev \ libavcodec-dev \ libavdevice-dev \ libavutil-dev \ libgammu-dev \ libswscale-dev \ libswresample-dev \ libavfilter-dev \ libpcap-dev \ libturbojpeg0 \ libyaml-dev \ libxml2 \ git \ cmake \ autoconf \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc # Install uv RUN pip3 install uv WORKDIR /workspaces # Set the default shell to bash instead of sh ENV SHELL /bin/bash ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ [![](https://img.shields.io/github/release/ollo69/ha-samsungtv-smart/all.svg?style=for-the-badge)](https://github.com/ollo69/ha-samsungtv-smart/releases) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) [![](https://img.shields.io/github/license/ollo69/ha-samsungtv-smart?style=for-the-badge)](LICENSE) [![](https://img.shields.io/badge/MAINTAINER-%40ollo69-red?style=for-the-badge)](https://github.com/ollo69) [![](https://img.shields.io/badge/COMMUNITY-FORUM-success?style=for-the-badge)](https://community.home-assistant.io) # HomeAssistant - SamsungTV Smart Component This is a custom component to allow control of SamsungTV devices in [HomeAssistant](https://home-assistant.io). Is a modified version of the built-in [samsungtv](https://www.home-assistant.io/integrations/samsungtv/) with some extra features.
**This plugin is only for 2016+ TVs model!** (maybe all tizen family) This project is a fork of the component [SamsungTV Tizen](https://github.com/jaruba/ha-samsungtv-tizen). I added some feature like the possibility to configure it using the HA user interface, simplifing the configuration process. I also added some code optimizition in the comunication layer using async aiohttp instead of request. **Part of the code and documentation available here come from the original project.**
# Additional Features: * Ability to send keys using a native Home Assistant service * Ability to send chained key commands using a native Home Assistant service * Supports Assistant commands (Google Home, should work with Alexa too, but untested) * Extended volume control * Ability to customize source list at media player dropdown list * Cast video URLs to Samsung TV * Connect to SmartThings Cloud API for additional features: see TV channel names, see which HDMI source is selected, more key codes to change input source * Display logos of TV channels (requires Smartthings enabled) and apps ![N|Solid](https://i.imgur.com/8mCGZoO.png) ![N|Solid](https://i.imgur.com/t3e4bJB.png) # Installation ### 1. Easy Mode Install via HACS. ### 2. Manual Install it as you would do with any homeassistant custom component: 1. Download `custom_components` folder. 1. Copy the `samsungtv_smart` directory within the `custom_components` directory of your homeassistant installation. The `custom_components` directory resides within your homeassistant configuration directory. **Note**: if the custom_components directory does not exist, you need to create it. After a correct installation, your configuration directory should look like the following. ``` └── ... └── configuration.yaml └── custom_components └── samsungtv_smart └── __init__.py └── media_player.py └── websockets.py └── shortcuts.py └── smartthings.py └── upnp.py └── exceptions.py └── ... ``` # Configuration Once the component has been installed, you need to configure it in order to make it work. There are two ways of doing so: - Using the web interface (Lovelace) [**recommended**] - Manually editing the `configuration.yaml` file **Important**: To complete the configuration procedure properly, you must be sure that your **TV is turned on and connected to the LAN (wired or wireless)**. Stay near to your TV during configuration because probably you will need to accept the access request that will prompt on your TV screen. **Note**: To configure the component for using **SmartThings (strongly suggested)** you need to generate an access token as explained in [this guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md). Also make sure your **TV is logged into your SmartThings account** and **registered in SmartThings phone app** before starting configuration. ### Option A: Configuration using the web UI [**recommended**] 1. From the Home Assistant front-end, navigate to 'Configuration' then 'Integrations'. Click `+` button in botton right corner, search '**SamsungTV Smart**' and click 'Configure'. 2. In the configuration mask, enter the IP address of the TV, the name for the Entity and the SmartThings personal access token (if created) and then click 'Submit' 3. **Important**: look for your TV screen and confirm **immediately** with OK if a popup appear. 4. Congrats! You're all set! **Note**: be sure that your TV and HA are connected to the same VLAN. Websocket connection through different VLAN normally not work because not supported by Samsung TV. If you have errors during configuration, try to power cycle your TV. This will close running applications that can prevent websocket connection initialization. ### Option B: Configuration via editing `configuration.yaml` **From v0.3.16 initial configuration from yaml is not allowed.**
You can still use `configuration.yaml` to set the additional parameter as explained below. ## Configuration options From the Home Assistant front-end, navigate to 'Configuration' then 'Integrations'. Identify the '**SamsungTV Smart**' integration configured for your TV and click the `OPTIONS` button.
Here you can change the following options: - **Use SmartThings TV Status information**
(default = True)
**This option is available only if SmartThings is configured.** When enabled the component will try to retrieve from SmartThings the information about the TV Status. This information is always used in conjunction with local ping result.
- **Use SmartThings TV Channels information**
(default = True)
**This option is available only if SmartThings is configured.** When enabled the component will try to retrieve from SmartThings the information about the TV Channel and TV Channel Name or the Running App
**Note: in some case this information is not properly updated, disabled it you have incorrect information.**
- **Use SmartThings TV Channels number information**
(default = False)
**This option is available only if SmartThings is configured.** When enabled then the TV Channel Names will show as media titles, by setting this to True the TV Channel Number will also be attached to the end of the media title (when applicable).
**Note: not always SmartThings provide the information for channel_name and channel_number.**
- **Logo options**
The background color and channel / service logo preference to use, example: "white-color" (background: white, logo: color).
Supported values: "none", "white-color", "dark-white", "blue-color", "blue-white", "darkblue-white", "transparent-color", "transparent-white"
Default value: "white-color" (background: white, logo: color)
Notice that your logo is missing or outdated? In case of a missing TV channel logo also make sure you have Smartthings enabled. This is required for the component to know the name of the TV channel.
Check guide [here](https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Logos.md) for updating the logo database this component is relying on. - **Allow use of local logo images**
(default = True)
When enabled the integration will try to get logo image for the current media from the `www/samsungtv_smart_logos` sub folder of home-assistant configuration folder. You can add new logo images in this folder, using the following rules for logo filename: - must be equal to the name of the `media_title` attribute, removing space, `_` and `.` characters and replacing `+` character with the string `plus` - must have the `.png` suffix - must be in `png` format (suggested size is 400x400 pixels) - **Applications list load mode at startup**
Possible values: `All Apps`, `Default Apps` and `Not Load`
This option determine the mode application list is automatic generated.
With `All Apps` the list will contain all apps installed on the TV, with `Default Apps` will be generated a minimal list with only the most common application, with `Not Load` application list will be empty.
**Note: If a custom `Application List` in config options is defined this option is not available.**
- **Method used to turn on TV**
Possible values: `WOL Packet` and `SmartThings`
**This option is available only if SmartThings is configured.** WOL Packet is better when TV use wired connection.
SmartThings normally work only when TV use wireless connection.
- **Show advanced options**
Selecting this option and clicking submit a new options menu is opened containing the list of other options described below. ### Advanced options - **Applications launch method used**
Possible values: `Control Web Socket Channel`, `Remote Web Socket Channel` and `Rest API Call`
This option determine the mode used to launch applications.
Use `Rest API Call` only if the other 2 methods do not work.
- **Number of times WOL packet is sent to turn on TV**
(default = 1, range from 1 to 5)
This option allow to configure the number of time the WOL packet is sent to turn on TV. Increase the value until the TV properly turn-on.
- **TCP port used to check power status**
(default = 0, range from 0 to 65535)
With this option is possible to check the availability of a specific port to determinate power status instead of using ICMP echo. To continue use ICMP echo, leave the value to `0`, otherwise set a port that is known becoming available when TV is on (possible working ports, depending on TV models, are `9110`, `9119`, `9197`).
**N.B. If you set an invalid port here, TV is always reported as `off`.**
- **Binary sensor to help detect power status**
An external `binary_sensor` selectable from a list that can be used to determinate TV power status.
This can be any available `binary_sensor` that can better determinate the status of the TV, for example a `binary_sensor` based on TV power consumption. It is suggested to not use a sensor based on `ping` platform because this method is already implemented by the integration.
- **Use volume mute status to detect fake power ON**
(default = True)
When enabled try to detect fake power on based on the Volume mute state, based on the assumption that when the TV is powered on the volume is always unmuted.
- **Dump apps list on log file at startup**
(default = False)
When enabled the component will try to dump the list of available apps on TV in the HA log file at Info level. The dump of the apps may not work for some TV models.
- **Power button switch to art mode**
(default = False)
When enabled the power button in UI will be used to toggle from `On` to `Art Mode` (and vice versa) and will not power off the TV (you can still use the `turn off` service to power off the TV).
**Note: This option is valid only for TV that support `Art Mode` ("The Frame" models).**
### Synched entities configuration - **List of entity to Power OFF with TV**
A list of HA entity to Turn OFF when the TV entity is turned OFF (maximum 4). Select entities from list. This call the service `homeassistant.turn_off` for maximum the first 4 entity in the provided list.
- **List of entity to Power ON with TV**
A list of HA entity to Turn ON when the TV entity is turned ON (maximum 4). Select entities from list. This call the service `homeassistant.turn_on` for maximum the first 4 entity in the provided list.
### Sources list configuration This contains the KEYS visible sources in the dropdown list in media player UI.
You can configure the pair list `Name: Key` using the yaml editor in the option page. If a source list is present in `configuration.yaml`, it will be imported in the options the first time that the integration is loaded.
Default value:
``` 1| TV: KEY_TV 2| HDMI: KEY_HDMI ``` If SmartThings is [configured](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md) and the source_list not, the component will try to identify and configure automatically the sources configured on the TV with the relative associated names (new feature, tested on QLed TV). The created list is available in the HA log file.
You can also chain KEYS, example: ``` 1| TV: KEY_SOURCES+KEY_ENTER ``` And even add delays (in milliseconds) between sending KEYS, example:
``` 1| TV: KEY_SOURCES+500+KEY_ENTER ``` Resources: [key codes](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_codes.md) / [key patterns](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_chaining.md)
**Warning: changing input source with voice commands only works if you set the device name in `source_list` as one of the whitelisted words that can be seen on [this page](https://web.archive.org/web/20181218120801/https://developers.google.com/actions/reference/smarthome/traits/modes#mode-settings) (under "Mode Settings")**
### Application list configuration This contains the APPS visible sources in the dropdown list in media player UI.
You can configure the pair list `Name: Key` using the yaml editor in the option page. If an application list is present in `configuration.yaml`, it will be imported in the options the first time that the integration is loaded.
If the `Application list` is not manually configured, during startup the integration will try to automatically generate a list of available application and a log message is generated with the content of the list. This list can be used to create a manual list following [app_list guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/App_list.md). Automatic list generation not work with some TV models.
Example value: ``` 1| Netflix: "11101200001" 2| YouTube: "111299001912" 3| Spotify: "3201606009684" ``` Known lists of App IDs: [List 1](https://github.com/tavicu/homebridge-samsung-tizen/issues/26#issuecomment-447424879), [List 2](https://github.com/Ape/samsungctl/issues/75#issuecomment-404941201)
### Channel list configuration This contains the tv CHANNELS visible sources in the dropdown list in media player UI. To guarantee performance keep the list small, recommended maximum 30 channels.
You can configure the pair list `Name: Key` using the yaml editor in the option page. If a channel list is present in `configuration.yaml`, it will be imported in the options the first time that the integration is loaded.
Example value: ``` 1| MTV: "14" 2| Eurosport: "20" 3| TLC: "21" ``` You can also specify the source that must be used for every channel. The source must be one of the source name defined in the `source_list`
Example value: ``` 1| MTV: 14@TV 2| Eurosport: 20@TV 3| TLC: 21@HDMI ``` ## Custom configuration parameters You can configure additional option for the component using configuration variable in `configuration.yaml` section.
Section in `configuration.yaml` file can also not be present and is not required for component to work. If you want to configure any parameters, you must create one section that start with `- host` as shown in the example below:
``` samsungtv_smart: - host: ... ``` Then you can add any of the following parameters:
- **mac:**
(string)(Optional)
This is an optional value, normally is automatically detected during setup phase and so is not required to specify it. You should try to configure this parameter only if the setup fail in the detection.
The mac-address is used to turn on the TV. If you set it manually, you must find the right value from the TV Menu or from your network router.
- **broadcast_address:**
(string)(Optional)
**Do not set this option if you do not know what it does, it can break turning your TV on.**
The ip address of the host to send the magic packet (for wakeonlan) to if the "mac" property is also set.
Default value: "255.255.255.255"
Example value: "192.168.1.255"
### Deprecated configuration parameters Deprecated parameters were used by old integration version. Are still valid but normally are automatically imported in application options and not used anymore, so after first import can be removed from `configuration.yaml`. - **source_list:**
(json)(Optional)
This contains the KEYS visible sources in the dropdown list in media player UI.
Default value: '{"TV": "KEY_TV", "HDMI": "KEY_HDMI"}'
If SmartThings is [configured](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md) and the source_list not, the component will try to identify and configure automatically the sources configured on the TV with the relative associated names (new feature, tested on QLed TV). The created list is available in the HA log file.
You can also chain KEYS, example: '{"TV": "KEY_SOURCES+KEY_ENTER"}'
And even add delays (in milliseconds) between sending KEYS, example:
'{"TV": "KEY_SOURCES+500+KEY_ENTER"}'
Resources: [key codes](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_codes.md) / [key patterns](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_chaining.md)
**Warning: changing input source with voice commands only works if you set the device name in `source_list` as one of the whitelisted words that can be seen on [this page](https://web.archive.org/web/20181218120801/https://developers.google.com/actions/reference/smarthome/traits/modes#mode-settings) (under "Mode Settings")**
- **app_list:**
(json)(Optional)
This contains the APPS visible sources in the dropdown list in media player UI.
Default value: AUTOGENERATED
If the `app_list` is not manually configured, during startup is generated a file in the custom component folder with the list of all available applications. This list can be used to create a manual list following [app_list guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/App_list.md)
Example value: '{"Netflix": "11101200001", "YouTube": "111299001912", "Spotify": "3201606009684"}'
Known lists of App IDs: [List 1](https://github.com/tavicu/homebridge-samsung-tizen/issues/26#issuecomment-447424879), [List 2](https://github.com/Ape/samsungctl/issues/75#issuecomment-404941201)
- **channel_list:**
(json)(Optional)
This contains the tv CHANNELS visible sources in the dropdown list in media player UI. To guarantee performance keep the list small, recommended maximum 30 channels.
Example value: '{"MTV": "14", "Eurosport": "20", "TLC": "21"}'
You can also specify the source that must be used for every channel. The source must be one of the defined in the `source_list`
Example value: '{"MTV": "14@TV", "Eurosport": "20@TV", "TLC": "21@HDMI"}'
### Removed configuration parameters Removed parameters were used by old integration version, are not used and supported anymore and replaced by application option. For this reason should be removed from `configuration.yaml`. - **api_key:**
(string)(Optional) (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)
API Key for the SmartThings Cloud API, this is optional but adds better state handling on, off, channel name, hdmi source, and a few new keys: `ST_TV`, `ST_HDMI1`, `ST_HDMI2`, `ST_HDMI3`, etc. (see more at [SmartThings Keys](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md#smartthings-keys))
Read [How to get an API Key for SmartThings](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md)
This parameter can also be provided during the component configuration using the user interface.
**Note: this parameter is used only during initial configuration and then stored in the registry. It's not possible to change the value after that the component is configured. To change the value you must delete the integration from UI.**
- **device_id:**
(string)(Optional) (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)
Device ID for the SmartThings Cloud API. This is optional, to be used only if component fails to automatically determinate it. Read [SmartThings Device ID](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md#smartthings-device-id) to understand how identify the correct value to use.
This parameter will be requested during component configuration from user interface when required.
**Note: this parameter is used only during initial configuration and then stored in the registry. It's not possible to change the value after that the component is configured. To change the value you must delete the integration from UI.**
- **device_name:** (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)
(string)(Optional)
This is an optional value, used only to identify the TV in SmartThings during initial configuration if you have more TV registered. You should configure this parameter only if the setup fails in the detection.
The device_name to use can be read using the SmartThings app
**Note: this parameter is used only during initial configuration.**
- **show_channel_number:** (obsolete/not used from v0.3.16 and replaced by Configuration options)
(boolean)(Optional)
If the SmartThings API is enabled (by settings "api_key" and "device_id"), then the TV Channel Names will show as media titles, by setting this to True the TV Channel Number will also be attached to the end of the media title (when applicable).
**Note: not always SmartThings provide the information for channel_name and channel_number.**
- **load_all_apps:** (obsolete/not used from v0.3.4 and replaced by Configuration options)
(boolean)(Optional)
This option is `True` by default.
Setting this parameter to false, if a custom `app_list` is not defined, the automatic app_list will be generated limited to few application (the most common).
- **update_method:** (obsolete/not used from v0.3.3)
(string)(Optional)
This change the ping method used for state update. Values: "ping", "websockets" and "smartthings"
Default value: "ping" if SmartThings is not enabled, else "smartthings"
Example update_method: "websockets"
- **update_custom_ping_url:** (obsolete/not used from v0.2.x)
(string)(Optional)
Use custom endpoint to ping.
Default value: PING TO 8001 ENDPOINT
Example update_custom_ping_url: "http://192.168.1.77:9197/dmr"
- **scan_app_http:** (obsolete/not used from v0.2.x)
(boolean)(Optional)
This option is `True` by default. In some cases (if numerical IDs are used when setting `app_list`) HTTP polling will be used (1 request per app) to get the running app.
This is a lengthy task that some may want to disable, you can do so by setting this option to `False`.
For more information about how we get the running app, read the [app_list guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/App_list.md).
# Usage ### Known Supported Voice Commands * Turn on `SAMSUNG-TV-NAME-HERE` (for some older TVs this only works if the TV is connected by LAN cable to the Network) * Turn off `SAMSUNG-TV-NAME-HERE` * Volume up on `SAMSUNG-TV-NAME-HERE` (increases volume by 1) * Volume down on `SAMSUNG-TV-NAME-HERE` (decreases volume by 1) * Set volume to 50 on `SAMSUNG-TV-NAME-HERE` (sets volume to 50 out of 100) * Mute `SAMSUNG-TV-NAME-HERE` (sets volume to 0) * Change input source to `DEVICE-NAME-HERE` on `SAMSUNG-TV-NAME-HERE` (only works if `DEVICE-NAME-HERE` is a whitelisted word from [this page](https://web.archive.org/web/20181218120801/https://developers.google.com/actions/reference/smarthome/traits/modes) under "Mode Settings") (if you find more supported voice commands, please create an issue so I can add them here) ### Cast to TV ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "url", "media_content_id": "FILE_URL", } ``` Replace `FILE_URL` with the url of your file ### Cast to YouTube ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "url", "media_content_id": "YOUTUBE_URL", "enqueue": "play", } ``` Replace `YOUTUBE_URL` with the url of the video you want to play All 4 enqueue modes are supported. Shorts videos URL are also supported. **Note**: `enqueue` is required, or the service will open the video using TV Web Browser. ### Send Keys ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "send_key", "media_content_id": "KEY_CODE" } ``` **Note**: Change "KEY_CODE" by desired [key_code](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_codes.md). (also works with key chaining and SmartThings keys: ST_TV, ST_HDMI1, ST_HDMI2, ST_HDMI3, etc. / see more at [SmartThings Keys](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md#smartthings-keys)) Script example: ``` tv_channel_down: alias: Channel down sequence: - service: media_player.play_media data: entity_id: media_player.samsung_tv55 media_content_type: "send_key" media_content_id: KEY_CHDOWN ``` ### Hold Keys ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "send_key", "media_content_id": "KEY_CODE, " } ``` **Note**: Change "KEY_CODE" by desired [key_code](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_codes.md) and with a valid numeric value in milliseconds (this also works with key chaining but not with SmartThings keys). ***Key Chaining Patterns*** --------------- Key chaining is also supported, which means a pattern of keys can be set by delimiting the keys with the "+" symbol, delays can also be set in milliseconds between the "+" symbols. [See the list of known Key Chaining Patterns](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_chaining.md) ***Open Browser Page*** --------------- ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "browser", "media_content_id": "https://www.google.com" } ``` ***Send Text*** --------------- To send a specific text to a selected text input ``` service: media_player.play_media ``` ```json { "entity_id": "media_player.samsungtv", "media_content_type": "send_text", "media_content_id": "your text" } ``` ***Select sound mode (SmartThings only)*** --------------- ``` service: media_player.select_sound_mode ``` ```json { "entity_id": "media_player.samsungtv", "sound_mode": "your mode" } ``` **Note**: You can get list of valid `sound_mode` in the `sound_mode_list` state attribute ***Select picture mode (SmartThings only)*** --------------- ``` service: samsungtv_smart.select_picture_mode ``` ```json { "entity_id": "media_player.samsungtv", "picture_mode": "your mode" } ``` **Note**: You can get list of valid `picture_mode` in the `picture_mode_list` state attribute ***Set Art Mode (for TV that support it)*** --------------- ``` service: samsungtv_smart.set_art_mode ``` ```json { "entity_id": "media_player.samsungtv" } ``` # Be kind! If you like the component, why don't you support me by buying me a coffe? It would certainly motivate me to further improve this work. [![Buy me a coffe!](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/ollo69) Credits ------- This integration is developed by [Ollo69][ollo69] based on integration [SamsungTV Tizen][samsungtv_tizen].
Original SamsungTV Tizen integration was developed by [jaruba][jaruba].
Logo support is based on [jaruba channels-logo][channels-logo] and was developed with the support of [Screwdgeh][Screwdgeh].
[ollo69]: https://github.com/ollo69 [samsungtv_tizen]: https://github.com/jaruba/ha-samsungtv-tizen [jaruba]: https://github.com/jaruba [Screwdgeh]: https://github.com/Screwdgeh [channels-logo]: https://github.com/jaruba/channel-logos ================================================ FILE: config/configuration.yaml ================================================ # Loads default set of integrations. Do not remove. default_config: # Load frontend themes from the themes folder frontend: themes: !include_dir_merge_named themes # Text to speech tts: - platform: google_translate logger: default: info #logs: # custom_components.samsungtv_smart: debug ================================================ FILE: custom_components/__init__.py ================================================ """Custom components module.""" ================================================ FILE: custom_components/samsungtv_smart/__init__.py ================================================ """The samsungtv_smart integration.""" from __future__ import annotations import asyncio import json import logging import os from pathlib import Path import socket from aiohttp import ClientConnectionError, ClientResponseError, ClientSession import async_timeout import voluptuous as vol from websocket import WebSocketException from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_BROADCAST_ADDRESS, CONF_DEVICE_ID, CONF_HOST, CONF_ID, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, CONF_TOKEN, MAJOR_VERSION, MINOR_VERSION, Platform, __version__, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .api.samsungws import ConnectionFailure, SamsungTVWS from .api.smartthings import SmartThingsTV from .const import ( ATTR_DEVICE_MAC, ATTR_DEVICE_MODEL, ATTR_DEVICE_NAME, ATTR_DEVICE_OS, CONF_APP_LIST, CONF_CHANNEL_LIST, CONF_DEVICE_NAME, CONF_LOAD_ALL_APPS, CONF_SCAN_APP_HTTP, CONF_SHOW_CHANNEL_NR, CONF_SOURCE_LIST, CONF_ST_ENTRY_UNIQUE_ID, CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON, CONF_UPDATE_CUSTOM_PING_URL, CONF_UPDATE_METHOD, CONF_USE_ST_INT_API_KEY, CONF_WS_NAME, DATA_CFG, DATA_CFG_YAML, DATA_OPTIONS, DEFAULT_PORT, DEFAULT_SOURCE_LIST, DEFAULT_TIMEOUT, DOMAIN, LOCAL_LOGO_PATH, MIN_HA_MAJ_VER, MIN_HA_MIN_VER, RESULT_NOT_SUCCESSFUL, RESULT_ST_DEVICE_NOT_FOUND, RESULT_SUCCESS, RESULT_WRONG_APIKEY, SIGNAL_CONFIG_ENTITY, WS_PREFIX, __min_ha_version__, ) from .logo import CUSTOM_IMAGE_BASE_URL, STATIC_IMAGE_BASE_URL # workaroud for failing import native domain when custom integration is present try: from homeassistant.components.smartthings.const import DOMAIN as ST_DOMAIN except ImportError: ST_DOMAIN = "smartthings" DEVICE_INFO = { ATTR_DEVICE_ID: "id", ATTR_DEVICE_MAC: "wifiMac", ATTR_DEVICE_NAME: "name", ATTR_DEVICE_MODEL: "modelName", ATTR_DEVICE_OS: "OS", } SAMSMART_PLATFORM = [Platform.MEDIA_PLAYER, Platform.REMOTE] SAMSMART_SCHEMA = { vol.Optional(CONF_SOURCE_LIST, default=DEFAULT_SOURCE_LIST): cv.string, vol.Optional(CONF_APP_LIST): cv.string, vol.Optional(CONF_CHANNEL_LIST): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, } def ensure_unique_hosts(value): """Validate that all configs have a unique host.""" vol.Schema(vol.Unique("duplicate host entries found"))( [socket.gethostbyname(entry[CONF_HOST]) for entry in value] ) return value CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( cv.ensure_list, [ cv.deprecated(CONF_LOAD_ALL_APPS), cv.deprecated(CONF_PORT), cv.deprecated(CONF_UPDATE_METHOD), cv.deprecated(CONF_UPDATE_CUSTOM_PING_URL), cv.deprecated(CONF_SCAN_APP_HTTP), vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_API_KEY): cv.string, vol.Optional(CONF_DEVICE_NAME): cv.string, vol.Optional(CONF_DEVICE_ID): cv.string, vol.Optional(CONF_LOAD_ALL_APPS, default=True): cv.boolean, vol.Optional(CONF_UPDATE_METHOD): cv.string, vol.Optional(CONF_UPDATE_CUSTOM_PING_URL): cv.string, vol.Optional(CONF_SCAN_APP_HTTP, default=True): cv.boolean, vol.Optional(CONF_SHOW_CHANNEL_NR, default=False): cv.boolean, vol.Optional(CONF_WS_NAME): cv.string, } ).extend(SAMSMART_SCHEMA), ], ensure_unique_hosts, ) }, extra=vol.ALLOW_EXTRA, ) _LOGGER = logging.getLogger(__name__) def tv_url(host: str, address: str = "") -> str: """Return url to the TV.""" return f"http://{host}:8001/api/v2/{address}" def is_min_ha_version(min_ha_major_ver: int, min_ha_minor_ver: int) -> bool: """Check if HA version at least a specific version.""" return MAJOR_VERSION > min_ha_major_ver or ( MAJOR_VERSION == min_ha_major_ver and MINOR_VERSION >= min_ha_minor_ver ) def is_valid_ha_version() -> bool: """Check if HA version is valid for this integration.""" return is_min_ha_version(MIN_HA_MAJ_VER, MIN_HA_MIN_VER) def _notify_message( hass: HomeAssistant, notification_id: str, title: str, message: str ) -> None: """Notify user with persistent notification.""" hass.async_create_task( hass.services.async_call( domain="persistent_notification", service="create", service_data={ "title": title, "message": message, "notification_id": f"{DOMAIN}.{notification_id}", }, ) ) def _load_option_list(src_list): """Load list parameters in JSON from configuration.yaml.""" if src_list is None: return None if isinstance(src_list, dict): return src_list result = {} try: result = json.loads(src_list) except TypeError: _LOGGER.error("Invalid format parameter: %s", str(src_list)) return result def token_file_name(hostname: str) -> str: """Return token file name.""" return f"{DOMAIN}_{hostname}_token" def _remove_token_file(hass, hostname, token_file=None): """Try to remove token file.""" if not token_file: token_file = hass.config.path(STORAGE_DIR, token_file_name(hostname)) if os.path.isfile(token_file): try: os.remove(token_file) except Exception as exc: # pylint: disable=broad-except _LOGGER.error( "Samsung TV - Error deleting token file %s: %s", token_file, str(exc) ) def _migrate_token(hass: HomeAssistant, entry: ConfigEntry, hostname: str) -> None: """Migrate token from old file to registry entry.""" token_file = hass.config.path(STORAGE_DIR, token_file_name(hostname)) if not os.path.isfile(token_file): token_file = ( os.path.dirname(os.path.realpath(__file__)) + f"/token-{hostname}.txt" ) if not os.path.isfile(token_file): return try: with open(token_file, "r", encoding="utf-8") as os_token_file: token = os_token_file.readline() except Exception as exc: # pylint: disable=broad-except _LOGGER.error("Error reading token file %s: %s", token_file, str(exc)) return if not token: _LOGGER.warning("No token found inside token file %s", token_file) return hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_TOKEN: token} ) _remove_token_file(hass, hostname, token_file) @callback def _migrate_options_format(hass: HomeAssistant, entry: ConfigEntry) -> None: """Migrate options to new format.""" opt_migrated = False new_options = {} for key, option in entry.options.items(): if key in [CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON]: if isinstance(option, str): new_options[key] = option.split(",") opt_migrated = True continue new_options[key] = option # load the option lists in entry option yaml_opt = hass.data.get(DOMAIN, {}).get(entry.entry_id, {}).get(DATA_CFG_YAML, {}) for key in [CONF_APP_LIST, CONF_CHANNEL_LIST, CONF_SOURCE_LIST]: if key not in new_options: # import will occurs only on first restart if option := _load_option_list(yaml_opt.get(key, {})): message = ( f"Configuration key '{key}' has been in imported in integration options," " you can now remove from configuration.yaml" ) _notify_message( hass, f"config-import-{key}", "SamsungTV Smart", message ) _LOGGER.warning(message) new_options[key] = option opt_migrated = True if opt_migrated: hass.config_entries.async_update_entry(entry, options=new_options) @callback def _migrate_entry_unique_id(hass: HomeAssistant, entry: ConfigEntry) -> None: """Migrate unique_is to new format.""" if CONF_ID in entry.data: new_unique_id = entry.data[CONF_ID] elif CONF_MAC in entry.data: new_unique_id = entry.data[CONF_MAC] else: new_unique_id = entry.data[CONF_HOST] if entry.unique_id == new_unique_id: return entries_list = hass.config_entries.async_entries(DOMAIN) for other_entry in entries_list: if other_entry.unique_id == new_unique_id: _LOGGER.warning( "Found duplicated entries %s and %s that refer to the same device." " Please remove unused entry", entry.data[CONF_HOST], other_entry.data[CONF_HOST], ) return _LOGGER.info( "Migrated entry unique id from %s to %s", entry.unique_id, new_unique_id ) hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) @callback def _migrate_smartthings_config(hass: HomeAssistant, entry: ConfigEntry) -> None: """Migrate smartthings entry usage configuration.""" if CONF_USE_ST_INT_API_KEY not in entry.data: return new_data = entry.data.copy() use_st = new_data.pop(CONF_USE_ST_INT_API_KEY) if use_st: if entries_list := hass.config_entries.async_entries(ST_DOMAIN, False, False): new_data[CONF_ST_ENTRY_UNIQUE_ID] = entries_list[0].unique_id hass.config_entries.async_update_entry(entry, data=new_data) @callback def get_smartthings_entries(hass: HomeAssistant) -> dict[str, str] | None: """Get the smartthing integration configured entries.""" entries_list = hass.config_entries.async_entries(ST_DOMAIN, False, False) if not entries_list: return None return { entry.unique_id: entry.title for entry in entries_list if CONF_TOKEN in entry.data } @callback def get_smartthings_api_key(hass: HomeAssistant, st_unique_id: str) -> str | None: """Get the smartthing integration configured API key.""" entries_list = hass.config_entries.async_entries(ST_DOMAIN, False, False) if not entries_list: return None for entry in entries_list: if entry.unique_id == st_unique_id: config_data = entry.data if CONF_TOKEN not in config_data: return None return config_data[CONF_TOKEN].get(CONF_ACCESS_TOKEN) return None async def _register_logo_paths(hass: HomeAssistant) -> str | None: """Register paths for local logos.""" static_logo_path = Path(__file__).parent / "static" static_paths = [ StaticPathConfig( STATIC_IMAGE_BASE_URL, str(static_logo_path), cache_headers=False ) ] local_logo_path = Path(hass.config.path("www", f"{DOMAIN}_logos")) url_logo_path = str(local_logo_path) if not local_logo_path.exists(): try: local_logo_path.mkdir(parents=True) except Exception as exc: # pylint: disable=broad-except _LOGGER.warning( "Error registering custom logo folder %s: %s", str(local_logo_path), exc ) url_logo_path = None if url_logo_path is not None: static_paths.append( StaticPathConfig(CUSTOM_IMAGE_BASE_URL, url_logo_path, cache_headers=False) ) await hass.http.async_register_static_paths(static_paths) return url_logo_path async def get_device_info(hostname: str, session: ClientSession) -> dict: """Try retrieve device information""" try: async with async_timeout.timeout(2): async with session.get( tv_url(host=hostname), raise_for_status=True ) as resp: info = await resp.json() except (asyncio.TimeoutError, ClientConnectionError): _LOGGER.warning("Error getting HTTP device info for TV: %s", hostname) return {} device = info.get("device") if not device: _LOGGER.warning("Error getting HTTP device info for TV: %s", hostname) return {} result = { key: device[value] for key, value in DEVICE_INFO.items() if value in device } if ATTR_DEVICE_ID in result: device_id = result[ATTR_DEVICE_ID] if device_id.startswith("uuid:"): result[ATTR_DEVICE_ID] = device_id[len("uuid:") :] return result class SamsungTVInfo: """Class to connect and collect TV information.""" def __init__(self, hass, hostname, ws_name): """Initialize the object.""" self._hass = hass self._hostname = hostname self._ws_name = ws_name self._ws_port = 0 self._ws_token = None self._ping_port = None @property def ws_port(self): """Return used WebSocket port.""" return self._ws_port @property def ws_token(self): """Return WebSocket token.""" return self._ws_token @property def ping_port(self): """Return the port used to ping the TV.""" return self._ping_port def _try_connect_ws(self): """Try to connect to device using web sockets on port 8001 and 8002""" self._ping_port = SamsungTVWS.ping_probe(self._hostname) if self._ping_port is None: _LOGGER.error( "Connection to SamsungTV %s failed. Check that TV is on", self._hostname ) return RESULT_NOT_SUCCESSFUL if self._ws_port and self._ws_token: port_list = tuple([self._ws_port, 8001, 8002]) else: port_list = (8001, 8002) for index, port in enumerate(port_list): timeout = 45 # We need this high timeout because waiting for TV auth popup token = None if len(port_list) > 2 and index == 0: timeout = DEFAULT_TIMEOUT token = self._ws_token try: _LOGGER.info( "Try to configure SamsungTV %s using port %s%s", self._hostname, str(port), " with existing token" if token else "", ) with SamsungTVWS( name=f"{WS_PREFIX} {self._ws_name}", # this is the name shown in the TV host=self._hostname, port=port, token=token, timeout=timeout, ) as remote: remote.open() self._ws_token = remote.token _LOGGER.info("Found working configuration using port %s", str(port)) self._ws_port = port return RESULT_SUCCESS except (OSError, ConnectionFailure, WebSocketException) as err: _LOGGER.info( "Configuration failed using port %s, error: %s", str(port), err ) _LOGGER.error("Web socket connection to SamsungTV %s failed", self._hostname) return RESULT_NOT_SUCCESSFUL @staticmethod async def _try_connect_st(api_key, device_id, session: ClientSession): """Try to connect to ST device""" try: async with async_timeout.timeout(10): _LOGGER.info("Try connection to SmartThings TV with id [%s]", device_id) with SmartThingsTV( api_key=api_key, device_id=device_id, session=session, ) as st_tv: result = await st_tv.async_device_health() if result: _LOGGER.info("Connection completed successfully.") return RESULT_SUCCESS _LOGGER.error("Connection to SmartThings TV not available.") return RESULT_ST_DEVICE_NOT_FOUND except ClientResponseError as err: _LOGGER.error("Failed connecting to SmartThings TV, error: %s", err) if err.status == 400: # Bad request, means that token is valid return RESULT_ST_DEVICE_NOT_FOUND except Exception as err: # pylint: disable=broad-except _LOGGER.error("Failed connecting with SmartThings, error: %s", err) return RESULT_WRONG_APIKEY @staticmethod async def get_st_devices(api_key, session: ClientSession, st_device_label=""): """Get list of available ST devices""" try: async with async_timeout.timeout(4): devices = await SmartThingsTV.get_devices_list( api_key, session, st_device_label ) except Exception as err: # pylint: disable=broad-except _LOGGER.error("Failed connecting with SmartThings, error: %s", err) return None return devices async def try_connect( self, session: ClientSession, api_key=None, st_device_id=None, *, ws_port=None, ws_token=None, ): """Try connect device""" if session is None: return RESULT_NOT_SUCCESSFUL if ws_port and ws_token: self._ws_port = ws_port self._ws_token = ws_token result = await self._hass.async_add_executor_job(self._try_connect_ws) if result == RESULT_SUCCESS: if api_key and st_device_id: result = await self._try_connect_st(api_key, st_device_id, session) return result async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Samsung TV integration.""" if not is_valid_ha_version(): msg = ( "This integration require at least HomeAssistant version" f" {__min_ha_version__}, you are running version {__version__}." " Please upgrade HomeAssistant to continue use this integration." ) _notify_message(hass, "inv_ha_version", "SamsungTV Smart", msg) _LOGGER.warning(msg) return True if DOMAIN in config: entries_list = hass.config_entries.async_entries(DOMAIN) for entry_config in config[DOMAIN]: # get ip address ip_address = entry_config[CONF_HOST] # check if already configured valid_entries = [ entry.entry_id for entry in entries_list if entry.data[CONF_HOST] == ip_address ] if not valid_entries: _LOGGER.warning( "Found yaml configuration for not configured device %s." " Please use UI to configure", ip_address, ) continue data_yaml = { key: value for key, value in entry_config.items() if key in SAMSMART_SCHEMA and value } if data_yaml: if DOMAIN not in hass.data: hass.data[DOMAIN] = {} hass.data[DOMAIN][valid_entries[0]] = {DATA_CFG_YAML: data_yaml} # Register path for local logo if local_logo_path := await _register_logo_paths(hass): hass.data.setdefault(DOMAIN, {})[LOCAL_LOGO_PATH] = local_logo_path return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" if not is_valid_ha_version(): return False # migrate unique id to a accepted format _migrate_entry_unique_id(hass, entry) # migrate smartthings entry usage configuration _migrate_smartthings_config(hass, entry) # migrate old token file to registry entry if required if CONF_TOKEN not in entry.data: await hass.async_add_executor_job( _migrate_token, hass, entry, entry.data[CONF_HOST] ) # migrate options to new format if required _migrate_options_format(hass, entry) # setup entry if DOMAIN not in hass.data: hass.data[DOMAIN] = {} add_conf = None config = entry.data.copy() if entry.entry_id in hass.data[DOMAIN]: add_conf = hass.data[DOMAIN][entry.entry_id].get(DATA_CFG_YAML, {}) for attr, value in add_conf.items(): if value: config[attr] = value # setup entry hass.data[DOMAIN][entry.entry_id] = { DATA_CFG: config, DATA_OPTIONS: entry.options.copy(), } if add_conf: hass.data[DOMAIN][entry.entry_id][DATA_CFG_YAML] = add_conf entry.async_on_unload(entry.add_update_listener(_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, SAMSMART_PLATFORM) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( entry, SAMSMART_PLATFORM ): hass.data[DOMAIN][entry.entry_id].pop(DATA_CFG) hass.data[DOMAIN][entry.entry_id].pop(DATA_OPTIONS) if not hass.data[DOMAIN][entry.entry_id]: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" await hass.async_add_executor_job(_remove_token_file, hass, entry.data[CONF_HOST]) if DOMAIN in hass.data: hass.data[DOMAIN].pop(entry.entry_id, None) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update when config_entry options update.""" hass.data[DOMAIN][entry.entry_id][DATA_OPTIONS] = entry.options.copy() async_dispatcher_send(hass, SIGNAL_CONFIG_ENTITY) ================================================ FILE: custom_components/samsungtv_smart/api/__init__.py ================================================ """SamsungTV Smart TV WS API library.""" ================================================ FILE: custom_components/samsungtv_smart/api/samsungcast.py ================================================ """Smartthings TV integration cast tube.""" from __future__ import annotations import logging import xml.etree.ElementTree as ET from casttube import YouTubeSession import requests _LOGGER = logging.getLogger(__name__) def _format_url(host: str, app: str) -> str: """Return URL used to retrieve screen id.""" return f"http://{host}:8080/ws/app/{app}" class CastTubeNotSupported(Exception): """Error during cast.""" class SamsungCastTube: """Class to cast video to youtube TV app.""" def __init__(self, host: str): """Initialize the class.""" self._host = host self._cast_api: YouTubeSession | None = None @staticmethod def _get_screen_id(host: str) -> str: """Retrieve screen id from the TV.""" url = _format_url(host, "YouTube") try: response = requests.get(url, timeout=5) except requests.ConnectionError as exc: _LOGGER.warning( "Failed to retrieve YouTube screenID for host %s: %s", host, exc ) raise CastTubeNotSupported() from exc screen_id = None tree = ET.fromstring(response.content.decode("utf8")) for elem in tree.iter(): if elem.tag.endswith("screenId"): _LOGGER.debug("YouTube ScreenID: %s", screen_id) screen_id = elem.text if screen_id is None: _LOGGER.warning("Failed to retrieve YouTube screenID for host %s", host) raise CastTubeNotSupported() return screen_id def _get_api(self) -> YouTubeSession: """Get the API to cast video.""" if not self._cast_api: screen_id = self._get_screen_id(self._host) self._cast_api = YouTubeSession(screen_id) return self._cast_api def play_video(self, video_id: str) -> None: """Play video_id immediatly.""" self._get_api().play_video(video_id) def play_next(self, video_id: str) -> None: """Play video_id after the currently playing video..""" self._get_api().play_next(video_id) def add_to_queue(self, video_id: str) -> None: """Add a video to the video queue.""" self._get_api().add_to_queue(video_id) def clear_queue(self) -> None: """Clear the video queue.""" self._get_api().clear_playlist() ================================================ FILE: custom_components/samsungtv_smart/api/samsungws.py ================================================ """ SamsungTVWS - Samsung Smart TV WS API wrapper Copyright (C) 2019 Xchwarze Copyright (C) 2020 Ollo69 This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335 USA """ from __future__ import annotations import base64 from datetime import datetime, timezone from enum import Enum import json import logging import socket import ssl import subprocess import sys from threading import Lock, Thread import time from typing import Any from urllib.parse import urlencode, urljoin import uuid import aiohttp import requests import websocket from .shortcuts import SamsungTVShortcuts DEFAULT_POWER_ON_DELAY = 120 MIN_APP_SCAN_INTERVAL = 9 MAX_APP_VALIDITY_SEC = 60 MAX_WS_PING_INTERVAL = 10 PING_TIMEOUT = 3 TYPE_DEEP_LINK = "DEEP_LINK" TYPE_NATIVE_LAUNCH = "NATIVE_LAUNCH" _WS_ENDPOINT_REMOTE_CONTROL = "/api/v2/channels/samsung.remote.control" _WS_ENDPOINT_APP_CONTROL = "/api/v2" _WS_ENDPOINT_ART = "/api/v2/channels/com.samsung.art-app" _WS_LOG_NAME = "websocket" _LOG_PING_PONG = False _LOGGING = logging.getLogger(__name__) def _set_ws_logger_level(level: int = logging.CRITICAL) -> None: """Set the websocket library logging level.""" ws_logger = logging.getLogger(_WS_LOG_NAME) if ws_logger.level < level: ws_logger.setLevel(level) def _format_rest_url(host: str, append: str = "") -> str: """Return URL used for rest commands.""" return f"http://{host}:8001/api/v2/{append}" def gen_uuid() -> str: """Generate new uuid.""" return str(uuid.uuid4()) def aware_utcnow() -> datetime: """Return current UTC time with timezone info.""" return datetime.now(timezone.utc) def kill_subprocess( process: subprocess.Popen[Any], ) -> None: """Force kill a subprocess and wait for it to exit.""" process.kill() process.communicate() process.wait() del process def _process_api_response(response, *, raise_error=True): """Process response received by TV.""" try: return json.loads(response) except json.JSONDecodeError as exc: _LOGGING.debug("Failed to parse response from TV. response text: %s", response) if raise_error: raise ResponseError( "Failed to parse response from TV. Maybe feature not supported on this model" ) from exc return response def _log_ping_pong(msg, *args): """Log ping pong message if enabled.""" if not _LOG_PING_PONG: return _LOGGING.debug(msg=msg, args=args) class Ping: """Class for handling Ping to a specific host.""" def __init__(self, host): """Initialize the object.""" self._ip_address = host if sys.platform == "win32": self._ping_cmd = ["ping", "-n", "1", "-w", "2000", host] else: self._ping_cmd = ["ping", "-n", "-q", "-c1", "-W2", host] def ping(self, port=0): """Check if IP is available using ICMP or trying open a specific port.""" if port > 0: return self._ping_socket(port) return self._ping() def _ping(self): """Send ICMP echo request and return True if success.""" with subprocess.Popen( self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL ) as pinger: try: pinger.communicate(timeout=1 + PING_TIMEOUT) return pinger.returncode == 0 except subprocess.TimeoutExpired: kill_subprocess(pinger) return False except subprocess.CalledProcessError: return False def _ping_socket(self, port): """Check if port is available and return True if success.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(PING_TIMEOUT - 1) return sock.connect_ex((self._ip_address, port)) == 0 class ConnectionFailure(Exception): """Error during connection.""" class ResponseError(Exception): """Error in response.""" class HttpApiError(Exception): """Error using HTTP API.""" class App: """Define a TV Application.""" def __init__(self, app_id, app_name, app_type): self.app_id = app_id self.app_name = app_name self.app_type = app_type class ArtModeStatus(Enum): """Define possible ArtMode status.""" Unsupported = 0 Unavailable = 1 Off = 2 On = 3 class SamsungTVAsyncRest: """Class that implement rest request in async.""" def __init__( self, host: str, session: aiohttp.ClientSession, timeout=None, ) -> None: """Initialize the class.""" self._host = host self._session = session self._timeout = None if timeout == 0 else timeout async def _rest_request(self, target: str, method: str = "GET") -> dict[str, Any]: """Perform async rest request.""" url = _format_rest_url(self._host, target) try: if method == "POST": req = self._session.post(url, timeout=self._timeout, verify_ssl=False) elif method == "PUT": req = self._session.put(url, timeout=self._timeout, verify_ssl=False) elif method == "DELETE": req = self._session.delete(url, timeout=self._timeout, verify_ssl=False) else: req = self._session.get(url, timeout=self._timeout, verify_ssl=False) async with req as resp: return _process_api_response(await resp.text()) except aiohttp.ClientConnectionError as ex: raise HttpApiError( "TV unreachable or feature not supported on this model." ) from ex async def async_rest_device_info(self) -> dict[str, Any]: """Get device info using rest api call.""" _LOGGING.debug("Get device info via rest api") return await self._rest_request("") async def async_rest_app_status(self, app_id: str) -> dict[str, Any]: """Get app status using rest api call.""" _LOGGING.debug("Get app %s status via rest api", app_id) return await self._rest_request("applications/" + app_id) async def async_rest_app_run(self, app_id: str) -> dict[str, Any]: """Run an app using rest api call.""" _LOGGING.debug("Run app %s via rest api", app_id) return await self._rest_request("applications/" + app_id, "POST") async def async_rest_app_close(self, app_id: str) -> dict[str, Any]: """Close an app using rest api call.""" _LOGGING.debug("Close app %s via rest api", app_id) return await self._rest_request("applications/" + app_id, "DELETE") async def async_rest_app_install(self, app_id: str) -> dict[str, Any]: """Install a new app using rest api call.""" _LOGGING.debug("Install app %s via rest api", app_id) return await self._rest_request("applications/" + app_id, "PUT") class SamsungTVWS: """Class to manage websocket communication with tizen TV.""" def __init__( self, host: str, *, token: str | None = None, token_file: str | None = None, port: int | None = 8001, timeout: int | None = None, key_press_delay: float | None = 1.0, name: str | None = "SamsungTvRemote", app_list: dict | None = None, ping_port: int | None = 0, ): """Initialize SamsungTVWS object.""" self.host = host self.token = token self.token_file = token_file self.port = port or 8001 self.timeout = None if timeout == 0 else timeout self.key_press_delay = 1.0 if key_press_delay is None else key_press_delay self.name = name or "SamsungTvRemote" self._app_list = dict(app_list) if app_list else None self._ping_port = ping_port or 0 self.connection = None self._artmode_status = ArtModeStatus.Unsupported self._power_on_requested = False self._power_on_requested_time = datetime.min self._power_on_delay = DEFAULT_POWER_ON_DELAY self._power_on_artmode = False self._installed_app = {} self._running_apps: dict[str, datetime] = {} self._running_app: str | None = None self._running_app_changed: bool | None = None self._last_running_scan = aware_utcnow() self._app_type = {} self._sync_lock = Lock() self._last_app_scan = datetime.min self._ping_thread = None self._ping_thread_run = False self._ws_remote = None self._client_remote = None self._last_ping = datetime.min self._is_connected = False self._ws_control = None self._client_control = None self._last_control_ping = datetime.min self._is_control_connected = False self._ws_art = None self._client_art = None self._last_art_ping = datetime.min self._client_art_supported = 2 self._ping = Ping(self.host) self._status_callback = None self._new_token_callback = None def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_traceback): self.close() @staticmethod def ping_probe(host): """Try to ping device and return usable port.""" ping = Ping(host) for port in (9197, 0): try: if ping.ping(port): return port except Exception: # pylint: disable=broad-except _LOGGING.debug("Failed to ping device using port %s", port) return None @staticmethod def _serialize_string(string): if isinstance(string, str): string = str.encode(string) return base64.b64encode(string).decode("utf-8") def _is_ssl_connection(self): return self.port == 8002 def _format_websocket_url(self, path, is_ssl=False, use_token=True): scheme = "wss" if is_ssl else "ws" base_uri = f"{scheme}://{self.host}:{self.port}" ws_uri = urljoin(base_uri, path) query = {"name": self._serialize_string(self.name)} if is_ssl and use_token: if token := self._get_token(): query["token"] = token ws_query = urlencode(query) return f"{ws_uri}?{ws_query}" def set_ping_port(self, port: int): """Set a new ping port.""" self._ping_port = port def update_app_list(self, app_list: dict | None): """Update application list.""" self._app_list = dict(app_list) if app_list else None def register_new_token_callback(self, func): """Register a callback function.""" self._new_token_callback = func def register_status_callback(self, func): """Register callback function used on status change.""" self._status_callback = func def unregister_status_callback(self): """Unregister callback function used on status change.""" self._status_callback = None def _get_token(self): """Get current token.""" if self.token_file is not None: try: with open(self.token_file, "r", encoding="utf-8") as token_file: return token_file.readline() except Exception as exc: # pylint: disable=broad-except _LOGGING.error("Failed to read TV token file: %s", str(exc)) return "" return self.token def _set_token(self, token): """Save new token.""" _LOGGING.debug("New token %s", token) if self.token_file is not None: _LOGGING.debug("Save new token to file %s", self.token_file) with open(self.token_file, "w", encoding="utf-8") as token_file: token_file.write(token) return if self.token is not None and self.token == token: return self.token = token if self._new_token_callback is not None: self._new_token_callback() def _ws_send( self, command, key_press_delay=None, *, use_control=False, ws_socket=None, raise_on_closed=False, ): """Send a command using the appropriate websocket.""" using_remote = False if not use_control: if self._ws_remote: connection = self._ws_remote using_remote = True else: connection = self.open() elif ws_socket: connection = ws_socket else: self._start_client(start_all=True) return False payload = json.dumps(command) try: connection.send(payload) except websocket.WebSocketConnectionClosedException: if raise_on_closed: raise _LOGGING.warning("_ws_send: connection is closed, send command failed") if using_remote or use_control: _LOGGING.info("_ws_send: try to restart communication threads") self._start_client(start_all=use_control) return False except websocket.WebSocketTimeoutException: _LOGGING.warning("_ws_send: timeout error sending command %s", payload) return False if using_remote: # we consider a message sent valid as a ping self._last_ping = aware_utcnow() if key_press_delay is None: if self.key_press_delay > 0: time.sleep(self.key_press_delay) elif key_press_delay > 0: time.sleep(key_press_delay) return True def _rest_request(self, target, method="GET"): """Send a rest command using http protocol.""" url = _format_rest_url(self.host, target) try: if method == "POST": response = requests.post(url, timeout=self.timeout) elif method == "PUT": response = requests.put(url, timeout=self.timeout) elif method == "DELETE": response = requests.delete(url, timeout=self.timeout) else: response = requests.get(url, timeout=self.timeout) except requests.ConnectionError as exc: raise HttpApiError( "TV unreachable or feature not supported on this model." ) from exc return _process_api_response(response.text, raise_error=False) def _check_conn_id(self, resp_data): """Check if returned connection id from WS server is valid for this TV.""" if not resp_data: return False msg_id = resp_data.get("id") if not msg_id: return False clients_info = resp_data.get("clients") for client in clients_info: device_name = client.get("deviceName") if device_name: if device_name == self._serialize_string(self.name): conn_id = client.get("id", "") if conn_id == msg_id: return True return False @staticmethod def _run_forever( ws_app: websocket.WebSocketApp, *, sslopt: dict = None, ping_interval: int = 0 ) -> None: """Call method run_forever changing library log level before.""" _set_ws_logger_level() ws_app.run_forever(sslopt=sslopt, ping_interval=ping_interval) def _client_remote_thread(self): """Start the main client WS thread that connect to the remote TV.""" if self._ws_remote: return is_ssl = self._is_ssl_connection() url = self._format_websocket_url(_WS_ENDPOINT_REMOTE_CONTROL, is_ssl=is_ssl) sslopt = {"cert_reqs": ssl.CERT_NONE} if is_ssl else {} websocket.setdefaulttimeout(self.timeout) self._ws_remote = websocket.WebSocketApp( url, on_message=self._on_message_remote, on_ping=self._on_ping_remote, ) _LOGGING.debug("Thread SamsungRemote started") # we set ping interval (1 hour) only to enable multi-threading mode # on socket. TV do not answer to ping but send ping to client self._run_forever(self._ws_remote, sslopt=sslopt, ping_interval=3600) self._is_connected = False if self._status_callback is not None: self._status_callback() if self._ws_art: self._ws_art.close() if self._ws_control: self._ws_control.close() self._ws_remote.close() self._ws_remote = None _LOGGING.debug("Thread SamsungRemote terminated") def _on_ping_remote(self, _, payload): """Manage ping message received by remote WS connection.""" _log_ping_pong("Received WS remote ping %s, sending pong", payload) self._last_ping = aware_utcnow() if self._ws_remote.sock: try: self._ws_remote.sock.pong(payload) except Exception as ex: # pylint: disable=broad-except _LOGGING.warning("WS remote send_pong failed, %s", ex) def _on_message_remote(self, _, message): """Manage messages received by remote WS connection.""" response = _process_api_response(message) _LOGGING.debug(response) event = response.get("event") if not event: return # we consider a message valid as a ping self._last_ping = aware_utcnow() if event == "ms.channel.connect": conn_data = response.get("data") if not self._check_conn_id(conn_data): return _LOGGING.debug("Message remote: received connect") token = conn_data.get("token") if token: self._set_token(token) self._is_connected = True self._request_apps_list() self._start_client(start_all=True) if self._status_callback is not None: self._status_callback() elif event == "ed.installedApp.get": _LOGGING.debug("Message remote: received installedApp") self._handle_installed_app(response) elif event == "ed.edenTV.update": _LOGGING.debug("Message remote: received edenTV") self._get_running_app(force_scan=True) def _request_apps_list(self): """Request to the TV the list of installed apps.""" _LOGGING.debug("Request app list") self._ws_send( { "method": "ms.channel.emit", "params": {"event": "ed.installedApp.get", "to": "host"}, }, key_press_delay=0, ) def _handle_installed_app(self, response): """Manage the list of installed apps received from the TV.""" list_app = response.get("data", {}).get("data") installed_app = {} for app_info in list_app: app_id = app_info["appId"] _LOGGING.debug("Found app: %s", app_id) app = App(app_id, app_info["name"], app_info["app_type"]) installed_app[app_id] = app self._installed_app = installed_app def _client_control_thread(self): """Start the client control WS thread used to manage running apps.""" if self._ws_control: return is_ssl = self._is_ssl_connection() url = self._format_websocket_url( _WS_ENDPOINT_APP_CONTROL, is_ssl=is_ssl, use_token=False ) sslopt = {"cert_reqs": ssl.CERT_NONE} if is_ssl else {} websocket.setdefaulttimeout(self.timeout) self._ws_control = websocket.WebSocketApp( url, on_message=self._on_message_control, on_ping=self._on_ping_control, ) _LOGGING.debug("Thread SamsungControl started") # we set ping interval (1 hour) only to enable multi-threading mode # on socket. TV do not answer to ping but send ping to client self._run_forever(self._ws_control, sslopt=sslopt, ping_interval=3600) self._is_control_connected = False self._ws_control.close() self._ws_control = None self._running_app_changed = None _LOGGING.debug("Thread SamsungControl terminated") def _on_ping_control(self, _, payload): """Manage ping message received by control WS channel.""" _log_ping_pong("Received WS control ping %s, sending pong", payload) self._last_control_ping = aware_utcnow() if self._ws_control.sock: try: self._ws_control.sock.pong(payload) except Exception as ex: # pylint: disable=broad-except _LOGGING.warning("WS control send_pong failed, %s", ex) def _on_message_control(self, _, message): """Manage messages received by control WS channel.""" response = _process_api_response(message) _LOGGING.debug(response) result = response.get("result") if result: self._set_running_app(response) return error = response.get("error") if error: self._manage_control_err(response) return event = response.get("event") if not event: return if event == "ms.channel.connect": conn_data = response.get("data") if not self._check_conn_id(conn_data): return _LOGGING.debug("Message control: received connect") self._is_control_connected = True self._get_running_app() elif event == "ed.installedApp.get": _LOGGING.debug("Message control: received installedApp") self._handle_installed_app(response) def _set_running_app(self, response): """Set the current running app based on received message.""" if not (app_id := response.get("id")): return if (result := response.get("result")) is None: return if isinstance(result, bool): is_running = result elif (is_running := result.get("visible")) is None: return call_time = aware_utcnow() self._last_running_scan = call_time self._running_apps[app_id] = call_time if self._running_app: if is_running and app_id != self._running_app: _LOGGING.debug("app running: %s", app_id) self._running_app = app_id self._running_app_changed = True elif not is_running and app_id == self._running_app: _LOGGING.debug("app stopped: %s", app_id) self._running_app = None self._running_app_changed = True elif is_running: _LOGGING.debug("app running: %s", app_id) self._running_app = app_id self._running_app_changed = True if self._running_app_changed is None: self._running_app_changed = True def _manage_control_err(self, response): """Manage errors from control WS channel.""" app_id = response.get("id") if not app_id: return error_code = response.get("error", {}).get("code", 0) if error_code == 404: # Not found error if self._installed_app: if app_id not in self._installed_app: _LOGGING.error("App ID %s not found", app_id) return # app_type = self._app_type.get(app_id) # if app_type is None: # _LOGGING.info( # "App ID %s with type DEEP_LINK not found, set as NATIVE_LAUNCH", # app_id, # ) # self._app_type[app_id] = 4 def _get_app_status(self, app_id, app_type): """Send a message to control WS channel to get the app status.""" _LOGGING.debug("Get app status: AppID: %s, AppType: %s", app_id, app_type) if not (self._ws_control and self._is_control_connected): return if app_type == 4: # app type 4 always return not found error return method = "ms.application.get" try: self._ws_send( { "id": app_id, "method": method, "params": {"id": app_id}, }, key_press_delay=0, use_control=True, ws_socket=self._ws_control, raise_on_closed=True, ) except websocket.WebSocketConnectionClosedException: _LOGGING.debug("Get app status aborted: connection closed") def _client_art_thread(self): """Start the client art WS thread used to manage art mode status.""" if self._ws_art: return is_ssl = self._is_ssl_connection() url = self._format_websocket_url( _WS_ENDPOINT_ART, is_ssl=is_ssl, use_token=False ) sslopt = {"cert_reqs": ssl.CERT_NONE} if is_ssl else {} websocket.setdefaulttimeout(self.timeout) self._ws_art = websocket.WebSocketApp( url, on_message=self._on_message_art, on_ping=self._on_ping_art, ) _LOGGING.debug("Thread SamsungArt started") # we set ping interval (1 hour) only to enable multi-threading mode # on socket. TV do not answer to ping but send ping to client self._run_forever(self._ws_art, sslopt=sslopt, ping_interval=3600) self._ws_art.close() self._ws_art = None _LOGGING.debug("Thread SamsungArt terminated") def _on_ping_art(self, _, payload): """Manage ping message received by art WS channel.""" _log_ping_pong("Received WS art ping %s, sending pong", payload) self._last_art_ping = aware_utcnow() if self._ws_art.sock: try: self._ws_art.sock.pong(payload) except Exception as ex: # pylint: disable=broad-except _LOGGING.warning("WS art send_pong failed: %s", ex) def _on_message_art(self, _, message): """Manage messages received by art WS channel.""" response = _process_api_response(message) _LOGGING.debug(response) event = response.get("event") if not event: return # we consider a message valid as a ping self._last_art_ping = aware_utcnow() if event == "ms.channel.connect": conn_data = response.get("data") if not self._check_conn_id(conn_data): return _LOGGING.debug("Message art: received connect") self._client_art_supported = 1 elif event == "ms.channel.ready": _LOGGING.debug("Message art: channel ready") self._get_artmode_status() elif event == "d2d_service_message": _LOGGING.debug("Message art: d2d message") self._handle_artmode_status(response) def _get_artmode_status(self): """Detect current art mode based on received message.""" _LOGGING.debug("Sending get_art_status") msg_data = { "request": "get_artmode_status", "id": gen_uuid(), } self._ws_send( { "method": "ms.channel.emit", "params": { "data": json.dumps(msg_data), "to": "host", "event": "art_app_request", }, }, key_press_delay=0, use_control=True, ws_socket=self._ws_art, ) def _handle_artmode_status(self, response): """Handle received art mode status.""" data_str = response.get("data") if not data_str: return data = _process_api_response(data_str) event = data.get("event", "") if event == "art_mode_changed": status = data.get("status", "") if status == "on": artmode_status = ArtModeStatus.On else: artmode_status = ArtModeStatus.Off elif event == "artmode_status": value = data.get("value", "") if value == "on": artmode_status = ArtModeStatus.On else: artmode_status = ArtModeStatus.Off elif event == "go_to_standby": artmode_status = ArtModeStatus.Unavailable elif event == "wakeup": self._get_artmode_status() return else: # Unknown message return if self._power_on_requested and artmode_status != ArtModeStatus.Unavailable: if artmode_status == ArtModeStatus.On and not self._power_on_artmode: self.send_key("KEY_POWER", key_press_delay=0) elif artmode_status == ArtModeStatus.Off and self._power_on_artmode: self.send_key("KEY_POWER", key_press_delay=0) self._power_on_requested = False self._artmode_status = artmode_status @property def is_connected(self): """Return if WS connection is open.""" return self._is_connected @property def artmode_status(self): """Return current art mode status.""" return self._artmode_status @property def installed_app(self): """Return a list of installed apps.""" return self._installed_app @property def running_app(self): """Return current running app.""" return self._running_app def is_app_running(self, app_id: str) -> bool | None: """Return if app_id is running app.""" if app_id == self._running_app: return True if (last_seen := self._running_apps.get(app_id)) is None: return None app_age = (self._last_running_scan - last_seen).total_seconds() if app_age >= MAX_APP_VALIDITY_SEC: self._running_apps.pop(app_id) return None return False def _ping_thread_method(self): """Start the ping thread that check the TV status.""" ping = Ping(self.host) while self._ping_thread_run: if ping.ping(self._ping_port): if not self._is_connected: self._start_client() else: self._check_remote() else: if self._is_connected: self.stop_client() time.sleep(1.0) def _check_remote(self): """Check current remote thread status.""" call_time = aware_utcnow() if self._ws_remote: difference = (call_time - self._last_ping).total_seconds() if difference >= MAX_WS_PING_INTERVAL: self.stop_client() if self._artmode_status != ArtModeStatus.Unsupported: self._artmode_status = ArtModeStatus.Unavailable else: self._check_art_mode() self._get_running_app() self._notify_app_change() if self._power_on_requested: difference = (call_time - self._power_on_requested_time).total_seconds() if difference > self._power_on_delay: self._power_on_requested = False def _check_art_mode(self): """Check current art mode and start related control thread if required.""" if self._artmode_status == ArtModeStatus.Unsupported: return if self._ws_art: difference = (aware_utcnow() - self._last_art_ping).total_seconds() if difference >= MAX_WS_PING_INTERVAL: self._artmode_status = ArtModeStatus.Unavailable self._ws_art.close() elif self._ws_remote: self._start_client(start_all=True) def _notify_app_change(self): """Notify that running app is changed.""" if not self._running_app_changed: return if not self._status_callback: self._running_app_changed = False return last_change = (aware_utcnow() - self._last_running_scan).total_seconds() if last_change >= 2: # delay 2 seconds before calling self._running_app_changed = False self._status_callback() def _get_running_app(self, *, force_scan=False): """Query current running app using control channel.""" if not (self._ws_control and self._is_control_connected): return scan_interval = 1 if force_scan else MIN_APP_SCAN_INTERVAL with self._sync_lock: call_time = aware_utcnow() difference = (call_time - self._last_app_scan).total_seconds() if difference < scan_interval: return self._last_app_scan = call_time if self._app_list is not None: app_to_check = {} for app_name, app_id in self._app_list.items(): app = None if self._installed_app: app = self._installed_app.get(app_id) else: app_type = self._app_type.get(app_id, 2) if app_type <= 4: app = App(app_id, app_name, app_type) if app: app_to_check[app_id] = app else: app_to_check = self._installed_app for app in app_to_check.values(): self._get_app_status(app.app_id, app.app_type) def set_power_on_request(self, set_art_mode=False, power_on_delay=0): """Set a power on request status and save the time of the rquest.""" self._power_on_requested = True self._power_on_requested_time = aware_utcnow() self._power_on_artmode = set_art_mode self._power_on_delay = max(power_on_delay, 0) or DEFAULT_POWER_ON_DELAY def set_power_off_request(self): """Remove a previous power on request.""" self._power_on_requested = False def start_poll(self): """Start polling the TV for status.""" if self._ping_thread is None or not self._ping_thread.is_alive(): self._ping_thread = Thread(target=self._ping_thread_method) self._ping_thread.name = "SamsungPing" self._ping_thread.daemon = True self._ping_thread_run = True self._ping_thread.start() def stop_poll(self): """Stop polling the TV for status.""" if self._ping_thread is not None and not self._ping_thread.is_alive(): self._ping_thread_run = False self._ping_thread.join() if self._is_connected: self.stop_client() self._ping_thread = None def _start_client(self, *, start_all=False): """Start all thread that connect to the TV websocket""" if self._client_remote is None or not self._client_remote.is_alive(): self._client_remote = Thread(target=self._client_remote_thread) self._client_remote.name = "SamsungRemote" self._client_remote.daemon = True self._client_remote.start() return if start_all: if self._client_control is None or not self._client_control.is_alive(): self._client_control = Thread(target=self._client_control_thread) self._client_control.name = "SamsungControl" self._client_control.daemon = True self._client_control.start() if self._client_art_supported > 0 and ( self._client_art is None or not self._client_art.is_alive() ): if self._client_art_supported > 1: self._client_art_supported = 0 self._client_art = Thread(target=self._client_art_thread) self._client_art.name = "SamsungArt" self._client_art.daemon = True self._client_art.start() def stop_client(self): """Stop the ws remote client thread.""" if self._ws_remote: self._ws_remote.close() def open(self): """Open a WS client connection with the TV.""" if self.connection is not None: return self.connection is_ssl = self._is_ssl_connection() url = self._format_websocket_url(_WS_ENDPOINT_REMOTE_CONTROL, is_ssl=is_ssl) sslopt = {"cert_reqs": ssl.CERT_NONE} if is_ssl else {} _LOGGING.debug("WS url %s", url) connection = websocket.create_connection(url, self.timeout, sslopt=sslopt) completed = False response = "" for _ in range(3): response = _process_api_response(connection.recv()) _LOGGING.debug(response) event = response.get("event", "-") if event != "ms.channel.connect": break conn_data = response.get("data") if self._check_conn_id(conn_data): completed = True token = conn_data.get("token") if token: self._set_token(token) break if not completed: self.close() raise ConnectionFailure(response) self.connection = connection return connection def close(self): """Close WS connection.""" if self.connection: self.connection.close() _LOGGING.debug("Connection closed.") self.connection = None def send_key(self, key, key_press_delay=None, cmd="Click"): """Send a key to the TV using appropriate WS connection.""" _LOGGING.debug("Sending key %s", key) return self._ws_send( { "method": "ms.remote.control", "params": { "Cmd": cmd, "DataOfCmd": key, "Option": "false", "TypeOfRemote": "SendRemoteKey", }, }, key_press_delay, ) def hold_key(self, key, seconds): """Send a key to the TV and keep it pressed for specific number of seconds""" if self.send_key(key, key_press_delay=0, cmd="Press"): time.sleep(seconds) return self.send_key(key, key_press_delay=0, cmd="Release") return False def send_text(self, text, send_delay=None): """Send a text string to the TV.""" if not text: return False base64_text = self._serialize_string(text) if self._ws_send( { "method": "ms.remote.control", "params": { "Cmd": f"{base64_text}", "DataOfCmd": "base64", "TypeOfRemote": "SendInputString", }, }, key_press_delay=send_delay, ): self._ws_send( { "method": "ms.remote.control", "params": { "TypeOfRemote": "SendInputEnd", }, }, key_press_delay=0, ) return True return False def move_cursor(self, x, y, duration=0): """Move the cursor in the TV to specific coordinate.""" self._ws_send( { "method": "ms.remote.control", "params": { "Cmd": "Move", "Position": {"x": x, "y": y, "Time": str(duration)}, "TypeOfRemote": "ProcessMouseDevice", }, }, key_press_delay=0, ) def run_app(self, app_id, action_type="", meta_tag="", *, use_remote=False): """Launch an app using appropriate WS channel.""" if not action_type: app = self._installed_app.get(app_id) if app: app_type = app.app_type else: app_type = self._app_type.get(app_id, 2) action_type = TYPE_DEEP_LINK if app_type == 2 else TYPE_NATIVE_LAUNCH elif action_type != TYPE_NATIVE_LAUNCH: action_type = TYPE_DEEP_LINK _LOGGING.debug( "Sending run app app_id: %s app_type: %s meta_tag: %s", app_id, action_type, meta_tag, ) if self._ws_control and action_type == TYPE_DEEP_LINK and not use_remote: return self._ws_send( { "id": app_id, "method": "ms.application.start", "params": {"id": app_id}, }, key_press_delay=0, use_control=True, ws_socket=self._ws_control, ) return self._ws_send( { "method": "ms.channel.emit", "params": { "event": "ed.apps.launch", "to": "host", "data": { # action_type: NATIVE_LAUNCH / DEEP_LINK # app_type == 2 ? 'DEEP_LINK' : 'NATIVE_LAUNCH', "action_type": action_type, "appId": app_id, "metaTag": meta_tag, }, }, }, key_press_delay=0, ) def open_browser(self, url): """Launch the browser app on the TV.""" _LOGGING.debug("Opening url in browser %s", url) return self.run_app("org.tizen.browser", TYPE_NATIVE_LAUNCH, url) def rest_device_info(self): """Get device info using rest api call.""" _LOGGING.debug("Get device info via rest api") return self._rest_request("") def rest_app_status(self, app_id): """Get app status using rest api call.""" _LOGGING.debug("Get app %s status via rest api", app_id) return self._rest_request("applications/" + app_id) def rest_app_run(self, app_id): """Run an app using rest api call.""" _LOGGING.debug("Run app %s via rest api", app_id) return self._rest_request("applications/" + app_id, "POST") def rest_app_close(self, app_id): """Close an app using rest api call.""" _LOGGING.debug("Close app %s via rest api", app_id) return self._rest_request("applications/" + app_id, "DELETE") def rest_app_install(self, app_id): """Install a new app using rest api call.""" _LOGGING.debug("Install app %s via rest api", app_id) return self._rest_request("applications/" + app_id, "PUT") def shortcuts(self): """Return a list of available shortcuts.""" return SamsungTVShortcuts(self) ================================================ FILE: custom_components/samsungtv_smart/api/shortcuts.py ================================================ """ SamsungTVWS - Samsung Smart TV WS API wrapper Copyright (C) 2019 Xchwarze This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335 USA """ class SamsungTVShortcuts: def __init__(self, remote): self.remote = remote # power def power(self): self.remote.send_key("KEY_POWER") # menu def home(self): self.remote.send_key("KEY_HOME") def menu(self): self.remote.send_key("KEY_MENU") def source(self): self.remote.send_key("KEY_SOURCE") def guide(self): self.remote.send_key("KEY_GUIDE") def tools(self): self.remote.send_key("KEY_TOOLS") def info(self): self.remote.send_key("KEY_INFO") # navigation def up(self): self.remote.send_key("KEY_UP") def down(self): self.remote.send_key("KEY_DOWN") def left(self): self.remote.send_key("KEY_LEFT") def right(self): self.remote.send_key("KEY_RIGHT") def enter(self, count=1): self.remote.send_key("KEY_ENTER") def back(self): self.remote.send_key("KEY_RETURN") # channel def channel_list(self): self.remote.send_key("KEY_CH_LIST") def channel(self, ch): for c in str(ch): self.digit(c) self.enter() def digit(self, d): self.remote.send_key("KEY_" + d) def channel_up(self): self.remote.send_key("KEY_CHUP") def channel_down(self): self.remote.send_key("KEY_CHDOWN") # volume def volume_up(self): self.remote.send_key("KEY_VOLUP") def volume_down(self): self.remote.send_key("KEY_VOLDOWN") def mute(self): self.remote.send_key("KEY_MUTE") # extra def red(self): self.remote.send_key("KEY_RED") def green(self): self.remote.send_key("KEY_GREEN") def yellow(self): self.remote.send_key("KEY_YELLOW") def blue(self): self.remote.send_key("KEY_BLUE") ================================================ FILE: custom_components/samsungtv_smart/api/smartthings.py ================================================ """SmartThings TV integration.""" from __future__ import annotations from asyncio import TimeoutError as AsyncTimeoutError from collections.abc import Callable from datetime import timedelta from enum import Enum import json import logging from aiohttp import ClientConnectionError, ClientResponseError, ClientSession from homeassistant.util import Throttle API_BASEURL = "https://api.smartthings.com/v1" API_DEVICES = f"{API_BASEURL}/devices" DEVICE_TYPE_OCF = "OCF" DEVICE_TYPE_NAME_TV = "Samsung OCF TV" DEVICE_TYPE_NAMES = ["Samsung OCF TV", "x.com.st.d.monitor"] COMMAND_POWER_OFF = { "capability": "switch", "command": "off", } COMMAND_POWER_ON = { "capability": "switch", "command": "on", } COMMAND_REFRESH = { "capability": "refresh", "command": "refresh", } COMMAND_SET_SOURCE = { "capability": "mediaInputSource", "command": "setInputSource", } COMMAND_SET_VD_SOURCE = { "capability": "samsungvd.mediaInputSource", "command": "setInputSource", } COMMAND_MUTE = { "capability": "audioMute", "command": "mute", } COMMAND_UNMUTE = { "capability": "audioMute", "command": "unmute", } COMMAND_VOLUME_UP = { "capability": "audioVolume", "command": "volumeUp", } COMMAND_VOLUME_DOWN = { "capability": "audioVolume", "command": "volumeDown", } COMMAND_SET_VOLUME = { "capability": "audioVolume", "command": "setVolume", } COMMAND_CHANNEL_UP = { "capability": "tvChannel", "command": "channelUp", } COMMAND_CHANNEL_DOWN = { "capability": "tvChannel", "command": "channelDown", } COMMAND_SET_CHANNEL = { "capability": "tvChannel", "command": "setTvChannel", } COMMAND_PAUSE = { "capability": "mediaPlayback", "command": "pause", } COMMAND_PLAY = { "capability": "mediaPlayback", "command": "play", } COMMAND_STOP = { "capability": "mediaPlayback", "command": "stop", } COMMAND_FAST_FORWARD = { "capability": "mediaPlayback", "command": "fastForward", } COMMAND_REWIND = { "capability": "mediaPlayback", "command": "rewind", } COMMAND_SOUND_MODE = { "capability": "custom.soundmode", "command": "setSoundMode", } COMMAND_PICTURE_MODE = { "capability": "custom.picturemode", "command": "setPictureMode", } DIGITAL_TV = "digitalTv" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) def _headers(api_key: str) -> dict[str, str]: return { "Authorization": f"Bearer {api_key}", "Accept": "application/json", "Connection": "keep-alive", } def _command(command: dict, arguments: list | None = None): cmd = {"component": "main", **command} if arguments: cmd["arguments"] = arguments cmd_full = {"commands": [cmd]} return str(cmd_full) class STStatus(Enum): """Represent SmartThings status.""" STATE_OFF = 0 STATE_ON = 1 STATE_UNKNOWN = 2 class SmartThingsTV: """Class to read status for TV registered in SmartThings cloud.""" def __init__( self, api_key: str, device_id: str, use_channel_info: bool = True, session: ClientSession | None = None, api_key_callback: Callable[[], str | None] | None = None, ): """Initialize SmartThingsTV.""" self._api_key = api_key self._device_id = device_id self._use_channel_info = use_channel_info if session: self._session = session self._managed_session = False else: self._session = ClientSession() self._managed_session = True self._device_name = None self._state = STStatus.STATE_UNKNOWN self._prev_state = STStatus.STATE_UNKNOWN self._muted = False self._volume = 10 self._source_list = None self._source_list_map = None self._source = "" self._channel = "" self._channel_name = "" self._sound_mode = None self._sound_mode_list = None self._picture_mode = None self._picture_mode_list = None self._is_forced_val = False self._forced_count = 0 self._api_key_callback = api_key_callback def __enter__(self): return self def __exit__(self, ext_type, ext_value, ext_traceback): pass def _get_api_key(self) -> str: """Get API key used to connect to smartthink.""" if self._api_key_callback is not None: if api_key := self._api_key_callback(): self._api_key = api_key return self._api_key @property def api_key(self) -> str: """Return current api_key.""" return self._api_key @property def device_id(self) -> str: """Return current device_id.""" return self._device_id @property def device_name(self) -> str: """Return current device_name.""" return self._device_name @property def state(self): """Return current state.""" return self._state @property def prev_state(self): """Return current state.""" return self._prev_state @property def muted(self) -> bool: """Return current muted state.""" return self._muted @property def volume(self) -> int: """Return current volume.""" return self._volume @property def source(self) -> str: """Return current source.""" return self._source @property def channel(self) -> str: """Return current channel.""" return self._channel @property def channel_name(self) -> str: """Return current channel name.""" return self._channel_name @property def source_list(self): """Return available source list.""" return self._source_list @property def sound_mode(self): """Return current sound mode.""" if self._state != STStatus.STATE_ON: return None return self._sound_mode @property def sound_mode_list(self): """Return available sound modes.""" if self._state != STStatus.STATE_ON: return None return self._sound_mode_list @property def picture_mode(self): """Return current picture mode.""" if self._state != STStatus.STATE_ON: return None return self._picture_mode @property def picture_mode_list(self): """Return available picture modes.""" if self._state != STStatus.STATE_ON: return None return self._picture_mode_list def get_source_name(self, source_id: str) -> str: """Get source name based on source id.""" if not self._source_list_map: return "" if source_id.upper() == DIGITAL_TV.upper(): source_id = "dtv" for map_value in self._source_list_map: map_id = map_value.get("id") if map_id and map_id == source_id: return map_value.get("name", "") return "" def _get_source_list_from_map(self) -> list: """Return source list from source map.""" if not self._source_list_map: return [] source_list = [] for map_value in self._source_list_map: if source_id := map_value.get("id"): if source_id.upper() == "DTV": source_list.append(DIGITAL_TV) else: source_list.append(source_id) return source_list def set_application(self, app_id): """Set running application info.""" if self._use_channel_info: self._channel = "" self._channel_name = app_id self._is_forced_val = True self._forced_count = 0 def _set_source(self, source): """Set current source info.""" if source != self._source: self._source = source self._channel = "" self._channel_name = "" self._is_forced_val = True self._forced_count = 0 @staticmethod def _load_json_list(dev_data, list_name): """Try load a list from string to json format.""" load_list = [] json_list = dev_data.get(list_name, {}).get("value") if json_list: try: load_list = json.loads(json_list) except (TypeError, ValueError): pass return load_list @staticmethod async def get_devices_list(api_key, session: ClientSession, device_label=""): """Get list of available SmartThings devices""" result = {} async with session.get( API_DEVICES, headers=_headers(api_key), raise_for_status=True, ) as resp: device_list = await resp.json() if device_list: _LOGGER.debug("SmartThings available devices: %s", str(device_list)) for dev in device_list.get("items", []): if (device_id := dev.get("deviceId")) is None: continue if dev.get("type", "") != DEVICE_TYPE_OCF: continue label = dev.get("label", "") if device_label: if label != device_label: continue elif dev.get("deviceTypeName", "") not in DEVICE_TYPE_NAMES: continue result[device_id] = { "name": dev.get("name", f"TV ID {device_id}"), "label": label, } _LOGGER.info("SmartThings discovered TV devices: %s", str(result)) return result @Throttle(MIN_TIME_BETWEEN_UPDATES) async def _device_refresh(self, **kwargs): """Refresh device status on SmartThings""" device_id = self._device_id if not device_id: return api_device = f"{API_DEVICES}/{device_id}" api_command = f"{api_device}/commands" if self._use_channel_info: async with self._session.post( api_command, headers=_headers(self._get_api_key()), data=_command(COMMAND_REFRESH), raise_for_status=False, ) as resp: if resp.status == 409: self._state = STStatus.STATE_OFF return resp.raise_for_status() await resp.json() return async def _async_send_command(self, data_cmd): """Send a command via SmartThings""" device_id = self._device_id if not device_id: return if not data_cmd: return api_device = f"{API_DEVICES}/{device_id}" api_command = f"{api_device}/commands" async with self._session.post( api_command, headers=_headers(self._get_api_key()), data=data_cmd, raise_for_status=True, ) as resp: await resp.json() await self._device_refresh() async def async_device_health(self): """Check device availability""" device_id = self._device_id if not device_id: return False api_device = f"{API_DEVICES}/{device_id}" api_device_health = f"{api_device}/health" # this get the real status of the device async with self._session.get( api_device_health, headers=_headers(self._get_api_key()), raise_for_status=True, ) as resp: health = await resp.json() _LOGGER.debug(health) if health["state"] == "ONLINE": return True return False async def async_device_update(self, use_channel_info: bool = None): """Query device status on SmartThing""" device_id = self._device_id if not device_id: return if use_channel_info is not None: self._use_channel_info = use_channel_info api_device = f"{API_DEVICES}/{device_id}" api_device_status = f"{api_device}/states" # not used, just for reference # api_device_main_status = f"{api_device}/components/main/status" self._prev_state = self._state try: is_online = await self.async_device_health() except ( AsyncTimeoutError, ClientConnectionError, ClientResponseError, ): self._state = STStatus.STATE_UNKNOWN return if is_online: self._state = STStatus.STATE_ON else: self._state = STStatus.STATE_OFF return await self._device_refresh() if self._state == STStatus.STATE_OFF: return async with self._session.get( api_device_status, headers=_headers(self._get_api_key()), raise_for_status=True, ) as resp: data = await resp.json() _LOGGER.debug(data) dev_data = data.get("main", {}) # device_state = data['main']['switch']['value'] # Volume device_volume = dev_data.get("volume", {}).get("value", 0) if device_volume and device_volume.isdigit(): self._volume = int(device_volume) / 100 else: self._volume = 0 # Muted state device_muted = dev_data.get("mute", {}).get("value", "") self._muted = device_muted == "mute" # Sound Mode self._sound_mode = dev_data.get("soundMode", {}).get("value") self._sound_mode_list = self._load_json_list(dev_data, "supportedSoundModes") # Picture Mode self._picture_mode = dev_data.get("pictureMode", {}).get("value") self._picture_mode_list = self._load_json_list( dev_data, "supportedPictureModes" ) # Sources and channel self._source_list_map = self._load_json_list( dev_data, "supportedInputSourcesMap" ) # self._source_list = self._load_json_list(dev_data, "supportedInputSources") self._source_list = self._get_source_list_from_map() if self._is_forced_val and self._forced_count <= 0: self._forced_count += 1 return self._is_forced_val = False self._forced_count = 0 device_source = dev_data.get("inputSource", {}).get("value", "") device_tv_chan = dev_data.get("tvChannel", {}).get("value", "") device_tv_chan_name = dev_data.get("tvChannelName", {}).get("value", "") if device_source: if device_source.upper() == DIGITAL_TV.upper(): device_source = DIGITAL_TV self._source = device_source # if the status is not refreshed this info may become not reliable if self._use_channel_info: self._channel = device_tv_chan self._channel_name = device_tv_chan_name else: self._channel = "" self._channel_name = "" async def async_turn_off(self): """Turn off TV via SmartThings""" data_cmd = _command(COMMAND_POWER_OFF) await self._async_send_command(data_cmd) async def async_turn_on(self): """Turn on TV via SmartThings""" data_cmd = _command(COMMAND_POWER_ON) await self._async_send_command(data_cmd) async def async_send_command(self, cmd_type, command=""): """Send a command to the device""" data_cmd = None if cmd_type == "setvolume": # sets volume data_cmd = _command(COMMAND_SET_VOLUME, [int(command)]) elif cmd_type == "stepvolume": # steps volume up or down if command == "up": data_cmd = _command(COMMAND_VOLUME_UP) elif command == "down": data_cmd = _command(COMMAND_VOLUME_DOWN) elif cmd_type == "audiomute": # mutes audio if command == "on": data_cmd = _command(COMMAND_MUTE) elif command == "off": data_cmd = _command(COMMAND_UNMUTE) elif cmd_type == "selectchannel": # changes channel data_cmd = _command(COMMAND_SET_CHANNEL, [command]) elif cmd_type == "stepchannel": # steps channel up or down if command == "up": data_cmd = _command(COMMAND_CHANNEL_UP) elif command == "down": data_cmd = _command(COMMAND_CHANNEL_DOWN) else: return await self._async_send_command(data_cmd) async def async_select_source(self, source): """Select source""" # if source not in self._source_list: # return data_cmd = _command(COMMAND_SET_SOURCE, [source]) # set property to reflect new changes self._set_source(source) await self._async_send_command(data_cmd) async def async_select_vd_source(self, source): """Select source""" # if source not in self._source_list: # return data_cmd = _command(COMMAND_SET_VD_SOURCE, [source]) await self._async_send_command(data_cmd) async def async_set_sound_mode(self, mode): """Select sound mode""" if self._state != STStatus.STATE_ON: return if mode not in self._sound_mode_list: raise InvalidSmartThingsSoundMode() data_cmd = _command(COMMAND_SOUND_MODE, [mode]) await self._async_send_command(data_cmd) self._sound_mode = mode async def async_set_picture_mode(self, mode): """Select picture mode""" if self._state != STStatus.STATE_ON: return if mode not in self._picture_mode_list: raise InvalidSmartThingsPictureMode() data_cmd = _command(COMMAND_PICTURE_MODE, [mode]) await self._async_send_command(data_cmd) self._picture_mode = mode class InvalidSmartThingsSoundMode(RuntimeError): """Selected sound mode is invalid.""" class InvalidSmartThingsPictureMode(RuntimeError): """Selected picture mode is invalid.""" ================================================ FILE: custom_components/samsungtv_smart/api/upnp.py ================================================ """Smartthings TV integration UPnP implementation.""" import logging from typing import Optional import xml.etree.ElementTree as ET from aiohttp import ClientSession import async_timeout DEFAULT_TIMEOUT = 0.2 _LOGGER = logging.getLogger(__name__) class SamsungUPnP: """UPnP implementation for Samsung TV.""" def __init__(self, host, session: Optional[ClientSession] = None): """Initialize the class.""" self._host = host self._connected = False if session: self._session = session self._managed_session = False else: self._session = ClientSession() self._managed_session = True async def _soap_request( self, action, arguments, protocole, *, timeout=DEFAULT_TIMEOUT ): """Send a SOAP request to the TV.""" headers = { "SOAPAction": f'"urn:schemas-upnp-org:service:{protocole}:1#{action}"', "content-type": "text/xml", } body = f""" 0 {arguments} """ try: async with async_timeout.timeout(timeout): async with self._session.post( f"http://{self._host}:9197/upnp/control/{protocole}1", headers=headers, data=body, raise_for_status=True, ) as resp: response = await resp.content.read() self._connected = True except Exception as exc: # pylint: disable=broad-except _LOGGER.debug(exc) self._connected = False return None return response @property def connected(self): """Return if connected to Samsung TV.""" return self._connected async def async_disconnect(self): """Disconnect from TV and close session.""" if self._managed_session: await self._session.close() async def async_get_volume(self): """Return volume status.""" response = await self._soap_request( "GetVolume", "Master", "RenderingControl" ) if response is None: return None tree = ET.fromstring(response.decode("utf8")) volume = None for elem in tree.iter(tag="CurrentVolume"): volume = elem.text return volume async def async_set_volume(self, volume): """Set the volume level.""" await self._soap_request( "SetVolume", f"Master{volume}", "RenderingControl", ) async def async_get_mute(self): """Return mute status.""" response = await self._soap_request( "GetMute", "Master", "RenderingControl" ) if response is None: return None tree = ET.fromstring(response.decode("utf8")) mute = None for elem in tree.iter(tag="CurrentMute"): mute = elem.text if mute is None: return None return int(mute) != 0 async def async_set_current_media(self, url): """Set media to playback and play it.""" if ( await self._soap_request( "SetAVTransportURI", f"{url}", "AVTransport", timeout=2.0, ) is None ): return False await self._soap_request("Play", "1", "AVTransport") return True async def async_play(self): """Play media that was already set as current.""" await self._soap_request("Play", "1", "AVTransport") ================================================ FILE: custom_components/samsungtv_smart/config_flow.py ================================================ """Config flow for Samsung TV.""" from __future__ import annotations import logging from numbers import Number import socket from typing import Any, Dict import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.config_entries import ( SOURCE_RECONFIGURE, SOURCE_USER, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import ( ATTR_DEVICE_ID, CONF_API_KEY, CONF_BASE, CONF_DEVICE_ID, CONF_HOST, CONF_ID, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TOKEN, SERVICE_TURN_ON, __version__, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( EntitySelector, EntitySelectorConfig, ObjectSelector, SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) from . import ( SamsungTVInfo, get_device_info, get_smartthings_api_key, get_smartthings_entries, is_valid_ha_version, ) from .const import ( ATTR_DEVICE_MAC, ATTR_DEVICE_MODEL, ATTR_DEVICE_NAME, ATTR_DEVICE_OS, CONF_APP_LAUNCH_METHOD, CONF_APP_LIST, CONF_APP_LOAD_METHOD, CONF_CHANNEL_LIST, CONF_DEVICE_MODEL, CONF_DEVICE_NAME, CONF_DEVICE_OS, CONF_DUMP_APPS, CONF_EXT_POWER_ENTITY, CONF_LOGO_OPTION, CONF_PING_PORT, CONF_POWER_ON_METHOD, CONF_SHOW_CHANNEL_NR, CONF_SOURCE_LIST, CONF_ST_ENTRY_UNIQUE_ID, CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON, CONF_TOGGLE_ART_MODE, CONF_USE_LOCAL_LOGO, CONF_USE_MUTE_CHECK, CONF_USE_ST_CHANNEL_INFO, CONF_USE_ST_STATUS_INFO, CONF_WOL_REPEAT, CONF_WS_NAME, DOMAIN, MAX_WOL_REPEAT, RESULT_ST_DEVICE_NOT_FOUND, RESULT_ST_DEVICE_USED, RESULT_SUCCESS, RESULT_WRONG_APIKEY, AppLaunchMethod, AppLoadMethod, PowerOnMethod, __min_ha_version__, ) from .logo import LOGO_OPTION_DEFAULT, LogoOption APP_LAUNCH_METHODS = { AppLaunchMethod.Standard.value: "Control Web Socket Channel", AppLaunchMethod.Remote.value: "Remote Web Socket Channel", AppLaunchMethod.Rest.value: "Rest API Call", } APP_LOAD_METHODS = { AppLoadMethod.All.value: "All Apps", AppLoadMethod.Default.value: "Default Apps", AppLoadMethod.NotLoad.value: "Not Load", } LOGO_OPTIONS = { LogoOption.Disabled.value: "Disabled", LogoOption.WhiteColor.value: "White background, Color logo", LogoOption.BlueColor.value: "Blue background, Color logo", LogoOption.BlueWhite.value: "Blue background, White logo", LogoOption.DarkWhite.value: "Dark background, White logo", LogoOption.TransparentColor.value: "Transparent background, Color logo", LogoOption.TransparentWhite.value: "Transparent background, White logo", } POWER_ON_METHODS = { PowerOnMethod.WOL.value: "WOL Packet (better for wired connection)", PowerOnMethod.SmartThings.value: "SmartThings (better for wireless connection)", } CONF_SHOW_ADV_OPT = "show_adv_opt" CONF_ST_DEVICE = "st_devices" CONF_USE_HA_NAME = "use_ha_name_for_ws" ADVANCED_OPTIONS = [ CONF_APP_LAUNCH_METHOD, CONF_DUMP_APPS, CONF_EXT_POWER_ENTITY, CONF_PING_PORT, CONF_WOL_REPEAT, CONF_TOGGLE_ART_MODE, CONF_USE_MUTE_CHECK, ] ENUM_OPTIONS = [ CONF_APP_LOAD_METHOD, CONF_APP_LAUNCH_METHOD, CONF_LOGO_OPTION, CONF_POWER_ON_METHOD, ] _LOGGER = logging.getLogger(__name__) def _get_ip(host): if host is None: return None try: return socket.gethostbyname(host) except socket.gaierror: return None class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Samsung TV config flow.""" VERSION = 1 # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 def __init__(self) -> None: """Initialize flow.""" self._user_data = None self._st_devices_schema = None self._tv_info: SamsungTVInfo | None = None self._host = None self._api_key = None self._st_entry_unique_id = None self._device_id = None self._name = None self._ws_name = None self._logo_option = None self._device_info = {} self._token = None self._ping_port = None self._error: str | None = None def _stdev_already_used(self, devices_id) -> bool: """Check if a device_id is in HA config.""" for entry in self._async_current_entries(): if entry.data.get(CONF_DEVICE_ID, "") == devices_id: return True return False def _remove_stdev_used(self, devices_list: Dict[str, Any]) -> Dict[str, Any]: """Remove entry already used""" res_dev_list = devices_list.copy() for dev_id in devices_list.keys(): if self._stdev_already_used(dev_id): res_dev_list.pop(dev_id) return res_dev_list @staticmethod def _extract_dev_name(device) -> str: """Extract device neme from SmartThings Info""" name = device["name"] label = device.get("label", "") if label: name += f" ({label})" return name def _prepare_dev_schema(self, devices_list) -> vol.Schema: """Prepare the schema for select correct ST device""" validate = {} for dev_id, infos in devices_list.items(): device_name = self._extract_dev_name(infos) validate[dev_id] = device_name return vol.Schema({vol.Required(CONF_ST_DEVICE): vol.In(validate)}) async def _get_st_deviceid(self, st_device_label="") -> str: """Try to detect SmartThings device id.""" session = async_get_clientsession(self.hass) devices_list = await SamsungTVInfo.get_st_devices( self._api_key, session, st_device_label ) if devices_list is None: return RESULT_WRONG_APIKEY devices_list = self._remove_stdev_used(devices_list) if devices_list: if len(devices_list) > 1: self._st_devices_schema = self._prepare_dev_schema(devices_list) else: self._device_id = list(devices_list.keys())[0] return RESULT_SUCCESS async def _try_connect(self, *, port=None, token=None, skip_info=False) -> str: """Try to connect and check auth.""" self._tv_info = SamsungTVInfo(self.hass, self._host, self._ws_name) session = async_get_clientsession(self.hass) result = await self._tv_info.try_connect( session, self._api_key, self._device_id, ws_port=port, ws_token=token ) if result == RESULT_SUCCESS: self._token = self._tv_info.ws_token self._ping_port = self._tv_info.ping_port if not skip_info: self._device_info = await get_device_info(self._host, session) return result @callback def _get_api_key(self) -> str | None: """Get api key in configured entries if available.""" for entry in self._async_current_entries(): if CONF_API_KEY in entry.data: if not entry.data.get(CONF_ST_ENTRY_UNIQUE_ID): return entry.data[CONF_API_KEY] return None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" if not is_valid_ha_version(): return self.async_abort( reason="unsupported_version", description_placeholders={ "req_ver": __min_ha_version__, "run_ver": __version__, }, ) if not self._user_data: if api_key := self._get_api_key(): self._user_data = {CONF_API_KEY: api_key} if user_input is None: return self._show_form() self._user_data = user_input ip_address = await self.hass.async_add_executor_job( _get_ip, user_input[CONF_HOST] ) if not ip_address: return self._show_form(errors="invalid_host") self._async_abort_entries_match({CONF_HOST: ip_address}) self._host = ip_address self._name = user_input[CONF_NAME] api_key = user_input.get(CONF_API_KEY) st_entry_unique_id = user_input.get(CONF_ST_ENTRY_UNIQUE_ID) if api_key and st_entry_unique_id: return self._show_form(errors="only_key_or_st") self._st_entry_unique_id = None if st_entry_unique_id: if not (api_key := get_smartthings_api_key(self.hass, st_entry_unique_id)): return self._show_form(errors="st_api_key_fail") self._st_entry_unique_id = st_entry_unique_id self._api_key = api_key use_ha_name = user_input.get(CONF_USE_HA_NAME, False) if use_ha_name: ha_conf = self.hass.config if hasattr(ha_conf, "location_name"): self._ws_name = ha_conf.location_name if not self._ws_name: self._ws_name = self._name result = RESULT_SUCCESS if self._api_key: result = await self._get_st_deviceid() if result == RESULT_SUCCESS and not self._device_id: if self._st_devices_schema: return await self.async_step_stdevice() return await self.async_step_stdeviceid() if result == RESULT_SUCCESS: result = await self._try_connect() return await self._manage_result(result, True) async def async_step_stdevice( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow to select ST device.""" if user_input is None: return self._show_form(step_id="stdevice") self._device_id = user_input.get(CONF_ST_DEVICE) result = await self._try_connect() return await self._manage_result(result) async def async_step_stdeviceid( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow to manual input a ST device.""" if user_input is None: return self._show_form(step_id="stdeviceid") device_id = user_input.get(CONF_DEVICE_ID) if self._stdev_already_used(device_id): return self._show_form(errors=RESULT_ST_DEVICE_USED, step_id="stdeviceid") self._device_id = device_id result = await self._try_connect() if result == RESULT_ST_DEVICE_NOT_FOUND: return self._show_form(errors=result, step_id="stdeviceid") return await self._manage_result(result) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" entry = self._get_reconfigure_entry() if entry.unique_id == entry.data[CONF_HOST]: return self.async_abort(reason="host_unique_id") if not self._ws_name: self._ws_name = entry.data[CONF_WS_NAME] if CONF_API_KEY in entry.data: self._device_id = entry.data.get(CONF_DEVICE_ID) if user_input is None: return self._show_form(errors=None, step_id=SOURCE_RECONFIGURE) ip_address = await self.hass.async_add_executor_job( _get_ip, user_input[CONF_HOST] ) if not ip_address: return self._show_form(errors="invalid_host", step_id=SOURCE_RECONFIGURE) self._async_abort_entries_match({CONF_HOST: ip_address}) api_key = user_input.get(CONF_API_KEY) st_entry_unique_id = user_input.get(CONF_ST_ENTRY_UNIQUE_ID) if api_key and st_entry_unique_id: return self._show_form(errors="only_key_or_st", step_id=SOURCE_RECONFIGURE) self._st_entry_unique_id = None if st_entry_unique_id: if not (api_key := get_smartthings_api_key(self.hass, st_entry_unique_id)): return self._show_form( errors="st_api_key_fail", step_id=SOURCE_RECONFIGURE ) self._st_entry_unique_id = st_entry_unique_id else: api_key = api_key or entry.data.get(CONF_API_KEY) self._host = ip_address self._api_key = api_key result = await self._try_connect( port=entry.data.get(CONF_PORT), token=entry.data.get(CONF_TOKEN), skip_info=True, ) return self._manage_reconfigure(result) async def _manage_result(self, result: str, is_user_step=False) -> ConfigFlowResult: """Manage the previous result.""" if result != RESULT_SUCCESS: self._error = result if result == RESULT_ST_DEVICE_NOT_FOUND: return await self.async_step_stdeviceid() if is_user_step: return self._show_form() return await self.async_step_user() if ATTR_DEVICE_ID in self._device_info: unique_id = self._device_info[ATTR_DEVICE_ID] else: mac = self._device_info.get(ATTR_DEVICE_MAC) unique_id = mac or self._host # as last option we use host as unique id await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self._save_entry() @callback def _manage_reconfigure(self, result: str) -> ConfigFlowResult: """Manage the reconfigure result.""" if result != RESULT_SUCCESS: self._error = result return self._show_form(step_id=SOURCE_RECONFIGURE) entry = self._get_reconfigure_entry() updates = { CONF_HOST: self._host, CONF_PORT: self._tv_info.ws_port, } if self._token: updates[CONF_TOKEN] = self._token if self._api_key: updates[CONF_API_KEY] = self._api_key if CONF_ST_ENTRY_UNIQUE_ID in entry.data or self._st_entry_unique_id: updates[CONF_ST_ENTRY_UNIQUE_ID] = self._st_entry_unique_id return self.async_update_reload_and_abort( entry, data_updates=updates, reload_even_if_entry_is_unchanged=False ) @callback def _save_entry(self) -> ConfigFlowResult: """Generate new entry.""" data = { CONF_HOST: self._host, CONF_NAME: self._name, CONF_PORT: self._tv_info.ws_port, CONF_WS_NAME: self._ws_name, } if self._token: data[CONF_TOKEN] = self._token for key, attr in { CONF_ID: ATTR_DEVICE_ID, CONF_DEVICE_NAME: ATTR_DEVICE_NAME, CONF_DEVICE_MODEL: ATTR_DEVICE_MODEL, CONF_DEVICE_OS: ATTR_DEVICE_OS, CONF_MAC: ATTR_DEVICE_MAC, }.items(): if attr in self._device_info: data[key] = self._device_info[attr] title = self._name if self._api_key and self._device_id: data[CONF_API_KEY] = self._api_key data[CONF_DEVICE_ID] = self._device_id if self._st_entry_unique_id: data[CONF_ST_ENTRY_UNIQUE_ID] = self._st_entry_unique_id title += " (SmartThings)" options = None if self._ping_port: options = {CONF_PING_PORT: self._ping_port} _LOGGER.info("Configured new entity %s with host %s", title, self._host) return self.async_create_entry(title=title, data=data, options=options) def _get_init_schema(self) -> vol.Schema: """Return the schema for initial configuration form.""" data = self._user_data or {} st_entries = get_smartthings_entries(self.hass) init_schema = { vol.Required(CONF_HOST, default=data.get(CONF_HOST, "")): str, vol.Required(CONF_NAME, default=data.get(CONF_NAME, "")): str, vol.Optional( CONF_USE_HA_NAME, default=data.get(CONF_USE_HA_NAME, False) ): bool, vol.Optional( CONF_API_KEY, description={"suggested_value": data.get(CONF_API_KEY, "")}, ): str, } if st_entries: st_unique_id = data.get(CONF_ST_ENTRY_UNIQUE_ID) sugg_val = st_unique_id if st_unique_id in st_entries else None init_schema.update( { vol.Optional( CONF_ST_ENTRY_UNIQUE_ID, description={"suggested_value": sugg_val}, ): SelectSelector(_dict_to_select(st_entries)), } ) return vol.Schema(init_schema) def _get_reconfigure_schema(self) -> vol.Schema: """Return the schema for reconfiguration form.""" entry = self._get_reconfigure_entry() data = entry.data st_entries = get_smartthings_entries(self.hass) init_schema = { vol.Required(CONF_HOST, default=data.get(CONF_HOST, "")): str, } if CONF_API_KEY in data and CONF_DEVICE_ID in data: st_unique_id = data.get(CONF_ST_ENTRY_UNIQUE_ID) use_st_key = st_entries is not None and st_unique_id in st_entries sugg_val = data[CONF_API_KEY] if not use_st_key else "" init_schema.update( { vol.Optional( CONF_API_KEY, description={"suggested_value": sugg_val} ): str, } ) if st_entries: sugg_val = st_unique_id if use_st_key else None init_schema.update( { vol.Optional( CONF_ST_ENTRY_UNIQUE_ID, description={"suggested_value": sugg_val}, ): SelectSelector(_dict_to_select(st_entries)), } ) return vol.Schema(init_schema) @callback def _show_form( self, errors: str | None = None, step_id=SOURCE_USER ) -> ConfigFlowResult: """Show the form to the user.""" base_err = errors or self._error self._error = None if step_id == "stdevice": data_schema = self._st_devices_schema elif step_id == "stdeviceid": data_schema = vol.Schema({vol.Required(CONF_DEVICE_ID): str}) elif step_id == "reconfigure": data_schema = self._get_reconfigure_schema() else: data_schema = self._get_init_schema() return self.async_show_form( step_id=step_id, data_schema=data_schema, errors={CONF_BASE: base_err} if base_err else None, ) @staticmethod @callback def async_get_options_flow(config_entry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(OptionsFlow): """Handle an option flow for Samsung TV Smart.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._entry_id = config_entry.entry_id self._adv_chk = False self._std_options = config_entry.options.copy() self._adv_options = { key: values for key, values in config_entry.options.items() if key in ADVANCED_OPTIONS } self._sync_ent_opt = { key: values for key, values in config_entry.options.items() if key in [CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON] } self._app_list = self._std_options.get(CONF_APP_LIST) self._channel_list = self._std_options.get(CONF_CHANNEL_LIST) self._source_list = self._std_options.get(CONF_SOURCE_LIST) api_key = config_entry.data.get(CONF_API_KEY) st_dev = config_entry.data.get(CONF_DEVICE_ID) self._use_st = api_key and st_dev @callback def _save_entry(self, data) -> ConfigFlowResult: """Save configuration options""" data.update(self._adv_options) data.update(self._sync_ent_opt) entry_data = {k: v for k, v in data.items() if v is not None} for key, value in entry_data.items(): if key in ENUM_OPTIONS: entry_data[key] = int(value) entry_data[CONF_APP_LIST] = self._app_list or {} entry_data[CONF_CHANNEL_LIST] = self._channel_list or {} entry_data[CONF_SOURCE_LIST] = self._source_list or {} return self.async_create_entry(title="", data=entry_data) async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Handle initial options flow.""" if user_input is not None: if self._adv_chk or user_input.pop(CONF_SHOW_ADV_OPT, False): self._adv_chk = True self._std_options = user_input return await self.async_step_menu() return self._save_entry(data=user_input) return self._async_option_form() @callback def _async_option_form(self): """Return configuration form for options.""" options = _validate_options(self._std_options) opt_schema = { vol.Required( CONF_LOGO_OPTION, default=options.get(CONF_LOGO_OPTION, str(LOGO_OPTION_DEFAULT.value)), ): SelectSelector(_dict_to_select(LOGO_OPTIONS)), vol.Required( CONF_USE_LOCAL_LOGO, default=options.get(CONF_USE_LOCAL_LOGO, True), ): bool, } if not self._app_list: opt_schema.update( { vol.Required( CONF_APP_LOAD_METHOD, default=options.get( CONF_APP_LOAD_METHOD, str(AppLoadMethod.All.value) ), ): SelectSelector(_dict_to_select(APP_LOAD_METHODS)), } ) if self._use_st: data_schema = vol.Schema( { vol.Required( CONF_USE_ST_STATUS_INFO, default=options.get(CONF_USE_ST_STATUS_INFO, True), ): bool, vol.Required( CONF_USE_ST_CHANNEL_INFO, default=options.get(CONF_USE_ST_CHANNEL_INFO, True), ): bool, vol.Required( CONF_SHOW_CHANNEL_NR, default=options.get(CONF_SHOW_CHANNEL_NR, False), ): bool, } ).extend(opt_schema) data_schema = data_schema.extend( { vol.Required( CONF_POWER_ON_METHOD, default=options.get( CONF_POWER_ON_METHOD, str(PowerOnMethod.WOL.value) ), ): SelectSelector(_dict_to_select(POWER_ON_METHODS)), } ) else: data_schema = vol.Schema(opt_schema) if not self._adv_chk: data_schema = data_schema.extend( {vol.Required(CONF_SHOW_ADV_OPT, default=False): bool} ) return self.async_show_form(step_id="init", data_schema=data_schema) async def async_step_menu(self, _=None): """Handle advanced options menu.""" return self.async_show_menu( step_id="menu", menu_options=[ "source_list", "app_list", "channel_list", "sync_ent", "init", "adv_opt", "save_exit", ], ) async def async_step_save_exit(self, _) -> ConfigFlowResult: """Handle save and exit flow.""" return self._save_entry(data=self._std_options) async def async_step_source_list(self, user_input=None): """Handle sources list flow.""" errors: dict[str, str] | None = None if user_input is not None: valid_list = _validate_tv_list(user_input[CONF_SOURCE_LIST]) if valid_list is not None: self._source_list = valid_list return await self.async_step_menu() errors = {CONF_BASE: "invalid_tv_list"} data_schema = vol.Schema( { vol.Optional( CONF_SOURCE_LIST, default=self._source_list ): ObjectSelector() } ) return self.async_show_form( step_id="source_list", data_schema=data_schema, errors=errors ) async def async_step_app_list(self, user_input=None) -> ConfigFlowResult: """Handle apps list flow.""" errors: dict[str, str] | None = None if user_input is not None: valid_list = _validate_tv_list(user_input[CONF_APP_LIST]) if valid_list is not None: self._app_list = valid_list return await self.async_step_menu() errors = {CONF_BASE: "invalid_tv_list"} data_schema = vol.Schema( {vol.Optional(CONF_APP_LIST, default=self._app_list): ObjectSelector()} ) return self.async_show_form( step_id="app_list", data_schema=data_schema, errors=errors ) async def async_step_channel_list(self, user_input=None) -> ConfigFlowResult: """Handle channels list flow.""" errors: dict[str, str] | None = None if user_input is not None: valid_list = _validate_tv_list(user_input[CONF_CHANNEL_LIST]) if valid_list is not None: self._channel_list = valid_list return await self.async_step_menu() errors = {CONF_BASE: "invalid_tv_list"} data_schema = vol.Schema( { vol.Optional( CONF_CHANNEL_LIST, default=self._channel_list ): ObjectSelector() } ) return self.async_show_form( step_id="channel_list", data_schema=data_schema, errors=errors ) async def async_step_sync_ent(self, user_input=None) -> ConfigFlowResult: """Handle syncronized entity flow.""" if user_input is not None: self._sync_ent_opt = user_input return await self.async_step_menu() return self._async_sync_ent_form() @callback def _async_sync_ent_form(self) -> ConfigFlowResult: """Return configuration form for syncronized entity.""" select_entities = EntitySelectorConfig( domain=_async_get_domains_service(self.hass, SERVICE_TURN_ON), exclude_entities=_async_get_entry_entities(self.hass, self._entry_id), multiple=True, ) options = _validate_options(self._sync_ent_opt) data_schema = vol.Schema( { vol.Optional( CONF_SYNC_TURN_OFF, description={ "suggested_value": options.get(CONF_SYNC_TURN_OFF, []) }, ): EntitySelector(select_entities), vol.Optional( CONF_SYNC_TURN_ON, description={"suggested_value": options.get(CONF_SYNC_TURN_ON, [])}, ): EntitySelector(select_entities), } ) return self.async_show_form(step_id="sync_ent", data_schema=data_schema) async def async_step_adv_opt(self, user_input=None) -> ConfigFlowResult: """Handle advanced options flow.""" if user_input is not None: self._adv_options = user_input return await self.async_step_menu() return self._async_adv_opt_form() @callback def _async_adv_opt_form(self) -> ConfigFlowResult: """Return configuration form for advanced options.""" select_entities = EntitySelectorConfig(domain=BS_DOMAIN) options = _validate_options(self._adv_options) data_schema = vol.Schema( { vol.Required( CONF_APP_LAUNCH_METHOD, default=options.get( CONF_APP_LAUNCH_METHOD, str(AppLaunchMethod.Standard.value) ), ): SelectSelector(_dict_to_select(APP_LAUNCH_METHODS)), vol.Required( CONF_WOL_REPEAT, default=min(options.get(CONF_WOL_REPEAT, 1), MAX_WOL_REPEAT), ): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=MAX_WOL_REPEAT)), vol.Required( CONF_PING_PORT, default=options.get(CONF_PING_PORT, 0) ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=65535)), vol.Optional( CONF_EXT_POWER_ENTITY, description={ "suggested_value": options.get(CONF_EXT_POWER_ENTITY, "") }, ): EntitySelector(select_entities), vol.Required( CONF_USE_MUTE_CHECK, default=options.get(CONF_USE_MUTE_CHECK, False), ): bool, vol.Required( CONF_DUMP_APPS, default=options.get(CONF_DUMP_APPS, False), ): bool, vol.Required( CONF_TOGGLE_ART_MODE, default=options.get(CONF_TOGGLE_ART_MODE, False), ): bool, } ) return self.async_show_form(step_id="adv_opt", data_schema=data_schema) def _validate_options(options: dict) -> dict: """Validate options format""" valid_options = {} for opt_key, opt_val in options.items(): if opt_key in [CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON]: if not isinstance(opt_val, list): continue if opt_key in ENUM_OPTIONS: valid_options[opt_key] = str(opt_val) else: valid_options[opt_key] = opt_val return valid_options def _validate_tv_list(input_list: dict[str, Any]) -> dict[str, str] | None: """Validate TV list from object selector.""" valid_list = {} for name_val, id_val in input_list.items(): if not id_val: continue if isinstance(id_val, Number): id_val = str(id_val) if not isinstance(id_val, str): return None valid_list[name_val] = id_val return valid_list def _dict_to_select(opt_dict: dict) -> SelectSelectorConfig: """Covert a dict to a SelectSelectorConfig.""" return SelectSelectorConfig( options=[SelectOptionDict(value=str(k), label=v) for k, v in opt_dict.items()], mode=SelectSelectorMode.DROPDOWN, ) def _async_get_domains_service(hass: HomeAssistant, service_name: str) -> list[str]: """Fetch list of domain that provide a specific service.""" return [ domain for domain, service in hass.services.async_services().items() if service_name in service ] def _async_get_entry_entities(hass: HomeAssistant, entry_id: str) -> list[str]: """Get the entities related to current entry""" return [ entry.entity_id for entry in (er.async_entries_for_config_entry(er.async_get(hass), entry_id)) ] ================================================ FILE: custom_components/samsungtv_smart/const.py ================================================ """Constants for the samsungtv_smart integration.""" from enum import Enum class AppLoadMethod(Enum): """Valid application load methods.""" All = 1 Default = 2 NotLoad = 3 class AppLaunchMethod(Enum): """Valid application launch methods.""" Standard = 1 Remote = 2 Rest = 3 class PowerOnMethod(Enum): """Valid power on methods.""" WOL = 1 SmartThings = 2 DOMAIN = "samsungtv_smart" MIN_HA_MAJ_VER = 2025 MIN_HA_MIN_VER = 6 __min_ha_version__ = f"{MIN_HA_MAJ_VER}.{MIN_HA_MIN_VER}.0" DATA_CFG = "cfg" DATA_CFG_YAML = "cfg_yaml" DATA_OPTIONS = "options" LOCAL_LOGO_PATH = "local_logo_path" WS_PREFIX = "[Home Assistant]" ATTR_DEVICE_MAC = "device_mac" ATTR_DEVICE_MODEL = "device_model" ATTR_DEVICE_NAME = "device_name" ATTR_DEVICE_OS = "device_os" CONF_APP_LAUNCH_METHOD = "app_launch_method" CONF_APP_LIST = "app_list" CONF_APP_LOAD_METHOD = "app_load_method" CONF_CHANNEL_LIST = "channel_list" CONF_DEVICE_MODEL = "device_model" CONF_DEVICE_NAME = "device_name" CONF_DEVICE_OS = "device_os" CONF_DUMP_APPS = "dump_apps" CONF_EXT_POWER_ENTITY = "ext_power_entity" CONF_LOAD_ALL_APPS = "load_all_apps" CONF_LOGO_OPTION = "logo_option" CONF_PING_PORT = "ping_port" CONF_POWER_ON_METHOD = "power_on_method" CONF_SHOW_CHANNEL_NR = "show_channel_number" CONF_SOURCE_LIST = "source_list" CONF_SYNC_TURN_OFF = "sync_turn_off" CONF_SYNC_TURN_ON = "sync_turn_on" CONF_TOGGLE_ART_MODE = "toggle_art_mode" CONF_USE_LOCAL_LOGO = "use_local_logo" CONF_USE_MUTE_CHECK = "use_mute_check" CONF_USE_ST_CHANNEL_INFO = "use_st_channel_info" CONF_USE_ST_STATUS_INFO = "use_st_status_info" CONF_WOL_REPEAT = "wol_repeat" CONF_WS_NAME = "ws_name" # for SmartThings integration api key usage CONF_ST_ENTRY_UNIQUE_ID = "st_entry_unique_id" CONF_USE_ST_INT_API_KEY = "use_st_int_api_key" # obsolete used for migration # obsolete CONF_UPDATE_METHOD = "update_method" CONF_UPDATE_CUSTOM_PING_URL = "update_custom_ping_url" CONF_SCAN_APP_HTTP = "scan_app_http" DEFAULT_APP = "TV/HDMI" DEFAULT_PORT = 8001 DEFAULT_SOURCE_LIST = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} DEFAULT_TIMEOUT = 6 MAX_WOL_REPEAT = 5 RESULT_NOT_SUCCESSFUL = "not_successful" RESULT_NOT_SUPPORTED = "not_supported" RESULT_ST_DEVICE_USED = "st_device_used" RESULT_ST_DEVICE_NOT_FOUND = "st_device_not_found" RESULT_ST_MULTI_DEVICES = "st_multiple_device" RESULT_SUCCESS = "success" RESULT_WRONG_APIKEY = "wrong_api_key" SERVICE_SELECT_PICTURE_MODE = "select_picture_mode" SERVICE_SET_ART_MODE = "set_art_mode" SIGNAL_CONFIG_ENTITY = f"{DOMAIN}_config" STD_APP_LIST = { "org.tizen.browser": { "st_app_id": "", "logo": "tizenbrowser.png", }, # Internet "11101200001": { "st_app_id": "RN1MCdNq8t.Netflix", "logo": "netflix.png", }, # Netflix "3201907018807": { "st_app_id": "org.tizen.netflix-app", "logo": "netflix.png", }, # Netflix (New) "111299001912": { "st_app_id": "9Ur5IzDKqV.TizenYouTube", "logo": "youtube.png", }, # YouTube "3201512006785": { "st_app_id": "org.tizen.ignition", "logo": "primevideo.png", }, # Prime Video # "3201512006785": { # "st_app_id": "evKhCgZelL.AmazonIgnitionLauncher2", # "logo": "", # }, # Prime Video "3201901017640": { "st_app_id": "MCmYXNxgcu.DisneyPlus", "logo": "disneyplus.png", }, # Disney+ "3202110025305": { "st_app_id": "rJyOSqC6Up.PPlusIntl", "logo": "paramountplus.png", }, # Paramount+ "11091000000": { "st_app_id": "4ovn894vo9.Facebook", "logo": "facebook.png", }, # Facebook "3201806016390": { "st_app_id": "yu1NM3vHsU.DAZN", "logo": "dazn.png", }, # Dazn "3201601007250": { "st_app_id": "QizQxC7CUf.PlayMovies", "logo": "", }, # Google Play "3201606009684": { "st_app_id": "rJeHak5zRg.Spotify", "logo": "spotify.png", }, # Spotify "3201512006963": { "st_app_id": "kIciSQlYEM.plex", "logo": "", }, # Plex } ================================================ FILE: custom_components/samsungtv_smart/diagnostics.py ================================================ """Diagnostics support for Samsung TV Smart.""" from __future__ import annotations from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_MAC, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN TO_REDACT = {CONF_API_KEY, CONF_MAC, CONF_TOKEN} async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict: """Return diagnostics for a config entry.""" diag_data = {"entry": async_redact_data(entry.as_dict(), TO_REDACT)} yaml_data = hass.data[DOMAIN].get(entry.unique_id, {}) if yaml_data: diag_data["config_data"] = async_redact_data(yaml_data, TO_REDACT) device_id = entry.data.get(CONF_ID, entry.entry_id) hass_data = _async_device_ha_info(hass, device_id) if hass_data: diag_data["device"] = hass_data return diag_data @callback def _async_device_ha_info(hass: HomeAssistant, device_id: str) -> dict | None: """Gather information how this TV device is represented in Home Assistant.""" device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) hass_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) if not hass_device: return None data = { "name": hass_device.name, "name_by_user": hass_device.name_by_user, "model": hass_device.model, "manufacturer": hass_device.manufacturer, "sw_version": hass_device.sw_version, "disabled": hass_device.disabled, "disabled_by": hass_device.disabled_by, "entities": {}, } hass_entities = er.async_entries_for_device( entity_registry, device_id=hass_device.id, include_disabled_entities=True, ) for entity_entry in hass_entities: if entity_entry.platform != DOMAIN: continue state = hass.states.get(entity_entry.entity_id) state_dict = None if state: state_dict = dict(state.as_dict()) # The entity_id is already provided at root level. state_dict.pop("entity_id", None) # The context doesn't provide useful information in this case. state_dict.pop("context", None) # Redact the `entity_picture` attribute as it contains a token. if "entity_picture" in state_dict["attributes"]: state_dict["attributes"] = { **state_dict["attributes"], "entity_picture": REDACTED, } data["entities"][entity_entry.entity_id] = { "name": entity_entry.name, "original_name": entity_entry.original_name, "disabled": entity_entry.disabled, "disabled_by": entity_entry.disabled_by, "entity_category": entity_entry.entity_category, "device_class": entity_entry.device_class, "original_device_class": entity_entry.original_device_class, "icon": entity_entry.icon, "original_icon": entity_entry.original_icon, "unit_of_measurement": entity_entry.unit_of_measurement, "state": state_dict, } return data ================================================ FILE: custom_components/samsungtv_smart/entity.py ================================================ """Base SamsungTV Entity.""" from __future__ import annotations from typing import Any from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_SW_VERSION, CONF_HOST, CONF_ID, CONF_MAC, CONF_NAME, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, Entity from .const import CONF_DEVICE_MODEL, CONF_DEVICE_NAME, CONF_DEVICE_OS, DOMAIN class SamsungTVEntity(Entity): """Defines a base SamsungTV entity.""" _attr_has_entity_name = True def __init__(self, config: dict[str, Any], entry_id: str) -> None: """Initialize the class.""" self._name = config.get(CONF_NAME, config[CONF_HOST]) self._mac = config.get(CONF_MAC) self._attr_unique_id = config.get(CONF_ID, entry_id) model = config.get(CONF_DEVICE_MODEL, "Samsung TV") if dev_name := config.get(CONF_DEVICE_NAME): model = f"{model} ({dev_name})" self._attr_device_info = DeviceInfo( manufacturer="Samsung Electronics", model=model, name=self._name, ) if self.unique_id: self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} if dev_os := config.get(CONF_DEVICE_OS): self._attr_device_info[ATTR_SW_VERSION] = dev_os if self._mac: self._attr_device_info[ATTR_CONNECTIONS] = { (CONNECTION_NETWORK_MAC, self._mac) } ================================================ FILE: custom_components/samsungtv_smart/logo.py ================================================ """Logo implementation for SamsungTV Smart.""" import asyncio from datetime import datetime, timedelta, timezone from enum import Enum import json import logging import os from pathlib import Path import re import traceback from typing import Optional import aiofiles from aiofiles import os as aiopath import aiohttp from .const import DOMAIN # Logo feature constants class LogoOption(Enum): """List of posible logo options.""" Disabled = 1 WhiteColor = 2 BlueColor = 3 BlueWhite = 4 DarkWhite = 5 TransparentColor = 6 TransparentWhite = 7 CUSTOM_IMAGE_BASE_URL = f"/api/{DOMAIN}/custom" STATIC_IMAGE_BASE_URL = f"/api/{DOMAIN}/static" CHAR_REPLACE = {" ": "", "+": "plus", "_": "", ".": "", ":": ""} LOGO_OPTIONS_MAPPING = { LogoOption.Disabled: "none", LogoOption.WhiteColor: "fff-color", LogoOption.BlueColor: "05a9f4-color", LogoOption.BlueWhite: "05a9f4-white", LogoOption.DarkWhite: "282c34-white", LogoOption.TransparentColor: "transparent-color", LogoOption.TransparentWhite: "transparent-white", } LOGO_OPTION_DEFAULT = LogoOption.WhiteColor LOGO_BASE_URL = "https://jaruba.github.io/channel-logos/" LOGO_FILE = "logo_paths.json" LOGO_FILE_DOWNLOAD = "logo_paths_download.json" LOGO_FILE_DAYS_BEFORE_UPDATE = 1 LOGO_MIN_SCORE_REQUIRED = 80 LOGO_MEDIATITLE_KEYWORD_REMOVAL = ["HDTV", "HD"] LOGO_MAX_PATHS = 30000 LOGO_NO_MATCH = "NO_MATCH" MAX_LOGO_CACHE = 200 _LOGGER = logging.getLogger(__name__) class LocalImageUrl: """Class to manage the local image url.""" def __init__(self, custom_logo_path=None): """Initialise the local image url class.""" self._custom_logo_path = custom_logo_path self._local_image_url = None self._last_media_title = None def get_image_url(self, media_title, local_logo_file=None): """Check local image is present.""" if not media_title and not local_logo_file: return None cf_local_logo_file = None if local_logo_file: cf_local_logo_file = local_logo_file.casefold() if cf_local_logo_file == self._last_media_title: return self._local_image_url if media_title == self._last_media_title: return self._local_image_url self._last_media_title = cf_local_logo_file or media_title self._local_image_url = None media_logo_file = media_title for searcher, replacer in CHAR_REPLACE.items(): media_logo_file = media_logo_file.replace(searcher, replacer) media_logo_file += ".png" if self._custom_logo_path: for logo_file in Path(self._custom_logo_path).iterdir(): if logo_file.name.casefold() == media_logo_file.casefold(): self._local_image_url = f"{CUSTOM_IMAGE_BASE_URL}/{logo_file.name}" self._last_media_title = media_title break if not self._local_image_url and local_logo_file: dir_path = Path(__file__).parent / "static" for logo_file in Path(dir_path).iterdir(): if logo_file.name.casefold() == local_logo_file.casefold(): self._local_image_url = f"{STATIC_IMAGE_BASE_URL}/{logo_file.name}" break return self._local_image_url class Logo: """ Class that fetches logos for Samsung TV Tizen. Works with https://github.com/jaruba/channel-logos. """ def __init__( self, logo_option: LogoOption, logo_file_download: str = None, session: Optional[aiohttp.ClientSession] = None, ): self._media_image_base_url = None self._logo_option = None self.set_logo_color(logo_option) if session: self._session = session else: self._session = aiohttp.ClientSession() self._images_paths = None self._logo_cache = {} self._last_check = None app_path = os.path.dirname(os.path.realpath(__file__)) self._logo_file_path = os.path.join(app_path, LOGO_FILE) self._logo_file_download_path = logo_file_download or os.path.join( app_path, LOGO_FILE_DOWNLOAD ) def set_logo_color(self, logo_type: LogoOption): """Sets the logo color option and image base url if not already set to this option""" logo_option = LOGO_OPTIONS_MAPPING[logo_type] if self._logo_option and self._logo_option == logo_option: return _LOGGER.debug("Setting logo option to %s", logo_option) self._logo_option = logo_option if logo_type == LogoOption.Disabled: self._media_image_base_url = None else: self._media_image_base_url = f"{LOGO_BASE_URL}export/{self._logo_option}" def check_requested(self): """Check if a new file update is requested.""" if self._media_image_base_url is None: return False check_time = datetime.now(timezone.utc).astimezone() if self._last_check is not None and self._last_check > check_time - timedelta( days=LOGO_FILE_DAYS_BEFORE_UPDATE ): return False return True async def _async_ensure_latest_path_file(self): """Does check if logo paths file exists and if it does - is it out of date or not.""" if not self.check_requested(): return check_time = datetime.now(timezone.utc).astimezone() update_file = not await aiopath.path.isfile(self._logo_file_download_path) if not update_file: file_date = datetime.fromtimestamp( await aiopath.path.getmtime(self._logo_file_download_path), timezone.utc ).astimezone() if file_date > check_time - timedelta(days=LOGO_FILE_DAYS_BEFORE_UPDATE): self._last_check = file_date return try: async with self._session.head( LOGO_BASE_URL + "logo_paths.json" ) as response: url_date = datetime.strptime( response.headers.get("Last-Modified"), "%a, %d %b %Y %X %Z", ).astimezone() update_file = url_date > file_date except (aiohttp.ClientError, asyncio.TimeoutError): _LOGGER.warning( "Not able to check for latest paths file for logos from %s%s. " "Check if the URL is accessible from this machine", LOGO_BASE_URL, "logo_paths.json", ) self._last_check = check_time if update_file: if await self._download_latest_path_file(): await self._read_path_file(True) async def _download_latest_path_file(self): """Download the last available logo file.""" try: async with self._session.get(LOGO_BASE_URL + "logo_paths.json") as response: response = (await response.read()).decode("utf-8") if response: async with aiofiles.open( self._logo_file_download_path, mode="w+", encoding="utf-8" ) as paths_file: await paths_file.write(response) return True except (aiohttp.ClientError, asyncio.TimeoutError): _LOGGER.warning( "Not able to download latest paths file for logos from %s%s. " "Check if the URL is accessible from this machine.", LOGO_BASE_URL, "logo_paths.json", ) except PermissionError: _LOGGER.warning( "No permission while trying to write the downloaded paths file to %s. " "Please check file writing permissions.", self._logo_file_download_path, ) except OSError: _LOGGER.warning( "Not able to write the downloaded paths file to %s. " "Disk might be full or another OS error occurred", self._logo_file_download_path, ) _LOGGER.warning(traceback.print_exc()) return False async def _read_path_file(self, force_read=False): """Read the logo path file and store result locally.""" if self._images_paths and not force_read: return logo_file = None if await aiopath.path.isfile(self._logo_file_download_path): logo_file = self._logo_file_download_path elif await aiopath.path.isfile(self._logo_file_path): logo_file = self._logo_file_path if not logo_file: _LOGGER.warning( "Not able to search for a logo. Logo paths file might be missing at %s or %s", self._logo_file_download_path, self._logo_file_path, ) return try: async with aiofiles.open(logo_file, "r") as f: image_paths = json.loads(await f.read()) except Exception as exc: # pylint: disable=broad-except _LOGGER.warning("Failed to read logo paths file %s: %s", logo_file, exc) return if image_paths: self._logo_cache = {} self._images_paths = image_paths def _add_to_cache(self, media_title, logo_path=LOGO_NO_MATCH): """Add a new item to the logo cache.""" if len(self._logo_cache) > MAX_LOGO_CACHE: self._logo_cache.popitem() self._logo_cache[media_title] = logo_path async def async_find_match(self, media_title): """Finds a match in the logo_paths file for a given media_title""" if self._media_image_base_url is None: _LOGGER.debug( "Media image base url was not set! Not able to find a matching logo" ) return None if media_title is None: _LOGGER.warning( "No media title right now! Not able to find a matching logo" ) return None _LOGGER.debug("Matching media title for %s", media_title) await self._async_ensure_latest_path_file() # remove string between parenthesis () removal = re.finditer(r"\((.*?)\)", media_title) for match in removal: media_title = media_title.replace(match.group(), "") # remove specific strings for word in LOGO_MEDIATITLE_KEYWORD_REMOVAL: media_title = media_title.lower().replace(word.lower(), "") # remove leading and trailing spaces media_title = media_title.lower().strip() # check if log is in the cache cached_logo = self._logo_cache.get(media_title) if cached_logo: if cached_logo == LOGO_NO_MATCH: return None return self._media_image_base_url + cached_logo # search best matching logo await self._read_path_file() if not self._images_paths: return None best_match = {"ratio": None, "title": None, "path": None} paths_checked = 0 for image_path in iter(self._images_paths.items()): if paths_checked > LOGO_MAX_PATHS: _LOGGER.warning( "Exceeded maximum amount of paths (%d) while searching for a match. Halting the search", LOGO_MAX_PATHS, ) break ratio = _levenshtein_ratio(media_title, image_path[0].lower()) if best_match["ratio"] is None or ratio > best_match["ratio"]: best_match = { "ratio": ratio, "title": image_path[0], "path": image_path[1], } if best_match["ratio"] == 1: break paths_checked += 1 best_ratio = best_match["ratio"] or 0.0 best_path = best_match["path"] or "" if best_ratio >= LOGO_MIN_SCORE_REQUIRED / 100 and best_path: found_logo = self._media_image_base_url + best_path _LOGGER.debug( "Match found for %s: %s (%f) %s", media_title, best_match["title"], best_ratio, found_logo, ) self._add_to_cache(media_title, best_path) return found_logo _LOGGER.debug( "No match found for %s: best candidate was %s (%f) %s", media_title, best_match["title"], best_ratio, self._media_image_base_url + best_path, ) self._add_to_cache(media_title) return None def _levenshtein_ratio(s: str, t: str): """Calculate match ratio between 2 strings.""" if not (s and t): return 0.0 rows = len(s) + 1 cols = len(t) + 1 distance = [[0 for _ in range(cols)] for _ in range(rows)] for i in range(1, rows): for k in range(1, cols): distance[i][0] = i distance[0][k] = k for col in range(1, cols): for row in range(1, rows): if s[row - 1] == t[col - 1]: cost = 0 else: cost = 2 distance[row][col] = min( distance[row - 1][col] + 1, distance[row][col - 1] + 1, distance[row - 1][col - 1] + cost, ) ratio = ((len(s) + len(t)) - distance[rows - 1][cols - 1]) / (len(s) + len(t)) return ratio ================================================ FILE: custom_components/samsungtv_smart/logo_paths.json ================================================ {"fuji tv":"/yS5UJjsSdZXML0YikWTYYHLPKhQ.png","abc":"/kMvN5R8g6L0SY5r9YZw9foYGQr0.png","bbc three":"/ex369Frq6w0PaQsVofp21C6tbuC.png","bbc one":"/mVn7xESaTNmjBUyUtGNvDQd3CT1.png","bell tv":"/5hksRDWDoqYYkq0q7KWk4MkMCoZ.png","nbc":"/sGeMxrk8fWDYnVMFut8IXnuIy0R.png","māori television":"/bwO92lNZstiQHtM7CSD7YNPGYM.png","itv":"/bT7I2LdsCyEtKKNRZ1p15Emz14B.png","discovery health channel":"/yiBepnHS6gdzlT6ZIehYnNl0nEG.png","cctv":"/ufmYwN934igUGuPaorjcfsg57IU.png","htb":"/7wuTb7iMz5yLoJ8hIyBnUxqR3mP.png","nickelodeon":"/ikZXxg6GnwpzqiZbRPhJGaZapqB.png","pbs":"/d4OH7tMO4ece61s4j7mJWqQejv.png","cbbc":"/9O6hVu6gePt7097QdqKxxSF9Suh.png","cbs":"/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png","mtv2":"/6o7HFq6Sm6mOt5Kh5iL6VzqJ4No.png","fox":"/mGIwo5uKaPMK4sRNwSwRl9nmRtd.png","abs-cbn":"/kdcvqvutGKUsIkeSVGl8pQMkc3k.png","the wb":"/9GlDHjQj9c2dkfARCR3zlH87R66.png","discovery kids":"/8woBOtitimA6diobq7sxCIwj37G.png","cbc television":"/cw5WW6cc9UANam4A6o1BDua9njN.png","bet":"/pejxmP1m6GiUZj01jhDN4a2tHKq.png","televisión nacional de chile":"/4aSX239LVbBu6LPqlUBdNMz0W3B.png","channel 4":"/hbifXPpM55B1fL5wPo7t72vzN78.png","univision":"/72FxNFjskNZMIKXoYrqkmmBROzM.png","espn":"/giGGjdUouGoQki7LAIvhAzXdjbN.png","usa network":"/g1e0H0Ka97IG5SyInMXdJkHGKiH.png","zdf":"/sfZaVx3svlzIoNoerdgb840TPD9.png","mtv":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","lifetime":"/kEeaVLcJ6L6jq3v5YlPcjQs9igm.png","nick jr.":"/zmXCDvQt7AUf5ci1ldjFfccsmJc.png","atv":"/loMzcvCNgox1LdFwpEQD8Q5vvdA.png","msnbc":"/eV4rShI9xT9tzziSFFqGvVbNtza.png","network ten":"/cUq4tWKFvubij22k2v3gGu0cniX.png","irib tv5":"/r5hZ043QIKBhvIGVOcwN5FVn5Me.png","upn":"/333LtWX9Z7H9uRrNcCl1JcTvdpR.png","tnt":"/krzxf46PwPXxUtjnn3g4eqrcyIu.png","tv5":"/gVinXXdpdbLHuXTUUfx4R9PyuPB.png","national geographic":"/q9rPBG1rHbUjII1Qn98VG2v7cFa.png","disney xd":"/nKM9EnV7jTpt3MKRbhBusJ03lAY.png","fox news channel":"/zYjYOy304S4JPXWncHS6D5K0mdi.png","ytv":"/cIMyE9cw1W4kMFGxmC17HKTnVz9.png","comedy central":"/6ooPjtXufjsoskdJqj6pxuvHEno.png","tvb jade":"/8QQTIU1Rd0Stw2jmlSi4emv6PkK.png","hbo":"/tuomPhY2UtuPTqqFnKMVHvSb724.png","gma network":"/ftB1h0NyMIeu1H1AtZ8BXtsYIiA.png","the comedy network":"/zWANL77d5GiGouIaL5o8a2hErRv.png","telecinco":"/3q9Kob7joLOHrsJPXQ9clhsF4az.png","disney channel":"/dZvpMwi5e6FFgdJlP08yHb1JlJG.png","spike":"/n2GcqhLEJF1elvp9v5XylSByM5J.png","cartoon network":"/nDFWFbAHEZ8Towq7fsVCgv4U245.png","ntv":"/9XFdqimbyRfbFHulEg1LEpKWE8z.png","televisa":"/3vFK8I1mW11dBfUcCopWd5rWDBH.png","cnn":"/2EIKV6SomKx8L52HoSViKyJO87m.png","rede globo":"/qFNe75EkUaIdNlTqadArD00a62m.png","outtv":"/rJI8nxnuiSB4O0roWpAAB7BEcqv.png","logo":"/ztSNqnJ8W1GOYfa3525xFBDb3NY.png","game show network":"/er3Bx1TMCviOUvhIXyOtFhL9f8w.png","discovery":"/tmttRFo2OiXQD0EHMxxlw8EzUuZ.png","history":"/aeariwRHHb23lyOr3AczHz5aIhb.png","nine network":"/pXibAQSgIIanNkSg68dUooI7IGU.png","showtime":"/Allse9kbjiP6ExaQrnSpIhkurEi.png","tbs":"/frMsfc7igYUd90lQXpxuasiN5x4.png","q":"/5az50emOs73Ex2HNswUr120J0ak.png","espn2":"/iWq7EXFKUO7FYPH6umvE6mB99Ta.png","the cw":"/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png","musiqueplus":"/qPld4NPqfUMLYqrNczsTg71PPgL.png","bravo":"/4rSnMehNZOLOIikboY9FvPZGAFg.png","abc family":"/p57JGkSUBdXbOtqkEKeTnfHn7kd.png","e!":"/ptpx2Ag52sYJG6LiX9zBlnKsQOS.png","syfy":"/iYfrkobwDhTOFJ4AXYPSLIEeaAT.png","adult swim":"/9AKyspxVzywuaMuZ1Bvilu8sXly.png","espnu":"/oByKzXnqvTqCD9ctG0sFMm5Mn4E.png","teletoon":"/mE2ElX5BSeUhiT3V8qUhLjd3Nbz.png","tlc":"/6GRfZSrYh9D6C88n9kWlyrySB2l.png","cmt":"/lSwwz91j5O3CLDypFZgiFYZOjp8.png","fox sports detroit":"/jySh2X4wkO8jQRSSqfx09joD0Nn.png","fx":"/aexGjtcs42DgRtZh7zOxayiry4J.png","cbc news network":"/9hqzoCyMCZEXi2Iiz9P4ivmoRQf.png","animal planet":"/xQ25rzpv83d74V1zpOzSHbYlwJq.png","city":"/eZipPYKRhkp9Hg1Kujyba3oghM7.png","mbs":"/7RNXnyiMbjgqtPAjja13wchcrGI.png","mediacorp channel 5":"/idQWLSFyRqV3mBsMPDLrvfAP59M.png","telefe":"/bBAkTXfNkyjsRVv5ASCgX1WCu0w.png","mbc":"/pOSCKaZhndUFYtxHXjQOV6xJi1s.png","tv tokyo":"/kGRavMqgyx4p2X4C96bjRCj50oI.png","channel 5":"/bMuKs6xuhI0GHSsq4WWd9FsntUN.png","bbc four":"/lYk9QLeOO9RFuDz8pnW8NcoEL8B.png","tv 2 zulu":"/946paJDhQTQ3iN66qT6niWHBSZ5.png","tv asahi":"/usmwgnOfBWuzet8vdWe3dfxXlNc.png","showcase":"/aE73SvqF48ZdeMQ0Ls8kzxiikwL.png","sky sports":"/xeyAPBYArO8q60Z7UJcXf6WPXBX.png","espnews":"/1bRRzGqexnzn5BodK4X4L2SmVia.png","ctv":"/volHUxY1MHjSPI4ju7j36EdhR2m.png","citv":"/rwuEsuBDb200skfrDTvBiJihMLd.png","fuse":"/vkxW1Up7dls1AZIsIjpkNutdxcU.png","three":"/45DWFxTFn4BCe1cNmIWK12nqnEx.png","sky living":"/k7EOxG3Ul7JWaRq2MzxWM04Gxc6.png","challenge":"/qsEPXw9Yp4KIWz5T5HrGBeO5BFl.png","associated television":"/hEdI56n4EHSP6kto7BUPEOb6bqo.png","seven network":"/83L2wF8tM76nUQHzOBOBAGlPQVP.png","national educational television":"/pjQ7a6Pplwm6KmR4kunOwoiqYaO.png","pbs kids":"/1W634Hy0VUkaQpEu0BLbLcveZw0.png","tvnz 2":"/wGMxGK0IlrDER4GqWKqr3wzOj1v.png","ifc":"/iEBH86QxEWoUKOsjDHnpT7qDdy4.png","tv 2":"/ynpqIZd5ZhypToHwGhK8WRhJlU8.png","bbc choice":"/lBk6aoAEPdbtnhR4H3eouQVq9Dp.png","london weekend television":"/iGAUX15FMJDjOwUfRG3Nmm8C1yL.png","sistema brasileiro de televisão (sbt)":"/jQ6pbmLCO2mheofZJPPByy7Wozq.png","a&e":"/ptSTdU4GPNJ1M8UVEOtA0KgtuNk.png","oxygen":"/acDtMnoo5byrtFLEJa6J5BYfgBW.png","venevisión":"/vJf1d51q3nOyYDwrtNfnPxBiwmw.png","ktla":"/a5w0UazZnO8AdCtyaldeWBbmYW.png","slice":"/uSmL90amaWU7yGAaZuhyJipuT6l.png","e4":"/A7k2PSYy38NzDPDhajalNWcMQ10.png","much ":"/yKnRDHAQ0lDt38GS62imutKRzMp.png","s4c":"/GOTWuhzHulAcXJMETunP2Wy8No.png","ici radio-canada télé":"/32OnRA75a9xn30N2fC44IcCzHkC.png","toon disney":"/7ahoCR2iIFYd0p2xbbrhQgnClJC.png","food network":"/uV7RnIEWFS2b3A8AD2b0SSklcpR.png","stv":"/ntnvyI2vhcEzEv8IHzm2CweEfJ5.png","itv2":"/wbGo5nGjF0nFxTajmY5WOZVAEQM.png","tv one":"/iEAtsT9WKt6nzBF4sPpwxfNFo80.png","utv":"/6PFI9tl5Glq7NLOCqsQXwr4rnlU.png","rté one":"/2kjTvhfxtXLnHspCUZQ9cv9gToV.png","sbs":"/aQ7chdTN42fz0qtmih3yYnDWp4F.png","tvnorge":"/v25T1fvtINzeQMC1AdwFPwOL1no.png","vh1":"/5pvUd1Qe8j28nLDrRXCTXC34ppW.png","starplus":"/9cEITzi1VyVkre3Fom57vJBOEW3.png","dumont television network":"/csmISkmy8gil9Vp0pnQlNZbLdjU.png","sat.1":"/tpuoOACmOTDY96gSQ2dT5sd1Q3Q.png","g4":"/apAZlt5T2uvebXaEN4f5Zw4C1pK.png","cbeebies":"/h05xw1JP88QtiOtvFw8HENlFafx.png","sportsnet new york":"/bSXHwrwZ0WknsA56lCynzvZ8cpI.png","channel 2":"/goKg57ALHXJnoMBNyKazKOlSNPU.png","space":"/5zfwLyBQnPuBsYeo08qf7pR8ooJ.png","animax":"/ozIYsP32g0ckOd7e2EqUTNpTwUE.png","wowow prime":"/dCN47YS5lugkwts5ellpW51cBzL.png","at-x":"/fERjndErEpveJmQZccJbJDi93rj.png","amc":"/alqLicR1ZMHMaZGP3xRQxn9sq7p.png","cnbc":"/q9ybGdIXHriXSAVynw0DwAahEzb.png","tvb":"/u6ij3DirPrXgbvMllZFGdFwyx8I.png","dd national":"/mXNmU8Ya7ljkap8P9F5wm717CvS.png","rcti":"/qML2Ii1tKVCjLw4n84iWsojuBlq.png","soapnet":"/uf48VYnrrEaHBSNLxJbbaaEuugv.png","channel 7":"/ibIbm8NsXYrrPLRmSG74hfsVMnR.png","hln":"/5Gjx3BpBh0NRagEeuqPd0NFGRIz.png","rcn":"/46LNdPSG2gBxy3s3G9csA8vLH8l.png","gtv":"/5nhtCgvP4jJ5ZN4ibLzcuw6dUbN.png","cnbc europe":"/fVspXHRXTD2mLc1YFzp0zdWCYsl.png","tvbs":"/lSdTUbOqNyvGHwiypMJVm0ZqTb1.png","noggin":"/jQmuRRuydB4XPm6yTv6Src7LPZa.png","orf":"/3Yu4dZFU9eaavEj9U5s5ptl4fHj.png","tvontario":"/hDshGefbigrvUnGeRlqSQzUtsJB.png","m-net":"/dJ9KWprPx7AXMOxBrGJoadLhhZQ.png","telemundo":"/mieMBXx82qgY8nmnyaH0rY2D943.png","cnn-news18":"/vyRTawSUJVJpRQbm8LKsYQkNSDp.png","dr2":"/hvXnIfzUJwwNryDZxWfKGRR1flc.png","family channel":"/uarlhdBymUYsmk0s4EoAaGtxuB6.png","mynetworktv":"/90qPHtj2iZXLwq5nMS1xDtVm2YZ.png","people's television network":"/yr4HMPCZfulgTrqWqLldnWpoVer.png","cbc":"/qe2RYSTCxbPh3jCaM1tk9E4uJZ6.png","wgn america":"/kCNFRiqVRMgNWKSWu0LzAIpy9um.png","tg4":"/3gyjl5W69islPd2OZipKM3H8QKb.png","movie central":"/tSIeioQXU8kPnaMleoaCSrdfEHt.png","axs tv":"/m4db3aJTfBhgpgXoiqQRRS2znXe.png","rtl":"/ttANGe4D31vZoMmtmolsHSlZUAy.png","travel channel":"/8SwN81R7P5vD5mhtOE0taw5mji4.png","hgtv":"/tzTtKdQ7vC2FkBvJDUErOhBPdKJ.png","netflix":"/wwemzKWzjKYJFfCeiB57q3r4Bcm.png","sky one":"/dVBHOr0nYCx9GSNesTVb1TT52Xj.png","treehouse tv":"/q0I6cg9HiMt7Jpvpi69cj8t5vOC.png","global tv":"/lpB2tPkovzbAbYfyBjJjbptygfV.png","nhk bs1":"/hX0BGE9ZWkb3rVAVknHwGbE1MxV.png","intercontinental broadcasting corporation":"/jpWs4vSQgf5AeG2zMPDySKgoZx8.png","nicktoons":"/xlUUkgMevNvnKXcZ5z7F6R2CaGw.png","playboy tv":"/eO5aasMR3jdIE6wtaPdHSyn5R2i.png","science":"/orcpefChr3hPSipPcoYa9gSq7ev.png","msg":"/w3lRAzzr0AZfHlDICAAPZRn0uVX.png","rté2":"/vmTwurCvAuE15Xx5SQs0o0uo0rX.png","irib tv3":"/vRLKJDsQPgA8BfBG0pCporH7EZF.png","kabillion":"/4R1535w4NgBHAwqUYK7Y2F75rMH.png","here tv":"/nTNJrWvGt1DT9iwERf81hSPrrws.png","rfd-tv":"/aq8hpQPfhUSEwohm27RAhYLnKZn.png","teennick":"/h04Po2f2Uqq19xYHB4mbkjC7njG.png","rai 2":"/ar0fBQkxzbBYe4S8zEGnrfZNBnm.png","visiontv":"/dtnGkrdsbMsTRDBIxGbciiqWgwO.png","kika":"/1dtumlarEVXeYX1BrUFS9qLoG2Z.png","fox reality channel":"/xlybmiKc9R93u1xBzgRBVHh1wAD.png","investigation discovery":"/3s6YJSYXW9mtJwoszTVI1BHDNc6.png","the movie network":"/7UcTAnlDj25ro8LQZJSFfyx4MXT.png","youtube":"/9Ga8A5QegQmiSVHp4hyusfMfpVk.png","france 3":"/1ePfTUHHvBlVQxa0Fltx5kWhIs5.png","audience":"/tmhbFiRpgJFSmza9GTQam8ICyHp.png","tv aichi":"/zFZ5KCuvx7K0vP9XgGcidfXjuUd.png","chch-dt":"/ea7lVHA90j9UVg5EAEriWkBY6Y5.png","france 5":"/8xK6AdNT6PfRyygRvb2MKCoqrv2.png","the filipino channel":"/A2ZQ14XP6dm8dfSZiKR6gwUSPFB.png","speed":"/kZBWkOGlWsDzcmWBwtRCL3nZERs.png","svt2":"/2XpT6uiGyZH6tXbYATb8mTdThSc.png","nick at nite":"/oiybiBcUD3kG1XGQd27oWxF3zvF.png","itv4":"/9jZyZWsW7ZBY0RfvANDFj18Lq6l.png","soho":"/w3WfrSFdWiEr5dJMC3dDMrIyzpp.png","rúv":"/r4KMDmIFmjf1xhE3OmbSgaGNxt6.png","sundancetv":"/xhTdszjVRy1tABMix2dffBcdDJ1.png","télétoon":"/d7roMuKFcpBtQFteOuce4HjQbFV.png","mega tv":"/9BWPicebrzShoc8uJFxHLugjc5K.png","rtl 4":"/llVa87a3nemOfjSTp8yTR4xQCUa.png","stöð 2":"/iSwAMV35QvYvtjEd6Mds945DXQt.png","abc me":"/5HcWDo0e9WFKAMx1CQ8qlztKpdV.png","disney junior":"/gcKywHY6hQ9ZO8x1R6rSUNQ8P8L.png","w network":"/bh9A7b8ejqcE15CKzUZ6Efo45Dl.png","canal+":"/n7BRwMvmF2xaSAH8PfJzacrhOU8.png","cnbc asia":"/44H155I10tsBBvJ67gO91N5BH3l.png","eleven":"/aWWP8dpuGhK3W6NduSkGlo3JbPn.png","rctv":"/eFSACJaHlGL4X9UwimqWs2n0URg.png","tf1":"/fqsd09CrijoGu6qfoNIdgUQmVGO.png","win television":"/su4QkMUe6HngIPIDHZV5WrDSvpk.png","ion television":"/eYUrZKBbpdNX4xfqrYYtGXjqo6A.png","mtv3":"/u9LyIrxe50xzah32svsarAiYwDd.png","espn classic":"/lv2pOqUDdHYjwKD90EV3muMWwST.png","more4":"/sO1KfGZzrqIiWohoHgzbvvHEAkr.png","8tv":"/maA70URks6mR45DatydS3UuEmlp.png","nba tv":"/bqrgXjfeWqTc9oTUgCcT4wTTQwq.png","tva":"/gc3SsJUQa7mGnHLds13zzoxG3zd.png","mtv australia":"/l98wmIRlhjGZN1nR9YMXi8oQSae.png","the weather channel":"/y53akJvADKuINKDfyrbnxk3AUmD.png","ard":"/n8OnhOKcr9buScZzEvfJKO6d6gz.png","ntv7":"/bYgylwAxgdBaBjVYHgFDrTkXhlW.png","československá televize":"/usWXQzUqjcRQ2fZYaKae5G0941M.png","bnn":"/2RG1bG0viFfNR5K6IzacRgccKXf.png","nfl network":"/bbczSB2PKAbypWMcYxWpNaSjQcD.png","polsat":"/wY4JejqerW2SLtbI5poStyHyXNn.png","trouble":"/5BYWSGLMj5aG0w7A2UtUjboVRU9.png","bbc world news":"/k7mbvioXIHBZqg5nWlLCHzq2OEH.png","starz":"/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png","canal sony":"/jGGIaOA4YmFeFi1S5krXZ2zaS8J.png","abc comedy":"/yoWi43bVJiBKolwn4zvq6omZl3o.png","c-span":"/fbDHCA8G5SviMKRvz1W6W0DRgLA.png","rtl televizija":"/AlVhoGZIyM56mL07y82SnqAookp.png","kron-tv":"/5SgFklnDuIIYNtD8gjCP8IiBcE1.png","oln":"/zS4kwaXhwNhMhoT55yOPtQqHxhX.png","fox8":"/84wCbPq14btKqZ60shwkf78viMA.png","bbc two":"/bfAVKGrJGcKTAndYktB7cf8UlBO.png","free speech tv":"/gcLVgJnxsOi41pf1EG3n5cc05zF.png","prosieben":"/hComKsCkgZRMWQQnNWu92p3ndSR.png","mtv canada":"/21R9OhboglOCwWQOKiS138PZ6fk.png","fox sports":"/3sFV9ixObo5To5GoGyEWg0U1qHU.png","kbs2":"/nFmWwUAf2D3iAtizUcmkxufaM0q.png","channel 3":"/jjndRHOq412g8D6Hjm63rZMxAnO.png","access 31":"/vCHdWATtpBx9HYqKvxCvZr6xhlR.png","vrak":"/jbVGAEHzFWfEGvP2SEJHw3TWqV3.png","tv4":"/qvD4lPRiY8cmgTpINpsWX1aNdcY.png","studio 23":"/1PRHnwFCZGuPH4nNSTCZY8A1b6a.png","unimás":"/du69v2G2f3MIxuf78hSlfDesIUu.png","cnbc world":"/hfmJTKDzTNlV9jYRROE3eaqF3VI.png","svt1":"/zFb3TsNzVC8STlkSzbnF1FTHXTA.png","cinemax":"/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png","fox sports networks":"/cjf6hfzFj7SPhJZAvLHP4Th69OP.png","france 2":"/b1cFa5FcHHnpejKiPybXSPcqKjT.png","sky angel":"/eLhbh9N9xJpUkVi7qksiT1Rh3kG.png","hot3":"/6GFmliU9Dv6pgSPfhMra6a89HA6.png","trutv":"/c48pVcWAEYhEFXrWFsYxx343mjx.png","reelz":"/ngpAA1R1mOkiwC7RAxTbgbip6DZ.png","eltrece":"/j8Sb3Y5vo3wfOjMExmxLF08mLwX.png","trinity broadcasting network":"/rYyfBLqn23ZeWkrjlCoxZQONcXf.png","play uk":"/59bzfm3ea1STcL3pSmKwxWBsG4u.png","military channel":"/7J1OGBuET20pHmy0fZyHXaqBu1z.png","cnn international":"/hlKkXccYxPfisU5c4wo6fWPut1G.png","tv3":"/kf1R7pshifwcdkhJO5tAEY1wInQ.png","bbc news":"/hmOvRkVWqP0vpFXwks8krePxdKu.png","sub":"/pjxwmLHvlQ658M2I6NOe9FwaPln.png","cuatro":"/nwUbl4jzIFLmbpJgenk5VN4wMp5.png","nrk1":"/4TTFfXnGIEosh3EPYX25ERWp1XG.png","mtv base":"/lj484gn1gauJ84vZLTlwxVC3gjb.png","gold":"/koB7D1eoGWaKvyKQtYsiLeXlSm.png","hallmark channel":"/9JTL7HcaiVxq7M6eu5m7giFqaxR.png","dave":"/5pS7SazUfbPHBlYy8pnt1y0TgFV.png","dr1":"/swq3ovsx3N3hUmqODqIAU8BSdos.png","canal 13":"/5JKjWvmR6PQLI4PYh9Lc0RvjiG9.png","tv land":"/lOCn3EqluXoP8olsZsJHoWITwQJ.png","tiny pop":"/5EduZEQmHFuJ7IN0vD0ASzEdzOH.png","antena 3":"/l7MngINTyv0O6mNlwNsUlhQ9iwZ.png","cts":"/vYmVFpUIzodAYpVyUzkW62n9Dz.png","italia 1":"/2cXinuyZFHdOT0hZW9ZSZpcQOe.png","sky two":"/lJMJ2ZIy7paWGAHArgAgVdP3Wlg.png","turner classic movies":"/bSp7U5Ok8JVsmnIVYBcKWx2QIsJ.png","wpvi-tv":"/vkZSDeY15RRknUSfi4NFRP6RPXj.png","mtv italy":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","rtl ii":"/ybTppkzqb3XOWKNcco144BFlosB.png","russia-1":"/t96cnWLH1OivW91EXygZDNSmhAK.png","nbcsn":"/42DK95aYJKNUHECYorwF9Y4pEWD.png","bbc kids":"/jQTIHNpmVPlxJSW2AFGxCalBGQs.png","fox soccer":"/bd3HqAbKmzHlEikgaVaS117ONOS.png","royal thai army radio and television channel 5":"/yzOyN0ZWBCYYKGJn1T8B7AnP7Qv.png","rtl 5":"/xcJeCtgQl8Ren1UiYgYCu8wpxNJ.png","golf channel":"/qeAzttzlfeWS0j2bJZ4EmZshRIR.png","galavisión":"/hm51VPYcKMALCh6bXbIXN6gfUyB.png","bio.":"/6E01nNIHCQK4jyULu9BXrjgrjD9.png","bbc red button":"/woHKsYIoXVKQkTnCdHnr6QSu1fD.png","revver":"/i7kP2gmsRljZpJbFrGFdiPy1ntD.png","bitetv":"/8IrJJJZjn7OoAjBCz5igoPLtzDq.png","familynet":"/ajOiVQbESqoIli1a7Ecy7LvpvQd.png","pro tv":"/eMJ75CQIE9q7LBE3SWsMtxUlP30.png","velocity":"/pwozKkEio5Nh1UvffRGL6rP73Ml.png","kanal 5":"/6kmasH8Da0KIhNPFh79FuLhCF5m.png","retro television network":"/kxjWiSSpQ6NLQV6lgR4jnxdrPaW.png","kanal d":"/qJXllHIGCEUF0MJUUnAMErglUjF.png","irib tv1":"/iFfnecuKaF8i5MewrOvTyXfgR1W.png","américa televisión":"/u5PIQvo5rpgb97FfhYAYFURKSjK.png","das erste":"/nGl2dDGonksWY4fTzPPdkK3oNyq.png","la 1":"/dN6GTZyNY32q2onfrvJ2iAeE4su.png","channel 1":"/7Xot603KMbXkbW4JOwyH2HCBsai.png","collegehumor":"/qtOgRuzVgP8skW96UmIYSIj61f4.png","we tv":"/gWllamFWxBczPiFxQzHZd2IAY9W.png","wttw":"/np80OW67ZkZDuuQmw9woMYrkHvZ.png","palladia":"/hP68W3FIJAyd4bv8UPgmbUPL2R5.png","arena":"/ccn7eromR1KWcv5xefUw9rGAVld.png","hulu":"/4giYeGORZzztkAAd1HOzrQ4iaLW.png","mtv europe":"/eQrvQf4A4P6iaTw2JTpzz1XVHCJ.png","beijing television":"/5LU9zEP1SredmscM25lv7Z1xW3y.png","c more":"/bWxX52rwNnkPVPtJCPKP5wlD9qP.png","asia television limited":"/94NJxCdLsn1tMd4EHiJPv2iUODt.png","pop tv":"/uHBySX5Mfre9yK5cjstlxvZIZqt.png","yes network":"/3frmQPITaRTkeHW77wZUYUOPSGl.png","btv":"/7YOAnseO7SUk9mDlRo08OIDbHTh.png","tvp2":"/6SnRcF523HHQ2b0396M6aBMvZ26.png","nhk g\t":"/bH9mwMa64tQeVhmTmkC8Qn9Eoe.png","aboriginal peoples television network":"/qqTeEEAdCEfvYlTFXll8uu8Ld8K.png","ttv main channel":"/2HVxTrTSKWoFsYB7KxxwMMP1LBt.png","sky news":"/9YoK4mgfqsvxqWMNHQcwfWxKxaT.png","fox footy channel":"/2dMiTtgbRffsskfaGOejD4TO6Ff.png","tvp1":"/5XBLFR7RjHrWrK2MHIpfxpkGei9.png","truevisions":"/vckXfsEJ2Co45VjsosM7mk20cqn.png","kcet":"/r3yany6hVMVFJ9N6gsKhEWFdi13.png","las estrellas":"/6ROTTOqsZo3XefxZAYowukFdJ6w.png","latina televisión":"/pJz1mGZl0xKchDHZkuV9XDLW4Go.png","bbc america":"/8Js4sUaxjE3RSxJcOCDjfXvhZqz.png","ctv two":"/s6BePy1BOPH3199Wkaf6WVnunpi.png","channel one":"/f8exyCoSdsESrCrlMgvETOnLgf8.png","caracol tv":"/6lbu3Xq8ZsTMrS0AnPfMtPhjxQk.png","star vijay":"/E4LG7cTNrM8wmuCVTFkoJIkst9.png","séries+":"/cB2uQLRWbK3D7JNYtBCrpKwPGm8.png","tvn":"/qMRwARkeUfBNFdLTfDztUpqUEpE.png","max":"/as9IofVgrJwSQSPi68nXPuoRRxp.png","canal 5":"/kaxn1QFpkkcgacw5DL5lKarz7HA.png","5star":"/pkvQvUAnAKn9BLyoWSk7l7cm7cp.png","setanta sports usa":"/8koUXsbccaeo14v9bfrPJkjXat3.png","israeli educational television":"/zwLZfirHrwWzBsymtN5Gy5TiVdJ.png","france 4":"/9v9Mink1IVxPNRZOmCZ8N58BOHe.png","rede manchete":"/5PyrCDbFSf3rVa3WyyfstEPQHS3.png","channel 9 mcot hd":"/fBUJZboOehTu3izEeECjocUIJyS.png","pop":"/m5bolPhhZJ2SZS4XEo8ZCdluCds.png","nova tv":"/iBibvLYwkZuYc8lawEgGoY9GyNn.png","hub network":"/tL3aAxpgiKdCbSEbmfLKv08jXdl.png","channel eser":"/xkcUMRrfd9Mf0Ymcoz7hwOH63vB.png","cctv-8":"/vvLZmx02cxpws9IX5P8se3ARBDG.png","super rtl":"/nDiZSdUQvqiAUROq6DubHA5pLDd.png","boomerang":"/lkMfZclFXosrByxWf459NrXBiRY.png","colors":"/1qTv9p35O9i5x0Swg1VbfzLqYBq.png","nrk2":"/uyuXWEhdRer47riC1lwpUHYI9sZ.png","zee tv":"/a9g7n8Frkiaaf5olShkyhHTk6bC.png","telehit":"/aD2mQH0cLAJOHg15J8pRIT3G7Ep.png","destination america":"/wEIVk7jOJnEwtKhrS5lNW7WPd4L.png","tv+":"/i6D7RMbssq9z0iXdGYeAGtqqWu4.png","esquire network":"/czsUqezqDyLlRnI3dUwytoOApp5.png","rai 1":"/pLsnP0qF90P4QAXTKGPgDsftKDl.png","recordtv":"/aiFTuwrebn6HOoNq32Ytkm3MEKZ.png","canale 5":"/5nhlFNs8ASHZij5ZNvF8sXwpLAL.png","star one":"/yqrTS5shXIjd8iAbUKUTqImgPQi.png","sctv":"/3Zs6z9zA8mELGA04fnFfy2zM3lm.png","tv9":"/d32YycZbbedFEIH1n0c9mkxcwew.png","dubai tv":"/pBS3SKvsoXDL1SEGjkseZ0vtwqn.png","rai 3":"/eRLfW6GOHrV9rOE0YnUIYzIUjyz.png","rustavi 2":"/h0HAku8p8oV4X3eN7SRbideIrYv.png","markíza":"/e3v7L2zlVEO1GLH7pv0f1CNFIeK.png","tv puls":"/8gwxEEoFc0NdhJ2PFurREGQTb0x.png","sky arts":"/jNJA48OhbJjDJtlBhmegJsMcAkH.png","rtp1":"/iJBaPuHCVjROCUXZzGuon3oDqMC.png","tvp":"/AqrV3Nzf7Ofja9asZpkUxkBeOlC.png","vtm":"/383jAo1fLu8FTnbjFRY7gS4s6K2.png","rts 1":"/7MHxcb0oaOEEerv1TAKMwJNS8Pn.png","link tv":"/8FrUVceUlrDQlMpoO5igxUPHuc3.png","onstyle":"/iOxSRZyRJ4W1eFxE4KjEAIzvgEQ.png","sahara one":"/qsm88BnftOCFmNa2NJFpmHpV5Pj.png","azn television":"/y0nd8l1EWIqttcOlgrobC4GrzMD.png","mtv pakistan":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","srf 1":"/txYJaM2VKunNRk3JooH1mnsVSOg.png","ant1":"/xHFlnu2Mn5OhekVLExa1XYtcqJe.png","mētele":"/kW9bMQqnMFtPHvdoyCDpRqh7Gxt.png","ndr fernsehen":"/ycqsY7zsvi2EkLAdAKn4rozvOpf.png","asn":"/cTgn4vc0AOHeEzL9ozg8vNmmrP7.png","wnbc":"/7OE3m5MRyJqBygASzLJ93TvvSUK.png","v":"/l3TVdYRGHqcDWMXxHdofswgynW9.png","the nashville network":"/9otXwFJUXTRyEY9jMSoGVhZXF5d.png","itv3":"/n4a1RiUD4F9lr31WLAEg8oHnci2.png","tele 5":"/2T0uzcONwa6w34MWdlN2QzJ08sr.png","tolo ":"/qsjFn5sFnqbIEgzqJapYRjvZ0i8.png","pax tv":"/tNnUEFZV7RmtUsHxVG3RmQn2WUn.png","abc news":"/sUZ0N10Qbg8zbZ1IIgyUTfhY6Ds.png","cp24":"/1o1hmmTRejn8gUH6IsvLFwm2f6S.png","astro ria":"/eD4l5QOBKFGojiWgXlQ3qQE7ntO.png","rush hd":"/oxLhbVU6SrEBB6CGK1lw6oyVLEp.png","televen":"/f0Ylm2274535HSBgicsuVpdnx9Y.png","ntr":"/a3DUk1Tpaubu4vGUITUa4NQSlGZ.png","kids station":"/7V7vR2yOXblLOZH0qa1Zcqs5BRs.png","tokyo mx":"/3qFArHw6nrFIdkH2tPht701sNhs.png","yle":"/9KQP9AEEqOpSVYgih9tqJKCkA1w.png","nyc tv":"/9eeH7SsakrYk1QC4KsUEug324ZK.png","dtour":"/fRv3FaOynrlFKmgNKxloMCH6BjD.png","new england sports network":"/tilq1nUQUDb7LNh5hcHyI5Laj0N.png","nhk educational tv":"/1N9ItImbsdy4AZcfs0kUlyYxQKV.png","diy network":"/bg1njNAqzcSefrDF9e64hQvRMbG.png","discovery real time":"/qaOBgzNyO8imgMFCuvMW1jtmM3d.png","ocn":"/x1PpeMBZ1bJzitJyPuuUMRN8u2z.png","nikkei cnbc":"/uZ5iuDKHe5N0LMXsGfe3UCvkeqQ.png","yle tv1":"/5WItBySbMUAklcJMVv6k4GEOR6a.png","čt1":"/4fuYKKu4XvCIgFh7Sg2j8Uzgjmv.png","nbn television":"/hl7xdx55m7dySt6sElrKZBOBp3p.png","fearnet on demand":"/uEbZJTy9migMWBlIeAQ6sHq30om.png","avro":"/uxRel2YeOgNiRwo28NIakZfSKi2.png","future television":"/6JuHPsBeKOV5uU0LAUQJEBjCjvZ.png","super deluxe":"/1r6pLSTR1h7DQTXa87UXoiIi3Er.png","tvr 2":"/AaJV5H4lZ9pMXBHyyimUU4OrgfS.png","the sports network":"/eEtN7vNV9ISMft8TYUVvWfq95OK.png","sic radical":"/zo5Fh88xy3wjk6HqiDVilStaTnh.png","yes":"/9yWETECjfnsxZ9UtviBZXaZ087k.png","mega":"/pd1QoAjnhfXZutHOQ7KZtu1ZWkO.png","latv":"/n5a1CuFl9aD5cPNC68NUrBEG4Dv.png","vpro":"/96hCWEsfmTZQsoUap0CcG21qaA.png","good food":"/zPoHdGnUH4MpIeS6SFqr0tr0wzw.png","the movie channel":"/psTUtm3E7tIZ3D8IDOtfaRxYvix.png","arutz hayeladim":"/oAvXUCqp03ZYqfwokrCbce2EG2F.png","my damn channel":"/cpVjXtmCE1zUrbim16qOsypLsBL.png","trans tv":"/7k6LEMLTtmiMfSqZ8Ai7DMpl2W3.png","smithsonian channel":"/meU2LRnHcSr0m0Upx5loxHWETqF.png","daystar":"/5y8rXfTAWTU3WYy6DSbLWZGiYcw.png","indosiar":"/rINnk0IoS0jd4gMHCOsmRTD9iJB.png","arte":"/6UIpEURdjnmcJPwgTDRzVRuwADr.png","chiba tv":"/8XNtModCxAwtvLy86b3phkE1jge.png","vh1 classic":"/nXv4IJqVHwFcAnDyJMv4P8UbrwS.png","nasa tv":"/uy6hjTBQa1qK5iP9aiJJGXKb4wj.png","bloomberg television":"/njT7K8UuwxAmS3ZgS48n79OJcWX.png","telesur":"/2ygPNCW0f24ACFG52nfvtOCKcZK.png","discovery life":"/q0s7tkK10OiIYM5eHO1dLRpOem9.png","aurora community channel":"/rTdsdWYeHNrXvYlOejMYgJlm8cK.png","ctv news channel":"/2A5L0nQnOHk0lF0FViwxX8W4Fcd.png","tr3s":"/iIW5CDRQMlYkFYF6YP3AV3TTwI5.png","eurosport":"/AfhbW2Y6X9uwoZAgoP0cOfSPoH7.png","sony entertainment television":"/jfGyjgH1i22xCQGFoQTPZyFLVR4.png","ztv":"/rvg8MLiM3Lzzwi7CTd02Wh0kUP4.png","christian broadcasting network":"/oscTUqzDq0xgrACJOuOlUy2WSyD.png","ren tv":"/mh5PeHiXKewYD8UYSKao5c7GFW9.png","canal j":"/uMMKGhrUmfXtE5SXLCyNtH6gJsV.png","lifestyle":"/9MBltiYrqFNgMSecwmFPrf1UAfS.png","bold ":"/ryeIUr3wnwUGnKZ8egBDN7bKgn4.png","home":"/rh6xH8UIqx4o3Jb0lMZArcsTurq.png","tv cultura":"/zfSn03BXad9VadOBPe5HcGrMoZW.png","al jazeera":"/vtEHxJKWedfOLGoibApOrFBBTES.png","tmf":"/zmukzR1VC0ee3sAYwE0kKUUDaoP.png","kqed":"/wARyrEcUQko2AzJ2JepJOr2s4fd.png","kqeh":"/gnRkWdoYbBSTssZzgVYsMPDcs59.png","one":"/dpgEQOqag77UYovQLM1GXNWywnH.png","cctv-2":"/kTWfqWGvWDzMTkmVGVEc363DjrC.png","venezolana de televisión":"/bwfWL9rt7QPvOXlflLFKG82RmoF.png","yesterday":"/gWBrjMLsPypHyQqin4cXz7i7v7.png","tv nova":"/twuMX6vryssdv8oY01ZD5eNTKLt.png","m6":"/ebCZs9U5Kq1GqOFBT1tlH1lZd8p.png","panamericana televisión":"/a4MhsRdI14SBcQYXioukdJTNw7R.png","teletama":"/yhFNPfWqQTOj8olwR4Gk2H5397X.png","tvp polonia":"/rCzuIh0Z0PLtBnoiQdP4VgFMPYP.png","jnn":"/3nGVNDvVqavpmlVk6jXDPtP4JSy.png","tv 2 zebra":"/29jlOx5toz0FQWAC4xvLHThRasi.png","funimation channel":"/a5oMoSOlNaH6AydiqLm6Gf5dnAx.png","tros":"/cqDmmhm6hajmnPYBIBNzdVrDqK7.png","kcts-tv":"/9qE1ID398SaX9zYiRiPHdZfPTbt.png","teleg":"/6chdmJlRJQ2LgsGbUuUsIQG9fXy.png","kgw":"/oEn2me5z7dMiuiyb5Fhq0NthIc2.png","mlb network":"/rN8HUqQIGsHQwTMamuQmeAZdqY7.png","great american country":"/oPiqGNF9lEOuyZVRfkph9YY2dEB.png","show tv":"/8kD8Spe7RlZOqNV54bIvLuAtprO.png","geo tv":"/5lRMiLqqjuuBFOfYi9Z0m0pCOKS.png","outdoor channel":"/iRg8Zh8V4EB3KvWFDrAfuwmHfS0.png","star world":"/cGR5NFbkIsJayT8lcvJyiQmZq3a.png","veronica":"/qmnO61PjyHfi2HqsAQszL20GLpx.png","starz encore":"/kZIZEsSe4GmqtaX01QZSxMpR8mS.png","vara":"/el8phAL6Xa6vXvvaQNiELmEOgx8.png","kro":"/b0egFPlnPdqnhhWywwKwpdl4GXJ.png","alpha tv":"/jaY3I8qSvcymAOMsafafhFatC80.png","rtp2":"/r5zfKDhZ33qIVV1QiVYRmrbhd3x.png","nova":"/krIUkWv3LOiaYpU2vUWbPrqTS1k.png","muchmore":"/ukImteHLH8BfaRwKH0zQG0yUPV6.png","w9":"/e5TTp11V850EmQjoeoTd93NyDHS.png","star tv":"/dEXIDBT111hJgZjKKHKB1o7jhQQ.png","rete 4":"/fWh7OAc6hGan6h7gYiPu6ARdAdN.png","national indigenous television":"/oTYxKyXcetIc1LXmvPHI60kXrYz.png","vox":"/tfrd4tN4KOr9cWmMBI1xdhWc6v6.png","super channel":"/rqGf031msXZRkHeoRu0nRlI8vW8.png","nbc weather plus":"/kOouDwONltKwZ7LACyEe3E6MP7d.png","star chinese channel":"/vdDLFYlbeQmrQpvxbIroRyYuIrJ.png","tvk":"/onniCKpgZl8VEVSS8oa08Oas9jT.png","nuvotv":"/zHWUAxaRHLGdjPp9OOIAU3wjDBh.png","mavtv":"/l8PRX4B4OVVZvpIv1WspN3ZS43i.png","national geographic channel":"/q9rPBG1rHbUjII1Qn98VG2v7cFa.png","kfve":"/izJGbSnzWPJgA7OgoaOUzW1fbON.png","american forces network":"/rqa28aS9PAUfBsRYqYRQGIqYTqP.png","cooking channel":"/w7zRUMYgEwdOWD9tAjHqFtREbMU.png","4music":"/zVmoePlyh7CyDS5lRPBvtwSR0mp.png","viva germany":"/3DTdPqvewDwM6SXOW7Ca45EX2HX.png","chilevisión":"/xU0AW8Lf6mwRGiOAwzUPM5XNbHT.png","sts":"/7746GOkRkVW8UviV1OqDNAzdpgE.png","mnet":"/8R0iHi9qYaEvWWv9XH5XoGpuRJk.png","azteca américa":"/f7ff3vL4zYHHqZD9OhhI7SoWB6M.png","cctv news":"/K9ZzEc7RdUaVzUnaAiYT2QIvkL.png","ary digital":"/eX0FNufESHTD8lFBwP31DcieOCe.png","tvm":"/bpMDF5niZ0OBMsclu9vJ7JOjnIh.png","sun tv":"/5g8LER5X7tvziu3We71m9HnBmy6.png","sic":"/lgc1ScGTjapJJZOZezszceuxRyR.png","fashiontv":"/hjznlXXosdr5bOJ9G0yU0rTgHRX.png","tvq":"/koirj3XCAq3J6OK3YpNepJIq7lO.png","tv osaka":"/6foQmoPac7WCDnDuDbuCZklmDuA.png","antena 1":"/rXFw15vvldWBxZuJOy0kmrkGFfO.png","own":"/m8H0hZOpkskkIWeqRdiuP2CZOgq.png","planet green":"/aYCHvH2wTWT930WChun3xJhj79e.png","kbs1":"/AjEOZyoAyvZ5iAqA91P0ra9X420.png","polsat news":"/eokeifaZXb84ScRE59fciPmjEsI.png","magyar televízió":"/f5Sn7vW86OR8wsoLmc0qPi34hXB.png","rtl klub":"/fGmCnh6tmQlCllhaFaHbKf6dSL.png","zdtv":"/46lTha6b37IjPBYl0Fp2lSSuDP2.png","disney channel latin america":"/rxhJszKVAvAZYTmF0vfRHfyo4Fb.png","pts":"/qEQ4L4VyAUM4Ea1x1CMrS5JaPOt.png","comcast sportsnet":"/l7UYVQv4kzjEqFwVjpJspvZeoE7.png","rt":"/1Jaw3XrH1YIljfYKzreC5vccham.png","tv 2 charlie":"/gstdG1UAEaAQSTYi8Wtx5fDXNoz.png","canal d":"/s3hn7KdtX26EYXFa0MRtLtoG8P6.png","5usa":"/aiNDCNGIJagS5D1lzgyheQYdkya.png","nrk3":"/sW3AjUOXSsmsmzZJl9M9vHEGqKd.png","tv3+":"/k4o90Kn4dlM463h1f7p4pGgXv5O.png","muz-tv":"/2OyKNnKFFViTCfpW6eMEHyoYSj7.png","h2":"/bd6vDgU9S8RHZQhjACj007vSnR6.png","the pet network":"/yCcrTmJqJv5tN7sBwsLUuBGvKmz.png","antv":"/6XpO7mkGVQns85enn0EmWepTy9V.png","viasat film":"/gNycHYrLZc1QEcUadiVsTVfi74x.png","sjuan":"/A5MluV2bOnC6X5pZbEQ5KJR6doV.png","sbs 2":"/hDsDVGDpMAF4RYDuLReoCiuy98k.png","life ok":"/9OdoJOEMff5ZSM1iisjmPJWIXTs.png","2×2":"/k8iLqMFhoJLznlycK54q63faZY2.png","ctv main channel":"/zqFRxhKTBqiQ1GLghwJUG5ftBJr.png","bs11":"/JQ5bx6n7Qmdmyqz6sqjo5Fz2iR.png","action":"/tJxGplLFqCjGARhg8dFWRUXTmFi.png","trans7":"/w31NBcd9jhVFCp6BFzhhTpFyGSL.png","mtv brasil":"/tPc6YzgcCoIwfWoILOmj0kMwBHe.png","west tv":"/Yp60FMzSW5tXpLEDQoBs7AF7Aw.png","trt 1":"/evw4LkwnmgnUHDQUUTPwpuhFsRp.png","formosa tv":"/qSdro8ifVBU5wcLg3T2tdT6u694.png","br fernsehen":"/oD5KfK3xZxJfbNFMhbpuovdwEi7.png","direct 8":"/4Dn5JdscAUXQQqVCZ6lDk8IVUVg.png","fox sports 2":"/inrSGtPRteNjHWKgmv0cJrWaPIt.png","sky cinema":"/eia3wASVi2KULA1hgy0gXIeSihs.png","tōkai television broadcasting":"/pX3gDCsG8WcQvQbCNJ37t5w0HyG.png","tvg network":"/sdi800YFyFgxuW4cuhefD2X59yp.png","bs-tbs":"/zcWERoR0KacvffK7PBeAdQfcfmI.png","jtbc":"/44I4aVlasm8Blb8WPGXTkMYuZJF.png","channel 7 ":"/lj8g9lWrTmRvO7yGPeDoNS8taDd.png","band":"/1bpeEUnCwQUrU5XQLP0msaIwv4e.png","bandai channel":"/xGS6thPrtg4hA4bvjyKuWcpPSrk.png","tulip television":"/qfUzYnpnN5bnd2bqGUH0OlAMell.png","cnbc tv18":"/sAz1kkzKmYNjTAYXVnYCzkHBITQ.png","armenia tv":"/4Qe34F4EZ2H6s9MTKKNjvFNtq6z.png","tv chosun":"/7KzMozIYj58zKLMAaybYxWUWjdg.png","tnt serie":"/oPPsO7TGHwxVW7h8gsi6BP6sHYs.png","nhl network":"/7T2utseIB8pm6YNJTDRBmsNTLto.png","byu television":"/6SS8KHz1KH594IG5hom9bL3DQI5.png","big ten network":"/4KrfOZXvC3AGcKuAI3Xm07xvnwo.png","nebraska educational telecommunications":"/4u8WeGjfq1Hox7SiGAm5e9urYX8.png","epix":"/9aH86hGHVQfvhAqrDRv1EINoxua.png","all-nippon news network":"/mlcZ8Y4Q71rKTMhH7ItHH2ZrCVo.png","mtv germany":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","central television ussr":"/eMCbJfoNCBZlbDKQO6NrfFyfYng.png","crackle":"/bR8S6Fjv3VGtEKyKF5lvvRJ5xfw.png","mbn":"/7XKVnL4rDYZNBcTfalthDYsnYzs.png","mtv asia":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","sbs plus":"/90qhJ7ek7FmvhxGciWqZIi5R3Ed.png","ocn movies":"/2f7wljCY0l5716iwnrGmhZm1oJj.png","tv joj":"/uTNAiA16CZpw9IqlvizCswtXV2m.png","trt çocuk":"/plxq6VJWQkcJLiB3hXhGbyBosvQ.png","tvh":"/vEUZNYjuSn0iTQyZ3SLNfVgI9Z8.png","theblaze":"/6hv8xCPpFAWuRBwcc17yPgfBEc6.png","xtv":"/joesfMvq4jbfQQzYSKBl9y0qhXi.png","la7":"/682kUhTAnoqLnjVrVCCDNWhd78f.png","kino polska":"/bKJKrhuxL7C7PcLzRZ2LIt8lxi6.png","canal+ poland":"/snQBlVJrlLu8mgvvNf7cn6NUP9g.png","hunan television":"/6WVfgvW4r3jiw6v80jflHSQr41j.png","domo+":"/hf9ytYXqJD3nltQmLIjfxm4HAQd.png","mtv poland":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","samanyolu tv":"/ocg7ggjBkHjBoWsivNoLfcepQHi.png","mbc 1":"/oG6DMW1SajkvYpbMAZ9m2LZURYx.png","mnctv":"/iocCLzVdgFJnrY6jgBcG7t0T1XO.png","kompas tv":"/c3Sb8OyrV14FtTJFi0uwrgQZYf5.png","rotana khalijia":"/iynB9WxVpc1Tbean0wR1ufueI7r.png","acasă":"/p7iv8u65nbOCdS3RlMUnJKDVhUY.png","prima":"/rkXrvlCHvu9kholozdXInpFIcD4.png","powned":"/nmLUhNof9VsJrBfxWLjGVQpZWiQ.png","tv6":"/2NWsdNseiHRbgsxNeSs7pawiJym.png","tv2":"/ze9D9aLekVWZqLdrD1YeBKBgwz8.png","mtv denmark":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","turkmax":"/904DQtkXsaJgda0Bd0lUJ6cqWgn.png","asian food channel":"/bInMzS40CwuFe5VshmnjJllxPnQ.png","astro aruna":"/uMUHnF7I3MdlqAY0aGNCanlzdZ.png","music 24":"/u0KvrWrvKdXOn5WW0Mx9CNINkS1.png","antenna tv":"/jjvypoVqza69K2KOoiXAAwWvIqB.png","abu dhabi tv":"/xkgPOy6LEhz6sxS9JUZi2SuZJA0.png","zhejiang television":"/jXR61DUeQu4jdryz85cjnCFXNNv.png","etv":"/zsAoHKZoisS3UkGVE1ypc00610B.png","bnt 1":"/3yGshVnJkeBZebtnAO2D6NMadK3.png","liv":"/gkbzGqaC61olfJUxGQpQNVzPNO4.png","yle tv2":"/pbnLDOMP0nFRgyXMehuAzxh4Y5w.png","quest":"/e8yVrQ3fBKOZdLGHVyJz25X0NwC.png","channel [v]":"/343n8epNQbuZzOvafIvan1WJNzA.png","bbc uktv":"/rVzjodqFFtiltpYXyXqNfdXJ8te.png","fuel tv":"/owTQrkg5emUr1bUwpPOOfdyW1vv.png","espn australia":"/h0cjpCMfeX9irSu3g6aLLniKP7E.png","nelonen":"/uFxen4Fik1EbOLfzTQC80zZvoeh.png","mtv russia":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","plus":"/vkrkqstsMEqFRoux9ivPHKeFQ4m.png","eden":"/4KwxxBQ1d0sJCXRbG7tLX0ORk8T.png","el 33":"/e3wM62UyjolroXeYPxfiay1WVFe.png","ltv1":"/2QYKk9W10eTizbGlkhFwVmpJM4N.png","ichannel":"/b4SB7k3AzNR0rbwZYDHgJEixXkY.png","lasexta":"/AtJXlAoj0ITHKDN5EPJZPHJgLxI.png","manoto":"/nsSvyXHfEIrZEnWo0lzFICq9QET.png","thai public broadcasting service":"/s5BTEEGw6NyRd0RiJlzbK8ofdnT.png","sportsnet":"/e5EpXQONx9dHwohXfEurZJEZgqR.png","whyy-tv":"/6atmMY5TxsEpGJJjAeJNnfn1Kwn.png","prime7":"/5tA0UNpqiAb8RhZ0lGJMXoMgK4a.png","amazon":"/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png","wwe network":"/iTD3AB16Bp9NK7HEVr2wOw43MPJ.png","één":"/tbpZK0xCfDzNPdzz5s4L1OJGjCS.png","playstation network":"/oLrbwkpqM9ShxO67b0HQTgT3tkW.png","w":"/Pp7UfEzEkXo1blroQ7PpcVQBqw.png","hr-fernsehen":"/rOebGShjIWMSBYOihHDSVdV3q5a.png","sbs6":"/77VYaPlhcGqnRbaMwi0oAcKv3dk.png","fxx":"/hDLXRZMBOCbpVYpkBbIlLvMXgdX.png","sky perfectv!":"/o4LTo79SR55GEYQKFZjq2MUDb7n.png","vgtv":"/dfOrOrLjVOcebSX4203B5BlrwKD.png","vier":"/1JGVNbqHVbTOUw8M5WPf2Goiq1T.png","nat geo wild":"/esXeePrsUGpF6mGQXs3jImz9QoN.png","al jazeera america":"/yTwWYAAwL2Ag4wxHVzOnhwkjwUQ.png","star channel":"/4FdEFkYde4AyjOoRTGw0gL9DREW.png","cw seed":"/wwo3PZyBpHL3Wz8eg4cr3kqVZQY.png","aol":"/i6qYMS1mYYQQh0INmAQPz20wzdu.png","bbc first":"/gLNh4QYTeYCKJzhZwzzwgMEqI3n.png","mbc masr":"/gdNW3Sdhx4tMhOhp4rr3Ok1iJqq.png","jiangsu television":"/lVrjBzC9COqah57JSkjx6pjOXtd.png","dragon tv":"/neufSFU0ZrXxA3c0JRNpuNXzLdx.png","duna tv":"/bPTVfSbxQsQJucD6ev7eF1c5n6j.png","yahoo! screen":"/117TR6CuHY1Tapv3T81Z8je6fvN.png","hbo nordic":"/sFWkLyP2ps3yUOajuqaUEWxpSWP.png","sky atlantic":"/6es7UmBjk2HTSZKq3NbtAxYEGCx.png","rmc découverte":"/xU5g31CXgsP7BhcDtXx9yW0Emrj.png","vimeo":"/lYL7PahejU2LKpMVixqksxnQ448.png","fox premium action":"/jjXxcZ0wMkQcvwocDkl4Y9jOQ7f.png","ocs city":"/6toCBtsVvbItflTy1gg8qhLwx2v.png","lifetime movies":"/dNUQ8xO2D13KfAke4lmT0a6JYHH.png","canvas":"/i5MdIv07gHYRPhYlQ6mm3RrATXd.png","sky uno":"/NN6klPGA1vVxbldSGZIllYIeK9.png","fyi":"/jQ5GW25gdpP0ooM22Kt6Oo8TRl.png","olive":"/nsERt4AAKUZaB9FafjZhAATa3jV.png","niconico":"/rU3iJ1dHKneqKLqZ1H1lc7eNy4T.png","net5":"/9nDYpxzCXDmcMWQbfeF5R7APPNv.png","hbo latin america":"/xiKKr4gL7TM7Q5Rn79LrYFeRjV3.png","multishow":"/5Ane2hnYZCjfVHV0OsHyzdtTtSg.png","contv":"/4RcQmKjjI1dwfQLpbaszYuTdDTC.png","gaia":"/kym0UnNZNRxGHdZsFBUQNCCRrcb.png","google play":"/wc4mcODIyf07UEnqEMusJS009v0.png","kabel eins":"/kTU5bdSiPMxHkOvQsTWQSZeOy2e.png","crunchyroll":"/81QfupgVijSH5v1H3VUbdlPm2r8.png","dmax":"/vltk9XNsceQOxO8onLEyJyUXXfo.png","sky italia":"/dNuhKIiAChEJdGA7TXQgwqbFU6y.png","foxlife":"/fLvIov11xLjV93nK92D9NQ33l7B.png","mdr fernsehen":"/iIppR5wX7s2soFTHZsHV5mgQ9Or.png","el rey network":"/9z6vxDNyom1T9WSDd9rCwaVLQLs.png","hbo europe":"/tyoN6zoxMJ71GBddxVkk4dpaeze.png","antenne 2":"/1JnQqStddzcIoa6XYB7LqJbLx01.png","télé-québec":"/c1YCG42hBUQruiOKRT7xD6erGZd.png","3sat":"/bwqI45tuLt3qygttnZUz4Imh1dC.png","tmc":"/mG8AujLo1qacorLWNXqHqMCxlPM.png","wdr fernsehen":"/xtK4PjzICkOEB2JSD9hgeQLow9K.png","bs fuji":"/oMtJVGvqLAyQZvYTvjDIcbVEzwD.png","kbs kyoto":"/j12pSWPsDBxE1mmIpB7M8VRzk78.png","gnt":"/ykNd5NwABd2hmcwvOrTrneIPz2G.png","tv8":"/nX4NrQzkUMjciGpripgTNLMPnJB.png","tvfplay ":"/iFXRW7tcLTfy2UYufxg8B509OEb.png","naver tv":"/zt8MWI6nc20BCTViJPrtFPJFAY3.png","ketnet":"/3IdQwXxK3WlYfc0wUumwBIcN43K.png","bbc iplayer":"/zg70HfOG0FHyKEvTGp7RIP4z94A.png","itvbe":"/cKcGa9lsFn0mSY6TDcskNJ0wDAh.png","swr fernsehen":"/f3WRCoSDZyXYmG6mcg3adgSAnfe.png","kansai telecasting corporation\t":"/1J33ZvSff1VOEt3aC8CyKmj9GEy.png","ictv":"/sSlI7H7o85Lixv3Lq5hYUeBGjHr.png","stream.cz":"/w1dYHHZ7E01XThKEFxzGC7SrnEm.png","tlc uk":"/nDCnUdHNseEmnVaek4u3bPl0bZR.png","mtv lebanon":"/26t1txCTRfGjN2qW5WZegWuTiF1.png","toons.tv":"/est3feuwDI2L6VxvJoDIa33PzJi.png","gbs":"/wi1TlXf4tnnnEA5m3DkbH1X7iC9.png","mie tv":"/iJZZIOdaKz7aYK01BTUo001ooPc.png","up tv":"/6lijJgGj3wKZMKPuMUyh8wpUMtx.png","nt1":"/7Cpds3V9PLm104O3iyjg0jrTatz.png","travel channel united kingdom":"/pGzkI87fhsp8w7Xi4uPhwMVvyJh.png","duna world":"/yEp1BDU3rbvF4IvZ6mzHaJ6LhN4.png","npo 3":"/wVbxJBRSvSdGeLTm4z7WOBgTD1B.png","ortf télévision":"/oRNBLezTiOW3WMly2rOqXoG5a5H.png","russia-2":"/liqQQATn4eTB72ZK4dFYfTv0F4x.png","ntv ":"/tbUMYj4Mm8RKiaVQ6vo8zjUKGsO.png","das vierte":"/1quKB0ElnUT4SZbadFaMBPMysjc.png","hum tv":"/xcQRcWC6mfhpSMvb47Vkr1uqvpc.png","stb":"/8VidRVpoPUAtOCWfqgtq5bMnyD0.png","sat.1 comedy":"/r2SY6PDU4wZQYdqffPrygomz1O1.png","yle fem":"/A8L0PEH6QEMB1SszGWuBaRdOghT.png","servus tv":"/9KwJgZRZazjR7iTDPQAKpyfuJP3.png","rtl telekids":"/qhLwkbUoGnSYSH1OBIFpREgDckA.png","rbb fernsehen":"/cqkLRyzEMuvaUYWWZGVCdZWwdVz.png","gemini tv":"/J8ukrtA7oCTidkE8tqJfl3vDeb.png","avrotros":"/wXrkCgSBWhF4cjbztPoWtDtOJqj.png","eo":"/yhWwg3mVe9TAb6oXeiGHc2hPz2q.png","twitch":"/9zEimOdB52l4V4zUa8cQ1pp9fxm.png","divinity":"/a2L3Q0HaMNWv8XJa91RcLK48T8E.png","mbc every1":"/cFhubSOjMZXKxVbg7fylXUk8HPb.png","facebook":"/6LEYZhup5GJmUyTgXTk8q44F0nJ.png","historia":"/eRZTyNxfFM8PCdquTDvyHERx0wo.png","z":"/cy7isDv4qfKnOOWEOn4BHRlqVEr.png","movie extra":"/5CcSuKdeR3Z1MCQmEVlCZq5mE34.png","1+1":"/xctncKucNjPJW800COSuGF8J1MC.png","stan":"/1akCJMjyZsiS4v3NTJD5Y0LFZ4R.png","redetv!":"/3eAsZvWd1bZF35edX61xDhFPsTh.png","tv-3":"/1U8FtJNwKMiUITng9A6pOY6jnt7.png","sohu":"/2Ew6jjFlEPxeubJEijHZY14Z27p.png","cartoonito":"/q7mFfgYZU6FK45npeHgXux4bHKK.png","canal brasil":"/xrNYfnO8sop22tP2R75g4JzRC3c.png","freeform":"/rsz16keQ0hiBWYpaKIFspsMwuqj.png","zdfneo":"/5xti6WP3i8AoI9o4Wl813JA98CF.png","rtvs":"/poAWYpzmESfFdh3uwSCc9TkfK7j.png","kyushu asahi broadcasting":"/zph6Ed1k57GnwNkwjKSLo6wh3uk.png","addiktv":"/2TQGuYdP3IJuKLps2k7NYxbNySK.png","sixx":"/xSfWRN9kd5mUp65JEKRD5IVmgcB.png","discovery family":"/zza75igMo6rkTC3IV8gT6TwXMNl.png","hdnet":"/4uR2HZZyKf0jikSAAGnY4TYYDIq.png","jim":"/hfFPMSsWBn9gFBQKSNnIohMo8Es.png","club illico":"/sqJrxmxTASr5HaDGfARp2Mv1Cm0.png","canal once":"/qgsS2eHcJNWLCEKVQ9x9WWYcqXl.png","moi & cie":"/b6KxdWSWYOXiKsEYTHMKhaqNiip.png","go90":"/a0PSZBhWKk0A1ID14u3DI4QrE8A.png","atreseries":"/vk0DiMkdXej24FSQ3oahd6bHhoS.png","discovery asia":"/vWHt63dgArLtqnsSTu744G6g3sS.png","hbo asia":"/e0TPhobVyxQ09bu9r5qj3rQLxkj.png","pure flix":"/9YhctbTDK06WO9Oye580bvWsnrF.png","telemadrid":"/3BrhXENv3fNvyGHsB9xNCat2BuS.png","prva":"/kcf1jXy3LI49k1tBB0qskDczkip.png","ovation":"/taRXRfIg76AzEGYMorSzB30Cpse.png","ici tou.tv":"/gnz7VENDBQfKeaJWDdT4f8O0bpG.png","televisión de galicia":"/xFCWp2bNmwQFG6QAF4Pij7TqAiZ.png","television maldives":"/1rLr1aixQ3Eu55neO1hYprwXbpK.png","timvision":"/6TbgPzmPzCnJ69fxSicj76d9io7.png","repubblica tv":"/cOwQwxIKPJTuvwsxDOboCC6gRF8.png","domashniy":"/pgxodwZf8IyWo65M89wcbZouM1N.png","tet ":"/uJtL9RshoQjvDWy7vqJI4UuUwvA.png","abc iview":"/yQrlu1TLTWLc4QSCsC1N7M7lGoz.png","iqiyi":"/t2HmORL2WgZdUUWyK2QOVIU6lwv.png","videoland":"/bkC8VkgQoolKh9rs1YeaiIIf1Rh.png","viceland":"/qatKbG5JUVlG3TXyTSQmakI3Pdt.png","chiller":"/n3uRSQoUznYxEHoedb3TMqJyKwU.png","crave":"/xlVkcuR2NqruUHh8gPaAw9BvOCT.png","abc spark":"/ofTYpgSeD9g1ngurAigv2GwesDT.png","la 2":"/r9zAeL4pm0miwpOZGoKYz2Q837j.png","zdfinfo":"/dQPFVeKoJHy27pF18gqpT9xzIm.png","bounce tv":"/n6jLkog079ad4w0Vm9f5sBfmC1X.png","history channel italia":"/aeariwRHHb23lyOr3AczHz5aIhb.png","rooster teeth":"/dMmhDfPq3Ulz6AIJ9G2ymeTKwUh.png","la 5":"/fBvBN22sqohV1yqsbOY0YqUvt4d.png","stream tv":"/tteR5Nu42tXwynoir9Tqnl5nMlM.png","mediaset premium":"/mKI1LBsgZPPKSmy4kq4hXTZI7Dg.png","cctv-1":"/x0f4gH2NOQpE77XF4E8IrrwfoKU.png","cielo":"/dMBZYliXeBx3LWWLaa3no5qbUD7.png","rtf télévision":"/h6JrmEHzmsNEaOo16N3YHP2GHHl.png","reshet 13":"/tyzqjTqeUob43G4h5RorVftFQOt.png","tvnz 1":"/tRUJaANZCvxWQbQfdWcP8UvOjTG.png","itunes store":"/h7YSa1EqKYtz5ouFRguyb1dzCHA.png","zoom":"/e88FdLtfoPvIWgECmjO3EbqgKgL.png","dr3":"/8tkTPwcCC6wRctDgT53Q67KIFIT.png","la une":"/kR47hzfGe3zFsFipHTuC123qyCe.png","telemundo puerto rico":"/mieMBXx82qgY8nmnyaH0rY2D943.png","viki":"/xKVCbqy4jjb8JgUZzQfS7JjRAUQ.png","kanal 2":"/vfrFks7wIMo61gkDTgHuXhfebcY.png","cadenatres":"/eylWpcQpVqVeNfMQz2afE4sSmgI.png","ava":"/kHJSF8VxsrQmhh85LGXoYXBVC1K.png","m1":"/kn04GbAmCv95bMiUaIQG7q4BH1n.png","crime+investigation":"/vitUTPUae38gzMRl8aMVecN0Cme.png","nitro":"/yce3GSxIR0USFkq2qA2ZTUqaJWr.png","asahi broadcasting corporation":"/ca3Qw7exc3RPSd0CQFOZuSNbGjF.png","fox crime":"/9Jvtpn5dHx3GwkLZFF3Uu7Qxt2X.png","tv wau":"/4F4WYqokszsuLaWXPKdlzAES8xX.png","youku":"/jGumA5iP8eXdQ22EOFY6kWrBzza.png","rai gulp":"/nzYlSSVEia1cj51VU28MNZt70IO.png","ahc":"/607cHSuLR2HBOOTbE76Db2KDmOS.png","sky 1":"/dVBHOr0nYCx9GSNesTVb1TT52Xj.png","npo 1":"/eDObRs53SyWCHlnFoKTTnwZGPpB.png","youtube premium":"/3p05CgodUb9gPayuliuhawNj1Wo.png","ami-télé":"/n9Ew7VScCbnPccoArCHgTFnohOo.png","pogo":"/qAhtyf2OX0bowig8g8CgG9iViRb.png","flooxer":"/vTpEiwbk7a9qi63xCwlTxhJ5k4W.png","cartoon network latin america":"/nDFWFbAHEZ8Towq7fsVCgv4U245.png","tvp kultura":"/8N2gw2i6CvoJgHQisNnfYESGKrU.png","btv cinema":"/j2Sk7rRQ8Q1J48OnoRrXGP3XbOj.png","pptv":"/oISsIyupFNddsKTsvL9sdBpJnyz.png","ncrv":"/fNRriPhwrnqLLTtus56ZVMrTUta.png","family chrgd":"/bvD5SchGvYFLyZlsvaPryPDTjl4.png","minimax":"/9COoUNYnEb6o8oGkVOrxEq90Ovq.png","axn asia":"/3Xls5ATyPM60HWKiDuVSErnQdzk.png","sabc 2":"/t1OEW9w4G6X4yXVn4Wd6dBXeAWm.png","abematv":"/tEYzkOmnBQ7jmxqdzJ6nAk429aO.png","infinity":"/8a4NMo0A1PYOeLv4v7dOazarcZj.png","seeso":"/7LtW83ODEmIxDDwGBTVo22A0vcV.png","epic":"/aA1J3TjNvUCs1zEssJpwbDeNF9T.png","televisión pública argentina":"/3jB8XdFxuxgmY6O9db5I1vyvoOC.png","tnt latin america":"/nLB04d4R5UrlH3AsYDRAcgAbtq7.png","tv5 québec canada":"/5KU8oRvXX9I1bWdiATsSlYqx6em.png","discovery world":"/qNNNVIAnREE8m12VmuXd20OGqby.png","wapa-tv":"/olygrPxYGt9otF7BuYOCAWuUsBA.png","el nueve":"/x63mh7ahQ4mReHkjg08BPzLjiWO.png","7two":"/7JvANenUas1w596l5ikfSBksDN6.png","sbc":"/artkCykJgOAh6lAjfrNNgANasgk.png","&tv":"/qmI9f9HOyY3L9ZGMgFfUExBJ9yf.png","prime":"/uHro7KWaW6h2Dj4ILFKYC1UJbd2.png","tv brasil":"/q6CBUNLpUaqvkI4ukrcmHdlIHn7.png","spektrum":"/4j1soX8JkM0W2nrlkrO15ir5Gy7.png","viaplay":"/6SLkjdD4tzmqI5TIxZ1BLlNqMdw.png","al saudiya":"/zIGFsV7DK4A5fnxBEmkrIlS2MGK.png","hbo canada":"/lFYhRrGIJ1x2wuTrEizMtsdRxFP.png","now tv":"/kwHeVfONZfDBfBwToWGYcYkW1U3.png","cctv-9":"/dU67Pko8o057r6mea8kAHEO2Gyp.png","cnc":"/nmrpwndiC6uTS4uksPwk4O75y4U.png","tnu":"/egMex7hlhlrCFbt0xB3H8PaBisv.png","c8":"/sxXmhPMQeGeYwbh7RKMqUztnHGY.png","trt haber":"/hdvgTXHZjvza8h0QHI9K58CARyE.png","bilibili":"/uA84wxiVlJJRbBkymIdKTw42zk8.png","carousel":"/mC98ktCFvsDb5vrAOOhLozTVn71.png","mult":"/dY0PUuBx2KXhXl95DzDY5ZLMylr.png","tlum hd":"/2rx79qHpPWEEeBIvIOrqFDPJas7.png","canal sur":"/yI3v9EhMP2Mt3tdIXuz6E8t1YN2.png","tvr 1 ":"/axE7fqLKR0oN9pC9HwrtcrTbjcm.png","ukraine ":"/jLFuGLkwOQHAekvU7CmXaBBj4JA.png","arte ":"/6UIpEURdjnmcJPwgTDRzVRuwADr.png","mango tv":"/c6GPQWwbXDuD59pGGutCBQ1T711.png","rt uk":"/eMZoX8lgiMv5kbxnRbnaeJ1rPg9.png","inter":"/fTwKj4hZMMGCFLHSglJOAKnvNH.png","star world india":"/cGR5NFbkIsJayT8lcvJyiQmZq3a.png","fox premium series":"/uPVVosI5lB0IyBzXpAPzkzoYE6w.png","fx brasil":"/sFK0I7f90UY5o6BDC1CLhJXKs6R.png","zvezda ":"/3nVHeDt2xktjuqyIC7VXWOEOOgH.png","kanal 4":"/pHeWzT9WtPst5TWVLFjeWQo53ZT.png","fox latin america":"/mGIwo5uKaPMK4sRNwSwRl9nmRtd.png","fdf":"/p9guqIXM0MWzk2F7uGPvJU12ase.png","canal+ family":"/slQe62dRo5kCuiwz7oLVQAHN1hi.png","dplay":"/cllBOlqfzv9t6KpOmMcpanhxBLI.png","funk":"/9MozrFdzzDNo9AcpkN4cz3TkGrF.png","nolife":"/55cfTZPHKBf1pReaVG07kLsfnXn.png","be mad":"/4H5aVdCbQcKd8qEkJIX9QA8xPWW.png","line tv":"/eBK2WsrRlpmLpyoeR09I6fDRJie.png","hgtv canada":"/iQgW9a6kOgMbxCVYB97643ICVDn.png","osn":"/d6D9pUDthmVYnmS7dmXj669l6Na.png","studio+":"/oRlKS6G0JJ7LLQUDfVgM7x1YCxk.png","fullscreen":"/9u8319x0HeSCxWyzko8KjNFgNhZ.png","facebook live":"/ausOAbylt6Id43JF1q3rfeYw2ao.png","nick pakistan":"/ikZXxg6GnwpzqiZbRPhJGaZapqB.png","sab tv":"/3hNAuxQ7LWXfbkTlMptD7shp7UE.png","cbs all access":"/7d02Rw9EDMWba5yeM6EZqPydmCn.png","al-nahar one":"/j2DofV0EVjP2TWC8aGqPAAcBsnD.png","super écran":"/vTzKinhC0oGQ5tNBGDf7EfW0PiE.png","rtl2":"/oG6mx3igu1POada9WinACUpN02o.png","tvi":"/sn36BiLSsKi3QcWvGQUn8KVfl0P.png","7mate":"/8AjZwKDE88I37Wf6OoOPJVsTPoP.png","lifestyle food":"/rww2vgf35bZ9a90GVCljJo8JUoh.png","astro warna":"/qBMcf4uoXu6LH4TEd9SFDB8T56M.png","astro prima":"/h3fFqUqdoT9YCsQxbuU5x4ehmmB.png","astro maya hd":"/hOC8CA7WyCDg0xvI1JqhAxbi9pb.png","blutv":"/aY0a4ZsUyG0zYKDE9H9I1eT4qdq.png","globonews":"/xlJCivVutt9N3GiXGVIMFTGUxGz.png","rts un":"/sISI8xF35P6wIjYmx85RHCRljy7.png","rtéjr":"/tk5BBwAt7oJrlZCNhXYAXooWt8.png","maxdome":"/W6znAbTNCBQUwyCwwxuqZUeAcz.png","al jazeera english":"/9nAXtejiDQYqkeIWWk5eulSo2c.png","bs asahi":"/79NRmO2Kj1lJ5nqdf8A4q1T6Edq.png","hot8":"/j4twJk4F0EmQVzNa3KbgX4NPB5o.png","manhattan neighborhood network":"/hOg1wHvttsEOT8v7DxmA6nkzfw.png","fusion":"/q0OTdtI98gljdgwHHIdNW2aaJZz.png","crime & investigation network":"/vitUTPUae38gzMRl8aMVecN0Cme.png","hulu japan":"/5EbNwVlVEHGOkSJFw9cjhaHJOhY.png","ici artv":"/u6n4LTVDa19B73PZcEQAiLqL6CG.png","vrv":"/u7mpqGD2mgtgV5QdV1ZFXh0xdtK.png","one 31":"/cxBpog9yHJTIclmGsojZC7tqwlH.png","évasion":"/rJ6xicSW61rop8ChUgI7r8vp52M.png","astro shuang xing":"/spr25hXf651DaXjTHoynIO4plMz.png","mediacorp channel 8":"/mgbUgCcgbpxzXD928ELSymL5JzO.png","sony pal":"/fOGv0UuTWkLxp4aOTw3s9C7T2Rs.png","suomitv":"/q78UuMAjpSIZnYMWo72KYJHbFm9.png","orf iii":"/alCTY3pXKHFPoPLgMaJrFirkhsu.png","ebs 1":"/vM4FN2WU2JU1mCiZw2BtqJ27zxR.png","dptv":"/8J6u2IEM9gPQrYhLfq9GGfXpswY.png","obbod":"/gEnWY6u3CxtY0HxecNK77Pi6vBz.png","hot vod young":"/g9I9S69LEwTOFJeSRF6NlF01Lt7.png","video pass":"/zSqGTh3kRtv3yeHRPJOk4BvBQew.png","rtv 1":"/z2rWC9f1T8J9O1MEHNXWmncCYFL.png","npr":"/jc6Wxu2us3rgsmbCJRLvfS1n53Y.png","hd1":"/sZvjj48224okQHs7HduRsNpikAx.png","nove":"/97Ztf6tKA78wgg5yZPnr1zrE7Bn.png","elisa viihde":"/gPkqpaV3B9JXb0jtzTqEjtA6fom.png","kyknet":"/2tNeujuBe8MR40hoUE4FLYA7Knl.png","13th street":"/o9bWCO9zWVv9hBXTFmyOVFMyLU5.png","dsf":"/yFiGMVOuLjaTeBOWbdARBpkkHoF.png","comédie+":"/qtIj2PTkOImRGPu7k2NdDKtTiKp.png","einsfestival":"/s125v6BpZSyKDUDPZUOpfnbKwph.png","rtltvi":"/oyOihoeQw7wcMx59hNhVwOI0wyR.png","family jr.":"/zJn9ypM6cfI5APVVnpl0a8Bp4BU.png","rajawali televisi":"/vxzYcVxYGgHdWMVrZTewxxcrntO.png","kidz":"/lHspOb8qSkK7P8UN02oOgXtSG6I.png","dove channel":"/vfTImdo7SUW6yJRM8XiKiNeajyx.png","ap1":"/dzfymvDFyoCfLE6Ot0I2YVAToa5.png","yle teema":"/lt1agd2pOlDmhaSmwSAklOxNZq4.png","mbc drama":"/v8IDgKmbU8J3VQxl8mrtIphwesh.png","really":"/eNQpKWZ9fmN6YIuQTObFfbFIWVy.png","tagesschau24":"/7XPIFSf35QHP4TMEtNXtjVLTpJf.png","ewtn televisión":"/7rbRK6vILJFsQIIQGRNEFvNsv3y.png","fbs":"/suqJy99xiGr8PV7eQovUFRgquYT.png","ocs max":"/hqYpZX7DKht3HTXbdi5SSCNEPxF.png","odisea":"/lp1kyWPAjJTgUdqVKMaMWuzTE0I.png","blim":"/zavliTuAnVSaip9OxbsXTs1v8uN.png","real madrid tv":"/puHOYN9L20sxZXaifbQQJ0mgIOS.png","true4u":"/cGSND9e3N4A2LE1DMdMTUwCLoLA.png","kbs joy":"/djRcqN9RaCcXCi0JGvcjISASVvU.png","jtbc2":"/bYcQHaavcJQV5XXc5LmiH5e4iSy.png","puhutv":"/q10rgiHNUffVdAs3neLZZmXUsca.png","on e":"/bMjDKMox1AgY8byELQIgrMdw4r9.png","alhayah ":"/eacM4a8Soc2FdgkBkmzH3j4au3u.png","apple music":"/nrHnK81jlSAA530PfZlU7t3p9wv.png","corrieredellosport.it":"/mZd26t06bKCSn7LZ05ezTanhqxc.png","tuttosport.com":"/gIhV1CB1aGQb32LNESiSQ27ehqS.png","nrk super":"/7wYa0aLdSPtTloukX4uyXKpsIVE.png","channel a":"/yZiuYgxiBGJAucKmcBIy7TnVkSv.png","al-nahar drama":"/sJwKdmChunUfVweZHYRqmAcCRrL.png","a plus":"/akshdZKWozy4kAgHLfdJMhg9gz4.png","gmm 25":"/ggKRyZIgrGhVM0fszfYWwzJIuio.png","kktv":"/a8E9RFxhWBDGfwPBKiyjDe8ZrXt.png","vrt nu":"/cQlIILsumz45oaxV4LsKAiZXTvf.png","rtl plus":"/zchm9Ut5NFhxkrID36QTXaMaUNZ.png","all 4":"/1yd6L2IUvuNmzvYYtd19rm9l7H0.png","ici explora":"/lRhRKVFzRQGeESBAETOZLcghtuM.png","erf 1":"/7fXyaPO11CDsGas0nhvyE1lBprN.png","rtl gold":"/zuBvtzbEUWXRx19rZjQSonYO0pt.png","kabel 1 doku":"/o7zjpTAiTwvYSXeVpCjLPLlQ0IH.png","axn":"/AjR6Ug74xJYMhtpxGFusVGAHfWd.png","ufc fight pass":"/mDWJAHRHefTi5BQnq9EbvGsYQkM.png","tencent video":"/6Lfll43wYG2eyereOBjpYFRSGs4.png","stargate command":"/fPXvpCLOBpAkhjjsyXQLFazuAqL.png","ozonetv":"/dvRKuaOBydS9P73UqdEBhfX9L9L.png","canal viva":"/kWvptNESFDqic8JITEsmfhiajuE.png","planète+":"/7wJmmffmEUVrvypzWI3AfL5xuro.png","star maa":"/3O4SFCgyGPJYmfWs51qPmk3T7lY.png","red bull tv":"/ibCAL6FyzVahFxVO6XB3TAYbp0R.png","arirangtv":"/z075mUlUUMe5WcEDA5wU09mtRsi.png","vtmkzoom":"/fHqpMM2uYdN5wg8ZCR43XXK1CwC.png","13e rue":"/e28DXTUjaK6SKCpJpmICQXllswx.png","myspace":"/fweyuLaPpCgJQIHd0XcMEsoQ2u9.png","canal vie":"/zJ9wiyWwHjf15OWLEccBLK7Glsa.png","tv setouchi":"/qi4L1ffRabF5LJqVimCaaUYpeSj.png","la red":"/pbwM9lVxbxJbFLroAxfc1zE3LsD.png","tv 2 fri":"/otdpdzEUOp6WPo46TSfuJuRgD9t.png","paramount network":"/4knr4ozp2IQrA3SMQlLSYKcM3ML.png","viafree":"/e6QNxf1eUhD1V2IRXOAMbG2dDfH.png","discovery channel":"/8qkdZlbrTSVfkJ73DjOBrwYtMSC.png","unis.tv":"/iYWhs21Qn0gIH3jTlA1cDTSqNYH.png","plug rtl":"/r8Meoc299hN06gDaplv1aJWzJkn.png","blackpills":"/aXVvr4Aw9JBj2SWrEjb1tJaHYPC.png","tv vest":"/8ZzYfUSyC6O5S4p1qG1Ha5fG9wS.png","techtv":"/pNE1La0x9MZJAFe8Z2QTEMG1wmq.png","g4techtv":"/fmHYPuOlLRah8sp7ZYUNg2LqsMF.png","the new tnn":"/g3qXB4IDKL8sIk9yDVeKlsfLeFT.png","casa":"/a0ByNgmzVO3NpC0P7gY5xntZo3J.png","dekkoo":"/qD8575BVoagIUosW3JQo25HYShP.png","altbalaji":"/zZ8gquIrrBvDGyMMcsSgArRuzyh.png","mtv latin america":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","orf 2":"/yMnlNdwOHo3YrqPFXVYgvz3pimN.png","the great courses":"/p8bhSRCzDu60XrsQXjWfoArbgqn.png","canal algérie":"/ed0NjMKYAG3SPGdNJjaJEBmFqs7.png","etb2":"/u2g0SE1ZpEzrJv9wueKAHEbfrTt.png","universal kids":"/p7CrTUDgneTfikaSP6hAlFcbmqF.png","dtv":"/tmLTjM3nAzu5I1YhO4aiJmOh3wT.png","la sept":"/7LE9NhjHzyQ4tOjgdKAn5eBZerk.png","hoichoi":"/qVqRSAy79VGjfN4qyeypuS5vLX1.png","#0":"/7odcr2uwJNhanhEaFOnITogxmRX.png","rtp play":"/mB5QFbQhbJh4haHFP50TaDjhbQ1.png","fox españa":"/mGIwo5uKaPMK4sRNwSwRl9nmRtd.png","viutv":"/mmQOH5hoPd9ZieQPr5FFsjAMgD8.png","ard-alpha":"/tdy3ElLfBqKuN5gRgvhBguEcTdv.png","dr ultra":"/udkodZN7FY6iXsYQjPDJwj3ex7H.png","bnnvara":"/tiBiiquRvGhxXteossLAWQkaFA1.png","tlc go":"/wjQaV8ijywhBHMjjNTTiwUBtw52.png","wpix":"/uTdbKD11HIWuUv7chni1TGiebEk.png","télétoon+":"/nLFQkHAhQXlhLGRXJTbuULYJWuv.png","yorin":"/vTPFIDfCy9dO0j0nORq8g2Qv08W.png","channel nine":"/azP1gQxvW4ssDevqHgCvN6mCeaH.png","irib tv2":"/uMqUppxmwSGDnOS8PROETr8wNcE.png","4fun.tv":"/kB3LXsRMnzy2CDg3TzXA753Uhxj.png","ortf télévision 2":"/AgVBX92ALf2fUZweiyhSKJYhkHo.png","bangladesh television":"/lI6c4QsJrfqFA1wTIrQtigM7jF.png","pick":"/zvM889g8dYVL8mFmyZVPLrtf543.png","t+e":"/wa0IRJ2W3X12ej0wKLHRWM1ApyU.png","iflix":"/ysdz7EX24pmSFlLzF224rvBVhrz.png","astro oasis":"/nkObiKyTKiQV1SbIw7AGTAL3vUR.png","6ter":"/oKO8f2L6QRNu7HpeyDr0SfgabfK.png","mtv japan":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","kanal 1":"/ovdg7mo5neFJK4bTkh4Q0JFfd5X.png","universal channel":"/l6ctvLJ7vFeLF0FIBMXeKpQEEPt.png","dc universe":"/eOL4PkiC0zkDpxKFQhBnmCtwx5p.png","dom kino":"/pnUbJqJnxj2HZyGhU0RAHYxH974.png","tv centre":"/gOua3AtNAQg4PWbTRKTpwWLfeIN.png","talpa":"/kejyjdzZg04SMRkRT6w8piOmXOd.png","xtvn":"/hcXvuYIsKJHhqd7Wzh4rmOBNyDk.png","fearless":"/zKdWJymRhBLgd7T1lrKMPcc0hdn.png","showmax":"/72uE6rBXSuth2k36cGsvIYxm5sp.png","o channel":"/u0605fwa355v0MaeyP0HgvXIdNI.png","toute l'histoire":"/x805Xveq5h4eSUZ9rbgXJe8R3ie.png","vidi space":"/dM96XxgerPiGJHlhnXi9pprnSU9.png","tv2000":"/tbEkWpDqFqHGlu7CYyfbjomskMn.png","ici rdi":"/TpM7iJC7rcUgIBYb9FwLslr6p0.png","mtv nederland":"/e9GMyvaguUc36ktS7iSFYP0WLKa.png","rsi la1":"/biCq8cxwDErIA6of5Mi1DHRv9U7.png","hallmark movies & mysteries":"/5pVL5llSGxKMxEDdfxmCDJUs3Dw.png","dk4":"/eSMIKk4bpKR4g2BobUmnzHT80GS.png","nhk bs premium":"/9iLW2n7zFTFwiCmlXPzZkwNZnsc.png","canal famille":"/2UbgDDv9cHAdGrznUWEi62M3RTi.png","tqs":"/iq7ZGb7RN4JCaWvuCvn3VngFCy7.png","razer":"/eEJhNL65DYewmplr4LWHhaiCqYw.png","nickelodeon india":"/ikZXxg6GnwpzqiZbRPhJGaZapqB.png","fox footy":"/ydzHHnyZsC60RViiNAvMe3v5la4.png","fox sports 1":"/xAih9wNDjqvDmX9O9BA1bekRHQr.png","country music channel":"/reGaYh8WHArHeTndzA4ikkkNByt.png","fox brasil":"/mGIwo5uKaPMK4sRNwSwRl9nmRtd.png","fox business network":"/7llt1tSy6CNazFNUuWSNvssfWI4.png","rtm2":"/jlBKRggeBhdxvVW39g0iCqRxdOH.png","nou":"/vqA3Tlc2rjS4ZnpllDYt9XpN5Mj.png","nhk":"/y96FXAPOfjLnQYTd9HLEP5toxpO.png","curiositystream":"/pO2jjhCt7UeCSqDJJ4O1AtunmxE.png","pbs digital studios":"/ah3lNfMwcr1YOV9NZuCFEhwdrLf.png","astro vaanavil":"/zSoyLUFwe5n13qG3aIphWEnKXaG.png","htv9":"/9nWl4V9190sE76KwIR5BpHIikHb.png","sundance now":"/tKitog3eIbbdLMnWNZg75ErhJr9.png","facebook watch":"/rtn9QlZo54aW04pBVbtDqNfO0Iq.png","atv home":"/toGJnf4MniOfRwl8ki6nGm5U9ov.png","sbs fune":"/ue22MrGpagp8u0dQ1bRemFSCI0e.png","rts deux":"/8G3g6cWhxdqCQeyvJHpUVZrZtqh.png","čt2":"/mSGMeLPTTCKKB9DVMJoFFzWNUqd.png","rtbf.be":"/rWYlPrkpIK1ZvDhpYTc03umbZYb.png","la deux":"/tSheAzX1u6cVVvzxaObx8gyblHV.png","jednotka":"/6bqAV4y12zSroTITdmPO0vYCpda.png","crime + investigation":"/vitUTPUae38gzMRl8aMVecN0Cme.png","tv rain ":"/dEv6BcTZWHCBXCJZiVTBQQGUM2E.png","brazzers tv":"/axTJr1j7O9xV8RhqeCM8ESPNJTI.png","noovo.ca":"/t1ETxTmqkcGHtz3cRemxR3zdxgK.png","npo 2":"/4GaXwPcTDWaZ83a9hOt2fTgh1kQ.png","tf6":"/hKHAEkO7gW3UA9VSVI7z13eN6Yn.png","lotus play":"/uaNCh8fyN14DsYYGBTkAjz6ZgQ5.png","w.":"/iNsZSUpyQbvRmCY1JYvi3JZ6yQh.png","v live":"/xlc6O9RcjItzvzYtxl6tCYMol1t.png","keshet 12":"/guJs28rlEaZTKllD5J1qD7WRQWP.png","paramount comedy 1":"/KbcFmyKSpMDMIsswtQzi7aVNZt.png","shanghai television":"/mutdWEoQEtand10DBc19dbI6hWG.png","motortrend":"/2BGpHwd0CpeH4fKN32O217RbtI8.png","jetix":"/et3ZqCUvzqGVFIs6OiPlw2rxRbc.png","fox kids":"/rUb71E91POVCxpXGJ49pf6m8Ukk.png","tfx":"/mTljUaXNXAlVSD8aGcrlQPpmdF3.png","brat":"/8zD7H0aJH9YEISk1OyFuLPpFu91.png","viasat3":"/w6MMMIa10yZowA18lLMKMfMZ5d4.png","kbs drama":"/6IJTwLUuzGVKwICJBC7RMgNm0Zq.png","bsn":"/34sNEcsgcTAuDidRMrgxdfNyVjL.png","tys":"/b4Ivxqz6UY0EogjYc09Y22ctC6F.png","hot entertainment":"/crZefYhlhwf4q1bp6qzDUPlotIi.png","cctv-14":"/pb8Fon4VjQ7BpGcthzjOqBIzyfW.png","hrt 1":"/i1rGoIBvxpP9wNYrjsELctDfCib.png","hrt 2":"/yToiGhuIZfIbcnrgxFBjSFTiYpZ.png","uktv style":"/5pm09nDmkt0Yv2eIJJIHaKUJnW0.png","goltv":"/2yFWfYPux3l6WdDDvqNuo4cBzo1.png","ecuavisa":"/9LYOSf9psHLY9DsB2AvfECtAzMG.png","etb 1":"/vVZ6oBrw4cxGEBv6pmKnwVPcpt.png","ert1":"/NYry3kLLITgJptE1pkeU52jRZl.png","tochigi tv":"/2v82HpzoKRveJEJcGHkeTcktMRM.png","tvtropolis":"/hZ4UqGsabYK356avLp8rrvRxny5.png","discovery home":"/nVAkhCXG3I4W4Nk5kGhA57BFSWa.png","uktv history":"/1oGs72mYB4gp10gmIMZeG3Sb5Fd.png","américa tv":"/rLnMBF8MVSeQr975Btb5zLQjP4a.png","universo":"/e2uRuY5VobEg67VLfow0Cq765o5.png","jtbc4":"/8ik7JYVYjKH6ybX0DGKB9Gbj9j6.png","abc nyheter":"/3onReYvZuqcf8w7WNKMjTjQO69u.png","canal q":"/bXJlpIwmXjWmTFDmQKWKFZpOyXm.png","start":"/oOin3L9AhYzQzKL1qDrYNbePzKE.png","3net":"/3NWCn4m29tjInw1QcwiJaI8b3XX.png","belgische radio en televisie":"/lpjHtqZkBZflKjCe33nHXKF7YNV.png","milkshake!":"/wsFJkUNuvuxd8s3XYDSDxh4HDOw.png","mbc m":"/5u9vcoUlsBo2M3rxjOnfNhXXB2E.png","fifth channel":"/1u6wDJazKUSS39RaDmAgZFMsIpC.png","perets":"/xelYsoekbDNZ8Bh6VEWwzSa6cXj.png","che":"/Ls0C5phLTXp1dYpHlXRm9gRiRE.png","360°":"/bca3aMuF0v3hgRFKWhZ3qcsDUsM.png","directv now":"/6Gsz4JrWMVvx5OPepEO4Bc5zPsn.png","tiji":"/bSSdpKi6hPwf06pNu5TIUymope0.png","3+":"/lCkMbSafIjIAj4FYGilRLM765Li.png","cbs reality":"/nXC4er1ccpuWVrI2cQNHjuQqAF2.png","voot":"/l6RWXnmlxVgMsu2G3Y938DQszee.png","foxtelecolombia":"/qQGutAt6YQYRAwWc9ZxHQe3b00e.png","russian detective":"/kXb9FRf9pRInsyFCuXSeeQk8pfk.png","infomix":"/jhWfbeJtP992xsG5MbeBigXtDpL.png","art hekayat":"/9fH1kXMwRhXciiEyF7fFGX7o2cf.png","art hekayat kaman":"/At6Y06E0eKvIsMACfuGZVuy0c9J.png","apple tv+":"/4KAy34EHvRM25Ih8wb82AuGU7zJ.png","mbc 4":"/zT01qgrQAqmLRjyPZ3otBhaYNMQ.png","dazn":"/kGipWQCpZcafdYmF0NICQmKw0uQ.png","srf zwei":"/3OcAkffT1ZJAkWPQZKTDdKp8wmC.png","super3":"/43vUOZkb5nBAYvYPj7haOeczoqU.png","genius kitchen":"/2kPJ4CjpyZqDvZYwYnb3lrb0etz.png","be tv":"/fAZfGwB4j4fTxZu44XvxUEDcwmU.png","smithsonian earth":"/9dgMIZKjZWCWxDkqj3KX8KFxRoq.png","asianet":"/jwCbVQKnETdndtnkUVF1xBIZwn.png","neox":"/2OOKfHMOMqN4l2QU5WLRjwp47iz.png","colors kannada":"/bt7SKpvQmDxnqKnJIhPhxOCPUCf.png","colors super":"/lU82n8N9gwTc3AhPOpXrssvzODu.png","colors marathi":"/wjGZg6wjVs2XIz6b0ihbai9yM3d.png","colors bangla":"/5Bj8Mtk5rQnBwcKKb1lWSkeqzTd.png","tv-6":"/6ErJUdDHOHD0AE8Ae2tdmPkOhTO.png","oksusu":"/xIHkp7frQMMTlXKEAUtDQUZgDa1.png","teletica":"/rd6CM8f887MW9MMkRyuuKIpMH6K.png","odyssey network":"/wyfqylHYxhnTMXVZTcpdZrvtpL8.png","zee5":"/cakduIXxOWnOasbKSMZ6Xvw5REG.png","hbo family":"/asKFyxwgYp8WluQkWhGFYj6IYYd.png","yle areena":"/vxp8LTTXwGFrzNM7zSgzl4DfSad.png","rivit tv":"/cCyMbuRim1x6WmnbYgCFn2AVm7i.png","energy":"/b7cMzGGOxz77OIwIDqZrSyGuJMP.png","azteca 7":"/hRxYQTVSCRh4uCs3Wk4AisHOKUI.png","anime zone":"/vmlXZtGw76HkFrXR5UqvEklpCy4.png","česká televize":"/x3BUIowxU2skXOpnOeOkl7kIYDo.png","ntv mir":"/oPqmdzTb2VmZdpjit8HV4bvbIT7.png","7tv":"/7FgVHt1k6lEQDMkvrCVvDbswjqn.png","cbs.com":"/fpkDVuVhNCDDh1JX5ikWGcyBx9Y.png","passionflix":"/zXeUVqLEoCFeiUHb3cDSrNWz0a.png","vt4":"/fueFFOI4BXl1OMiISPMRLxcdbn8.png","q2":"/3uhuCtWhe4tVc1TfiUq0QuE6GT9.png","crypt tv":"/ydUEoQoCFfLvj8KFoLh5AC343Sb.png","discovery kids (br)":"/8woBOtitimA6diobq7sxCIwj37G.png","omni":"/lGE7lnRo1zmxbdo8hCFxhr5WuJU.png","joodse omroep":"/2O22eqwTVXviivKCiwLJyut7XhX.png","gulli":"/4b8ZM73YKqFCJLbQB70Pyw5wQCK.png","onf/nfb":"/dPdaLDtWPiuJzXP1CuNUxxV0DSU.png","sony liv":"/8FhUdXfYC3E2EfntzwCdHYvo4vt.png","fox action movies":"/zltsrFgk824Oe4oWNmvKyya2EtB.png","jewelry television":"/e9JDcchf0AKjBOXnkClvizhm8Ba.png","fight network":"/7Ht7PCwpRkYyoOOxVlQPJUa0QFl.png","hkstv":"/Ar1dX8uhWORSAV6vkKVQM1EoaLb.png","rmc sport":"/q38SfscVUlRaXW1l6w0G7vkdBlG.png","cctv-5":"/zYZ87Aa4549mSAzanVZqgcmgVOU.png","mono29":"/43zGTXOvLAIqIfuJYHhMUFxG7i8.png","n24":"/1dmD88GE8Y5YRtVdhy9yI3pxovK.png","massengeschmack.tv":"/jZFkmHcuSDYxECLeOLSFSQY2jHZ.png","sky atlantic ":"/iVij5p9Mxfoj7mOmcLvVlKgIx9p.png","hello sunshine":"/b94sYzp4YUqi3DqIUnWnfrmxlN.png","contar":"/bLcnvFY4lGJYoWWEc2qpAAkgewp.png","canal 22":"/cIbIoO5hoRSRAhrUYOeYCRK4Zg3.png","ondirecttv":"/uPNX6Cikdj4ES3TkyOKA9yedDxP.png","canal fox":"/1DSpHrWyOORkL9N2QHX7Adt31mQ.png","sky witness":"/mGbOeG43ckHhAAsK391NyLyndoK.png","discovery channel ":"/vWHt63dgArLtqnsSTu744G6g3sS.png","kvcw":"/6NO3G5vuhkewHTuUAoZpjTxcPFh.png","hooq":"/s8QZ9FywliUa3FQdjDGU2hopcfi.png","acorn tv":"/vKbnLMCglPQch0OPeN4KpPjvNHW.png","umc":"/4nGQWWLyDbQpJvUbXYlZKHFjUFP.png","spiegel geschichte":"/3Urhc0dBh4jKL8InhwsjY2xRL3E.png","studio 4":"/eUwMEnQJlXBNBXydcsBUJSqKVuN.png","yes tv":"/o36tNm5Scu1mOltjUxUdSbKmNCl.png","ruutu":"/ieV9u8Q0WqTvlRcR3TTceA1X2r5.png","style network":"/hmm0xFl7nydC8VR7YdTQ0TXowQ6.png","eros now":"/4fbNjuFBZ9OnG7keYr3xJFYG5hi.png","aj+ arabi":"/qatnFtgbatXo5TqY2P7Sx5y6wvK.png","rds info":"/dqHb8f2Fdi5fpzysHIKh5SCRiw9.png","chukyo tv":"/KaIK93kJFD8WKH451owMdmNOSz.png","rnb":"/kwC7bfbGknKuL7uxKahk6paamlp.png","ustream":"/pRc6dPI8TIaZ61bF24bh6Zwk6QS.png","qatar tv":"/5SM3cwPsh5uqb3YKAQWVzn2FGcz.png","sat.1 gold":"/wYrKXhcChgIqfDRONnANmOoFBm.png","nps":"/bbHC6qV98IoKn3veSx8wDiOjtCi.png","nos":"/982W7v4h7zS9yO4VZIjHw1ORCl.png","disney+":"/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png","focus":"/3mbNzHGZJoIluOgebfCgwsQcuU0.png","rang":"/A04tNYZI1mi0jxFCsSlKrUPmGeQ.png","kakao tv":"/uZpVywEEp1FaMAWZKQD0ZSa9nel.png","freesports":"/1xCjgcYsJ4DaD3NHzDl5bNuay05.png","sky travel":"/m0oaYs13Y3K2vsqoxcWYjBBpDyM.png","tro":"/4sPm8L8wX1gp3iBXqU5bhGqdYvQ.png","fod":"/xMGdd8hvu91BK098UwbUv9IdjM5.png","gusto":"/xhoPVvSmHRm1984Sd8GWmEQ6YQa.png","kbs w":"/ouocJy6EukDqdAIS5tqER5uzmIX.png","man-ga":"/v8WRVyDAQ7xA7Lt7jTaz5AFpH3z.png","tvn 7":"/yaevuAiWmYoYyoa9N2uYyDVRPtz.png","love nature":"/sQzxQ1GOyNyxyaznugkicB1JNDO.png","n-tv":"/aW9VAddPX7Z1GNCOAXzXtaFsYSa.png","abcd":"/qFL0BoydPB60mO22edlV3RfhLUT.png","deejay tv":"/xkRLUdy3thCCST8gkVwdJnP4Cfm.png","snapchat":"/yQcvQqcOWxprmIuzjnQEZfKjj0G.png","rocket beans tv":"/iH3nusZNkYn8lXPCZHKCS4pXrlc.png","canal savoir":"/z3JFzSXjwgzGuCCynk2nifjQ5qQ.png","dkiss":"/70ezozaVCQmchziAM8HgBFmT9Qp.png","wow presents plus":"/29fbulsOz7d9JDJLjiNkxq0jS3Y.png","rocketjump":"/7w2U3oxMYybsd6lBtcJEiT3zcII.png","eurêka !":"/uoYFG9jVdyA7OctqW1WZdSE3pnX.png","netvideo":"/kNTluzyx84RrGizI5zh03lwADmD.png","gyao!":"/e4Zb74V62MpQRgtgZnBeGEt4UE9.png","hungama":"/znFJryX0SQflQHmJcgbWkuvWN5N.png","tlc (hu)":"/i3Qh6pjy2DZqxAXcuOGXNTyHl2v.png","toei tokusatsu fan club":"/8jJMPoqXIBdcWKlGFTrkGi0Zyj2.png","rtl+":"/2joN5UqxLRyrE8JJJUnbcvqU4hm.png","squat":"/inl7RihwWZmBS7tGPNMCA9dMCUO.png","kanal 6":"/2IbmlZYWPDCAvSXLrYxKsFfMwwx.png","hallmark movies now":"/zjgwf8ZDrMf7zxhe8WdwzPFKnjc.png","abc kids":"/efIOJEqy89zWpK4fDlOXwXtTbkE.png","sjónvarp símans":"/dhHde76oIEfmqjAuvlkxlGKWYGK.png","tnt comedy":"/w0ZtygaHGHBxLmdS2ZqNDDYyb0w.png","premier":"/sE6SzJwo3yDXntlBohmHNGxmevq.png","hub vv drama":"/dlSncZ9PmlvCTGOC56hX05LdQ8G.png","gyeongin tv":"/bjdegNjZyWFcCIAxyA13o2FkrzL.png","vtv ":"/rqLpVk7zzDFUT0BFOIEVbC6XvEP.png","tv3 latvia":"/uxOAZGDIoP5ZRGSw9QX9TxNpLgk.png","pursuit channel":"/jgaDTPtdlNIz7L0X8UuyB6WNE5J.png","spectrum":"/ta0tkJ6VUugXwQzdea6T7jzUYkI.png","the fantasy network":"/xlRy985kuvtyLXurGkudazmgYYw.png","safari tv":"/1qRHeqcNJQzvzdaeAh2NBahs0zk.png","yoopa":"/5TMxGA733YHkPFXIA8uxs5VxN2Z.png","makeful":"/44K8Iq6d382bQvP15bgGoRZ3bjO.png","top channel ":"/aQa3PvbykKhL3WWTCovE8KOrCpo.png","food network polska":"/jhxxYJx97vqPiGL6rZHDVixJpfN.png","ullu":"/plFwH8j6fGrDE2wJ5rYyqJ2046N.png","zeste":"/a0J7SLebqWlpVDr1lJwW6uAwVOF.png","open beyond tv":"/qH5M9dRWAwpPJ34PehcOudqh2hq.png","family gekijo":"/uYiTfGXmZnCzGNCB59QcaZmnffT.png","imdb freedive":"/AqLQUhGYbgIalivsos4IeM3RIPR.png","télésud":"/6bLMsJU521MWNT6wgLt6oaMIvP7.png","tv5monde":"/b6KDy3HbsO4RpnDPtHDSfsRq90c.png","russia-kultura":"/jN9V5mPoi7nXNr2y97v9vJXLIj.png","tvnow":"/fXCqxsW0QhZTTfL5YxDuDeKNvxJ.png","nrk p3":"/nVIPfpdHtgy2SSczqsv39EiiC6Z.png","teleuv":"/zxwuX0eT489Od5KkPGIV9nScR6g.png","now 26":"/zYJkN0FO4WclBYu6GWWB9fgeHJm.png","workpoint tv":"/luK3LME1iDphfp4r7Te00PIi2AH.png","super tv2":"/9Z6XVfyFRFneRhrIPUfX9QZjGHz.png","shudder":"/Ap2jtXPy3QlLMi1GYLtasq7VoN3.png","matv":"/iZepWlKX8Pj0MYjfxyF4EHQcLez.png","tvi24":"/p0LHKVYtJaPi0SyAS34ubx0eTtC.png","mx player":"/xKMlXKggJ1mTsfC5OSnlBKuFccO.png","bon appétit":"/8crroBYIfllpwrM2Fsuce8XZe2k.png","kan 11":"/hcG3NWwfOP7lW9tgxcWwDovEdT1.png","mtmad":"/zH1XEtQleT7o6cnfBmePj0xiJBY.png","animax korea":"/aA74agiTaAoFrGmOfSDyBlpZocE.png","aniplus":"/1RVv3b0CO9YHf9PN1DEih3CDUwz.png","viu":"/yEv7NZDYYxWak9cytEKzjO4Te1L.png","cbc gem":"/m4nAbyBNzPHitupGll2n9WR5kp8.png","france tv slash":"/6MNsYgexPfbWm7pxfosNqltwVDa.png","france ô":"/y6b6Vc4Jwn4zHrwyYa99I4ODUCc.png","comedy central (au)":"/waWpWvfVRCCFUt295SEMkebc0ai.png","playhouse disney":"/4v1r0aXZkoTTIi8MJcNrvTaIG0g.png","machinima":"/pjiP4WqBZm90oYHK4vYrzl64dRr.png","clan":"/lVYNNXEJhUAoCxBl0cJ9KvsIjRs.png","mall.tv":"/7IkEe8QvysSJyMZplqrliiUfqE4.png","imdb":"/hcypVQGTkMikAgJlpasssIohf0w.png","azərbaycan televiziyası":"/7BzkJB5PwrVWJY8T9yw1tKk4El8.png","mewatch":"/p0kcc0CINYJIKD3hDkaW1vejCAg.png","idnes.tv":"/buZupHGVRcmtpa5j6mvnJKzOhhj.png","iwant tfc":"/C9LDkqA2vosa6ibxue9gZrCaQr.png","claro video":"/mqWLsBYQmQbIuLbHLnrQW7LXUNF.png","friday!":"/bFrRqO2SCc8UXXpraOfqSgsbVQk.png","metv":"/qrqFyJGAxOJvKXP2y2dQss5XDkW.png","mbc 2":"/fDqP1EkqM0Bobpj3H3bXj3b8ZAt.png","otv":"/qQGOqPmNuhKUgWHsVulcvU1rP2z.png","env":"/9QVkKvpaetT4SQOBu2pD6Q5Qis8.png","planète+ a&e":"/h8WAnO9OIO0k2hLyKxTbXplYFUz.png","metro":"/mfeKu8D3mrR5efzkZ0Hq70ww5kO.png","bell fibe tv1":"/xJuu166ou5sK95UK2zzMkTkN6Jf.png","estrella tv":"/iGw7s1jiRcWggyURxSHfv2DgIIE.png","tvn style":"/bd7eZpipCRGTjLtbQeYzlo93h4s.png","canal+ discovery":"/uAfVBVVmDQIEVRGZQGzb0X3X5hX.png","polsat play":"/6hJkIbD5xRfNMvHbluo4zKmVUJR.png","drink tv":"/mU3bZsaS8bX08azWJJIQFCDRcch.png","ttv":"/jyl5bWGbhSJ3ZnMcvZAJwUs5chp.png","señal colombia":"/w0rERIXkvfI01ey6kDv0H3QQTWt.png","rai yoyo":"/6tIBzDghJkH9hFntmMKYevW04cK.png","quibi":"/aYl3rkrvZZZUtv7JnKNbJeWFwdp.png","kanal 7":"/j5sCHmg2vY1sUmgOftAVr7HsuHa.png","bioscope":"/jy8ZINiQHxbLCHatbnxxmD23L5T.png","amrita tv":"/6dhn5HQprA0S3xDvXZWW1MPCOBU.png","lcn":"/io8XzF0aChBUCPr881njwSZxt6m.png","nippon news network":"/m8ABeozh6TciFlSLpYOaK0vZlWs.png","sari-sari channel":"/wKwaz7ToYijKepNGUl7SlQ6bnYt.png","cctv-10":"/ytV0qzKx3o5sdtM0tqLXBWEugEb.png","canal 1":"/dVoNhpCtTKW1Tr7sxngUx9bshys.png","o tvn":"/hRUFZpGepmxZ0LCdgpb1uISz4nw.png","zeus network":"/8BVAYP3Y61LvjNZr9mcIr1IBSoH.png","joyn":"/2OWbACUYTojW3CSzbEuPicnAabp.png","sony espn":"/4JjhUXUKpnk7ALQJe5nPbFdn5Tf.png","hum sitaray":"/cqqwSxneFMDBcqjBnJ3OfKstvNH.png","xee":"/2LUK4SQdjmQ6rTnQyQzbhT9evh3.png","trece":"/fh32Yocc1650C20U9KNudtxIssj.png","trt 2":"/axA7x7TBJgN5WHjmGoEmQorOq8u.png","hbo max":"/nmU0UMDJB3dRRQSTUqawzF2Od1a.png","itv encore":"/ucMo8oAZCwmn5IFjUV9yDnWpC2O.png","100 tv":"/xWb0SVMUndHG3UQCrMOFt3aTiG1.png","foro tv":"/oX35kX14EpjkEluiYE28FF3drE0.png","tv hokkaido":"/g1jerEP7O2rIYd83AaLKXeZlbpI.png","build series":"/ZJ7pobVIemTKkpRPgDJDLBGCau.png","zee zindagi":"/8DNSIug88tDYzxWuNBQwbAt6Sw8.png","viva":"/g0Uf0hblsRggoZBPwYBrKkFj8do.png","bs4":"/naTpOY8AjRtgBw0JXafrhAD8ZBT.png","azul televisión":"/f9LUy0pcsI3DfEWc28wgHs7NBEv.png","pluto tv":"/6xI75dFULiEks0Dqm3Uag7CiC29.png","namava":"/uLOLNOsN5xMvbB6ppk4Dax7uzhS.png","gagaoolala":"/7pXbP8D3T3BfXKyJpbalc96lFll.png","animax asia":"/gNsvkmJY7QzRQ7AZ0QK9PeONIm2.png","canalplay":"/ij9WCzOHq8oPo1nHnIISqdkFcPo.png","la cinq":"/vZDDmxWjogmw3sdpiTQhaiZdOmW.png","ab1":"/9uwYyKu45dv5DyZEuEp2JXzy6Cx.png","ab4":"/kB986fmXXXfNtpxJ7fbIfd5JmKs.png","bbc scotland":"/pkei1FCLR4Eox16BrN6sGL5FEfL.png","dr ramasjang":"/i46iC26w4Yt1Q4JUK9RtjYLDt2M.png","amarin tv":"/x7mp7VgOFS1LqFvwZJ5OXcB8xLe.png","prosieben maxx":"/eQPoTcjYtZocXyv1RFFWZJ3JKZ9.png","pantaya":"/ytTHs0Mw26jCFQtRUqdajqR5F13.png","globoplay":"/vMj2Q30VxvNt0VAIQzb8ZQHWwNZ.png","cine.ar":"/cLd8MF4WjSNENPYVHTPbfEfWRXH.png","investigation":"/ewPhtyNWEN4Jl6kgp42knjAxRxK.png","azteca uno":"/yJyGhrAaFdmOHjiSjinNjIcErHs.png","ami-tv":"/hEdPOGY7TwSRZqvsEswM6BzhzfS.png","funnyordie.com":"/sBzU4PjJ8vH9YGUbfFnXs0sDHuM.png","astro ceria":"/iY099ZpmP1j5t9QkE2Q3bbBcsHT.png","jstv":"/nhVwbUQM3WIbZdf2Px6AjN1m981.png","tps jeunesse":"/8XnIxN8hFMCjfwssg6HM88Wh1g9.png","vudu":"/xsiFrVU7PirDYY0av8SiyswYzLf.png","toonami":"/5BHAWTdsmpCcgNQxUkOATOn6iNV.png","bet+":"/vEBCYgIAVklR4lg0ev9bASLzE6h.png","redlight tv":"/mnXLCetNP9oEZx1KnvkL7etj0o0.png","peacock":"/gIAcGTjKKr0KOHL5s4O36roJ8p7.png","wavve":"/5x28VbYCOuLfMRePMDOcax94SD9.png","pts taigi":"/3mRQw6lQrZIaOu8aYR1VAUMjULk.png","stuff.co.nz":"/qx7huXKL5kYg1dXuDxcOA7BXFZO.png","svt play":"/xtXMBjBC6YhQzODw0XWERsHvhdz.png","insight tv":"/cysCdp10yByvUrvRoJxNUtdMFhr.png","čt art":"/pbZg2N5BVb2Hc5cpJIYjbuqWCDj.png","čt :d":"/jr6QJwDKiEV2flOJnv2bkyfIhdn.png","televize seznam":"/ePO2Wcv5B3zw5eJgUxNp0CJHpIf.png","čt24":"/27GU3PdUPXwsX5TqoGKCraPJj6T.png","hakka tv":"/lanjDqaaJhNFjHLLqBb691P6taR.png","prima zoom":"/gW4dPB7TFHP1AGnL9bvhQnRWaj5.png","universal tv":"/bKzvRKx4kTWdJLl3trEc3p7cldt.png","hikari tv":"/1hWkGw1Srw74dFzIYDgssn6OoR6.png","hikari tv channel+":"/pywXRLxb6ZuXxZzMugQCAKN57ab.png","سورية دراما":"/iTTCHsQr9flXKcvyDTxZ4cbHept.png","prima cool":"/erohPAehVomIV3pglLuDquAQsCo.png","prima love":"/753SEOKvGzRE18mfuCz0cr8LStW.png","çufo (al)":"/4EvKLwhdagu2KfhicZt2KbkOpu.png","הופ!":"/a1TGWWQUFBNdqTV1XKpa6GGkJJO.png","first channel":"/swp6U9KaEEry2NwZXFswBErRAeA.png","腾讯视频":"/kiDJEB9eyV0syoyF51zjM4ySjpI.png","hallmark drama":"/bVFOBsvRyN3cJ1oEhGu7BqPZwGq.png","tfo":"/8FWZNgPWEazdwA54aul7bKOvFnq.png","الشرقية":"/wivcWv2xjte9iRqvZxHYOKGczUk.png","rai storia":"/7ypIuH80IDEZwScONIjrBqihXVU.png","bayam":"/nykmIgHsU6aQrmuNJc0lSxzz2qz.png","virgin media one":"/pPfZlk1P3zfNyIMginkstYPn9wC.png","raiplay":"/xuijJ556OSE9xqw9C6Y8nf3liKP.png","comic-con hq":"/5AbiaYEKlsY0qFjWsdC7qxdSsFQ.png"," 10 play":"/4Z1e4FlcFhak6BoKE13EckL8fmF.png","bs tv tokyo":"/lUEv1JU8b5UcZJ1XoJkAq8o6UVd.png","dumpert":"/mglYZoTvCPemKgrpRtJXney78x0.png","junior tv":"/gqeFiLLY4RhN8Jw0WlcM04LyOxT.png","rds":"/fvLrKSvF9uhQeCFKJKhUCkyPXmU.png","tnt españa":"/fwt8nHdUAN655lIWC1RButfsiY2.png","latvijas televizija":"/4cAeVQcPBxttZC9Y9FQItgFKBXU.png","novyi kanal":"/hy4KtbiU5oUWL5JjmvYy3dmyJLb.png","la trois":"/dRvulHecDfIgJNBA4Dyltcpaqm5.png","svt barn":"/ck9dgW7WZTTYmcTsXuR2MIzihtv.png","alibi":"/goAPipwx4tSALdM9kJZ7ovGjEZg.png","diyanet tv":"/ir108f6LYvYMaEfGJqVVva0xbiR.png","cottage life":"/dfZGwt6pF1IczG4nWIOM3jXB5Ga.png","la5":"/ifDMYB7mJ0VkbvNaP2SW7mIbtzD.png","arte creative":"/80vsDtw538sEbfeMZayWqxzbB9D.png","ici tou.tv extra":"/wP7QDZA7TOCa3B5rD00DtBjI31.png","tello":"/97OWtUjBFLxtztMrplqrOhYtMzD.png","2stv":"/azCNFEbDIIIeJ8yM5hDKQtTKH6X.png","sen tv":"/sUisp9lJUDI7dA7SP0X7wdJYsZX.png","wido":"/dkcFYUXomfR3HhZcEeH6eWXPY1E.png","club rtl":"/7tjZK7sd7P7zz6fthAl2ELN93vU.png","jtbc3 fox sports":"/15puhQImE5hGj1lxXG4IUdN64im.png","comedy central netherlands":"/6ooPjtXufjsoskdJqj6pxuvHEno.png","vidio.com":"/eYdP5Uikvq9K2x0KK1emIx2WJdJ.png","kan educational":"/3R5OK6NxfFUnumjCkDCs5yxc4EV.png","à punt":"/iKEJEInEYPYDfQIZVGfEFVterDJ.png","5-tv":"/nIey7JqAgMmH0UC1ZXuTawGdAHL.png","discovery science":"/afxnsEk7jsfQlVbK0scU6gcuUeu.png","tv barrandov":"/vQde5X0ChsnMA0jNenBgtj2gTwW.png","nrj 12":"/uC0JP1tx3FPNTb8B0prVgRCscdf.png","rtv slovenija":"/yD8yjPqs10C0ayb38hKMXpQonJ3.png","wakanim":"/mBVrs71WfQiivUPrRQKvzAaXlb6.png","tvnz duke":"/tzwUoHIMWKhQzBw6sytWK6faCjv.png","net tv":"/yBzinXzZCmHWmK233JEX54xPdL5.png","hbo brasil":"/tuomPhY2UtuPTqqFnKMVHvSb724.png","tvn movies":"/kIxFo8qXuqVEVra2EKeuJGlPGBO.png","nagoya tv":"/6NBvugZ46HATVS6nXmWaLBHAtE8.png","hidive":"/rTjC2AeaaSTPjIh9YIqkPzVWpUw.png","puls 4":"/fs3NkllP0yllYAOOFcIBWKxLmtx.png","fct":"/1UBs9MeAJZyt8LzHowk25cXP7Ez.png","sky crime":"/j1DtfhlOytvbV6QVnrZHT3xBkAP.png","gunma tv":"/oe9SS3K7nqf4s1VJpbqWx6wlxBb.png","hbc":"/n77lM12X5dhGHb0I57RmbtqxFsd.png","ktv":"/pblprS2dwTv9p69RGJkscm5365o.png","tv5unis.ca":"/6Q0QyebAHKzDLHqW3xzajNYnVPA.png","aptn":"/d5oq1JLxnwRJ8YWCmOnr12iLIqA.png","rmc story":"/uKuCdsGlB3QETtIqoReAkEFS8am.png","d anime store":"/xm9X7UUrhOcsRC6o3RZtrNvaqG8.png","sapporo television broadcasting":"/qV2FvarOatWAtBGk7tLe2KuQMyn.png","weverse":"/cIpoMkLXqYUPQgyESTbVcnPivh5.png","rkk kumamoto broadcasting":"/vSn3Au44pqNvrRAtOE7CjBWOOdz.png","vice tv":"/iA3HkJ8eNQZngQAjETO24YV8p1n.png","hokkaido cultural broadcasting":"/uKTzwez1p8tB4dnHMCeDjdN7xlR.png","ab3":"/yZdt7s80AWA1aqM8OsP6rD4CfMA.png","wetv":"/lf9ih4TfFC845idJHjuggJhQNL.png","television nishinippon corporation":"/6HPq1wlbqjCDa93OgaYp7E0ANIM.png","kitanihon broadcasting":"/kGUilAgTPGgaALslFvjyGvn4TKj.png","tps star":"/d7mFDlWCM6dvNah1Dn5q3haTq8k.png","atresplayer premium":"/tRcLVDie5E5dIIRvA8Ojjuvlp7k.png","yomiuri telecasting corporation":"/wWiz0zwFX6WOXQSwsPyTCKITNQK.png","rakuten tv":"/4OMk9wkAMwjcVM905BDyDC3iIgC.png","anime houdai":"/zQbXySts9Nz2wus72JOqUPWG3Qt.png","anitele":"/6DTnriulEzrdURGjPV4QbY7WPuJ.png","video market":"/gx4yubRa2NkJ2Amia2kiNeQ2cja.png","omroep max":"/9kyApMSkL89Dtmidc3r9IRtSfBT.png","nagasaki culture telecasting corporation":"/qEIIHhehdHtTQ4fhuXrA29mbzmo.png","aha":"/whVDNlpH9Z9gqU3v6m4o4stnjgX.png","imago tv":"/jnTO547ERX26cOLTpKOSIPLquiD.png","dailymotion":"/sjaEKT3ynyGDpOODdpLqsqJuy16.png","rtl-tvi":"/1a0bsGJysuLK6iH0OaBnAfxeEB7.png","kbs n sports":"/cfocxbV1M6E0NGEAhpLPhlio9g4.png","rtl9":"/umOhb2Hp2MrBL4rYbYALlsdBY7j.png","la sexta":"/AtJXlAoj0ITHKDN5EPJZPHJgLxI.png","chubu-nippon broadcasting":"/gM0yhHNZxgJUUjb1o4abliDf0qN.png","fem":"/bvjpiZxP8cCU8HYcHnC0wvCvs7x.png","myvideo":"/q2n52WeF8xiBIA8efwtPXOyPT4V.png","vijf":"/AgdI3DKRqx8wrXkPQAExHQSwmfh.png","deutsche welle":"/wiZ9S6UFFGro47TnzgAR13IhneP.png","distrito comedia (mx)":"/9qOOQy1cVCs84SE18ivIWpL8Mqc.png","aragón tv":"/22uvDUdf6WfQr7jpSs34iKIUkUx.png","kinopoisk hd":"/fyXApa9qRaYy9xUBlykh0iZBEF3.png","rai play":"/3EOt7Yoe23FBVVAKbUPjMzxkzRD.png","canal+ (telenor)":"/5rTyarrnWBpX1lOInrVRM4zXr5r.png","téva":"/81QFkWQND1V5DQou9d65025qBTf.png","el toro tv":"/ZYHWzIaxCjtzogQz2PYddxwXEv.png","gemplex":"/m7Np4sh424iI6ApJrpb0tl49vc.png","rti1":"/el1B87vLnyEbkjTxuyxjOpi8dkw.png","france.tv":"/33mdJBGZzealAB1stOtWTCF0S5Q.png","filimo":"/5bajjCwynBYseyzOUxOfbqpQy3t.png","el terrat tv":"/th0Y7HsUwXvFOH8seRIzWk83OWe.png","ひかりtv":"/4iCrWtLJbSHABWZX022VEjvftmb.png","u-next":"/ydvZrfyYbBVXPDT1Tv2P6EmRmDZ.png","okko":"/pMy0zJmwHTsmR2mxLd4ifkOSq6h.png","čt sport":"/qB4QfmivJzhX6JLTfbB8BjuOD0K.png","disney channel ":"/rxhJszKVAvAZYTmF0vfRHfyo4Fb.png","revry":"/dXmPubyyiZtgHXU5bSbSQNnZq6v.png","snackabletv":"/mDiDmKmP4cWTfjuTSaHfVkOnOT4.png","hbo españa":"/z59yULluBjVC1NzIGzxSPBEjr2K.png","sama dubai tv":"/kbZ37FNUSIVFYhhMjGJV7X7AlJ0.png","paravi":"/zAo9jDJJ9RRLOZ0ApH8xQcfJiRG.png","more.tv":"/mrv0nhpnFIqc3ouMtHzSkH6lBn1.png","el sótano":"/nJcGLqbapXY2dfLVbhfqB6LCLUf.png","čt3":"/wCn4ZTUgH6Di5NXmpJ7dQNWhM09.png","mbc 5":"/6y1eVaiCffvH7HOKSdEIJfIh8xq.png","laugh out loud":"/9LiLRKyEokkUQPQTAKRv2wGezLc.png","nippon television":"/bWiM2iY82B32NGmPCOFatgfekIR.png","movistar+":"/8DdXvwYKBnLU4ZV9bOTOBAn7Zci.png","6play":"/rPMeCuFqgCj4gNVLX75uEcFApHQ.png","boing":"/iTubxZz2a5zjePACJYEJoQkIrJp.png","disney+ hotstar":"/sFfLNkL0DZPbYlgmxu9aXp7TiHZ.png","kapamilya channel":"/xal22lPb7A1c1FQoYdHzpOm37Kf.png","ivi":"/97MWdMOnRccJvATuYw6wtMa4WJQ.png","tvnz kidzone":"/1DzG1ozQW9zZ8HP7L2UiTT5h9zS.png","tvnz6":"/gm9eU2DrGROjAkIoODCjnAHkOO2.png","rai 5":"/mvh2XSuVFwksHTwaYNSr3umfAIR.png","ten":"/8OeBWslnqoiEfBR9XpIOQrNgwI5.png","ifilm":"/3hchCBmgzMuMbWoElH7y7YtCBUD.png","friday影音":"/kP69k9W9KSCbtA4SRI3x2LmByAD.png","nara television":"/9M08u5EOibJxZCWPG2kasrLrKAR.png","channel 20":"/6h6b9RCqBJDCKFSrT2F7wjMp7uZ.png","i-television":"/3t75XJS7vO7Ih6rJkIqUuZkePDp.png","shizuoka broadcasting system":"/snEUE1ykwOwTAHvQIzKs9iYYQkO.png","iwate broadcasting":"/zoFgqDe0v8svSA01yExlhcG1Bss.png","broadcasting system of san-in":"/cQWQ5UZqFviyJNqvbuvgZUyly5b.png","hokuriku broadcasting":"/wvBiZKeuACCz8kxeUJ8obHfHnqZ.png","oita broadcasting system":"/iRxliwAcihUyTIjJbpxQ9zMGbEj.png","tv-u fukushima":"/7jZrfXteN6dWampK5E6qJSScuZm.png","tv-u yamagata":"/zOz9LK6WWLUWGuG0fthzDzCIXlA.png","tohoku broadcasting":"/6oIE2LmtONObnw5wtFO4IIXwgkb.png","mitele":"/2sMrOSRL5z5SneEjIvwRAXm8B1d.png","wakayama telecasting":"/qBhdAhitNODSGDEmeK8p0XZ0JAG.png","arre":"/6915j5uXxekwxJO6RFnqJMlMFc1.png","bindass":"/3JOLGKlXiPII99E6sikyiUt140p.png","nrt2":"/dttgimrdLJq9Tmkpov3RyPlrbQx.png","jiocinema":"/yXrkdA9NlUfX2mRDhPgk0ye9frq.png","britbox":"/n8YeTJow7qmgfjgoQ51Xi2MQezB.png","shant tv":"/sTexVkysmuo2hjHzBG5YHn4TMCY.png","azteca trece":"/tP07zSQEFmN84mfdYrPqdyxGDO0.png","óčko":"/hvp4sRYLwe18yWagNcBkSVwnzwd.png","13c":"/fWv4H0txKqviTx4aq5HxSFJ7j7J.png","imdb tv":"/vizcQUuniStEN7Xm9IminQbuisT.png","ct-1 (ussr)":"/usMoGOAxJ1YfYkPTwPTsDeVIvEJ.png","nova s":"/ljhUSTgggthaCgI1q7gYoVvU2Fd.png","steam":"/wWvkikegnLxnBEkVTVmjRSlUpwH.png","tchelet tv":"/2O5ZRXQgH8yRvFCAXFyY2mw3OzP.png","hero tv":"/uF8cYTk1EOlFO003DX4LVSQHVbp.png","anime network":"/5Jc6AwN606Yg3efvFTQddaHrdFg.png","rtk":"/5eolJ0d90bDHlgx0PTBIu2klH1I.png","mts tv":"/t2ISbkv7CJHWkz2eLh9cGzFaZ1V.png","vizion plus":"/o5oZOnIRbV3jxZusMOWkQLcnriK.png","tva sports":"/a2hV9XrqawPM4fsg9CWoeTzW3Mc.png","voyagevoyage.ca":"/pFzYb9kpO100rhN5kdx2zwT9sLk.png","ctc media":"/8Sko4MFVPiwWInOuV1CsjHz0In1.png","children channel":"/9XSgTaER2dC4CVQJ759SCazA2vf.png","la fabrique culturelle":"/yq4KcjrdazO6YJhxuaS43iA7ltL.png","تطبيق أكوام | لمشاهدة الافلام والمسلسلات مجانًا":"/bxUTlwXm2v1PDnVIkQj79Ewtdx5.png","xbox live":"/4yYlTy9YoiJdSbxpsTJSKrLrSiU.png","super":"/yVgt27IiNXdelp7B1BHj5IAWFfe.png","streamz":"/mwVrzJ290lzATbpMg22Fk4MVBcs.png","disney.com":"/7Lpp3EiQyEWnHWUlDIBtr2Jzp92.png","espn+":"/mnrrbM7g7gvthN6iyYZ8ndrGKix.png","court tv":"/uZHvgQG1ThBWbmDH6Os3YL6sA9J.png","hbo go":"/3UnuWwVr6PCtXyGzZZqgYQTZhCq.png","sky documentaries":"/ePgfopNKnZ6iSRMh1Gcm7IIamh1.png","commercial television":"/d8K1SKholLjEPHaY5evDwRR7wgj.png","noovo":"/5dvZFVBad4gj3vdCFY3kBHlIwg3.png","山西电视台":"/7SfN4GyCU58ijp0DQJpKdGJURHV.png","tgzg":"/zGbaSOBIXizdsGvxbWusDEGCGtd.png","star bharat":"/xGtgKrr8vyXSJLjAteCBeCljvwM.png","fuji news network":"/tjDkrfSswzDxx57bXnVwcx8QRUQ.png","mbs動画イズム":"/esUBmXT8s6wZrzRYYb3AGmhNkuo.png","rthk":"/szBBidFy2MHGUejAtu4EjaiS3Mt.png","a+":"/r9ekzGSuDr1cIrJ4jenm6iPxwRk.png","accuweather":"/accuweather.png","amazon fire tv":"/amazon_fire_tv.png","amazon prime":"/amazon_prime.png","android tv":"/android_tv.png","aol on":"/aol_on.png","apple tv":"/apple_tv.png","canal digitaal":"/canal_digitaal.png","chromecast":"/chromecast.png","filmbox live":"/filmbox_live.png","funbox":"/funbox.png","karaoke channel":"/karaoke_channel.png","kpn":"/kpn.png","national geographic kids":"/national_geographic_kids.png","nintendo switch":"/nintendo_switch.png","nintendo wii":"/nintendo_wii.png","nl ziet":"/nl_ziet.png","npo start":"/npo_start.png","nvidia shield":"/nvidia_shield.png","pandora":"/pandora.png","pathe thuis":"/pathe_thuis.png","playstation":"/playstation.png","plex":"/plex.png","plutotv":"/plutotv.png","rtl xl":"/rtl_xl.png","spotify":"/spotify.png","steam link":"/steam_link.png","stremio":"/stremio.png","ted":"/ted.png","thuisbezorgd":"/thuisbezorgd.png","tubi":"/tubi.png","ufc":"/ufc.png","veoh":"/veoh.png","viewster":"/viewster.png","xbox":"/xbox.png","youtube kids":"/youtube kids.png","kuwait tv":"/hEV0q3GBV4pDIkLbQdlt4NRPwS1.png","c2s network":"/kpZyuoVeHOhPvFrSmBu7iGJ5jGm.png","astro maya":"/3xJVfJa4df96AuMitba1rGko5yP.png","iwant":"/C9LDkqA2vosa6ibxue9gZrCaQr.png","ais play":"/yeAj99gPJjTbTLsWxy4eXqJv0hU.png","skai":"/sAJZ8o5kVDhetos2SsLTZL7n7ZP.png","shanxi television":"/7SfN4GyCU58ijp0DQJpKdGJURHV.png","tzgz":"/zGbaSOBIXizdsGvxbWusDEGCGtd.png","radiodiffusion télévision française rtf":"/gqZtBobtvJupQAGPGL0HpErWPeG.png","astro aec":"/7AvGS1q9vYjUs2GkVKnoypLC0nv.png","f1 tv":"/eGPTbt05LYgBniGaj8NFusMzRWm.png","tl streaming":"/bwjj3k5haH2cXpONzngRlt1QO3o.png","ua: pershyi":"/iPUQ6RicbWvOQxqyr0vJNlz9HOr.png","24 kanal":"/8D7vVsJzuNKzlKLvAvK4hK2eCDF.png","hromadske":"/p5eGMTKV3jABp6yoQljnj33bvcj.png","ctv comedy channel":"/l49FKtSEfGaPDPffzsSOn0wKRDL.png","ziggo sport":"/1Bb3dz4RZG6ymUOfYcCBgGaaw3f.png","rtl z":"/8594cSaaSi9GBTtzdVoixvZODhx.png","rtl 7":"/cnkoWK84gsVqPYa4NBCFz74chhz.png","rtl 8":"/AjHAm84qS6CmzPV0tGLGmwMgXiP.png","sbs 9":"/2l7Sw99wIpAlBKyxXY1u2YWHVoH.png","ziggo tv":"/j3ITOYByfBOqB8brTtGLdLetLNe.png","duck tv":"/paWTgAPLyViNI5L6nAXgh4SEj3E.png","channel awesome":"/xXprcFVtay4af4ScxEZRByQg793.png","巴哈姆特動畫瘋 (tw)":"/786RBHyCRB8ymRI7F9YbdIGvsGs.png"} ================================================ FILE: custom_components/samsungtv_smart/manifest.json ================================================ { "domain": "samsungtv_smart", "name": "SamsungTV Smart", "after_dependencies": ["smartthings"], "codeowners": ["@jaruba", "@ollo69", "@screwdgeh"], "config_flow": true, "dependencies": ["http"], "documentation": "https://github.com/ollo69/ha-samsungtv-smart", "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/ollo69/ha-samsungtv-smart/issues", "requirements": [ "websocket-client!=1.4.0,>=0.58.0", "wakeonlan>=2.0.0", "aiofiles>=0.8.0", "casttube>=0.2.1" ], "version": "0.14.5" } ================================================ FILE: custom_components/samsungtv_smart/media_player.py ================================================ """Support for interface with an Samsung TV.""" from __future__ import annotations import asyncio from collections.abc import Callable from datetime import datetime, timedelta, timezone from enum import Enum import logging from socket import error as socketError from time import sleep from typing import Any from urllib.parse import parse_qs, urlparse from aiohttp import ClientConnectionError, ClientResponseError, ClientSession import async_timeout import voluptuous as vol from wakeonlan import send_magic_packet from websocket import WebSocketTimeoutException from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, MediaPlayerDeviceClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) from homeassistant.components.media_player.browse_media import ( async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_BROADCAST_ADDRESS, CONF_DEVICE_ID, CONF_HOST, CONF_PORT, CONF_SERVICE, CONF_SERVICE_DATA, CONF_TIMEOUT, CONF_TOKEN, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import CONF_SERVICE_ENTITY_ID, async_call_from_config from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util import Throttle, dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from . import get_smartthings_api_key from .api.samsungcast import SamsungCastTube from .api.samsungws import ArtModeStatus, SamsungTVAsyncRest, SamsungTVWS from .api.smartthings import SmartThingsTV, STStatus from .api.upnp import SamsungUPnP from .const import ( CONF_APP_LAUNCH_METHOD, CONF_APP_LIST, CONF_APP_LOAD_METHOD, CONF_CHANNEL_LIST, CONF_DUMP_APPS, CONF_EXT_POWER_ENTITY, CONF_LOGO_OPTION, CONF_PING_PORT, CONF_POWER_ON_METHOD, CONF_SHOW_CHANNEL_NR, CONF_SOURCE_LIST, CONF_ST_ENTRY_UNIQUE_ID, CONF_SYNC_TURN_OFF, CONF_SYNC_TURN_ON, CONF_TOGGLE_ART_MODE, CONF_USE_LOCAL_LOGO, CONF_USE_MUTE_CHECK, CONF_USE_ST_CHANNEL_INFO, CONF_USE_ST_STATUS_INFO, CONF_WOL_REPEAT, CONF_WS_NAME, DATA_CFG, DATA_OPTIONS, DEFAULT_APP, DEFAULT_PORT, DEFAULT_SOURCE_LIST, DEFAULT_TIMEOUT, DOMAIN, LOCAL_LOGO_PATH, MAX_WOL_REPEAT, SERVICE_SELECT_PICTURE_MODE, SERVICE_SET_ART_MODE, SIGNAL_CONFIG_ENTITY, STD_APP_LIST, WS_PREFIX, AppLaunchMethod, AppLoadMethod, PowerOnMethod, ) from .entity import SamsungTVEntity from .logo import LOGO_OPTION_DEFAULT, LocalImageUrl, Logo, LogoOption ATTR_ART_MODE_STATUS = "art_mode_status" ATTR_IP_ADDRESS = "ip_address" ATTR_PICTURE_MODE = "picture_mode" ATTR_PICTURE_MODE_LIST = "picture_mode_list" CMD_OPEN_BROWSER = "open_browser" CMD_RUN_APP = "run_app" CMD_RUN_APP_REMOTE = "run_app_remote" CMD_RUN_APP_REST = "run_app_rest" CMD_SEND_KEY = "send_key" CMD_SEND_TEXT = "send_text" DELAYED_SOURCE_TIMEOUT = 80 KEYHOLD_MAX_DELAY = 5.0 KEYPRESS_DEFAULT_DELAY = 0.5 KEYPRESS_MAX_DELAY = 2.0 KEYPRESS_MIN_DELAY = 0.2 MAX_ST_ERROR_COUNT = 4 MEDIA_TYPE_BROWSER = "browser" MEDIA_TYPE_KEY = "send_key" MEDIA_TYPE_TEXT = "send_text" POWER_OFF_DELAY = 20 ST_APP_SEPARATOR = "/" ST_UPDATE_TIMEOUT = 5 YT_APP_IDS = ("111299001912", "9Ur5IzDKqV.TizenYouTube") YT_VIDEO_QS = "v" YT_SVIDEO = "/shorts/" MAX_CONTROLLED_ENTITY = 4 SUPPORT_SAMSUNGTV_SMART = ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.STOP ) MIN_TIME_BETWEEN_ST_UPDATE = timedelta(seconds=5) ST_API_KEY_UPDATE_INTERVAL = timedelta(minutes=30) SCAN_INTERVAL = timedelta(seconds=15) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Samsung TV from a config entry.""" # session used by aiohttp session = async_get_clientsession(hass) local_logo_path = hass.data[DOMAIN].get(LOCAL_LOGO_PATH) config = hass.data[DOMAIN][entry.entry_id][DATA_CFG] logo_file = hass.config.path(STORAGE_DIR, f"{DOMAIN}_logo_paths") def update_token_func(token: str, token_key: str) -> None: """Update config entry with the new token.""" hass.config_entries.async_update_entry( entry, data={**entry.data, token_key: token} ) async_add_entities( [ SamsungTVDevice( config, entry.entry_id, hass.data[DOMAIN][entry.entry_id], session, update_token_func, logo_file, local_logo_path, ) ], True, ) # register services platform = entity_platform.current_platform.get() platform.async_register_entity_service( SERVICE_SELECT_PICTURE_MODE, {vol.Required(ATTR_PICTURE_MODE): cv.string}, "async_select_picture_mode", ) platform.async_register_entity_service( SERVICE_SET_ART_MODE, {}, "async_set_art_mode", ) def _get_default_app_info(app_id): """Get information for default app.""" if not app_id: return None, None, None if app_id in STD_APP_LIST: info = STD_APP_LIST[app_id] return app_id, info.get("st_app_id"), info.get("logo") for info in STD_APP_LIST.values(): st_app_id = info.get("st_app_id", "") if st_app_id == app_id: return app_id, None, info.get("logo") return None, None, None class ArtModeSupport(Enum): """Define ArtMode support lever.""" UNSUPPORTED = 0 PARTIAL = 1 FULL = 2 class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_device_class = MediaPlayerDeviceClass.TV _attr_name = None def __init__( self, config: dict[str, Any], entry_id: str, entry_data: dict[str, Any] | None, session: ClientSession, update_token_func: Callable[[str, str], None], logo_file: str, local_logo_path: str | None, ) -> None: """Initialize the Samsung device.""" super().__init__(config, entry_id) self._entry_data = entry_data self._host = config[CONF_HOST] # Set entity attributes self._attr_media_title = None self._attr_media_image_url = None self._attr_media_image_remotely_accessible = False # Assume that the TV is not muted and volume is 0 self._attr_is_volume_muted = False self._attr_volume_level = 0.0 # Device information from TV self._device_info: dict[str, Any] | None = None # Save a reference to the imported config self._broadcast = config.get(CONF_BROADCAST_ADDRESS) # Assume that the TV is in Play mode and state is off self._playing = True self._state = MediaPlayerState.OFF # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). self._started_up = False self._end_of_power_off = None self._fake_on = None self._delayed_set_source = None self._delayed_set_source_time = None # generic for sources and apps self._source = None self._running_app = None self._yt_app_id = None # prepare TV lists options self._default_source_used = False self._source_list = None self._dump_apps = True self._app_list = None self._app_list_st = None self._channel_list = None # config options reloaded on change self._use_st_status: bool = True self._use_channel_info: bool = True self._use_mute_check: bool = False self._show_channel_number: bool = False # ws initialization ws_name = config.get(CONF_WS_NAME, self._name) self._ws = SamsungTVWS( host=self._host, token=config.get(CONF_TOKEN), port=config.get(CONF_PORT, DEFAULT_PORT), timeout=config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), key_press_delay=KEYPRESS_DEFAULT_DELAY, name=f"{WS_PREFIX} {ws_name}", # this is the name shown in the TV external device. ) def new_token_callback(): """Update config entry with the new token.""" run_callback_threadsafe( self.hass.loop, update_token_func, self._ws.token, CONF_TOKEN ) self._ws.register_new_token_callback(new_token_callback) # rest api initialization self._rest_api = SamsungTVAsyncRest( host=self._host, session=session, timeout=DEFAULT_TIMEOUT, ) # upnp initialization self._upnp = SamsungUPnP(host=self._host, session=session) # smartthings initialization st_entry_uniqueid: str | None = config.get(CONF_ST_ENTRY_UNIQUE_ID) def api_key_callback() -> str | None: """Get new api key and update config entry with the new token.""" return self._update_smartthing_token(st_entry_uniqueid, update_token_func) self._st = None self._st_api_key = config.get(CONF_API_KEY) device_id = config.get(CONF_DEVICE_ID) if self._st_api_key and device_id: use_callbck: bool = st_entry_uniqueid is not None self._st = SmartThingsTV( api_key=self._st_api_key, device_id=device_id, use_channel_info=True, session=session, api_key_callback=api_key_callback if use_callbck else None, ) self._st_error_count = 0 self._st_last_exc = None self._setvolumebyst = False # logo control initializzation self._local_image_url = LocalImageUrl(local_logo_path) self._logo_option = LOGO_OPTION_DEFAULT self._logo = Logo( logo_option=self._logo_option, logo_file_download=logo_file, session=session, ) # YouTube cast self._cast_api = SamsungCastTube(self._host) # update config options for first time self._update_config_options(True) @Throttle(ST_API_KEY_UPDATE_INTERVAL) @callback def _update_smartthing_token( self, st_unique_id: str, update_token_func: Callable[[str, str], None] ) -> str | None: """Update the smartthing token when change on native integration.""" _LOGGER.debug("Trying to update smartthing access token") if not (new_token := get_smartthings_api_key(self.hass, st_unique_id)): _LOGGER.warning( "Failed to retrieve SmartThings integration access token," " using last available" ) return self._st_api_key if new_token != self._st_api_key: _LOGGER.info("SmartThings access token updated") update_token_func(new_token, CONF_API_KEY) self._st_api_key = new_token return self._st_api_key async def async_added_to_hass(self): """Set config parameter when add to hass.""" await super().async_added_to_hass() # this will update config options when changed self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_CONFIG_ENTITY, self._update_config_options ) ) def update_status_callback(): """Update current TV status.""" run_callback_threadsafe(self.hass.loop, self._status_changed_callback) self._ws.register_status_callback(update_status_callback) await self.hass.async_add_executor_job(self._ws.start_poll) async def async_will_remove_from_hass(self): """Run when entity will be removed from hass.""" self._ws.unregister_status_callback() await self.hass.async_add_executor_job(self._ws.stop_poll) @staticmethod def _split_app_list(app_list: dict[str, str]) -> list[dict[str, str]]: """Split the application list for standard and SmartThings.""" apps = {} apps_st = {} for app_name, app_ids in app_list.items(): try: app_id_split = app_ids.split(ST_APP_SEPARATOR, 1) except (ValueError, AttributeError): _LOGGER.warning( "Invalid ID [%s] for App [%s] will be ignored." " Use integration options to correct the App ID", app_ids, app_name, ) continue app_id = app_id_split[0] if len(app_id_split) == 1: _, st_app_id, _ = _get_default_app_info(app_id) else: st_app_id = app_id_split[1] apps[app_name] = app_id apps_st[app_name] = st_app_id or app_id return [apps, apps_st] def _load_tv_lists(self, first_load=False): """Load TV sources, apps and channels.""" # load sources list default_source_used = False source_list = self._get_option(CONF_SOURCE_LIST, {}) if not source_list: source_list = DEFAULT_SOURCE_LIST default_source_used = True self._source_list = source_list self._default_source_used = default_source_used # load apps list app_list = self._get_option(CONF_APP_LIST, {}) if app_list: double_list = self._split_app_list(app_list) self._app_list = double_list[0] self._app_list_st = double_list[1] else: self._app_list = None if first_load else {} self._app_list_st = None if first_load else {} # load channels list self._channel_list = self._get_option(CONF_CHANNEL_LIST, {}) @callback def _update_config_options(self, first_load=False): """Update config options.""" self._load_tv_lists(first_load) self._use_st_status = self._get_option(CONF_USE_ST_STATUS_INFO, True) self._use_channel_info = self._get_option(CONF_USE_ST_CHANNEL_INFO, True) self._use_mute_check = self._get_option(CONF_USE_MUTE_CHECK, False) self._show_channel_number = self._get_option(CONF_SHOW_CHANNEL_NR, False) self._ws.update_app_list(self._app_list) self._ws.set_ping_port(self._get_option(CONF_PING_PORT, 0)) @callback def _status_changed_callback(self): """Called when status changed.""" _LOGGER.debug("status_changed_callback called") self.async_schedule_update_ha_state(True) def _get_option(self, param, default=None): """Get option from entity configuration.""" if not self._entry_data: return default option = self._entry_data[DATA_OPTIONS].get(param) return default if option is None else option def _get_device_spec(self, key: str) -> Any | None: """Check if a flag exists in latest device info.""" if not ((info := self._device_info) and (device := info.get("device"))): return None return device.get(key) def _power_off_in_progress(self): """Check if a power off request is in progress.""" return ( self._end_of_power_off is not None and self._end_of_power_off > dt_util.utcnow() ) async def _update_volume_info(self): """Update the volume info.""" if self._state == MediaPlayerState.ON: # if self._st and self._setvolumebyst: # self._attr_volume_level = self._st.volume # self._attr_is_volume_muted = self._st.muted # return if (volume := await self._upnp.async_get_volume()) is not None: self._attr_volume_level = int(volume) / 100 else: self._attr_volume_level = None self._attr_is_volume_muted = await self._upnp.async_get_mute() def _get_external_entity_status(self): """Get status from external binary sensor.""" if not (ext_entity := self._get_option(CONF_EXT_POWER_ENTITY)): return True return not self.hass.states.is_state(ext_entity, STATE_OFF) async def _check_status(self): """Check TV status with WS and others method to check power status.""" if self._get_device_spec("PowerState") is not None: _LOGGER.debug("Checking if TV %s is on using device info", self._host) # Ensure we get an updated value info = await self._async_load_device_info(force=True) return info is not None and info["device"]["PowerState"] == "on" result = self._ws.is_connected if result and self._st: if ( self._st.state == STStatus.STATE_OFF and self._st.prev_state != STStatus.STATE_OFF and self._state == MediaPlayerState.ON and self._use_st_status ): result = False if result: result = self._get_external_entity_status() if result: if self._ws.artmode_status in (ArtModeStatus.On, ArtModeStatus.Unavailable): result = False return result @callback def _get_running_app(self): """Retrieve name of running apps.""" st_running_app = None if self._app_list is not None: for app, app_id in self._app_list.items(): if app_running := self._ws.is_app_running(app_id): self._running_app = app return if app_running is False: continue if self._st and self._st.channel_name != "": st_app_id = self._app_list_st.get(app, "") if st_app_id == self._st.channel_name: st_running_app = app self._running_app = st_running_app or DEFAULT_APP def _get_st_sources(self): """Get sources from SmartThings.""" if self._state != MediaPlayerState.ON or not self._st: _LOGGER.debug( "Samsung TV is OFF or SmartThings not configured, _get_st_sources not executed" ) return st_source_list = {} source_list = self._st.source_list if source_list: def get_next_name(index): if index >= len(source_list): return "" next_input = source_list[index] if not ( next_input.upper() in ["DIGITALTV", "TV"] or next_input.startswith("HDMI") ): return next_input return "" for i, _ in enumerate(source_list): try: # SmartThings source list is an array that may contain the input # or the assigned name, if we found a name that is not an input # we use it as input name input_name = source_list[i] is_tv = input_name.upper() in ["DIGITALTV", "TV"] is_hdmi = input_name.startswith("HDMI") if is_tv or is_hdmi: input_type = "ST_TV" if is_tv else "ST_" + input_name if input_type in st_source_list.values(): continue name = self._st.get_source_name(input_name) if not name: name = get_next_name(i + 1) st_source_list[name or input_name] = input_type except Exception: # pylint: disable=broad-except pass if len(st_source_list) > 0: _LOGGER.info( "Samsung TV: loaded sources list from SmartThings: %s", str(st_source_list), ) self._source_list = st_source_list self._default_source_used = False def _gen_installed_app_list(self): """Get apps installed on TV.""" if self._dump_apps: self._dump_apps = self._get_option(CONF_DUMP_APPS, False) if not (self._app_list is None or self._dump_apps): return app_list = self._ws.installed_app if not app_list: return app_load_method = AppLoadMethod( self._get_option(CONF_APP_LOAD_METHOD, AppLoadMethod.All.value) ) # app_list is a list of dict filtered_app_list = {} filtered_app_list_st = {} dump_app_list = {} for app in app_list.values(): try: app_name = app.app_name app_id = app.app_id def_app_id, st_app_id, _ = _get_default_app_info(app_id) # app_list is automatically created only with apps in hard coded short # list (STD_APP_LIST). Other available apps are dumped in a file that # can be used to create a custom list. # This is to avoid unuseful long list that can impact performance if app_load_method != AppLoadMethod.NotLoad: if def_app_id or app_load_method == AppLoadMethod.All: filtered_app_list[app_name] = app_id filtered_app_list_st[app_name] = st_app_id or app_id dump_app_list[app_name] = ( app_id + ST_APP_SEPARATOR + st_app_id if st_app_id else app_id ) except Exception: # pylint: disable=broad-except pass if self._app_list is None: self._app_list = filtered_app_list self._app_list_st = filtered_app_list_st if self._dump_apps: _LOGGER.info( "List of available apps for SamsungTV %s: %s", self._host, dump_app_list, ) self._dump_apps = False def _get_source(self): """Return the current input source.""" if self.state != MediaPlayerState.ON: self._source = None return self._source use_st: bool = self._st is not None and self._st.state == STStatus.STATE_ON if self._running_app != DEFAULT_APP or not use_st: self._source = self._running_app return self._source if self._st.source in ["digitalTv", "TV"]: cloud_key = "ST_TV" else: cloud_key = "ST_" + self._st.source found_source = self._running_app for attr, value in self._source_list.items(): if value == cloud_key: found_source = attr break self._source = found_source return self._source async def _smartthings_keys(self, source_key: str): """Manage the SmartThings key commands.""" if not self._st: _LOGGER.error( "SmartThings not configured. Command not valid: %s", source_key ) return False if self._st.state != STStatus.STATE_ON: _LOGGER.warning( "SmartThings not available. Command not sent: %s", source_key ) return False if source_key.startswith("ST_HDMI"): await self._st.async_select_source(source_key.replace("ST_", "")) elif source_key == "ST_TV": await self._st.async_select_source("digitalTv") elif source_key.startswith("ST_VD:"): if cmd := source_key.replace("ST_VD:", ""): await self._st.async_select_vd_source(cmd) elif source_key == "ST_CHUP": await self._st.async_send_command("stepchannel", "up") elif source_key == "ST_CHDOWN": await self._st.async_send_command("stepchannel", "down") elif source_key.startswith("ST_CH"): ch_num = source_key.replace("ST_CH", "") if ch_num.isdigit(): await self._st.async_send_command("selectchannel", ch_num) elif source_key in ["ST_MUTE", "ST_UNMUTE"]: await self._st.async_send_command( "audiomute", "off" if source_key == "ST_UNMUTE" else "on" ) elif source_key == "ST_VOLUP": await self._st.async_send_command("stepvolume", "up") elif source_key == "ST_VOLDOWN": await self._st.async_send_command("stepvolume", "down") elif source_key.startswith("ST_VOL"): vol_lev = source_key.replace("ST_VOL", "") if vol_lev.isdigit(): await self._st.async_send_command("setvolume", vol_lev) else: raise ValueError(f"Unsupported SmartThings command: {source_key}") return True def _log_st_error(self, st_error: bool): """Log start or end problem in ST communication""" if self._st_error_count == 0 and not st_error: return if st_error: if self._st_error_count == MAX_ST_ERROR_COUNT: return self._st_error_count += 1 if self._st_error_count == MAX_ST_ERROR_COUNT: msg_chk = "Check connection status with TV on the phone App" if self._st_last_exc is not None: _LOGGER.error( "%s - Error refreshing from SmartThings. %s. Error: %s", self.entity_id, msg_chk, self._st_last_exc, ) else: _LOGGER.warning( "%s - SmartThings report TV is off but status detected is on. %s", self.entity_id, msg_chk, ) return if self._st_error_count >= MAX_ST_ERROR_COUNT: _LOGGER.warning("%s - Connection to SmartThings restored", self.entity_id) self._st_error_count = 0 async def _async_load_device_info( self, force: bool = False ) -> dict[str, Any] | None: """Try to gather infos of this TV.""" if self._device_info is not None and not force: return self._device_info try: device_info: dict[str, Any] = await self._rest_api.async_rest_device_info() _LOGGER.debug("Device info on %s is: %s", self._host, device_info) self._device_info = device_info except Exception as ex: # pylint: disable=broad-except _LOGGER.debug("Error retrieving device info on %s: %s", self._host, ex) return None return self._device_info @Throttle(MIN_TIME_BETWEEN_ST_UPDATE) async def _async_st_update(self, **kwargs) -> bool | None: """Update SmartThings state of device.""" try: async with async_timeout.timeout(ST_UPDATE_TIMEOUT): await self._st.async_device_update(self._use_channel_info) except ( asyncio.TimeoutError, ClientConnectionError, ClientResponseError, ) as exc: _LOGGER.debug("%s - SmartThings error: [%s]", self.entity_id, exc) self._st_last_exc = exc return False self._st_last_exc = None return True async def async_update(self): """Update state of device.""" # Required to get source and media title st_error: bool | None = None if self._st: if (st_update := await self._async_st_update()) is not None: st_error = not st_update result = await self._check_status() if not self._started_up or not result: use_mute_check = False self._fake_on = None else: use_mute_check = self._use_mute_check if use_mute_check and self._state == MediaPlayerState.OFF: first_detect = self._fake_on is None if first_detect or self._fake_on is True: if (is_muted := await self._upnp.async_get_mute()) is None: self._fake_on = True else: self._fake_on = is_muted if self._fake_on: if first_detect: _LOGGER.debug( "%s - Detected fake power on, status not updated", self.entity_id, ) result = False if st_error is not None: if result and not st_error: st_error = self._st.state != STStatus.STATE_ON self._log_st_error(st_error) self._state = MediaPlayerState.ON if result else MediaPlayerState.OFF self._started_up = True # NB: We are checking properties, not attribute! if self.state == MediaPlayerState.ON: if self._delayed_set_source: difference = ( datetime.now(timezone.utc) - self._delayed_set_source_time ).total_seconds() if difference > DELAYED_SOURCE_TIMEOUT: self._delayed_set_source = None else: await self._async_select_source_delayed(self._delayed_set_source) await self._async_load_device_info() await self._update_volume_info() self._get_running_app() await self._update_media() if self._state == MediaPlayerState.OFF: self._end_of_power_off = None def send_command( self, payload, command_type=CMD_SEND_KEY, key_press_delay: float = 0, press=False, ): """Send a key to the tv and handles exceptions.""" if key_press_delay < 0: key_press_delay = None # means "default" provided with constructor ret_val = False try: if command_type == CMD_RUN_APP: ret_val = self._ws.run_app(payload) elif command_type == CMD_RUN_APP_REMOTE: app_cmd = payload.split(",") app_id = app_cmd[0] action_type = "" meta_tag = "" if len(app_cmd) > 1: action_type = app_cmd[1].strip() if len(app_cmd) > 2: meta_tag = app_cmd[2].strip() ret_val = self._ws.run_app( app_id, action_type, meta_tag, use_remote=True ) elif command_type == CMD_RUN_APP_REST: result = self._ws.rest_app_run(payload) _LOGGER.debug("Rest API result launching app %s: %s", payload, result) ret_val = True elif command_type == CMD_OPEN_BROWSER: ret_val = self._ws.open_browser(payload) elif command_type == CMD_SEND_TEXT: ret_val = self._ws.send_text(payload) elif command_type == CMD_SEND_KEY: hold_delay = 0 source_keys = payload.split(",") key_code = source_keys[0] if len(source_keys) > 1: def get_hold_time(): hold_time = source_keys[1].replace(" ", "") if not hold_time: return 0 if not hold_time.isdigit(): return 0 hold_time = int(hold_time) / 1000 return min(hold_time, KEYHOLD_MAX_DELAY) hold_delay = get_hold_time() if hold_delay > 0: ret_val = self._ws.hold_key(key_code, hold_delay) else: ret_val = self._ws.send_key( key_code, key_press_delay, "Press" if press else "Click" ) else: _LOGGER.debug("Send command: invalid command type -> %s", command_type) except (ConnectionResetError, AttributeError, BrokenPipeError): _LOGGER.debug( "Error in send_command() -> ConnectionResetError/AttributeError/BrokenPipeError" ) except WebSocketTimeoutException: _LOGGER.debug( "Failed sending payload %s command_type %s", payload, command_type, exc_info=True, ) except OSError: _LOGGER.debug("Error in send_command() -> OSError") return ret_val async def async_send_command( self, payload, command_type=CMD_SEND_KEY, key_press_delay: float = 0, press=False, ): """Send a key to the tv in async mode.""" return await self.hass.async_add_executor_job( self.send_command, payload, command_type, key_press_delay, press ) async def _update_media(self): """Update media and logo status.""" logo_option_changed = False new_media_title = self._get_new_media_title() if not new_media_title: self._attr_media_title = None self._attr_media_image_url = None self._attr_media_image_remotely_accessible = False return new_logo_option = LogoOption( self._get_option(CONF_LOGO_OPTION, self._logo_option.value) ) if self._logo_option != new_logo_option: self._logo_option = new_logo_option self._logo.set_logo_color(new_logo_option) logo_option_changed = True if not logo_option_changed: logo_option_changed = self._logo.check_requested() if not logo_option_changed: if self._attr_media_title and new_media_title == self._attr_media_title: return _LOGGER.debug( "New media title is: %s, old media title is: %s, running app is: %s", new_media_title, self._attr_media_title or "", self._running_app, ) remote_access = False if (media_image_url := await self._local_media_image(new_media_title)) is None: media_image_url = await self._logo.async_find_match(new_media_title) remote_access = media_image_url is not None self._attr_media_title = new_media_title self._attr_media_image_url = media_image_url self._attr_media_image_remotely_accessible = remote_access def _get_new_media_title(self): """Get the current media title.""" if self._state != MediaPlayerState.ON: return None if self._running_app == DEFAULT_APP: if self._st and self._st.state != STStatus.STATE_OFF: if self._st.source in ["digitalTv", "TV"]: if self._st.channel_name != "": if self._show_channel_number and self._st.channel != "": return self._st.channel_name + " (" + self._st.channel + ")" return self._st.channel_name if self._st.channel != "": return self._st.channel return None if (run_app := self._st.channel_name) != "": # the channel name holds the running app ID # regardless of the self._cloud_source value # if the app ID is in the configured apps but is not running_app, # means that this is not the real running app / media title st_apps = self._app_list_st or {} if run_app not in list(st_apps.values()): return self._st.channel_name media_title = self._get_source() if media_title and media_title != DEFAULT_APP: return media_title return None async def _local_media_image(self, media_title): """Get local media image if available.""" if not self._get_option(CONF_USE_LOCAL_LOGO, True): return None app_id = media_title if self._running_app != DEFAULT_APP: if run_app_id := self._app_list.get(self._running_app): app_id = run_app_id _, _, logo_file = _get_default_app_info(app_id) return await self.hass.async_add_executor_job( self._local_image_url.get_image_url, media_title, logo_file ) @property def supported_features(self) -> int: """Flag media player features that are supported.""" features = SUPPORT_SAMSUNGTV_SMART if self.state == MediaPlayerState.ON: features |= MediaPlayerEntityFeature.BROWSE_MEDIA if self._st: features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE return features @property def extra_state_attributes(self): """Return the optional state attributes.""" data = {ATTR_IP_ADDRESS: self._host} if self._ws.artmode_status != ArtModeStatus.Unsupported: status_on = self._ws.artmode_status == ArtModeStatus.On data.update({ATTR_ART_MODE_STATUS: STATE_ON if status_on else STATE_OFF}) if self._st: picture_mode = self._st.picture_mode picture_mode_list = self._st.picture_mode_list if picture_mode: data[ATTR_PICTURE_MODE] = picture_mode if picture_mode_list: data[ATTR_PICTURE_MODE_LIST] = picture_mode_list return data @property def media_channel(self): """Channel currently playing.""" if self._state == MediaPlayerState.ON: if self._st: if self._st.source in ["digitalTv", "TV"] and self._st.channel != "": return self._st.channel return None @property def media_content_type(self): """Return the content type of current playing media.""" if self._state == MediaPlayerState.ON: if self._running_app == DEFAULT_APP: if self.media_channel: return MediaType.CHANNEL return MediaType.VIDEO return MediaType.APP return None @property def app_id(self): """ID of the current running app.""" if self._state != MediaPlayerState.ON: return None if self._app_list_st and self._running_app != DEFAULT_APP: if app := self._app_list_st.get(self._running_app): return app if self._st: if not self._st.channel and self._st.channel_name: return self._st.channel_name return DEFAULT_APP @property def state(self): """Return the state of the device.""" # Warning: we assume that after a sending a power off command, the command is successful # so for 20 seconds (defined in POWER_OFF_DELAY) the state will be off regardless of the # actual state. This is to have better feedback to the command in the UI, but the logic # might cause other issues in the future if self._power_off_in_progress(): return MediaPlayerState.OFF return self._state @property def source_list(self): """List of available input sources.""" # try to get source list from SmartThings if a custom source list is not defined if self._st and self._default_source_used: self._get_st_sources() self._gen_installed_app_list() source_list = [] source_list.extend(list(self._source_list)) if self._app_list: source_list.extend(list(self._app_list)) if self._channel_list: source_list.extend(list(self._channel_list)) return source_list @property def channel_list(self): """List of available channels.""" if not self._channel_list: return None return list(self._channel_list) @property def source(self): """Return the current input source.""" return self._get_source() @property def sound_mode(self): """Name of the current sound mode.""" if self._st: return self._st.sound_mode return None @property def sound_mode_list(self): """List of available sound modes.""" if self._st: return self._st.sound_mode_list or None return None @property def support_art_mode(self) -> ArtModeSupport: """Return if art mode is supported.""" if self._ws.artmode_status != ArtModeStatus.Unsupported: return ArtModeSupport.FULL if self._get_device_spec("FrameTVSupport") == "true": return ArtModeSupport.PARTIAL return ArtModeSupport.UNSUPPORTED def _send_wol_packet(self, wol_repeat=None): """Send a WOL packet to turn on the TV.""" if not self._mac: _LOGGER.error("MAC address not configured, impossible send WOL packet") return False if not wol_repeat: wol_repeat = self._get_option(CONF_WOL_REPEAT, 1) wol_repeat = max(1, min(wol_repeat, MAX_WOL_REPEAT)) ip_address = self._broadcast or "255.255.255.255" send_success = False for i in range(wol_repeat): if i > 0: sleep(0.25) try: send_magic_packet(self._mac, ip_address=ip_address) send_success = True except socketError as exc: _LOGGER.warning( "Failed tentative n.%s to send WOL packet: %s", i, exc, ) except (TypeError, ValueError) as exc: _LOGGER.error("Error sending WOL packet: %s", exc) return False return send_success async def _async_power_on(self, set_art_mode=False): """Turn the media player on.""" cmd_power_on = "KEY_POWER" cmd_power_art = "KEY_POWER" if set_art_mode: if self._ws.artmode_status == ArtModeStatus.Off: # art mode from on await self.async_send_command(cmd_power_art) self._state = MediaPlayerState.OFF return True if self._ws.artmode_status == ArtModeStatus.On: if set_art_mode: return False # power on from art mode await self.async_send_command(cmd_power_art) return True if self.state != MediaPlayerState.OFF: return False result = True if not await self.async_send_command(cmd_power_on): turn_on_method = PowerOnMethod( self._get_option(CONF_POWER_ON_METHOD, PowerOnMethod.WOL.value) ) if turn_on_method == PowerOnMethod.SmartThings and self._st: await self._st.async_turn_on() else: result = await self.hass.async_add_executor_job(self._send_wol_packet) if result: self._state = MediaPlayerState.OFF self._end_of_power_off = None self._ws.set_power_on_request(set_art_mode) return result async def _async_turn_on(self, set_art_mode=False): """Turn the media player on.""" self._delayed_set_source = None if not await self._async_power_on(set_art_mode): return False if self._state != MediaPlayerState.OFF: return True await self._async_switch_entity(not set_art_mode) return True async def async_turn_on(self): """Turn the media player on.""" await self._async_turn_on() async def async_set_art_mode(self): """Turn the media player on setting in art mode.""" if ( self._state == MediaPlayerState.ON and self.support_art_mode == ArtModeSupport.PARTIAL ): await self.async_send_command("KEY_POWER") elif self.support_art_mode == ArtModeSupport.FULL: await self._async_turn_on(True) def _turn_off(self): """Turn off media player.""" if self._power_off_in_progress(): return False cmd_power_off = "KEY_POWER" cmd_power_art = "KEY_POWER" self._ws.set_power_off_request() if self._state == MediaPlayerState.ON: if self.support_art_mode == ArtModeSupport.UNSUPPORTED: self.send_command(cmd_power_off) else: self.send_command(f"{cmd_power_art},3000") elif self._ws.artmode_status == ArtModeStatus.On: self.send_command(f"{cmd_power_art},3000") else: return False self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=POWER_OFF_DELAY) return True async def async_turn_off(self): """Turn the media player on.""" result = await self.hass.async_add_executor_job(self._turn_off) if result: await self._async_switch_entity(False) async def async_toggle(self): """Toggle the power on the media player.""" if ( self.state == MediaPlayerState.ON and self.support_art_mode != ArtModeSupport.UNSUPPORTED ): if self._get_option(CONF_TOGGLE_ART_MODE, False): await self.async_set_art_mode() return await super().async_toggle() async def async_volume_up(self): """Volume up the media player.""" if self._state != MediaPlayerState.ON: return await self.async_send_command("KEY_VOLUP") if self.volume_level is not None: self._attr_volume_level = min(1.0, self.volume_level + 0.01) async def async_volume_down(self): """Volume down media player.""" if self._state != MediaPlayerState.ON: return await self.async_send_command("KEY_VOLDOWN") if self.volume_level is not None: self._attr_volume_level = max(0.0, self.volume_level - 0.01) async def async_mute_volume(self, mute): """Send mute command.""" if self._state != MediaPlayerState.ON: return if self.is_volume_muted is not None and mute == self.is_volume_muted: return await self.async_send_command("KEY_MUTE") if self.is_volume_muted is not None: self._attr_is_volume_muted = mute async def async_set_volume_level(self, volume): """Set the volume level.""" if self._state != MediaPlayerState.ON: return if self.volume_level is None: return if self._st and self._setvolumebyst: await self._st.async_send_command("setvolume", int(volume * 100)) else: await self._upnp.async_set_volume(int(volume * 100)) self._attr_volume_level = volume def media_play_pause(self): """Simulate play pause media player.""" if self._playing: self.media_pause() else: self.media_play() def media_play(self): """Send play command.""" self._playing = True self.send_command("KEY_PLAY") def media_pause(self): """Send media pause command to media player.""" self._playing = False self.send_command("KEY_PAUSE") def media_stop(self): """Send media pause command to media player.""" self._playing = False self.send_command("KEY_STOP") def media_next_track(self): """Send next track command.""" if self.media_channel: self.send_command("KEY_CHUP") else: self.send_command("KEY_FF") def media_previous_track(self): """Send the previous track command.""" if self.media_channel: self.send_command("KEY_CHDOWN") else: self.send_command("KEY_REWIND") async def _async_send_keys(self, source_key): """Send key / chained keys.""" prev_wait = True if "+" in source_key: all_source_keys = source_key.split("+") for this_key in all_source_keys: if this_key.isdigit(): prev_wait = True await asyncio.sleep( min( max((int(this_key) / 1000), KEYPRESS_MIN_DELAY), KEYPRESS_MAX_DELAY, ) ) else: # put a default delay between key if set explicit if not prev_wait: await asyncio.sleep(KEYPRESS_DEFAULT_DELAY) prev_wait = False if this_key.startswith("ST_"): await self._smartthings_keys(this_key) else: await self.async_send_command(this_key) return True if source_key.startswith("ST_"): return await self._smartthings_keys(source_key) return await self.async_send_command(source_key) async def _async_set_channel_source(self, channel_source=None): """Select the source for a channel.""" if not channel_source: if self._running_app == DEFAULT_APP: return True _LOGGER.error("Current source invalid for channel") return False if self._source == channel_source: return True if channel_source not in self._source_list: _LOGGER.error("Invalid channel source: %s", channel_source) return False await self.async_select_source(channel_source) if self._source != channel_source: _LOGGER.error("Error selecting channel source: %s", channel_source) return False await asyncio.sleep(3) return True async def _async_set_channel(self, channel): """Set a specific channel.""" if channel.startswith("http"): await self.async_play_media(MediaType.URL, channel) return True channel_cmd = channel.split("@") channel_no = channel_cmd[0] channel_source = None if len(channel_cmd) > 1: channel_source = channel_cmd[1] try: cv.positive_int(channel_no) except vol.Invalid: _LOGGER.error("Channel must be positive integer") return False if not await self._async_set_channel_source(channel_source): return False if self._st: return await self._smartthings_keys(f"ST_CH{channel_no}") def send_digit(): for digit in channel_no: self.send_command("KEY_" + digit) sleep(KEYPRESS_DEFAULT_DELAY) self.send_command("KEY_ENTER") await self.hass.async_add_executor_job(send_digit) return True async def _async_launch_app(self, app_data, meta_data=None): """Launch app with different methods.""" method = "" app_cmd = app_data.split("@") app_id = app_cmd[0] if self._app_list: if app_id_from_list := self._app_list.get(app_id): app_id = app_id_from_list if meta_data: app_id += f",,{meta_data}" method = CMD_RUN_APP_REMOTE elif len(app_cmd) > 1: req_method = app_cmd[1].strip() if req_method in (CMD_RUN_APP, CMD_RUN_APP_REMOTE, CMD_RUN_APP_REST): method = req_method if not method: app_launch_method = AppLaunchMethod( self._get_option(CONF_APP_LAUNCH_METHOD, AppLaunchMethod.Standard.value) ) if app_launch_method == AppLaunchMethod.Remote: method = CMD_RUN_APP_REMOTE elif app_launch_method == AppLaunchMethod.Rest: method = CMD_RUN_APP_REST else: method = CMD_RUN_APP await self.async_send_command(app_id, method) def _get_youtube_app_id(self): """Search youtube app id used to launch video.""" if self._yt_app_id is not None: return len(self._yt_app_id) > 0 if not self._app_list: return False self._yt_app_id = "" for app_name, app_id in self._app_list.items(): if app_name.casefold().find("youtube") >= 0: if not self._yt_app_id: self._yt_app_id = app_id if app_id in YT_APP_IDS: self._yt_app_id = app_id break _LOGGER.debug("YouTube App ID: %s", self._yt_app_id or "not found") return len(self._yt_app_id) > 0 def _get_youtube_video_id(self, url): """Try to get youtube video id from url.""" url_parsed = urlparse(url) url_host = str(url_parsed.hostname).casefold() url_path = url_parsed.path if url_host.find("youtube") < 0: _LOGGER.debug("URL not related to Youtube") return None video_id = None url_query = parse_qs(url_parsed.query) if YT_VIDEO_QS in url_query: video_id = url_query[YT_VIDEO_QS][0] elif url_path and str(url_path).casefold().startswith(YT_SVIDEO): video_id = url_path[len(YT_SVIDEO) :] if not video_id: _LOGGER.warning("Youtube video ID not found in url: %s", url) return None if not self._get_youtube_app_id(): _LOGGER.warning("Youtube app ID not available, configure in apps list") return None _LOGGER.debug("Youtube video ID: %s", video_id) return video_id def _cast_youtube_video(self, video_id: str, enqueue: MediaPlayerEnqueue): """ Cast a youtube video using samsungcast library. This method is sync and must run in job executor. """ if enqueue == MediaPlayerEnqueue.PLAY: self._cast_api.play_video(video_id) elif enqueue == MediaPlayerEnqueue.NEXT: self._cast_api.play_next(video_id) elif enqueue == MediaPlayerEnqueue.ADD: self._cast_api.add_to_queue(video_id) elif enqueue == MediaPlayerEnqueue.REPLACE: self._cast_api.clear_queue() self._cast_api.play_video(video_id) async def _async_play_youtube_video( self, video_id: str, enqueue: MediaPlayerEnqueue ): """Play a YouTube video using YouTube app.""" run_app_id = None if self._running_app != DEFAULT_APP: run_app_id = self._app_list.get(self._running_app) # launch youtube app if not running if run_app_id != self._yt_app_id: await self._async_launch_app(self._yt_app_id) await asyncio.sleep(3) # we wait for YouTube app to start await self.hass.async_add_executor_job( self._cast_youtube_video, video_id, enqueue ) async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs ): """Support running different media type command.""" enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE) if media_source.is_media_source_id(media_id): media_type = MediaType.URL play_item = await media_source.async_resolve_media(self.hass, media_id) media_id = play_item.url else: media_type = media_type.lower() if media_type in [MEDIA_TYPE_BROWSER, MediaType.URL]: media_id = async_process_play_media_url(self.hass, media_id) try: cv.url(media_id) except vol.Invalid: _LOGGER.error('Media ID must be a valid url (ex: "http://"') return # Type channel if media_type == MediaType.CHANNEL: await self._async_set_channel(media_id) # Launch an app elif media_type == MediaType.APP: await self._async_launch_app(media_id) # Send custom key elif media_type == MEDIA_TYPE_KEY: try: cv.string(media_id) except vol.Invalid: _LOGGER.error('Media ID must be a string (ex: "KEY_HOME"') return await self._async_send_keys(media_id) # Open url or youtube app elif media_type == MediaType.URL: if enqueue and (video_id := self._get_youtube_video_id(media_id)): await self._async_play_youtube_video(video_id, enqueue) return if await self._upnp.async_set_current_media(media_id): self._playing = True return await self.async_send_command(media_id, CMD_OPEN_BROWSER) # Open url in browser elif media_type == MEDIA_TYPE_BROWSER: await self.async_send_command(media_id, CMD_OPEN_BROWSER) # Trying to make stream component work on TV elif media_type == "application/vnd.apple.mpegurl": if await self._upnp.async_set_current_media(media_id): self._playing = True elif media_type == MEDIA_TYPE_TEXT: await self.async_send_command(media_id, CMD_SEND_TEXT) else: raise NotImplementedError(f"Unsupported media type: {media_type}") async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) async def async_select_source(self, source): """Select input source.""" running_app = DEFAULT_APP self._delayed_set_source = None if self.state != MediaPlayerState.ON: if await self._async_turn_on(): self._delayed_set_source = source self._delayed_set_source_time = datetime.now(timezone.utc) return if self._source_list and source in self._source_list: source_key = self._source_list[source] if not await self._async_send_keys(source_key): return elif self._app_list and source in self._app_list: app_id = self._app_list[source] running_app = source await self._async_launch_app(app_id) if self._st: self._st.set_application(self._app_list_st[source]) elif self._channel_list and source in self._channel_list: source_key = self._channel_list[source] await self._async_set_channel(source_key) return else: _LOGGER.error("Unsupported source") return self._running_app = running_app self._source = source async def _async_select_source_delayed(self, source): """Select input source with delayed ST option.""" if self._st: if self._st.state != STStatus.STATE_ON: # wait for smartthings available return await self.async_select_source(source) async def async_select_sound_mode(self, sound_mode): """Select sound mode.""" if not self._st: raise NotImplementedError() await self._st.async_set_sound_mode(sound_mode) async def async_select_picture_mode(self, picture_mode): """Select picture mode.""" if not self._st: raise NotImplementedError() await self._st.async_set_picture_mode(picture_mode) async def _async_switch_entity(self, power_on: bool): """Switch on/off related configure HA entity.""" if power_on: service_name = f"{HA_DOMAIN}.{SERVICE_TURN_ON}" conf_entity = CONF_SYNC_TURN_ON else: service_name = f"{HA_DOMAIN}.{SERVICE_TURN_OFF}" conf_entity = CONF_SYNC_TURN_OFF entity_list = self._get_option(conf_entity) if not entity_list: return for index, entity in enumerate(entity_list): if index >= MAX_CONTROLLED_ENTITY: _LOGGER.warning( "SamsungTV Smart - Maximum %s entities can be controlled", MAX_CONTROLLED_ENTITY, ) break if entity: await _async_call_service(self.hass, service_name, entity) return async def _async_call_service( hass, service_name, entity_id, variable_data=None, ): """Call a HA service.""" service_data = { CONF_SERVICE: service_name, CONF_SERVICE_ENTITY_ID: entity_id, } if variable_data: service_data[CONF_SERVICE_DATA] = variable_data try: await async_call_from_config( hass, service_data, blocking=False, validate_config=True, ) except HomeAssistantError as ex: _LOGGER.error("SamsungTV Smart - error %s", ex) return ================================================ FILE: custom_components/samsungtv_smart/remote.py ================================================ """Support for the SamsungTV remote.""" from __future__ import annotations from collections.abc import Iterable from datetime import datetime import logging from typing import Any from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, ) from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_SERVICE, CONF_SERVICE_DATA, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import CONF_SERVICE_ENTITY_ID, async_call_from_config from .const import DATA_CFG, DOMAIN from .entity import SamsungTVEntity from .media_player import MEDIA_TYPE_KEY JOIN_COMMAND = "+" _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Samsung TV from a config entry.""" @callback def _add_remote_entity(utc_now: datetime) -> None: """Create remote entity.""" mp_entity_id = None entity_reg = er.async_get(hass) tv_entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) for tv_entity in tv_entries: if tv_entity.domain == MP_DOMAIN: mp_entity_id = tv_entity.entity_id break if mp_entity_id is None: return config = hass.data[DOMAIN][entry.entry_id][DATA_CFG] async_add_entities([SamsungTVRemote(config, entry.entry_id, mp_entity_id)]) # we wait for TV media player entity to be created async_call_later(hass, 5, _add_remote_entity) class SamsungTVRemote(SamsungTVEntity, RemoteEntity): """Device that sends commands to a SamsungTV.""" _attr_name = None _attr_should_poll = False def __init__(self, config: dict[str, Any], entry_id: str, mp_entity_id: str): """Initialize the remote.""" super().__init__(config, entry_id) self._mp_entity_id = mp_entity_id async def _async_call_service( self, service_name, variable_data=None, ): """Call a HA service.""" service_data = { CONF_SERVICE: f"{MP_DOMAIN}.{service_name}", CONF_SERVICE_ENTITY_ID: self._mp_entity_id, } if variable_data: service_data[CONF_SERVICE_DATA] = variable_data try: await async_call_from_config( self.hass, service_data, blocking=True, validate_config=True, ) except HomeAssistantError as ex: _LOGGER.error("SamsungTV Smart Remote - error %s", ex) return async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._async_call_service(SERVICE_TURN_OFF) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._async_call_service(SERVICE_TURN_ON) async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device. Supported keys vary between models. See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md """ num_repeats = kwargs[ATTR_NUM_REPEATS] commands = JOIN_COMMAND.join(command) content_id = commands for _ in range(num_repeats - 1): content_id += f"{JOIN_COMMAND}{commands}" service_data = { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_KEY, ATTR_MEDIA_CONTENT_ID: content_id, } await self._async_call_service(SERVICE_PLAY_MEDIA, service_data) ================================================ FILE: custom_components/samsungtv_smart/services.yaml ================================================ select_picture_mode: name: Select Picture Mode description: Send to samsung TV the command to change picture mode. fields: entity_id: name: Entity Name description: Name of the target entity required: true example: "media_player.tv" selector: entity: integration: samsungtv_smart picture_mode: name: Picture Mode description: Name of the picture mode to switch to. Possible options can be found in the picture_mode_list state attribute. required: true example: "Standard" selector: text: set_art_mode: name: Set Art Mode description: Send to samsung TV the command to set art mode. fields: entity_id: name: Entity Name description: Name of the target entity required: true example: "media_player.tv" selector: entity: integration: samsungtv_smart ================================================ FILE: custom_components/samsungtv_smart/translations/en.json ================================================ { "config": { "abort": { "already_configured": "This Samsung TV is already configured.", "already_in_progress": "Samsung TV configuration is already in progress.", "host_unique_id": "Reconfiguration not possible beacause hostname is used as unique id. Please remove this entry and configure a new one.", "reconfigure_successful": "Samsung TV reconfiguration completed successfully.", "unsupported_version": "This integration require at least HomeAssistant version {req_ver}, you are running version {run_ver}." }, "error": { "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Please check your TV's settings to authorize Home Assistant.", "invalid_host": "Invalid host.", "not_successful": "Unable to create WebSocket connection with this Samsung TV device.", "not_supported": "This Samsung TV device is currently not supported.", "only_key_or_st": "Specify only access token or SmartThings entry.", "st_api_key_fail": "Failed to retrieve access token from SmartThings entry.", "st_device_not_found": "SmartThings TV deviceID not found.", "st_device_used": "SmartThings TV deviceID already used.", "wrong_api_key": "Wrong SmartThings token." }, "flow_title": "SamsungTV Smart: {model}", "step": { "confirm": { "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization. Manual configurations for this TV will be overwritten." }, "user": { "data": { "host": "Host or IP address", "name": "Name assigned to the entity", "use_ha_name_for_ws": "Use HA instance name for identification on TV", "api_key": "SmartThings generated token (optional)", "st_entry_unique_id": "SmartThings entry used to provide SmartThings credential" }, "description": "Enter your Samsung TV information. SmartThings is optional but really suggested.\nAfter confirm you should see a popup on your TV asking for authorization." }, "reconfigure": { "data": { "host": "Host or IP address", "api_key": "SmartThings generated token (optional, if empty will be used the existing)", "st_entry_unique_id": "SmartThings entry used to provide SmartThings credential" }, "description": "Enter your Samsung TV information to update. SmartThings configuration can be changed but not removed.\nAfter confirm you could see a popup on your TV asking for authorization." }, "stdevice": { "data": { "st_devices": "SmartThings TV" }, "description": "You have multiple TVs configured on your account. Select the TV you are configuring from the list." }, "stdeviceid": { "data": { "device_id": "SmartThings TV deviceID" }, "description": "Automatic SmartThings deviceID detection failed. To continue you must identify the deviceID on the SmartThings site (see documentation) and insert it here." } } }, "options": { "step": { "init": { "title": "SamsungTV Smart options", "data": { "use_st_status_info": "Use SmartThings TV Status information", "use_st_channel_info": "Use SmartThings TV Channels information", "show_channel_number": "Use SmartThings TV Channels number information", "app_load_method": "Applications list load mode at startup", "logo_option": "Display a logo for known sources, apps and channels", "use_local_logo": "Allow use of local logo images", "power_on_method": "Method used to turn on TV", "show_adv_opt": "Show options menu" } }, "menu": { "title": "SamsungTV Smart options menu", "menu_options": { "adv_opt": "Advanced options", "app_list": "Applications list configuration", "channel_list": "Channels list configuration", "init": "Standard options", "save_exit": "Save options and exit", "source_list": "Sources list configuration", "sync_ent": "Synched entities configuration" } }, "adv_opt": { "title": "SamsungTV Smart advanced options", "data": { "app_launch_method": "Applications launch method used", "dump_apps": "Dump apps list on log file at startup (when possible)", "use_mute_check": "Use volume mute status to detect fake power ON", "wol_repeat": "Number of time WOL packet is sent to turn on TV", "power_on_delay": "Seconds to delay power ON status", "ping_port": "TCP port used to check power status (0 for ICMP)", "ext_power_entity": "Binary sensor to help detect power status", "toggle_art_mode": "Power button switch to art mode (Frame TV only)" } }, "sync_ent": { "title": "SamsungTV Smart synched entities", "data": { "sync_turn_off": "List of entities to Power OFF with TV", "sync_turn_on": "List of entities to power ON with TV" } }, "app_list": { "title": "SamsungTV Smart applications list configuration", "data": { "app_list": "Applications list:" } }, "channel_list": { "title": "SamsungTV Smart channels list configuration", "data": { "channel_list": "Channels list:" } }, "source_list": { "title": "SamsungTV Smart sources list configuration", "data": { "source_list": "Sources list:" } } }, "error": { "invalid_tv_list": "Invalid format. Please check documentation" } } } ================================================ FILE: custom_components/samsungtv_smart/translations/hu.json ================================================ { "config": { "abort": { "already_configured": "Ez a Samsung TV már konfigurálva van.", "already_in_progress": "A Samsung TV konfigurációja már folyamatban van.", "unsupported_version": "Ehhez az integrációhoz legalább a HomeAssistant {req_ver} verzió szükséges, jelenleg a(z) {run_ver} verziót használja." }, "error": { "auth_missing": "A Home Assistantnek nincs engedélye a ehhez a Samsung TV-hez való kapcsolódáshoz. Kérjük, ellenőrizze a TV beállításait, hogy engedélyezni tudja a Home Assistant számára.", "invalid_host": "Érvénytelen hoszt.", "not_successful": "Nem sikerült létrehozni WebSocket kapcsolatot ezzel a Samsung TV készülékkel.", "not_supported": "Ez a Samsung TV készülék jelenleg nem támogatott.", "wrong_api_key": "Hibás SmartThings token.", "st_device_not_found": "SmartThings TV eszközazonosító (deviceID) nem található.", "st_device_used": "SmartThings TV eszközazonosító (deviceID) már használatban van." }, "flow_title": "SamsungTV Smart: {model}", "step": { "confirm": { "description": "Szeretné beállítani a Samsung TV-t ({model})? Ha még soha nem csatlakoztatta a Home Assistantot, akkor megjelenik egy felugró ablak a TV-n, ami engedélyezést kér. A manuális konfigurációk ezen TV esetében felülíródnak." }, "user": { "data": { "host": "Hoszt vagy IP cím", "name": "Az entitásnak adott név", "use_ha_name_for_ws": "Használja a HA példány nevét az azonosításhoz a TV-n", "api_key": "SmartThings által generált token (opcionális)" }, "description": "Adja meg a Samsung TV adataidat. A SmartThings token opcionális, de erősen javasolt.\nMiután megerősítette, látnia kell egy felugró ablakot a TV-n, ami engedélyezést kér." }, "stdevice": { "data": { "st_devices": "SmartThings TV" }, "description": "Több TV-t is konfigurált a fiókján. Válassza ki a konfigurálandó TV-t a listából." }, "stdeviceid": { "data": { "device_id": "SmartThings TV eszközazonosító (deviceID)" }, "description": "Az automatikus SmartThings eszközazonosítás sikertelen volt. A folytatáshoz az eszközazonosítót be kell azonosítania a SmartThings weboldalon (bővebben a dokumentációban) és be kell illesztenie ide." } } }, "options": { "step": { "init": { "title": "SamsungTV Smart beállítások", "data": { "use_st_status_info": "Használja a SmartThings TV állapotinformációt", "use_st_channel_info": "Használja a SmartThings TV csatornainformációt", "show_channel_number": "Használja a SmartThings TV csatornaszám információt", "app_load_method": "Alkalmazások lista betöltési módja induláskor", "logo_option": "Mutassa a logót ismert forrásokhoz, alkalmazásokhoz és csatornákhoz", "use_local_logo": "Engedélyezze a helyi logóképek használatát", "power_on_method": "A TV bekapcsolásához használt módszer", "show_adv_opt": "Opciók menü mutatása" } }, "menu": { "title": "SamsungTV Smart opciók menü", "menu_options": { "adv_opt": "Haladó opciók", "app_list": "Alkalmazások listájának konfigurálása", "channel_list": "Csatornák listájának konfigurálása", "init": "Alapbeállítások", "save_exit": "Opciók mentése és kilépés", "source_list": "Források listájának konfigurálása", "sync_ent": "Szinkronizált entitások konfigurálása" } }, "adv_opt": { "title": "SamsungTV Smart haladó opciók", "data": { "app_launch_method": "Az alkalmazások indítási módszerének használata", "dump_apps": "Az alkalmazások listájának naplófájlba való kiírása induláskor (amennyiben lehetséges)", "use_mute_check": "Hangerőnémítás állapotának használata hamis bekapcsolás észleléséhez", "wol_repeat": "WOL (Wake-on-LAN) csomag ismétlései a TV bekapcsolásához", "power_on_delay": "Másodpercek a bekapcsolási állapot késleltetéséhez", "ping_port": "TCP port, amelyet a bekapcsolási állapot ellenőrzéséhez használnak (0 az ICMP-hez)", "ext_power_entity": "Bemeneti érzékelő a bekapcsolási állapot felismeréséhez", "toggle_art_mode": "Art módra váltó bekapcsológomb (kizárólag Frame TV esetén)" } }, "sync_ent": { "title": "SamsungTV Smart szinkronizált entitások", "data": { "sync_turn_off": "Lista azokról az entitásokról, amelyeket kikapcsol a TV-vel", "sync_turn_on": "Lista azokról az entitásokról, amelyeket bekapcsol a TV-vel" } }, "app_list": { "title": "SamsungTV Smart alkalmazások listájának konfigurálása", "data": { "app_list": "Alkalmazások listája:" } }, "channel_list": { "title": "SamsungTV Smart csatornák listájának konfigurálása", "data": { "channel_list": "Csatornák listája:" } }, "source_list": { "title": "SamsungTV Smart források listájának konfigurálása", "data": { "source_list": "Források listája:" } } }, "error": { "invalid_tv_list": "Érvénytelen formátum. Kérjük, ellenőrizze a dokumentációt." } } } ================================================ FILE: custom_components/samsungtv_smart/translations/it.json ================================================ { "config": { "abort": { "already_configured": "Questo Samsung TV \u00e8 gi\u00e0 configurato.", "already_in_progress": "La configurazione di Samsung TV \u00e8 gi\u00e0 in corso.", "host_unique_id": "Riconfigurazione non possibile poichè il nome host è usato come unique id. Rimuovere questa entry e configurarne una nuova.", "reconfigure_successful": "Riconfigurazione Samsung TV completata correttamente.", "unsupported_version": "Questa integrazione richiede almeno la versione {req_ver} di HomeAssistant, tu stai usando la versione {run_ver}." }, "error": { "auth_missing": "Home Assistant non \u00e8 autorizzato a connettersi a questo Samsung TV. Controlla le impostazioni del tuo TV per autorizzare Home Assistant.", "invalid_host": "Nome host non valido.", "not_successful": "Impossibile aprire la connessione WebSocket con questo dispositivo Samsung TV.", "not_supported": "Questo dispositivo Samsung TV non \u00e8 attualmente supportato.", "only_key_or_st": "Specificare solo il token di accesso o l'entry SmartThings.", "st_api_key_fail": "Errore nel recuperare il token di accesso dall'entry SmartThings selezionata.", "st_device_not_found": "SmartThings TV deviceID non trovato.", "st_device_used": "SmartThings TV deviceID gi\u00e0 utilizzato.", "wrong_api_key": "SmartThings token errato." }, "flow_title": "SamsungTV Smart: {model}", "step": { "confirm": { "description": "Vuoi configurare Samsung TV {model}? Se non hai mai connesso Home Assistant in precedenza, dovresti vedere un messaggio sul tuo TV in cui \u00e8 richiesta l'autorizzazione. Le configurazioni manuali per questo TV verranno sovrascritte." }, "user": { "data": { "host": "Host o indirizzo IP", "name": "Nome dell'entit\u00e0", "use_ha_name_for_ws": "Usare il nome dell'istanza HA per identificarsi sulla TV", "api_key": "SmartThings token generato (opzionale)", "st_entry_unique_id": "Entry SmartThings usata per fornire le credenziali SmartThings" }, "description": "Inserisci le informazioni del tuo Samsung TV. L'uso di SmartThings \u00e8 opzionale ma fortemente consigliato.\nDopo aver confermato i dati, dovresti vedere un messaggio sul TV in cui \u00e8 richiesta l'autorizzazione." }, "reconfigure": { "data": { "host": "Host o indirizzo IP", "api_key": "SmartThings token generato (opzionale, se vuoto verrà usato quello esistente)", "st_entry_unique_id": "Entry SmartThings usata per fornire le credenziali SmartThings" }, "description": "Inserisci le informazioni da aggiornare del tuo Samsung TV. Le configurazioni SmartThings possono essere cambiate ma non rimosse.\nDopo aver confermato i dati, potresti vedere un messaggio sul TV in cui \u00e8 richiesta l'autorizzazione." }, "stdevice": { "data": { "st_devices": "SmartThings TV" }, "description": "Hai più di un TV configurato sul tuo account. Seleziona il TV che stai configurando dalla lista." }, "stdeviceid": { "data": { "device_id": "SmartThings TV deviceID" }, "description": "Identificazione automatica del deviceID SmartThings fallita. Per continuare devi identificare il deviceID sul sito SmartThings (vedere documentazione) e inserirlo qui." } } }, "options": { "step": { "init": { "title": "Opzioni SamsungTV Smart", "data": { "use_st_status_info": "Usa informazioni Stato TV da SmartThings", "use_st_channel_info": "Usa informazioni Canale TV da SmartThings", "show_channel_number": "Usa informazioni Numero Canale TV da SmartThings", "app_load_method": "Modalità caricamento lista applicazioni all'avvio", "logo_option": "Visualizza il logo per sorgenti, apps e canali conosciuti", "use_local_logo": "Permetti l'uso delle immagini logo locali", "power_on_method": "Metodo usato per accendere la TV", "show_adv_opt": "Mostra menu opzioni" } }, "menu": { "title": "Menù opzioni SamsungTV Smart", "menu_options": { "adv_opt": "Opzioni avanzate", "app_list": "Configurazione lista applicazioni", "channel_list": "Configurazione lista canali", "init": "Opzioni standard", "save_exit": "Salva le opzioni ed esci", "source_list": "Configurazione lista sorgenti", "sync_ent": "Configurazione entità collegate" } }, "adv_opt": { "title": "Opzioni avanzate SamsungTV Smart", "data": { "app_launch_method": "Metodo usato per lanciare le applicazioni", "dump_apps": "Mostra lista apps nel file di log all'avvio (se possibile)", "use_mute_check": "Utilizza lo stato di volume muto per identificare false accensioni", "wol_repeat": "Numero di volte che il pacchetto WOL viene inviato per accendere la TV", "power_on_delay": "Secondi di ritardo per passaggio allo stato ON", "ping_port": "Porta TCP usata per identificare lo stato (0 per ICMP)", "ext_power_entity": "Binary sensor usato per aiutare a identificare lo stato", "toggle_art_mode": "Pulsante di accensione passa a art mode (solo per Frame TV)" } }, "sync_ent": { "title": "Entità collegate SamsungTV Smart", "data": { "sync_turn_off": "Elenco entit\u00e0 da Spegnere con la TV", "sync_turn_on": "Elenco entit\u00e0 da Accendere con la TV" } }, "app_list": { "title": "Configurazione lista applicazioni SamsungTV Smart", "data": { "app_list": "Lista applicazioni:" } }, "channel_list": { "title": "Configurazione lista canali SamsungTV Smart", "data": { "channel_list": "Lista canali:" } }, "source_list": { "title": "Configurazione lista sorgenti SamsungTV Smart", "data": { "source_list": "Lista sorgenti:" } } }, "error": { "invalid_tv_list": "Formato not valido. Controlla la documentazione" } } } ================================================ FILE: custom_components/samsungtv_smart/translations/pt-BR.json ================================================ { "config": { "abort": { "already_configured": "Essa TV Samsung já está configurada.", "already_in_progress": "A configuração da TV Samsung já está em andamento.", "unsupported_version": "Esta integração requer pelo menos a versão do HomeAssistant {req_ver}, você está executando a versão {run_ver}." }, "error": { "auth_missing": "O Home Assistant não está autorizado a se conectar a essa TV Samsung. Verifique as configurações da sua TV para autorizar o Home Assistant.", "invalid_host": "Host inválido.", "not_successful": "Não é possível criar uma conexão WebSocket com essa TV Samsung.", "not_supported": "Essa TV Samsung não é compatível no momento.", "wrong_api_key": "Token errado do SmartThings.", "st_device_not_found": "ID de TV SmartThings não encontrado.", "st_device_used": "ID de TV SmartThings já está em uso." }, "flow_title": "SamsungTV Smart: {model}", "step": { "confirm": { "description": "Deseja configurar a TV Samsung {model}? Se você nunca conectou o Home Assistant antes, verá um pop-up na TV solicitando uma autorização. As configurações manuais para esta TV serão substituídas." }, "user": { "data": { "host": "Host ou endereço IP", "name": "Nome atribuído à entidade", "use_ha_name_for_ws": "Use o nome da instância do HA para a identificação na TV", "api_key": "Token gerado pelo SmartThings (opcional)" }, "description": "Insira as informações da sua TV Samsung.O token SmartThings é opcional, mas muito sugerido.\nDepois de confirmar os dados, verá um pop-up na TV solicitando uma autorização." }, "stdevice": { "data": { "st_devices": "SmartThings TV" }, "description": "Você tem várias TVs configuradas em sua conta. Selecione a TV que você está configurando na lista." }, "stdeviceid": { "data": { "device_id": "ID de TV SmartThings" }, "description": "Falha na detecção automática de ID do SmartThings. Para continuar você deve identificar o ID no site SmartThings (ver documentação) e inseri-lo aqui." } } }, "options": { "step": { "init": { "title": "Opções SamsungTV Smart", "data": { "use_st_status_info": "Use as informações de status da TV SmartThings", "use_st_channel_info": "Use as informações dos canais de TV SmartThings", "show_channel_number": "Use as informações de número dos canais de TV SmartThings", "app_load_method": "Modo de carregamento da lista de aplicativos na inicialização", "logo_option": "Exiba uma logo para fontes, aplicativos e canais conhecidos", "use_local_logo": "Permitir o uso de imagens de logotipos locais", "power_on_method": "Método usado para ligar a TV", "show_adv_opt": "Mostrar menu opções" } }, "adv_opt": { "title": "Opções avançadas SamsungTV Smart", "data": { "app_launch_method": "Método usado na inicialização de aplicativos", "dump_apps": "Despejar a lista de aplicativos no arquivo de log na inicialização (quando possível)", "use_mute_check": "Use o status de volume mudo para detectar um falso status de LIGADO", "wol_repeat": "Número de tempo que o pacote WOL é enviado para ligar a TV", "power_on_delay": "Segundos de delay para o status LIGADO", "ping_port": "Porta TCP usada para verificar o status ligado/desligado (0 para ICMP)", "ext_power_entity": "Binary sensor para ajudar a detectar o status de energia" } }, "sync_ent": { "title": "SamsungTV Smart synched entities", "data": { "sync_turn_off": "Lista de entidades para desligar com a TV", "sync_turn_on": "Lista de entidades para ligar com a TV" } } } } } ================================================ FILE: docs/App_list.md ================================================ # HomeAssistant - SamsungTV Smart Component ***app_list guide*** --------------- **Note:** Although this is an optional value, **it is highly recommended to set it manually**, even if in some (rare) cases the app list can be gotten from the TV successfully. The `app_list` is used to set apps that you have installed on your TV. The app names can be associated with 2 types of IDs that Samsung TVs support: numerical IDs and alphanumerical IDs. An application normally has both a numerical ID and an alphanumerical ID associated with it. Here are some known lists of app IDs: [List 1](https://github.com/tavicu/homebridge-samsung-tizen/issues/26#issuecomment-447424879), [List 2](https://github.com/Ape/samsungctl/issues/75#issuecomment-404941201) (Another way of finding the alphanumerical ID is by enabling the [SmartThings API](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md) and running an app on the TV, this will show the alphanumerical ID as `media_title` in the component) Here are 3 examples values for `app_list`: - `'{"Netflix": "11101200001", "Prime Video": "3201512006785", "Spotify": "3201606009684"}'` - `'{"Netflix": "org.tizen.netflix-app", "Prime Video": "org.tizen.ignition", "Spotify": "3201606009684"}'` - `'{"Netflix": "11101200001/org.tizen.netflix-app", "Prime Video": "3201512006785/org.tizen.ignition", "Spotify": "3201606009684"}'` (the last one is the prefered method, which includes both numerical and alphanumerical IDs, for increased support of this component) In order to understand these example values, you must first understand what these IDs are used for. An app ID is used to start the app on the TV and also to identify the running app on the TV. To run the app on the TV, both numerical and alphanumerical IDs can be used. To get the running app from the TV, two different ways are used: - one works only with numerical IDs by doing HTTP polling on the TV (1 request for each app in `app_list`), this is a lengthy task that should be avoided if possible (this can be completely avoided by setting `scan_app_http` to `False` in your component's config) - another works only with the alphanumerical IDs by getting the running app from the SmartThings API (requires SmartThings API enabled in your component's config) **Note:** There is one rare case, for a few apps (like "Prime Video") where the numerical ID will work to start the app, but not to identify the running app, while it's alphanumerical ID will work to get the running app but not run the app. It is for this case that we also allow setting both numerical and alphanumerical IDs at the same time (separated by "/") which will allow this component to correctly handle this rare case too. ================================================ FILE: docs/Key_chaining.md ================================================ # HomeAssistant - SamsungTV Smart Component ***Key Chaining Patterns*** --------------- **Note:** If SmartThings API was enabled by setting `api_key` and `device_id`, then these codes are also supported: `ST_TV`, `ST_HDMI1`, `ST_HDMI2`, `ST_HDMI3`, etc. which will change the input source faster then key chaining patterns will Key codes can be chained with the "+" symbol, delays can also be set in milliseconds (200 to 2000, default=500) between the "+" symbols. This is a list of known and tested key chaining patterns. To see the complete list of known key codes, [check this list](./Key_codes.md) **Switch to Live TV** `KEY_EXIT+2000+KEY_TV+KEY_EXIT` **Switch to first Source in List (2019 TV)** `KEY_SOURCE+KEY_DOWN+KEY_UP+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_ENTER` **Switch to second Source in List (2019 TV)** `KEY_SOURCE+KEY_DOWN+KEY_UP+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_ENTER` **Switch to third Source in List (2019 TV)** `KEY_SOURCE+KEY_DOWN+KEY_UP+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_RIGHT+KEY_ENTER` **Switch to forth Source in List (2019 TV)** `KEY_SOURCE+KEY_DOWN+KEY_UP+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_RIGHT+KEY_RIGHT+KEY_ENTER` **Switch to first Source in List (2017 TV)** `KEY_SOURCE+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_ENTER` **Switch to second Source in List (2017 TV)** `KEY_SOURCE+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_ENTER` **Switch to third Source in List (2017 TV)** `KEY_SOURCE+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_RIGHT+KEY_ENTER` **Switch to forth Source in List (2017 TV)** `KEY_SOURCE+KEY_LEFT+KEY_LEFT+KEY_LEFT+KEY_RIGHT+KEY_RIGHT+KEY_RIGHT+KEY_ENTER` ================================================ FILE: docs/Key_codes.md ================================================ # HomeAssistant - SamsungTV Smart Component ***Key Codes*** --------------- If [SmartThings API](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md) was enabled by setting `api_key`, then these codes are also supported: `ST_TV`, `ST_HDMI1`, `ST_HDMI2`, `ST_HDMI3`, etc. (see the [entire list of SmartThings keys](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md#smartthings-keys)) The list of accepted keys may vary depending on the TV model, but the following list has some common key codes and their descriptions. *Power Keys* ____________ Key|Description ---|----------- KEY_POWEROFF|PowerOFF KEY_POWERON|PowerOn KEY_POWER|PowerToggle *Input Keys* ____________ Key|Description ---|----------- KEY_SOURCE|Source KEY_COMPONENT1|Component1 KEY_COMPONENT2|Component2 KEY_AV1|AV1 KEY_AV2|AV2 KEY_AV3|AV3 KEY_SVIDEO1|SVideo1 KEY_SVIDEO2|SVideo2 KEY_SVIDEO3|SVideo3 KEY_HDMI|HDMI KEY_FM_RADIO|FMRadio KEY_DVI|DVI KEY_DVR|DVR KEY_TV|TV KEY_ANTENA|AnalogTV KEY_DTV|DigitalTV KEY_AMBIENT|AmbientMode *Number Keys* _____________ Key|Description ---|----------- KEY_1|Key1 KEY_2|Key2 KEY_3|Key3 KEY_4|Key4 KEY_5|Key5 KEY_6|Key6 KEY_7|Key7 KEY_8|Key8 KEY_9|Key9 KEY_0|Key0 *Misc Keys* ___________ Key|Description ---|----------- KEY_PANNEL_CHDOWN|3D KEY_ANYNET|AnyNet+ KEY_ESAVING|EnergySaving KEY_SLEEP|SleepTimer KEY_DTV_SIGNAL|DTVSignal *Channel Keys* ______________ Key|Description ---|----------- KEY_CHUP|ChannelUp KEY_CHDOWN|ChannelDown KEY_PRECH|PreviousChannel KEY_FAVCH|FavoriteChannels KEY_CH_LIST|ChannelList KEY_AUTO_PROGRAM|AutoProgram KEY_MAGIC_CHANNEL|MagicChannel *Volume Keys* _____________ Key|Description ---|----------- KEY_VOLUP|VolumeUp KEY_VOLDOWN|VolumeDown KEY_MUTE|Mute *Direction Keys* ________________ Key|Description ---|----------- KEY_UP|NavigationUp KEY_DOWN|NavigationDown KEY_LEFT|NavigationLeft KEY_RIGHT|NavigationRight KEY_RETURN|NavigationReturn/Back KEY_ENTER|NavigationEnter KEY_EXIT|NavigationExit *Media Keys* ____________ Key|Description ---|----------- KEY_REWIND|Rewind KEY_STOP|Stop KEY_PLAY|Play KEY_FF|FastForward KEY_REC|Record KEY_PAUSE|Pause KEY_LIVE|Live KEY_QUICK_REPLAY|fnKEY_QUICK_REPLAY KEY_STILL_PICTURE|fnKEY_STILL_PICTURE KEY_INSTANT_REPLAY|fnKEY_INSTANT_REPLAY *Picture in Picture* ____________________ Key|Description ---|----------- KEY_PIP_ONOFF|PIPOn/Off KEY_PIP_SWAP|PIPSwap KEY_PIP_SIZE|PIPSize KEY_PIP_CHUP|PIPChannelUp KEY_PIP_CHDOWN|PIPChannelDown KEY_AUTO_ARC_PIP_SMALL|PIPSmall KEY_AUTO_ARC_PIP_WIDE|PIPWide KEY_AUTO_ARC_PIP_RIGHT_BOTTOM|PIPBottomRight KEY_AUTO_ARC_PIP_SOURCE_CHANGE|PIPSourceChange KEY_PIP_SCAN|PIPScan *Modes* _______ Key|Description ---|----------- KEY_VCR_MODE|VCRMode KEY_CATV_MODE|CATVMode KEY_DSS_MODE|DSSMode KEY_TV_MODE|TVMode KEY_DVD_MODE|DVDMode KEY_STB_MODE|STBMode KEY_PCMODE|PCMode *Color Keys* ____________ Key|Description ---|----------- KEY_GREEN|Green KEY_YELLOW|Yellow KEY_CYAN|Cyan KEY_RED|Red KEY_COLOR|Color selection *Teletext* __________ Key|Description ---|----------- KEY_TTX_MIX|TeletextMix KEY_TTX_SUBFACE|TeletextSubface *AspectRatio* ______________ Key|Description ---|----------- KEY_ASPECT|AspectRatio KEY_PICTURE_SIZE|PictureSize KEY_4_3|AspectRatio4:3 KEY_16_9|AspectRatio16:9 KEY_EXT14|AspectRatio3:4(Alt) KEY_EXT15|AspectRatio16:9(Alt) *Picture Mode* ______________ Key|Description ---|----------- KEY_PMODE|PictureMode KEY_PANORAMA|PictureModePanorama KEY_DYNAMIC|PictureModeDynamic KEY_STANDARD|PictureModeStandard KEY_MOVIE1|PictureModeMovie KEY_GAME|PictureModeGame KEY_CUSTOM|PictureModeCustom KEY_EXT9|PictureModeMovie(Alt) KEY_EXT10|PictureModeStandard(Alt) *Menus* _______ Key|Description ---|----------- KEY_MENU|Menu KEY_TOPMENU|TopMenu KEY_TOOLS|Tools KEY_HOME|Home KEY_CONTENTS|Contents KEY_GUIDE|Guide KEY_DISC_MENU|DiscMenu KEY_DVR_MENU|DVRMenu KEY_HELP|Help *OSD* _____ Key|Description ---|----------- KEY_INFO|Info KEY_CAPTION|Caption KEY_CLOCK_DISPLAY|ClockDisplay KEY_SETUP_CLOCK_TIMER|SetupClock KEY_SUB_TITLE|Subtitle *Zoom* ______ Key|Description ---|----------- KEY_ZOOM_MOVE|ZoomMove KEY_ZOOM_IN|ZoomIn KEY_ZOOM_OUT|ZoomOut KEY_ZOOM1|Zoom1 KEY_ZOOM2|Zoom2 *Other Keys* ____________ Key|Description ---|----------- KEY_WHEEL_LEFT|WheelLeft KEY_WHEEL_RIGHT|WheelRight KEY_ADDDEL|Add/Del KEY_PLUS100|Plus100 KEY_AD|AD KEY_LINK|Link KEY_TURBO|Turbo KEY_CONVERGENCE|Convergence KEY_DEVICE_CONNECT|DeviceConnect KEY_11|Key11 KEY_12|Key12 KEY_FACTORY|KeyFactory KEY_3SPEED|Key3SPEED KEY_RSURF|KeyRSURF KEY_FF_|FF_ KEY_REWIND_|REWIND_ KEY_ANGLE|Angle KEY_RESERVED1|Reserved1 KEY_PROGRAM|Program KEY_BOOKMARK|Bookmark KEY_PRINT|Print KEY_CLEAR|Clear KEY_VCHIP|VChip KEY_REPEAT|Repeat KEY_DOOR|Door KEY_OPEN|Open KEY_DMA|DMA KEY_MTS|MTS KEY_DNIe|DNIe KEY_SRS|SRS KEY_CONVERT_AUDIO_MAINSUB|ConvertAudioMain/Sub KEY_MDC|MDC KEY_SEFFECT|SoundEffect KEY_PERPECT_FOCUS|PERPECTFocus KEY_CALLER_ID|CallerID KEY_SCALE|Scale KEY_MAGIC_BRIGHT|MagicBright KEY_W_LINK|WLink KEY_DTV_LINK|DTVLink KEY_APP_LIST|ApplicationList KEY_BACK_MHP|BackMHP KEY_ALT_MHP|AlternateMHP KEY_DNSe|DNSe KEY_RSS|RSS KEY_ENTERTAINMENT|Entertainment KEY_ID_INPUT|IDInput KEY_ID_SETUP|IDSetup KEY_ANYVIEW|AnyView KEY_MS|MS KEY_MORE| KEY_MIC| KEY_NINE_SEPERATE| KEY_AUTO_FORMAT|AutoFormat KEY_DNET|DNET KEY_EXTRA|RemoteAccess *Auto Arc Keys* _______________ Key|Description ---|----------- KEY_AUTO_ARC_C_FORCE_AGING| KEY_AUTO_ARC_CAPTION_ENG| KEY_AUTO_ARC_USBJACK_INSPECT| KEY_AUTO_ARC_RESET| KEY_AUTO_ARC_LNA_ON| KEY_AUTO_ARC_LNA_OFF| KEY_AUTO_ARC_ANYNET_MODE_OK| KEY_AUTO_ARC_ANYNET_AUTO_START| KEY_AUTO_ARC_CAPTION_ON| KEY_AUTO_ARC_CAPTION_OFF| KEY_AUTO_ARC_PIP_DOUBLE| KEY_AUTO_ARC_PIP_LARGE| KEY_AUTO_ARC_PIP_LEFT_TOP| KEY_AUTO_ARC_PIP_RIGHT_TOP| KEY_AUTO_ARC_PIP_LEFT_BOTTOM| KEY_AUTO_ARC_PIP_CH_CHANGE| KEY_AUTO_ARC_AUTOCOLOR_SUCCESS| KEY_AUTO_ARC_AUTOCOLOR_FAIL| KEY_AUTO_ARC_JACK_IDENT| KEY_AUTO_ARC_CAPTION_KOR| KEY_AUTO_ARC_ANTENNA_AIR| KEY_AUTO_ARC_ANTENNA_CABLE| KEY_AUTO_ARC_ANTENNA_SATELLITE| *Panel Keys* ____________ Key|Description ---|----------- KEY_PANNEL_POWER| KEY_PANNEL_CHUP| KEY_PANNEL_VOLUP| KEY_PANNEL_VOLDOW| KEY_PANNEL_ENTER| KEY_PANNEL_MENU| KEY_PANNEL_SOURCE| KEY_PANNEL_ENTER|

*Extended Keys* _______________ Key|Description ---|----------- KEY_EXT1| KEY_EXT2| KEY_EXT3| KEY_EXT4| KEY_EXT5| KEY_EXT6| KEY_EXT7| KEY_EXT8| KEY_EXT11| KEY_EXT12| KEY_EXT13| KEY_EXT16| KEY_EXT17| KEY_EXT18| KEY_EXT19| KEY_EXT20| KEY_EXT21| KEY_EXT22| KEY_EXT23| KEY_EXT24| KEY_EXT25| KEY_EXT26| KEY_EXT27| KEY_EXT28| KEY_EXT29| KEY_EXT30| KEY_EXT31| KEY_EXT32| KEY_EXT33| KEY_EXT34| KEY_EXT35| KEY_EXT36| KEY_EXT37| KEY_EXT38| KEY_EXT39| KEY_EXT40| KEY_EXT41| Please note that some codes are different on the 2016+ TVs. For example, `KEY_POWEROFF` is `KEY_POWER` on the newer TVs. ***References*** ---------------- The code list has been extracted from: https://github.com/kdschlosser/samsungctl ================================================ FILE: docs/Smartthings.md ================================================ # HomeAssistant - SamsungTV Smart Integration ## ***Enable SmartThings*** - Setup instructions ### SmartThings authentication To use SmartThings feature in integration you must provide authentication information. There are 2 way to do this. #### Method 1: Use native SmartThings integration (suggested) Configure on your HA instance the [native HA SmartThings integration](https://www.home-assistant.io/integrations/smartthings/). In this way the API key to access to SmartThings will be automatically provided to `Samsung TV Smart` integration and you don't have to do any other steps. Just remenber to select the `SmartThings entry used to provide SmartThings credential` in Samsung TV Smart configuration flow. #### Method 2: Create personal access token (deprecated) 1. Log into the [personal access tokens page](https://account.smartthings.com/tokens) and click '[Generate new token](https://account.smartthings.com/tokens/new)' 2. Enter a token name (can be whatever you want), for example, 'Home Assistant' and select the following authorized scopes: - Devices (all) - Installed Applications (all) - Scenes (all) - Applications (all) - Locations (all) - Schedules (all) 3. Click 'Generate token'. When the token is displayed, copy and save it somewhere safe (such as your keystore) as you will not be able to retrieve it again. **Note:** starting from 30 December 2024 generated `personal access token (PAT)` have a duration of 24 hours as explained [here](https://developer.smartthings.com/docs/getting-started/authorization-and-permissions). For this reason use of `PAT` is not recommended because you should manually update your token every 24 hours. In case you can use the integration reconfigure option to update it.
### Configure Home Assistant Once the SmartThings token has been generated, you need to configure the integration with it in order to make it work as explained in the main guide. If you previously configured the [native HA SmartThings integration](https://www.home-assistant.io/integrations/smartthings/), remenber to select the `SmartThings entry used to provide SmartThings credential` during configuration flow. **Note:** if the integration has been already configured for your TV, you must delete it from the HA web interface and then re-configure it to enable SmartThings integration.
#### SmartThings Device ID If during configuration flow automatic detection of SmartThings device ID fails, a new configuration page will open requesting you to manual insert it. To identify your TV device ID use the following steps: - Go [here](https://my.smartthings.com/advanced/devices) and login with your SmartThings credential - Click on the name of your TV from the list of available devices - In the new page search the column called `Device ID` - Copy the value (is a UUID code) and paste it in the HomeAssistant configuration page ***Benefits of Enabling SmartThings*** --------------- - Better states for running apps (read [app_list guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/App_list.md) for more information) - New keys available (read more below about [SmartThings Keys](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md#smartthings-keys)) - Shows TV channel names - Shows accurate states for HDMI or TV input sources ***SmartThings Keys*** --------------- *Input Keys* ____________ Key|Description ---|----------- ST_TV|TV ST_VD:`src`|`src` ST_HDMI1|HDMI1 ST_HDMI2|HDMI2 ST_HDMI3|HDMI3 ST_HDMI4|HDMI4 ... With ST_VD:`src` replace `src` with the name of the source you want activate *Channel Keys* ______________ Key|Description ---|----------- ST_CHUP|ChannelUp ST_CHDOWN|ChannelDown ST_CH1|Channel1 ST_CH2|Channel2 ST_CH3|Channel3 ... *Volume Keys* ______________ Key|Description ---|----------- ST_MUTE|Mute/Unmute ST_VOLUP|VolumeUp ST_VOLDOWN|VolumeDown ST_VOL1|VolumeLevel1 ST_VOL2|VolumeLevel2 ... ST_VOL100|VolumeLevel100 ================================================ FILE: hacs.json ================================================ { "name": "SamsungTV Smart", "content_in_root": false, "zip_release": true, "filename": "samsungtv_smart.zip", "homeassistant": "2025.6.0" } ================================================ FILE: info.md ================================================ # HomeAssistant - SamsungTV Smart Component This is a custom component to allow control of SamsungTV devices in [HomeAssistant](https://home-assistant.io). Is a modified version of the built-in [samsungtv](https://www.home-assistant.io/integrations/samsungtv/) with some extra features.
**This plugin is only for 2016+ TVs model!** (maybe all tizen family) This project is a fork of the component [SamsungTV Tizen](https://github.com/jaruba/ha-samsungtv-tizen). I added some feature like the possibility to configure it using the HA user interface, simplifing the configuration process. I also added some code optimizition in the comunication layer using async aiohttp instead of request. **Part of the code and documentation available here come from the original project.**
# Additional Features: * Ability to send keys using a native Home Assistant service * Ability to send chained key commands using a native Home Assistant service * Supports Assistant commands (Google Home, should work with Alexa too, but untested) * Extended volume control * Ability to customize source list at media player dropdown list * Cast video URLs to Samsung TV * Connect to SmartThings Cloud API for additional features: see TV channel names, see which HDMI source is selected, more key codes to change input source * Display logos of TV channels (requires Smartthings enabled) and apps # Configuration Once the component has been installed, you need to configure it using web UI in order to make it work. **Important**: To complete the configuration procedure properly, you must be sure that your **TV is turned on and connected to the LAN (wired or wireless)**. Stay near to your TV during configuration because probably you will need to accept the access request that will prompt on your TV screen. **Note**: To configure the component for using **SmartThings (strongly suggested)** you need to generate an access token as explained in [this guide](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md). Also make sure your **TV is logged into your SmartThings account** and **registered in SmartThings phone app** before starting configuration. ### Configuration using the web UI 1. From the Home Assistant front-end, navigate to 'Configuration' then 'Integrations'. Click `+` button in botton right corner, search '**SamsungTV Smart**' and click 'Configure'. 2. In the configuration mask, enter the IP address of the TV, the name for the Entity and the SmartThings personal access token (if created) and then click 'Submit' 3. **Important**: look for your TV screen and confirm **immediatly** with OK if a popup appear. 4. Congrats! You're all set! **Note**: be sure that your TV and HA are connected to the same VLAN. Websocket connection through different VLAN normally not work because not supported by Samsung TV. If you have errors during configuration, try to power cycle your TV. This will close running applications that can prevent websocket connection initialization. **Please refer to [readme](https://github.com/ollo69/ha-samsungtv-smart/blob/master/README.md) for details on optional parameter and additional configuration instruction.** # Be kind! If you like the component, why don't you support me by buying me a coffe? It would certainly motivate me to further improve this work. [![Buy me a coffe!](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/ollo69) Credits ------- This integration is developed by [Ollo69][ollo69] based on integration [SamsungTV Tizen][samsungtv_tizen].
Original SamsungTV Tizen integration was developed by [jaruba][jaruba].
Logo support is based on [jaruba channels-logo][channels-logo] and was developed with the support of [Screwdgeh][Screwdgeh].
[ollo69]: https://github.com/ollo69 [samsungtv_tizen]: https://github.com/jaruba/ha-samsungtv-tizen [jaruba]: https://github.com/jaruba [Screwdgeh]: https://github.com/Screwdgeh [channels-logo]: https://github.com/jaruba/channel-logos ================================================ FILE: requirements.txt ================================================ # Home Assistant Core colorlog==6.8.2 homeassistant==2025.6.3 pip>=21.3.1,<=24.3.2 ruff==0.0.261 pre-commit==3.0.0 flake8==6.1.0 isort==5.12.0 black==25.1.0 websocket-client!=1.4.0,>=0.58.0 wakeonlan>=2.0.0 aiofiles>=0.8.0 casttube>=0.2.1 ================================================ FILE: requirements_test.txt ================================================ # Strictly for tests pytest==8.3.5 #pytest-cov==2.9.0 #pytest-homeassistant pytest-homeassistant-custom-component==0.13.254 # From our manifest.json for our custom component websocket-client!=1.4.0,>=0.58.0 wakeonlan>=2.0.0 aiofiles>=0.8.0 casttube>=0.2.1 ================================================ FILE: script/integration_init ================================================ #!/usr/bin/env bash # Create empty init in custom components directory echo "Init custom components directory" touch "${PWD}/custom_components/__init__.py" ================================================ FILE: scripts/develop ================================================ #!/usr/bin/env bash set -e cd "$(dirname "$0")/.." # Create config dir if not present if [[ ! -d "${PWD}/config" ]]; then mkdir -p "${PWD}/config" hass --config "${PWD}/config" --script ensure_config fi # Set the path to custom_components ## This let's us have the structure we want /custom_components/integration_blueprint ## while at the same time have Home Assistant configuration inside /config ## without resulting to symlinks. export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" # Start Home Assistant #hass --config "${PWD}/config" --debug hass --config "${PWD}/config" ================================================ FILE: scripts/lint ================================================ #!/usr/bin/env bash set -e cd "$(dirname "$0")/.." ruff check . --fix ================================================ FILE: scripts/setup ================================================ #!/usr/bin/env bash set -e cd "$(dirname "$0")/.." python3 -m pip install --requirement requirements.txt ================================================ FILE: setup.cfg ================================================ [coverage:run] source = custom_components [coverage:report] exclude_lines = pragma: no cover raise NotImplemented() if __name__ == '__main__': main() show_missing = true [tool:pytest] testpaths = tests norecursedirs = .git addopts = --strict-markers --cov=custom_components # asyncio_mode = auto [isort] # https://github.com/timothycrosley/isort # https://github.com/timothycrosley/isort/wiki/isort-Settings # splits long import on multiple lines indented by 4 spaces profile = black line_length = 88 # will group `import x` and `from x import` of the same module. force_sort_within_sections = true sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER default_section = THIRDPARTY known_first_party = homeassistant known_local_folder = custom_components, tests forced_separate = tests combine_as_imports = true [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-complexity = 25 doctests = True # To work with Black # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504 noqa-require-code = True ================================================ FILE: tests/__init__.py ================================================ """custom integation tests.""" ================================================ FILE: tests/conftest.py ================================================ """Global fixtures for integration_blueprint integration.""" # Fixtures allow you to replace functions with a Mock object. You can perform # many options via the Mock to reflect a particular behavior from the original # function that you want to see without going through the function's actual logic. # Fixtures can either be passed into tests as parameters, or if autouse=True, they # will automatically be used across all tests. # # Fixtures that are defined in conftest.py are available across all tests. You can also # define fixtures within a particular test file to scope them locally. # # pytest_homeassistant_custom_component provides some fixtures that are provided by # Home Assistant core. You can find those fixture definitions here: # https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py # # See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that # pytest includes fixtures OOB which you can use as defined on this page) from unittest.mock import patch import pytest pytest_plugins = "pytest_homeassistant_custom_component" # This fixture enables loading custom integrations in all tests. # Remove to enable selective use of this fixture @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): yield # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent # notifications. These calls would fail without this fixture since the persistent_notification # integration is never loaded during a test. @pytest.fixture(name="skip_notifications", autouse=True) def skip_notifications_fixture(): """Skip notification calls.""" with patch("homeassistant.components.persistent_notification.async_create"), patch( "homeassistant.components.persistent_notification.async_dismiss" ): yield