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://github.com/ollo69/ha-samsungtv-smart/releases)
[](https://github.com/custom-components/hacs)
[](LICENSE)
[](https://github.com/ollo69)
[](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


# 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.
[](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.
[](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