Showing preview only (402K chars total). Download the full file or copy to clipboard to get everything.
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.<br/>
**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.**<br/>
# 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.**<br>
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.<br/>
Here you can change the following options:
- **Use SmartThings TV Status information**<br/>
(default = True)<br/>
**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.<br/>
- **Use SmartThings TV Channels information**<br/>
(default = True)<br/>
**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<br/>
**Note: in some case this information is not properly updated, disabled it you have incorrect information.**<br/>
- **Use SmartThings TV Channels number information**<br/>
(default = False)<br/>
**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).<br/>
**Note: not always SmartThings provide the information for channel_name and channel_number.**<br/>
- **Logo options**<br/>
The background color and channel / service logo preference to use, example: "white-color" (background: white, logo: color).<br/>
Supported values: "none", "white-color", "dark-white", "blue-color", "blue-white", "darkblue-white", "transparent-color", "transparent-white"<br/>
Default value: "white-color" (background: white, logo: color)<br/>
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.<br/>
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**<br/>
(default = True)<br/>
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**<br/>
Possible values: `All Apps`, `Default Apps` and `Not Load`<br/>
This option determine the mode application list is automatic generated.<br>
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.<br/>
**Note: If a custom `Application List` in config options is defined this option is not available.**<br>
- **Method used to turn on TV**<br/>
Possible values: `WOL Packet` and `SmartThings`<br/>
**This option is available only if SmartThings is configured.**
WOL Packet is better when TV use wired connection.<br/>
SmartThings normally work only when TV use wireless connection.<br/>
- **Show advanced options**<br/>
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**<br/>
Possible values: `Control Web Socket Channel`, `Remote Web Socket Channel` and `Rest API Call`<br/>
This option determine the mode used to launch applications.<br/>
Use `Rest API Call` only if the other 2 methods do not work.<br/>
- **Number of times WOL packet is sent to turn on TV**<br/>
(default = 1, range from 1 to 5)<br/>
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.<br/>
- **TCP port used to check power status**<br/>
(default = 0, range from 0 to 65535)<br/>
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`).</br>
**N.B. If you set an invalid port here, TV is always reported as `off`.**</br>
- **Binary sensor to help detect power status**<br/>
An external `binary_sensor` selectable from a list that can be used to determinate TV power status.<br/>
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.</br>
- **Use volume mute status to detect fake power ON**<br/>
(default = True)<br/>
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.<br/>
- **Dump apps list on log file at startup**<br/>
(default = False)<br/>
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.<br/>
- **Power button switch to art mode**<br/>
(default = False)<br/>
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).<br/>
**Note: This option is valid only for TV that support `Art Mode` ("The Frame" models).**<br>
### Synched entities configuration
- **List of entity to Power OFF with TV**<br/>
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.<br/>
- **List of entity to Power ON with TV**<br/>
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.<br/>
### Sources list configuration
This contains the KEYS visible sources in the dropdown list in media player UI.<br/>
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.<br/>
Default value:<br/>
```
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.<br/>
You can also chain KEYS, example:
```
1| TV: KEY_SOURCES+KEY_ENTER
```
And even add delays (in milliseconds) between sending KEYS, example:<br/>
```
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)<br/>
**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")**<br/>
### Application list configuration
This contains the APPS visible sources in the dropdown list in media player UI.<br/>
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.<br/>
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.<br/>
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)<br/>
### 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.<br/>
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.<br/>
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`<br/>
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.<br/>
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:<br/>
```
samsungtv_smart:
- host: <YOUR TV IP ADDRES>
...
```
Then you can add any of the following parameters:<br/>
- **mac:**<br/>
(string)(Optional)<br/>
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.<br/>
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.<br/>
- **broadcast_address:**<br/>
(string)(Optional)<br/>
**Do not set this option if you do not know what it does, it can break turning your TV on.**<br/>
The ip address of the host to send the magic packet (for wakeonlan) to if the "mac" property is also set.<br/>
Default value: "255.255.255.255"<br/>
Example value: "192.168.1.255"<br/>
### 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:**<br/>
(json)(Optional)<br/>
This contains the KEYS visible sources in the dropdown list in media player UI.<br/>
Default value: '{"TV": "KEY_TV", "HDMI": "KEY_HDMI"}'<br/>
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.<br/>
You can also chain KEYS, example: '{"TV": "KEY_SOURCES+KEY_ENTER"}'<br/>
And even add delays (in milliseconds) between sending KEYS, example:<br/>
'{"TV": "KEY_SOURCES+500+KEY_ENTER"}'<br/>
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)<br/>
**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")**<br/>
- **app_list:**<br/>
(json)(Optional)<br/>
This contains the APPS visible sources in the dropdown list in media player UI.<br/>
Default value: AUTOGENERATED<br/>
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)<br/>
Example value: '{"Netflix": "11101200001", "YouTube": "111299001912", "Spotify": "3201606009684"}'<br/>
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)<br/>
- **channel_list:**<br/>
(json)(Optional)<br/>
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.<br/>
Example value: '{"MTV": "14", "Eurosport": "20", "TLC": "21"}'<br/>
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`<br/>
Example value: '{"MTV": "14@TV", "Eurosport": "20@TV", "TLC": "21@HDMI"}'<br/>
### 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:**<br/>
(string)(Optional) (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)<br/>
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))<br/>
Read [How to get an API Key for SmartThings](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Smartthings.md)<br/>
This parameter can also be provided during the component configuration using the user interface.<br/>
**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.**<br/>
- **device_id:**<br/>
(string)(Optional) (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)<br/>
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.<br/>
This parameter will be requested during component configuration from user interface when required.<br/>
**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.**<br/>
- **device_name:** (obsolete/not used from v0.3.16 - configuration from yaml is not allowed)<br/>
(string)(Optional)<br/>
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.<br/>
The device_name to use can be read using the SmartThings app<br/>
**Note: this parameter is used only during initial configuration.**<br/>
- **show_channel_number:** (obsolete/not used from v0.3.16 and replaced by Configuration options)<br/>
(boolean)(Optional)<br/>
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).<br/>
**Note: not always SmartThings provide the information for channel_name and channel_number.**<br/>
- **load_all_apps:** (obsolete/not used from v0.3.4 and replaced by Configuration options)<br/>
(boolean)(Optional)<br/>
This option is `True` by default.</br>
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).<br/>
- **update_method:** (obsolete/not used from v0.3.3)<br/>
(string)(Optional)<br/>
This change the ping method used for state update. Values: "ping", "websockets" and "smartthings"<br/>
Default value: "ping" if SmartThings is not enabled, else "smartthings"<br/>
Example update_method: "websockets"<br/>
- **update_custom_ping_url:** (obsolete/not used from v0.2.x)<br/>
(string)(Optional)<br/>
Use custom endpoint to ping.<br/>
Default value: PING TO 8001 ENDPOINT<br/>
Example update_custom_ping_url: "http://192.168.1.77:9197/dmr"<br/>
- **scan_app_http:** (obsolete/not used from v0.2.x)<br/>
(boolean)(Optional)<br/>
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.<br/>
This is a lengthy task that some may want to disable, you can do so by setting this option to `False`.<br/>
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).<br/>
# 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, <hold_time>"
}
```
**Note**: Change "KEY_CODE" by desired [key_code](https://github.com/ollo69/ha-samsungtv-smart/blob/master/docs/Key_codes.md) and <hold_time> 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].<br/>
Original SamsungTV Tizen integration was developed by [jaruba][jaruba].<br/>
Logo support is based on [jaruba channels-logo][channels-logo] and was developed with the support of [Screwdgeh][Screwdgeh].<br/>
[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"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:{action} xmlns:u="urn:schemas-upnp-org:service:{protocole}:1">
<InstanceID>0</InstanceID>
{arguments}
</u:{action}>
</s:Body>
</s:Envelope>"""
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", "<Channel>Master</Channel>", "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"<Channel>Master</Channel><DesiredVolume>{volume}</DesiredVolume>",
"RenderingControl",
)
async def async_get_mute(self):
"""Return mute status."""
response = await self._soap_request(
"GetMute", "<Channel>Master</Channel>", "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"<CurrentURI>{url}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>",
"AVTransport",
timeout=2.0,
)
is None
):
return False
await self._soap_request("Play", "<Speed>1</Speed>", "AVTransport")
return True
async def async_play(self):
"""Play media that was already set as current."""
await self._soap_request("Play", "<Speed>1</Speed>", "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
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
SYMBOL INDEX (358 symbols across 14 files)
FILE: custom_components/samsungtv_smart/__init__.py
function ensure_unique_hosts (line 110) | def ensure_unique_hosts(value):
function tv_url (line 154) | def tv_url(host: str, address: str = "") -> str:
function is_min_ha_version (line 159) | def is_min_ha_version(min_ha_major_ver: int, min_ha_minor_ver: int) -> b...
function is_valid_ha_version (line 166) | def is_valid_ha_version() -> bool:
function _notify_message (line 171) | def _notify_message(
function _load_option_list (line 188) | def _load_option_list(src_list):
function token_file_name (line 204) | def token_file_name(hostname: str) -> str:
function _remove_token_file (line 209) | def _remove_token_file(hass, hostname, token_file=None):
function _migrate_token (line 223) | def _migrate_token(hass: HomeAssistant, entry: ConfigEntry, hostname: st...
function _migrate_options_format (line 251) | def _migrate_options_format(hass: HomeAssistant, entry: ConfigEntry) -> ...
function _migrate_entry_unique_id (line 285) | def _migrate_entry_unique_id(hass: HomeAssistant, entry: ConfigEntry) ->...
function _migrate_smartthings_config (line 315) | def _migrate_smartthings_config(hass: HomeAssistant, entry: ConfigEntry)...
function get_smartthings_entries (line 330) | def get_smartthings_entries(hass: HomeAssistant) -> dict[str, str] | None:
function get_smartthings_api_key (line 344) | def get_smartthings_api_key(hass: HomeAssistant, st_unique_id: str) -> s...
function _register_logo_paths (line 360) | async def _register_logo_paths(hass: HomeAssistant) -> str | None:
function get_device_info (line 390) | async def get_device_info(hostname: str, session: ClientSession) -> dict:
class SamsungTVInfo (line 419) | class SamsungTVInfo:
method __init__ (line 422) | def __init__(self, hass, hostname, ws_name):
method ws_port (line 432) | def ws_port(self):
method ws_token (line 437) | def ws_token(self):
method ping_port (line 442) | def ping_port(self):
method _try_connect_ws (line 446) | def _try_connect_ws(self):
method _try_connect_st (line 497) | async def _try_connect_st(api_key, device_id, session: ClientSession):
method get_st_devices (line 524) | async def get_st_devices(api_key, session: ClientSession, st_device_la...
method try_connect (line 538) | async def try_connect(
function async_setup (line 563) | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
function async_setup_entry (line 612) | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> ...
function async_unload_entry (line 658) | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) ->...
function async_remove_entry (line 671) | async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) ->...
function _update_listener (line 680) | async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> N...
FILE: custom_components/samsungtv_smart/api/samsungcast.py
function _format_url (line 14) | def _format_url(host: str, app: str) -> str:
class CastTubeNotSupported (line 19) | class CastTubeNotSupported(Exception):
class SamsungCastTube (line 23) | class SamsungCastTube:
method __init__ (line 26) | def __init__(self, host: str):
method _get_screen_id (line 32) | def _get_screen_id(host: str) -> str:
method _get_api (line 56) | def _get_api(self) -> YouTubeSession:
method play_video (line 63) | def play_video(self, video_id: str) -> None:
method play_next (line 67) | def play_next(self, video_id: str) -> None:
method add_to_queue (line 71) | def add_to_queue(self, video_id: str) -> None:
method clear_queue (line 75) | def clear_queue(self) -> None:
FILE: custom_components/samsungtv_smart/api/samsungws.py
function _set_ws_logger_level (line 64) | def _set_ws_logger_level(level: int = logging.CRITICAL) -> None:
function _format_rest_url (line 71) | def _format_rest_url(host: str, append: str = "") -> str:
function gen_uuid (line 76) | def gen_uuid() -> str:
function aware_utcnow (line 81) | def aware_utcnow() -> datetime:
function kill_subprocess (line 86) | def kill_subprocess(
function _process_api_response (line 97) | def _process_api_response(response, *, raise_error=True):
function _log_ping_pong (line 110) | def _log_ping_pong(msg, *args):
class Ping (line 117) | class Ping:
method __init__ (line 120) | def __init__(self, host):
method ping (line 128) | def ping(self, port=0):
method _ping (line 134) | def _ping(self):
method _ping_socket (line 148) | def _ping_socket(self, port):
class ConnectionFailure (line 155) | class ConnectionFailure(Exception):
class ResponseError (line 159) | class ResponseError(Exception):
class HttpApiError (line 163) | class HttpApiError(Exception):
class App (line 167) | class App:
method __init__ (line 170) | def __init__(self, app_id, app_name, app_type):
class ArtModeStatus (line 176) | class ArtModeStatus(Enum):
class SamsungTVAsyncRest (line 185) | class SamsungTVAsyncRest:
method __init__ (line 188) | def __init__(
method _rest_request (line 199) | async def _rest_request(self, target: str, method: str = "GET") -> dic...
method async_rest_device_info (line 218) | async def async_rest_device_info(self) -> dict[str, Any]:
method async_rest_app_status (line 223) | async def async_rest_app_status(self, app_id: str) -> dict[str, Any]:
method async_rest_app_run (line 228) | async def async_rest_app_run(self, app_id: str) -> dict[str, Any]:
method async_rest_app_close (line 233) | async def async_rest_app_close(self, app_id: str) -> dict[str, Any]:
method async_rest_app_install (line 238) | async def async_rest_app_install(self, app_id: str) -> dict[str, Any]:
class SamsungTVWS (line 244) | class SamsungTVWS:
method __init__ (line 247) | def __init__(
method __enter__ (line 309) | def __enter__(self):
method __exit__ (line 312) | def __exit__(self, exc_type, exc_value, exc_traceback):
method ping_probe (line 316) | def ping_probe(host):
method _serialize_string (line 329) | def _serialize_string(string):
method _is_ssl_connection (line 334) | def _is_ssl_connection(self):
method _format_websocket_url (line 337) | def _format_websocket_url(self, path, is_ssl=False, use_token=True):
method set_ping_port (line 349) | def set_ping_port(self, port: int):
method update_app_list (line 353) | def update_app_list(self, app_list: dict | None):
method register_new_token_callback (line 357) | def register_new_token_callback(self, func):
method register_status_callback (line 361) | def register_status_callback(self, func):
method unregister_status_callback (line 365) | def unregister_status_callback(self):
method _get_token (line 369) | def _get_token(self):
method _set_token (line 380) | def _set_token(self, token):
method _ws_send (line 395) | def _ws_send(
method _rest_request (line 445) | def _rest_request(self, target, method="GET"):
method _check_conn_id (line 463) | def _check_conn_id(self, resp_data):
method _run_forever (line 483) | def _run_forever(
method _client_remote_thread (line 490) | def _client_remote_thread(self):
method _on_ping_remote (line 520) | def _on_ping_remote(self, _, payload):
method _on_message_remote (line 530) | def _on_message_remote(self, _, message):
method _request_apps_list (line 561) | def _request_apps_list(self):
method _handle_installed_app (line 572) | def _handle_installed_app(self, response):
method _client_control_thread (line 583) | def _client_control_thread(self):
method _on_ping_control (line 610) | def _on_ping_control(self, _, payload):
method _on_message_control (line 620) | def _on_message_control(self, _, message):
method _set_running_app (line 646) | def _set_running_app(self, response):
method _manage_control_err (line 677) | def _manage_control_err(self, response):
method _get_app_status (line 696) | def _get_app_status(self, app_id, app_type):
method _client_art_thread (line 722) | def _client_art_thread(self):
method _on_ping_art (line 747) | def _on_ping_art(self, _, payload):
method _on_message_art (line 757) | def _on_message_art(self, _, message):
method _get_artmode_status (line 781) | def _get_artmode_status(self):
method _handle_artmode_status (line 802) | def _handle_artmode_status(self, response):
method is_connected (line 840) | def is_connected(self):
method artmode_status (line 845) | def artmode_status(self):
method installed_app (line 850) | def installed_app(self):
method running_app (line 855) | def running_app(self):
method is_app_running (line 859) | def is_app_running(self, app_id: str) -> bool | None:
method _ping_thread_method (line 871) | def _ping_thread_method(self):
method _check_remote (line 885) | def _check_remote(self):
method _check_art_mode (line 904) | def _check_art_mode(self):
method _notify_app_change (line 916) | def _notify_app_change(self):
method _get_running_app (line 928) | def _get_running_app(self, *, force_scan=False):
method set_power_on_request (line 959) | def set_power_on_request(self, set_art_mode=False, power_on_delay=0):
method set_power_off_request (line 966) | def set_power_off_request(self):
method start_poll (line 970) | def start_poll(self):
method stop_poll (line 979) | def stop_poll(self):
method _start_client (line 988) | def _start_client(self, *, start_all=False):
method stop_client (line 1016) | def stop_client(self):
method open (line 1021) | def open(self):
method close (line 1056) | def close(self):
method send_key (line 1063) | def send_key(self, key, key_press_delay=None, cmd="Click"):
method hold_key (line 1079) | def hold_key(self, key, seconds):
method send_text (line 1086) | def send_text(self, text, send_delay=None):
method move_cursor (line 1116) | def move_cursor(self, x, y, duration=0):
method run_app (line 1130) | def run_app(self, app_id, action_type="", meta_tag="", *, use_remote=F...
method open_browser (line 1179) | def open_browser(self, url):
method rest_device_info (line 1184) | def rest_device_info(self):
method rest_app_status (line 1189) | def rest_app_status(self, app_id):
method rest_app_run (line 1194) | def rest_app_run(self, app_id):
method rest_app_close (line 1199) | def rest_app_close(self, app_id):
method rest_app_install (line 1204) | def rest_app_install(self, app_id):
method shortcuts (line 1209) | def shortcuts(self):
FILE: custom_components/samsungtv_smart/api/shortcuts.py
class SamsungTVShortcuts (line 24) | class SamsungTVShortcuts:
method __init__ (line 25) | def __init__(self, remote):
method power (line 29) | def power(self):
method home (line 33) | def home(self):
method menu (line 36) | def menu(self):
method source (line 39) | def source(self):
method guide (line 42) | def guide(self):
method tools (line 45) | def tools(self):
method info (line 48) | def info(self):
method up (line 52) | def up(self):
method down (line 55) | def down(self):
method left (line 58) | def left(self):
method right (line 61) | def right(self):
method enter (line 64) | def enter(self, count=1):
method back (line 67) | def back(self):
method channel_list (line 71) | def channel_list(self):
method channel (line 74) | def channel(self, ch):
method digit (line 80) | def digit(self, d):
method channel_up (line 83) | def channel_up(self):
method channel_down (line 86) | def channel_down(self):
method volume_up (line 90) | def volume_up(self):
method volume_down (line 93) | def volume_down(self):
method mute (line 96) | def mute(self):
method red (line 100) | def red(self):
method green (line 103) | def green(self):
method yellow (line 106) | def yellow(self):
method blue (line 109) | def blue(self):
FILE: custom_components/samsungtv_smart/api/smartthings.py
function _headers (line 111) | def _headers(api_key: str) -> dict[str, str]:
function _command (line 119) | def _command(command: dict, arguments: list | None = None):
class STStatus (line 127) | class STStatus(Enum):
class SmartThingsTV (line 135) | class SmartThingsTV:
method __init__ (line 138) | def __init__(
method __enter__ (line 177) | def __enter__(self):
method __exit__ (line 180) | def __exit__(self, ext_type, ext_value, ext_traceback):
method _get_api_key (line 183) | def _get_api_key(self) -> str:
method api_key (line 191) | def api_key(self) -> str:
method device_id (line 196) | def device_id(self) -> str:
method device_name (line 201) | def device_name(self) -> str:
method state (line 206) | def state(self):
method prev_state (line 211) | def prev_state(self):
method muted (line 216) | def muted(self) -> bool:
method volume (line 221) | def volume(self) -> int:
method source (line 226) | def source(self) -> str:
method channel (line 231) | def channel(self) -> str:
method channel_name (line 236) | def channel_name(self) -> str:
method source_list (line 241) | def source_list(self):
method sound_mode (line 246) | def sound_mode(self):
method sound_mode_list (line 253) | def sound_mode_list(self):
method picture_mode (line 260) | def picture_mode(self):
method picture_mode_list (line 267) | def picture_mode_list(self):
method get_source_name (line 273) | def get_source_name(self, source_id: str) -> str:
method _get_source_list_from_map (line 285) | def _get_source_list_from_map(self) -> list:
method set_application (line 298) | def set_application(self, app_id):
method _set_source (line 306) | def _set_source(self, source):
method _load_json_list (line 316) | def _load_json_list(dev_data, list_name):
method get_devices_list (line 328) | async def get_devices_list(api_key, session: ClientSession, device_lab...
method _device_refresh (line 366) | async def _device_refresh(self, **kwargs):
method _async_send_command (line 391) | async def _async_send_command(self, data_cmd):
method async_device_health (line 412) | async def async_device_health(self):
method async_device_update (line 436) | async def async_device_update(self, use_channel_info: bool = None):
method async_turn_off (line 535) | async def async_turn_off(self):
method async_turn_on (line 540) | async def async_turn_on(self):
method async_send_command (line 545) | async def async_send_command(self, cmd_type, command=""):
method async_select_source (line 573) | async def async_select_source(self, source):
method async_select_vd_source (line 582) | async def async_select_vd_source(self, source):
method async_set_sound_mode (line 589) | async def async_set_sound_mode(self, mode):
method async_set_picture_mode (line 599) | async def async_set_picture_mode(self, mode):
class InvalidSmartThingsSoundMode (line 610) | class InvalidSmartThingsSoundMode(RuntimeError):
class InvalidSmartThingsPictureMode (line 614) | class InvalidSmartThingsPictureMode(RuntimeError):
FILE: custom_components/samsungtv_smart/api/upnp.py
class SamsungUPnP (line 15) | class SamsungUPnP:
method __init__ (line 18) | def __init__(self, host, session: Optional[ClientSession] = None):
method _soap_request (line 29) | async def _soap_request(
method connected (line 64) | def connected(self):
method async_disconnect (line 68) | async def async_disconnect(self):
method async_get_volume (line 73) | async def async_get_volume(self):
method async_set_volume (line 87) | async def async_set_volume(self, volume):
method async_get_mute (line 95) | async def async_get_mute(self):
method async_set_current_media (line 111) | async def async_set_current_media(self, url):
method async_play (line 128) | async def async_play(self):
FILE: custom_components/samsungtv_smart/config_flow.py
function _get_ip (line 148) | def _get_ip(host):
class SamsungTVConfigFlow (line 157) | class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
method __init__ (line 164) | def __init__(self) -> None:
method _stdev_already_used (line 182) | def _stdev_already_used(self, devices_id) -> bool:
method _remove_stdev_used (line 189) | def _remove_stdev_used(self, devices_list: Dict[str, Any]) -> Dict[str...
method _extract_dev_name (line 199) | def _extract_dev_name(device) -> str:
method _prepare_dev_schema (line 207) | def _prepare_dev_schema(self, devices_list) -> vol.Schema:
method _get_st_deviceid (line 215) | async def _get_st_deviceid(self, st_device_label="") -> str:
method _try_connect (line 233) | async def _try_connect(self, *, port=None, token=None, skip_info=False...
method _get_api_key (line 250) | def _get_api_key(self) -> str | None:
method async_step_user (line 258) | async def async_step_user(
method async_step_stdevice (line 325) | async def async_step_stdevice(
method async_step_stdeviceid (line 337) | async def async_step_stdeviceid(
method async_step_reconfigure (line 355) | async def async_step_reconfigure(
method _manage_result (line 404) | async def _manage_result(self, result: str, is_user_step=False) -> Con...
method _manage_reconfigure (line 427) | def _manage_reconfigure(self, result: str) -> ConfigFlowResult:
method _save_entry (line 452) | def _save_entry(self) -> ConfigFlowResult:
method _get_init_schema (line 488) | def _get_init_schema(self) -> vol.Schema:
method _get_reconfigure_schema (line 519) | def _get_reconfigure_schema(self) -> vol.Schema:
method _show_form (line 555) | def _show_form(
method async_get_options_flow (line 579) | def async_get_options_flow(config_entry) -> OptionsFlowHandler:
class OptionsFlowHandler (line 584) | class OptionsFlowHandler(OptionsFlow):
method __init__ (line 587) | def __init__(self, config_entry: ConfigEntry) -> None:
method _save_entry (line 610) | def _save_entry(self, data) -> ConfigFlowResult:
method async_step_init (line 624) | async def async_step_init(self, user_input=None) -> ConfigFlowResult:
method _async_option_form (line 635) | def _async_option_form(self):
method async_step_menu (line 699) | async def async_step_menu(self, _=None):
method async_step_save_exit (line 714) | async def async_step_save_exit(self, _) -> ConfigFlowResult:
method async_step_source_list (line 718) | async def async_step_source_list(self, user_input=None):
method async_step_app_list (line 739) | async def async_step_app_list(self, user_input=None) -> ConfigFlowResult:
method async_step_channel_list (line 756) | async def async_step_channel_list(self, user_input=None) -> ConfigFlow...
method async_step_sync_ent (line 777) | async def async_step_sync_ent(self, user_input=None) -> ConfigFlowResult:
method _async_sync_ent_form (line 785) | def _async_sync_ent_form(self) -> ConfigFlowResult:
method async_step_adv_opt (line 810) | async def async_step_adv_opt(self, user_input=None) -> ConfigFlowResult:
method _async_adv_opt_form (line 818) | def _async_adv_opt_form(self) -> ConfigFlowResult:
function _validate_options (line 861) | def _validate_options(options: dict) -> dict:
function _validate_tv_list (line 875) | def _validate_tv_list(input_list: dict[str, Any]) -> dict[str, str] | None:
function _dict_to_select (line 889) | def _dict_to_select(opt_dict: dict) -> SelectSelectorConfig:
function _async_get_domains_service (line 897) | def _async_get_domains_service(hass: HomeAssistant, service_name: str) -...
function _async_get_entry_entities (line 906) | def _async_get_entry_entities(hass: HomeAssistant, entry_id: str) -> lis...
FILE: custom_components/samsungtv_smart/const.py
class AppLoadMethod (line 6) | class AppLoadMethod(Enum):
class AppLaunchMethod (line 14) | class AppLaunchMethod(Enum):
class PowerOnMethod (line 22) | class PowerOnMethod(Enum):
FILE: custom_components/samsungtv_smart/diagnostics.py
function async_get_config_entry_diagnostics (line 16) | async def async_get_config_entry_diagnostics(
function _async_device_ha_info (line 35) | def _async_device_ha_info(hass: HomeAssistant, device_id: str) -> dict |...
FILE: custom_components/samsungtv_smart/entity.py
class SamsungTVEntity (line 22) | class SamsungTVEntity(Entity):
method __init__ (line 27) | def __init__(self, config: dict[str, Any], entry_id: str) -> None:
FILE: custom_components/samsungtv_smart/logo.py
class LogoOption (line 22) | class LogoOption(Enum):
class LocalImageUrl (line 61) | class LocalImageUrl:
method __init__ (line 64) | def __init__(self, custom_logo_path=None):
method get_image_url (line 70) | def get_image_url(self, media_title, local_logo_file=None):
class Logo (line 108) | class Logo:
method __init__ (line 114) | def __init__(
method set_logo_color (line 138) | def set_logo_color(self, logo_type: LogoOption):
method check_requested (line 152) | def check_requested(self):
method _async_ensure_latest_path_file (line 165) | async def _async_ensure_latest_path_file(self):
method _download_latest_path_file (line 202) | async def _download_latest_path_file(self):
method _read_path_file (line 238) | async def _read_path_file(self, force_read=False):
method _add_to_cache (line 268) | def _add_to_cache(self, media_title, logo_path=LOGO_NO_MATCH):
method async_find_match (line 274) | async def async_find_match(self, media_title):
function _levenshtein_ratio (line 360) | def _levenshtein_ratio(s: str, t: str):
FILE: custom_components/samsungtv_smart/media_player.py
function async_setup_entry (line 161) | async def async_setup_entry(
function _get_default_app_info (line 208) | def _get_default_app_info(app_id):
class ArtModeSupport (line 224) | class ArtModeSupport(Enum):
class SamsungTVDevice (line 232) | class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity):
method __init__ (line 238) | def __init__(
method _update_smartthing_token (line 371) | def _update_smartthing_token(
method async_added_to_hass (line 390) | async def async_added_to_hass(self):
method async_will_remove_from_hass (line 408) | async def async_will_remove_from_hass(self):
method _split_app_list (line 414) | def _split_app_list(app_list: dict[str, str]) -> list[dict[str, str]]:
method _load_tv_lists (line 442) | def _load_tv_lists(self, first_load=False):
method _update_config_options (line 468) | def _update_config_options(self, first_load=False):
method _status_changed_callback (line 479) | def _status_changed_callback(self):
method _get_option (line 484) | def _get_option(self, param, default=None):
method _get_device_spec (line 491) | def _get_device_spec(self, key: str) -> Any | None:
method _power_off_in_progress (line 497) | def _power_off_in_progress(self):
method _update_volume_info (line 504) | async def _update_volume_info(self):
method _get_external_entity_status (line 518) | def _get_external_entity_status(self):
method _check_status (line 524) | async def _check_status(self):
method _get_running_app (line 553) | def _get_running_app(self):
method _get_st_sources (line 571) | def _get_st_sources(self):
method _gen_installed_app_list (line 623) | def _gen_installed_app_list(self):
method _get_source (line 677) | def _get_source(self):
method _smartthings_keys (line 702) | async def _smartthings_keys(self, source_key: str):
method _log_st_error (line 747) | def _log_st_error(self, st_error: bool):
method _async_load_device_info (line 778) | async def _async_load_device_info(
method _async_st_update (line 796) | async def _async_st_update(self, **kwargs) -> bool | None:
method async_update (line 813) | async def async_update(self):
method send_command (line 870) | def send_command(
method async_send_command (line 949) | async def async_send_command(
method _update_media (line 961) | async def _update_media(self):
method _get_new_media_title (line 1003) | def _get_new_media_title(self):
method _local_media_image (line 1033) | async def _local_media_image(self, media_title):
method supported_features (line 1048) | def supported_features(self) -> int:
method extra_state_attributes (line 1058) | def extra_state_attributes(self):
method media_channel (line 1075) | def media_channel(self):
method media_content_type (line 1084) | def media_content_type(self):
method app_id (line 1095) | def app_id(self):
method state (line 1110) | def state(self):
method source_list (line 1123) | def source_list(self):
method channel_list (line 1140) | def channel_list(self):
method source (line 1147) | def source(self):
method sound_mode (line 1152) | def sound_mode(self):
method sound_mode_list (line 1159) | def sound_mode_list(self):
method support_art_mode (line 1166) | def support_art_mode(self) -> ArtModeSupport:
method _send_wol_packet (line 1174) | def _send_wol_packet(self, wol_repeat=None):
method _async_power_on (line 1203) | async def _async_power_on(self, set_art_mode=False):
method _async_turn_on (line 1242) | async def _async_turn_on(self, set_art_mode=False):
method async_turn_on (line 1254) | async def async_turn_on(self):
method async_set_art_mode (line 1258) | async def async_set_art_mode(self):
method _turn_off (line 1268) | def _turn_off(self):
method async_turn_off (line 1290) | async def async_turn_off(self):
method async_toggle (line 1296) | async def async_toggle(self):
method async_volume_up (line 1307) | async def async_volume_up(self):
method async_volume_down (line 1315) | async def async_volume_down(self):
method async_mute_volume (line 1323) | async def async_mute_volume(self, mute):
method async_set_volume_level (line 1333) | async def async_set_volume_level(self, volume):
method media_play_pause (line 1345) | def media_play_pause(self):
method media_play (line 1352) | def media_play(self):
method media_pause (line 1357) | def media_pause(self):
method media_stop (line 1362) | def media_stop(self):
method media_next_track (line 1367) | def media_next_track(self):
method media_previous_track (line 1374) | def media_previous_track(self):
method _async_send_keys (line 1381) | async def _async_send_keys(self, source_key):
method _async_set_channel_source (line 1413) | async def _async_set_channel_source(self, channel_source=None):
method _async_set_channel (line 1437) | async def _async_set_channel(self, channel):
method _async_launch_app (line 1471) | async def _async_launch_app(self, app_data, meta_data=None):
method _get_youtube_app_id (line 1502) | def _get_youtube_app_id(self):
method _get_youtube_video_id (line 1520) | def _get_youtube_video_id(self, url):
method _cast_youtube_video (line 1547) | def _cast_youtube_video(self, video_id: str, enqueue: MediaPlayerEnque...
method _async_play_youtube_video (line 1562) | async def _async_play_youtube_video(
method async_play_media (line 1579) | async def async_play_media(
method async_browse_media (line 1645) | async def async_browse_media(self, media_content_type=None, media_cont...
method async_select_source (line 1649) | async def async_select_source(self, source):
method _async_select_source_delayed (line 1681) | async def _async_select_source_delayed(self, source):
method async_select_sound_mode (line 1690) | async def async_select_sound_mode(self, sound_mode):
method async_select_picture_mode (line 1696) | async def async_select_picture_mode(self, picture_mode):
method _async_switch_entity (line 1702) | async def _async_switch_entity(self, power_on: bool):
function _async_call_service (line 1729) | async def _async_call_service(
FILE: custom_components/samsungtv_smart/remote.py
function async_setup_entry (line 40) | async def async_setup_entry(
class SamsungTVRemote (line 66) | class SamsungTVRemote(SamsungTVEntity, RemoteEntity):
method __init__ (line 72) | def __init__(self, config: dict[str, Any], entry_id: str, mp_entity_id...
method _async_call_service (line 77) | async def _async_call_service(
method async_turn_off (line 103) | async def async_turn_off(self, **kwargs: Any) -> None:
method async_turn_on (line 108) | async def async_turn_on(self, **kwargs: Any) -> None:
method async_send_command (line 113) | async def async_send_command(self, command: Iterable[str], **kwargs: A...
FILE: tests/conftest.py
function auto_enable_custom_integrations (line 28) | def auto_enable_custom_integrations(enable_custom_integrations):
function skip_notifications_fixture (line 36) | def skip_notifications_fixture():
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (417K chars).
[
{
"path": ".devcontainer/devcontainer.json",
"chars": 1348,
"preview": "{\n \"name\": \"SamsungTV Smart Integration\",\n \"dockerFile\": \"../Dockerfile.dev\",\n \"postCreateCommand\": \"scripts/setup\",\n"
},
{
"path": ".dockerignore",
"chars": 185,
"preview": "# General files\n.git\n.github\nconfig\ndocs\n\n# Development\n.devcontainer\n.vscode\n\n# Test related files\ntests\n\n# Other virtu"
},
{
"path": ".gitattributes",
"chars": 301,
"preview": "# Ensure Docker script files uses LF to support Docker for Windows.\n# Ensure \"git config --global core.autocrlf input\" b"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 684,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 608,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: Feature Request\nassignees: ''\n\n---\n\n"
},
{
"path": ".github/workflows/hassfest.yaml",
"chars": 302,
"preview": "name: Validate with Hassfest\n\non:\n push:\n branches:\n - master\n\n pull_request:\n branches: [\"*\"]\n\n schedule:"
},
{
"path": ".github/workflows/linting.yaml",
"chars": 556,
"preview": "name: Linting\n\non:\n push:\n branches:\n - master\n\n pull_request:\n branches: [\"*\"]\n\njobs:\n lint:\n runs-on:"
},
{
"path": ".github/workflows/release.yml",
"chars": 682,
"preview": "name: \"Release\"\n\non:\n release:\n types:\n - \"published\"\n\npermissions: {}\n\njobs:\n release:\n name: \"Release\"\n "
},
{
"path": ".github/workflows/stale.yaml",
"chars": 1396,
"preview": "# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.\n#\n# You c"
},
{
"path": ".github/workflows/validate.yaml",
"chars": 352,
"preview": "name: Validate with Hacs\n\non:\n push:\n branches:\n - master\n\n pull_request:\n branches: [\"*\"]\n\n schedule:\n "
},
{
"path": ".gitignore",
"chars": 210,
"preview": "# artifacts\n__pycache__\n.pytest*\n.cache\n*.egg-info\n*/build/*\n*/dist/*\n\n# pycharm\n.idea/\n\n# Unit test / coverage reports\n"
},
{
"path": ".prettierignore",
"chars": 57,
"preview": "*.md\n.strict-typing\nazure-*.yml\ndocs/source/_templates/*\n"
},
{
"path": ".pylintrc",
"chars": 1561,
"preview": "[MESSAGES CONTROL]\n# PyLint message control settings\n# Reasons disabled:\n# format - handled by black\n# locally-disabled "
},
{
"path": ".ruff.toml",
"chars": 1926,
"preview": "# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml\n\ntarget-version ="
},
{
"path": ".vscode/extensions.json",
"chars": 72,
"preview": "{\n \"recommendations\": [\"esbenp.prettier-vscode\", \"ms-python.python\"]\n}\n"
},
{
"path": ".vscode/launch.json",
"chars": 1012,
"preview": "{\n // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n \"version\": \"0.2.0\",\n \"config"
},
{
"path": ".vscode/settings.json",
"chars": 447,
"preview": "{\n //\"editor.formatOnSave\": true\n \"[python]\": {\n \"editor.defaultFormatter\": \"ms-python.black-formatter\",\n \"edito"
},
{
"path": ".vscode/tasks.json",
"chars": 1186,
"preview": "{\n \"version\": \"2.0.0\",\n \"tasks\": [\n {\n \"label\": \"Run Home Assistant on port 8123\",\n \""
},
{
"path": "Dockerfile.dev",
"chars": 1279,
"preview": "FROM mcr.microsoft.com/devcontainers/python:1-3.13\n\nSHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]\n\n# Uninstall pre-install"
},
{
"path": "LICENSE",
"chars": 11324,
"preview": "Apache License\n Version 2.0, January 2004\n http://www.apache.org/licens"
},
{
"path": "README.md",
"chars": 28320,
"preview": "[](https://github.com/ol"
},
{
"path": "config/configuration.yaml",
"chars": 301,
"preview": "# Loads default set of integrations. Do not remove.\ndefault_config:\n\n# Load frontend themes from the themes folder\nfront"
},
{
"path": "custom_components/__init__.py",
"chars": 32,
"preview": "\"\"\"Custom components module.\"\"\"\n"
},
{
"path": "custom_components/samsungtv_smart/__init__.py",
"chars": 22684,
"preview": "\"\"\"The samsungtv_smart integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport logging\nim"
},
{
"path": "custom_components/samsungtv_smart/api/__init__.py",
"chars": 41,
"preview": "\"\"\"SamsungTV Smart TV WS API library.\"\"\"\n"
},
{
"path": "custom_components/samsungtv_smart/api/samsungcast.py",
"chars": 2357,
"preview": "\"\"\"Smartthings TV integration cast tube.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport xml.etree.Element"
},
{
"path": "custom_components/samsungtv_smart/api/samsungws.py",
"chars": 43441,
"preview": "\"\"\"\nSamsungTVWS - Samsung Smart TV WS API wrapper\n\nCopyright (C) 2019 Xchwarze\nCopyright (C) 2020 Ollo69\n\n This libra"
},
{
"path": "custom_components/samsungtv_smart/api/shortcuts.py",
"chars": 2667,
"preview": "\"\"\"\nSamsungTVWS - Samsung Smart TV WS API wrapper\n\nCopyright (C) 2019 Xchwarze\n\n This library is free software; you c"
},
{
"path": "custom_components/samsungtv_smart/api/smartthings.py",
"chars": 18124,
"preview": "\"\"\"SmartThings TV integration.\"\"\"\n\nfrom __future__ import annotations\n\nfrom asyncio import TimeoutError as AsyncTimeoutE"
},
{
"path": "custom_components/samsungtv_smart/api/upnp.py",
"chars": 4241,
"preview": "\"\"\"Smartthings TV integration UPnP implementation.\"\"\"\n\nimport logging\nfrom typing import Optional\nimport xml.etree.Eleme"
},
{
"path": "custom_components/samsungtv_smart/config_flow.py",
"chars": 31768,
"preview": "\"\"\"Config flow for Samsung TV.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom numbers import Number\nimport "
},
{
"path": "custom_components/samsungtv_smart/const.py",
"chars": 4089,
"preview": "\"\"\"Constants for the samsungtv_smart integration.\"\"\"\n\nfrom enum import Enum\n\n\nclass AppLoadMethod(Enum):\n \"\"\"Valid ap"
},
{
"path": "custom_components/samsungtv_smart/diagnostics.py",
"chars": 3403,
"preview": "\"\"\"Diagnostics support for Samsung TV Smart.\"\"\"\n\nfrom __future__ import annotations\n\nfrom homeassistant.components.diagn"
},
{
"path": "custom_components/samsungtv_smart/entity.py",
"chars": 1523,
"preview": "\"\"\"Base SamsungTV Entity.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nfrom homeassistant.const impor"
},
{
"path": "custom_components/samsungtv_smart/logo.py",
"chars": 13622,
"preview": "\"\"\"Logo implementation for SamsungTV Smart.\"\"\"\n\nimport asyncio\nfrom datetime import datetime, timedelta, timezone\nfrom e"
},
{
"path": "custom_components/samsungtv_smart/logo_paths.json",
"chars": 75272,
"preview": "{\"fuji tv\":\"/yS5UJjsSdZXML0YikWTYYHLPKhQ.png\",\"abc\":\"/kMvN5R8g6L0SY5r9YZw9foYGQr0.png\",\"bbc three\":\"/ex369Frq6w0PaQsVofp"
},
{
"path": "custom_components/samsungtv_smart/manifest.json",
"chars": 571,
"preview": "{\n \"domain\": \"samsungtv_smart\",\n \"name\": \"SamsungTV Smart\",\n \"after_dependencies\": [\"smartthings\"],\n \"codeowners\": ["
},
{
"path": "custom_components/samsungtv_smart/media_player.py",
"chars": 62086,
"preview": "\"\"\"Support for interface with an Samsung TV.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom collections.abc"
},
{
"path": "custom_components/samsungtv_smart/remote.py",
"chars": 4108,
"preview": "\"\"\"Support for the SamsungTV remote.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterable\nfrom d"
},
{
"path": "custom_components/samsungtv_smart/services.yaml",
"chars": 914,
"preview": "select_picture_mode:\n name: Select Picture Mode\n description: Send to samsung TV the command to change picture mode.\n "
},
{
"path": "custom_components/samsungtv_smart/translations/en.json",
"chars": 5850,
"preview": "{\n \"config\": {\n \"abort\": {\n \"already_configured\": \"This Samsung TV is already configured.\",\n \"already_in_p"
},
{
"path": "custom_components/samsungtv_smart/translations/hu.json",
"chars": 5437,
"preview": "{\n \"config\": {\n \"abort\": {\n \"already_configured\": \"Ez a Samsung TV már konfigurálva van.\",\n \"already_in_pr"
},
{
"path": "custom_components/samsungtv_smart/translations/it.json",
"chars": 6349,
"preview": "{\n \"config\": {\n \"abort\": {\n \"already_configured\": \"Questo Samsung TV \\u00e8 gi\\u00e0 configurato.\",\n \"alre"
},
{
"path": "custom_components/samsungtv_smart/translations/pt-BR.json",
"chars": 3979,
"preview": "{\n \"config\": {\n \"abort\": {\n \"already_configured\": \"Essa TV Samsung já está configurada.\",\n \"already_in_pro"
},
{
"path": "docs/App_list.md",
"chars": 2732,
"preview": "# HomeAssistant - SamsungTV Smart Component\n\n***app_list guide***\n---------------\n\n**Note:** Although this is an optiona"
},
{
"path": "docs/Key_chaining.md",
"chars": 1634,
"preview": "# HomeAssistant - SamsungTV Smart Component\n\n***Key Chaining Patterns***\n---------------\n\n**Note:** If SmartThings API w"
},
{
"path": "docs/Key_codes.md",
"chars": 6892,
"preview": "# HomeAssistant - SamsungTV Smart Component\n\n***Key Codes***\n---------------\nIf [SmartThings API](https://github.com/oll"
},
{
"path": "docs/Smartthings.md",
"chars": 3980,
"preview": "# HomeAssistant - SamsungTV Smart Integration\n\n## ***Enable SmartThings*** - Setup instructions\n\n### SmartThings authent"
},
{
"path": "hacs.json",
"chars": 151,
"preview": "{\n \"name\": \"SamsungTV Smart\",\n \"content_in_root\": false,\n \"zip_release\": true,\n \"filename\": \"samsungtv_smart.zip\",\n "
},
{
"path": "info.md",
"chars": 4046,
"preview": "\n# HomeAssistant - SamsungTV Smart Component\n\nThis is a custom component to allow control of SamsungTV devices in [HomeA"
},
{
"path": "requirements.txt",
"chars": 239,
"preview": "# Home Assistant Core\ncolorlog==6.8.2\nhomeassistant==2025.6.3\npip>=21.3.1,<=24.3.2\nruff==0.0.261\npre-commit==3.0.0\nflake"
},
{
"path": "requirements_test.txt",
"chars": 256,
"preview": "# Strictly for tests\npytest==8.3.5\n#pytest-cov==2.9.0\n#pytest-homeassistant\npytest-homeassistant-custom-component==0.13."
},
{
"path": "script/integration_init",
"chars": 157,
"preview": "#!/usr/bin/env bash\n\n# Create empty init in custom components directory\necho \"Init custom components directory\"\ntouch \"$"
},
{
"path": "scripts/develop",
"chars": 609,
"preview": "#!/usr/bin/env bash\n\nset -e\n\ncd \"$(dirname \"$0\")/..\"\n\n# Create config dir if not present\nif [[ ! -d \"${PWD}/config\" ]]; "
},
{
"path": "scripts/lint",
"chars": 73,
"preview": "#!/usr/bin/env bash\n\nset -e\n\ncd \"$(dirname \"$0\")/..\"\n\nruff check . --fix\n"
},
{
"path": "scripts/setup",
"chars": 108,
"preview": "#!/usr/bin/env bash\n\nset -e\n\ncd \"$(dirname \"$0\")/..\"\n\npython3 -m pip install --requirement requirements.txt\n"
},
{
"path": "setup.cfg",
"chars": 1251,
"preview": "[coverage:run]\nsource =\n custom_components\n\n[coverage:report]\nexclude_lines =\n pragma: no cover\n raise NotImpleme"
},
{
"path": "tests/__init__.py",
"chars": 31,
"preview": "\"\"\"custom integation tests.\"\"\"\n"
},
{
"path": "tests/conftest.py",
"chars": 1912,
"preview": "\"\"\"Global fixtures for integration_blueprint integration.\"\"\"\n\n# Fixtures allow you to replace functions with a Mock obje"
}
]
About this extraction
This page contains the full source code of the ollo69/ha-samsungtv-smart GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (381.6 KB), approximately 113.6k tokens, and a symbol index with 358 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.