Compare commits

..

48 Commits

Author SHA1 Message Date
Paul Bottein 3b56c87d4c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-22 19:09:31 +02:00
Paul Bottein 3e892e3748 Catch token error 2026-05-22 18:57:28 +02:00
Paul Bottein adda8978ca Fix discover string 2026-05-22 18:53:00 +02:00
Paul Bottein befecb3d40 Improve error string 2026-05-22 18:51:51 +02:00
Paul Bottein 84fd027082 Fix discovery 2026-05-22 18:49:57 +02:00
Paul Bottein 97710425db Add discovery 2026-05-22 18:37:04 +02:00
Paul Bottein aa62d1dff8 Fix media not playing test 2026-05-22 18:16:09 +02:00
Paul Bottein ba4a67f503 Move comment 2026-05-22 18:06:49 +02:00
Paul Bottein ce135ccafa Improve mock 2026-05-22 18:03:39 +02:00
Paul Bottein b3a07fb123 Remove media player play 2026-05-22 17:55:40 +02:00
Paul Bottein d0138679ce Remove reauth and improve tests 2026-05-22 17:53:04 +02:00
Paul Bottein 14defc4486 Remove generated requirements_test_all.txt content 2026-05-22 11:07:38 +02:00
Paul Bottein f8d8daa136 Media player as unavailable 2026-05-22 10:43:55 +02:00
Paul Bottein 2d8781ef9d Use fixtures 2026-05-22 10:26:20 +02:00
Paul Bottein 416a3b2c56 Bump quality scale to silver 2026-05-22 10:12:50 +02:00
Paul Bottein 8bae4774d7 Bump API to 3.1.0 2026-05-22 10:09:33 +02:00
Paul Bottein 74fba71ff4 Check media id format 2026-05-22 10:01:26 +02:00
Paul Bottein 7e8c889c26 Remove status calls 2026-05-22 10:01:26 +02:00
Paul Bottein 49bf5b86be Bump requirements 2026-05-22 10:01:26 +02:00
Paul Bottein 9bcebd2918 Clean up 2026-05-22 10:01:26 +02:00
Paul Bottein 7104ee5f8d Improve test naming 2026-05-22 10:01:26 +02:00
Paul Bottein bff7d0ef35 Improve coverage 2026-05-22 10:01:26 +02:00
Paul Bottein 2d71439385 Migrate Yoto integration to async client 2026-05-22 10:01:26 +02:00
Paul Bottein 95bcfe464f Continue integration 2026-05-22 10:01:26 +02:00
Paul Bottein fd4b7e4adf Bump lib version 2026-05-22 10:01:26 +02:00
Paul Bottein fd8a99140f WIP: Add yoto integration 2026-05-22 10:01:25 +02:00
dependabot[bot] 1ef3301253 Bump github/codeql-action from 4.35.4 to 4.35.5 (#171813)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 09:47:16 +02:00
Manu 525952f016 Add entity translations to System Bridge integration (#171807) 2026-05-22 09:00:54 +02:00
Shay Levy 3257275c5a Fix LG webOS TV hardcoded exception strings (#171777) 2026-05-22 08:28:19 +02:00
Max Michels cb54fd4921 Replace duplicate constants with homeassistant.const imports (#171809) 2026-05-22 07:57:08 +02:00
Max Michels b391fc61ea Replace duplicate constants with homeassistant.const imports (#171808) 2026-05-22 07:56:29 +02:00
J. Nick Koston fcd4e4939c Bump habluetooth to 6.2.0 (#171800) 2026-05-21 23:08:17 -05:00
J. Nick Koston deb8b5da05 Parallelize pytest --collect-only in split_tests.py (#171772) 2026-05-21 22:58:01 -04:00
g4bri3lDev c7754a6ce9 Bump py-opendisplay to 7.2.3 (#171775) 2026-05-21 22:52:36 -04:00
J. Nick Koston 242724bd50 Bump aiodiscover to 3.2.3 (#171803) 2026-05-21 22:51:54 -04:00
Max Michels 42454563db Replace duplicate constants with homeassistant.const imports (#171790)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-21 22:51:34 -04:00
J. Nick Koston bf03d0c216 Bump dbus-fast to 5.0.3 (#171595) 2026-05-21 21:11:35 -05:00
Max Michels 568107e06b Replace duplicate constants with homeassistant.const imports (#171784) 2026-05-22 01:33:48 +03:00
Jens Timmerman 7da44428b6 Bump guntamatic to v1.9.0 (#171631) 2026-05-21 22:55:29 +01:00
Max Michels 0a27f31949 Replace duplicate constants with homeassistant.const imports (#171781) 2026-05-21 22:53:07 +01:00
Erwin Douna 905b868c82 Add recreate services to Portainer (#167225)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2026-05-21 22:52:07 +01:00
Max Michels 3187289913 Replace duplicate constants with homeassistant.const imports (#171776) 2026-05-22 00:18:54 +03:00
Max Michels 87cecd4a44 Replace duplicate constants with homeassistant.const imports (#171778) 2026-05-22 00:18:23 +03:00
Robert Svensson fed38b0e38 Replace duplicate ATTR_LOCKED constant with homeassistant.const import in deconz (#171779) 2026-05-22 00:17:22 +03:00
Raphael Hehl 6a36d1260b Bump uiprotect to 10.5.0 (#171768)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 15:42:31 -05:00
Raphael Hehl 49fc1b413d Bump pydantic to 2.13.4 (#171763)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 14:42:06 -05:00
Abílio Costa bffb0417cc Instruct agents to run prek after doing changes (#171757) 2026-05-21 20:16:26 +01:00
G Johansson 8b8c687fc3 Remove not needed exception handling in dnsip (#171758) 2026-05-21 20:58:32 +02:00
82 changed files with 4581 additions and 402 deletions
@@ -1,52 +0,0 @@
name: Cache and install APT packages
description: >-
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
needs. Removes the conflicting Microsoft apt source before any apt run, and
points the dynamic linker at the host's multiarch lib subdirectories so
shared libraries that rely on update-alternatives or postinst-managed paths
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
action does not execute postinst scripts on cache restore.
inputs:
packages:
description: Space-delimited list of apt packages to install.
required: true
version:
description: Cache version. Bump to invalidate the cache.
required: false
default: "1"
execute_install_scripts:
description: >-
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
actually cached by the upstream action, so this is largely a no-op today.
required: false
default: "false"
runs:
using: composite
steps:
- name: Remove conflicting Microsoft apt source
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
execute_install_scripts: ${{ inputs.execute_install_scripts }}
- name: Refresh dynamic linker cache
shell: bash
run: |
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
# cache restore, so update-alternatives symlinks (eg the one libblas
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
# Add every /usr/lib/<multiarch> subdirectory that holds shared
# libraries to the ldconfig search path so the dynamic linker still
# finds them. Use dpkg-architecture to derive the host's multiarch
# tuple so this works on non-x86_64 runners too.
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
-name '*.so.*' -printf '%h\n' \
| sort -u \
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
sudo ldconfig
@@ -1,42 +0,0 @@
name: Set up uv and managed Python
description: >-
Pins uv (avoids the raw.githubusercontent.com manifest fetch on cache miss)
and proactively installs the requested Python so cached venvs created with
`uv venv` resolve their interpreter symlinks in jobs that only restore the
venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the
interpreter until uv first uses it, so jobs that just activate the venv
blow up with broken symlinks on cache hit.
inputs:
python-version:
description: The Python version uv should install and use.
required: true
uv-version:
description: The uv version setup-uv should install.
required: true
outputs:
python-version:
description: The Python version uv reports as installed.
value: ${{ steps.uv.outputs.python-version }}
runs:
using: composite
steps:
- name: Set up uv
id: uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
# Persist astral's managed Python across jobs so 'uv venv' below is
# fast on the second job onwards.
cache-python: true
# Lint-only and codegen jobs touch no Python deps, so the post-step
# cache save would otherwise abort the job.
ignore-nothing-to-cache: true
- name: Install Python interpreter
shell: bash
env:
PYTHON_VERSION: ${{ inputs.python-version }}
run: uv python install "${PYTHON_VERSION}"
+1
View File
@@ -25,6 +25,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+241 -133
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 4
CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
@@ -60,7 +60,9 @@ env:
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_VERSION: 1
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -84,13 +86,12 @@ jobs:
core: ${{ steps.core.outputs.changes }}
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
python_versions: ${{ steps.info.outputs.python_versions }}
default_python: ${{ steps.info.outputs.default_python }}
uv_version: ${{ steps.info.outputs.uv_version }}
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
test_group_count: ${{ steps.info.outputs.test_group_count }}
test_groups: ${{ steps.info.outputs.test_groups }}
@@ -115,6 +116,10 @@ jobs:
# Include HA_SHORT_VERSION to force the immediate creation
# of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: core
@@ -237,11 +242,6 @@ jobs:
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
echo "python_versions: ${all_python_versions}"
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
echo "default_python: ${default_python}"
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
echo "uv_version: ${uv_version}"
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
echo "test_full_suite: ${test_full_suite}"
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
echo "integrations_glob: ${integrations_glob}"
@@ -351,12 +351,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up uv and Python ${{ matrix.python-version }}
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -384,41 +384,80 @@ jobs:
path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
- name: Install additional OS dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
timeout-minutes: 10
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
mkdir -p ${APT_CACHE_DIR}
mkdir -p ${APT_LIST_CACHE_DIR}
fi
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
libavformat-dev \
libavutil-dev \
libswresample-dev \
libswscale-dev \
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
env:
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
run: |
uv venv venv --python "${PYTHON_VERSION}"
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt
uv pip install -r requirements_all.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
- name: Dump pip freeze
run: |
python -m venv venv
. venv/bin/activate
python --version
uv pip freeze >> pip_freeze.txt
@@ -467,22 +506,36 @@ jobs:
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -516,10 +569,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -552,10 +605,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Run gen_copilot_instructions.py
run: |
python -m script.gen_copilot_instructions validate
@@ -607,10 +660,10 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -658,10 +711,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -711,10 +764,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -762,10 +815,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
@@ -823,26 +876,38 @@ jobs:
- info
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -887,27 +952,39 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1028,28 +1105,40 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libmariadb-dev-compat \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libmariadb-dev-compat
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1177,35 +1266,42 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up PostgreSQL apt repository
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
- name: Cache PostgreSQL development headers
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: postgresql-server-dev-14
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1353,27 +1449,39 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:python"
+1
View File
@@ -15,6 +15,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
Generated
+2
View File
@@ -2056,6 +2056,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.0",
"habluetooth==6.1.0"
"dbus-fast==5.0.3",
"habluetooth==6.2.0"
]
}
+2 -2
View File
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .const import ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
-2
View File
@@ -43,8 +43,6 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
ATTR_VALVE = "valve"
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.0",
"aiodiscover==3.2.3",
"cached-ipaddress==1.0.1"
]
}
+1 -6
View File
@@ -6,7 +6,6 @@ import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
@@ -78,11 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
) from err
errors = [
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
result for result in results if isinstance(result, (TimeoutError, DNSError))
]
if errors and len(errors) == len(results):
await _close_resolvers()
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["guntamatic==1.8.0"]
"requirements": ["guntamatic==1.9.0"]
}
+2 -1
View File
@@ -4,12 +4,13 @@ from dataclasses import dataclass
from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .application_credentials import authorization_server_context
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
+2 -8
View File
@@ -13,7 +13,7 @@ from yarl import URL
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -24,13 +24,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
-2
View File
@@ -2,8 +2,6 @@
DOMAIN = "mcp"
# pylint: disable-next=home-assistant-duplicate-const
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"
+1 -5
View File
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__)
STREAMABLE_API = "/api/mcp"
TIMEOUT = 60 # Seconds
# Content types
# pylint: disable-next=home-assistant-duplicate-const
CONTENT_TYPE_JSON = "application/json"
# Legacy SSE endpoint
SSE_API = f"/{DOMAIN}/sse"
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
@@ -15,5 +15,5 @@
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==5.9.0"]
"requirements": ["py-opendisplay==7.2.3"]
}
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
pil_image,
refresh_mode=refresh_mode,
dither_mode=dither_mode,
tone_compression=tone_compression,
tone=tone_compression,
fit=fit_mode,
rotate=rotation,
)
@@ -118,6 +118,9 @@
"services": {
"prune_images": {
"service": "mdi:delete-sweep"
},
"recreate_container": {
"service": "mdi:restart"
}
}
}
@@ -20,6 +20,9 @@ from .coordinator import PortainerConfigEntry
ATTR_DATE_UNTIL = "until"
ATTR_DANGLING = "dangling"
ATTR_TIMEOUT = "timeout"
ATTR_PULL_IMAGE = "pull_image"
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
SERVICE_PRUNE_IMAGES = "prune_images"
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
@@ -32,6 +35,17 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
},
)
SERVICE_RECREATE_CONTAINER = "recreate_container"
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
vol.Optional(ATTR_TIMEOUT): vol.All(
cv.time_period, vol.Range(min=timedelta(minutes=1))
),
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
}
)
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
"""Extract config entry from the service call."""
@@ -75,6 +89,45 @@ async def _get_endpoint_id(
return endpoint_data.endpoint.id
async def _get_container_and_endpoint_ids(
call: ServiceCall,
) -> tuple[PortainerConfigEntry, int, str]:
"""Get config entry, endpoint ID and container ID from the container device ID."""
device_reg = dr.async_get(call.hass)
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
config_entry: PortainerConfigEntry | None = None
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
if loaded_entry.entry_id in device.config_entries:
config_entry = loaded_entry
break
if config_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
coordinator = config_entry.runtime_data
for data in coordinator.data.values():
for container_name, container_data in data.containers.items():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
) in device.identifiers:
return config_entry, data.endpoint.id, container_data.container.id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
async def prune_images(call: ServiceCall) -> None:
"""Prune unused images in Portainer, with more controls."""
config_entry = await _extract_config_entry(call)
@@ -104,6 +157,40 @@ async def prune_images(call: ServiceCall) -> None:
) from err
async def recreate_container(call: ServiceCall) -> None:
"""Recreate a container in Portainer, with more controls."""
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
call
)
coordinator = config_entry.runtime_data
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
try:
await coordinator.portainer.container_recreate(
endpoint_id=endpoint_id,
container_id=container_id,
**({"timeout": timeout} if timeout is not None else {}),
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
await coordinator.async_request_refresh()
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
@@ -113,3 +200,10 @@ async def async_setup_services(hass: HomeAssistant) -> None:
prune_images,
SERVICE_PRUNE_IMAGES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
recreate_container,
SERVICE_RECREATE_CONTAINER_SCHEMA,
)
@@ -16,3 +16,20 @@ prune_images:
required: false
selector:
boolean: {}
recreate_container:
fields:
container_device_id:
required: true
selector:
device:
integration: portainer
model: Container
timeout:
required: false
selector:
duration:
pull_image:
required: false
selector:
boolean:
@@ -235,6 +235,24 @@
}
},
"name": "Prune unused images"
},
"recreate_container": {
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
"fields": {
"container_device_id": {
"description": "The container to recreate.",
"name": "Container"
},
"pull_image": {
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
"name": "Pull image"
},
"timeout": {
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
"name": "Timeout"
}
},
"name": "Recreate container"
}
},
"system_health": {
+1 -2
View File
@@ -15,6 +15,7 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.const import (
ATTR_MODEL,
CONF_MAC,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
@@ -30,8 +31,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
ATTR_DEVICE = "device"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
BLE_TEMP_HANDLE = 0x24
BLE_TEMP_UUID = "0000ff92-0000-1000-8000-00805f9b34fb"
+2 -2
View File
@@ -1,5 +1,7 @@
"""Define constants for the SleepIQ component."""
from homeassistant.const import PRESSURE
DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "sleepiq"
@@ -11,8 +13,6 @@ FIRMNESS = "firmness"
ICON_EMPTY = "mdi:bed-empty"
ICON_OCCUPIED = "mdi:bed"
IS_IN_BED = "is_in_bed"
# pylint: disable-next=home-assistant-duplicate-const
PRESSURE = "pressure"
SLEEP_NUMBER = "sleep_number"
FOOT_WARMING_TIMER = "foot_warming_timer"
FOOT_WARMER = "foot_warmer"
+1 -2
View File
@@ -11,14 +11,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTime
from homeassistant.const import PRESSURE, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
HEART_RATE,
HRV,
PRESSURE,
RESPIRATORY_RATE,
SLEEP_DURATION,
SLEEP_NUMBER,
+1 -2
View File
@@ -7,6 +7,7 @@ import smarttub
import voluptuous as vol
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -19,8 +20,6 @@ from .entity import SmartTubOnboardSensorBase
# the desired duration, in hours, of the cycle
ATTR_DURATION = "duration"
ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
# the hour of the day at which to start the cycle (0-23)
ATTR_START_HOUR = "start_hour"
-4
View File
@@ -38,12 +38,8 @@ PLATFORMS = [
Platform.SENSOR,
]
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_LOCK = "lock"
SERVICE_REMOTE_START = "remote_start"
SERVICE_REMOTE_STOP = "remote_stop"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_UNLOCK = "unlock"
SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door"
ATTR_DOOR = "door"
@@ -4,9 +4,10 @@ import logging
from subarulink.exceptions import SubaruException
from homeassistant.const import SERVICE_UNLOCK
from homeassistant.exceptions import HomeAssistantError
from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN
from .const import SERVICE_REMOTE_START, VEHICLE_NAME, VEHICLE_VIN
_LOGGER = logging.getLogger(__name__)
@@ -7,14 +7,13 @@ from surepy.enums import Location
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
import voluptuous as vol
from homeassistant.const import Platform
from homeassistant.const import ATTR_LOCATION, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -18,7 +18,5 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW
SERVICE_SET_LOCK_STATE = "set_lock_state"
SERVICE_SET_PET_LOCATION = "set_pet_location"
ATTR_FLAP_ID = "flap_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_LOCK_STATE = "lock_state"
ATTR_PET_NAME = "pet_name"
@@ -8,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -16,7 +16,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_FLAP_ID,
ATTR_LOCATION,
ATTR_LOCK_STATE,
ATTR_PET_NAME,
DOMAIN,
@@ -17,15 +17,51 @@
"boot_time": {
"default": "mdi:av-timer"
},
"cpu_power_core": {
"default": "mdi:chip"
},
"cpu_power_package": {
"default": "mdi:chip"
},
"cpu_speed": {
"default": "mdi:speedometer"
},
"display_refresh_rate": {
"default": "mdi:monitor"
},
"display_resolution_x": {
"default": "mdi:monitor"
},
"display_resolution_y": {
"default": "mdi:monitor"
},
"displays_connected": {
"default": "mdi:monitor"
},
"gpu_core_clock_speed": {
"default": "mdi:speedometer"
},
"gpu_fan_speed": {
"default": "mdi:fan"
},
"gpu_memory_clock_speed": {
"default": "mdi:speedometer"
},
"gpu_memory_free": {
"default": "mdi:memory"
},
"gpu_memory_used": {
"default": "mdi:memory"
},
"gpu_memory_used_percentage": {
"default": "mdi:memory"
},
"gpu_power_usage": {
"default": "mdi:lightning-bolt"
},
"gpu_usage_percentage": {
"default": "mdi:percent"
},
"kernel": {
"default": "mdi:devices"
},
@@ -38,6 +74,9 @@
"memory_used": {
"default": "mdi:memory"
},
"memory_used_percentage": {
"default": "mdi:memory"
},
"os": {
"default": "mdi:devices"
},
@@ -47,6 +86,12 @@
"processes": {
"default": "mdi:counter"
},
"processes_load_cpu": {
"default": "mdi:percent"
},
"space_used": {
"default": "mdi:harddisk"
},
"version": {
"default": "mdi:counter"
},
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
@@ -284,10 +284,10 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
),
SystemBridgeSensorEntityDescription(
key="memory_used_percentage",
translation_key="memory_used_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:memory",
value=lambda data: data.memory.virtual.percent,
),
SystemBridgeSensorEntityDescription(
@@ -380,11 +380,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"filesystem_{partition.mount_point.replace(':', '')}",
name=f"{partition.mount_point} space used",
translation_key="space_used",
translation_placeholders={"partition": partition.mount_point},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:harddisk",
value=(
lambda data, dk=index_device, pk=index_partition: (
partition_usage(data, dk, pk)
@@ -427,10 +427,10 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_resolution_x",
name=f"Display {display.id} resolution x",
translation_key="display_resolution_x",
translation_placeholders={"display_id": display.id},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PIXELS,
icon="mdi:monitor",
value=lambda data, k=index: display_resolution_horizontal(
data, k
),
@@ -441,10 +441,10 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_resolution_y",
name=f"Display {display.id} resolution y",
translation_key="display_resolution_y",
translation_placeholders={"display_id": display.id},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PIXELS,
icon="mdi:monitor",
value=lambda data, k=index: display_resolution_vertical(
data, k
),
@@ -455,12 +455,12 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"display_{display.id}_refresh_rate",
name=f"Display {display.id} refresh rate",
translation_key="display_refresh_rate",
translation_placeholders={"display_id": display.id},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:monitor",
value=lambda data, k=index: display_refresh_rate(data, k),
),
entry.data[CONF_PORT],
@@ -474,13 +474,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_core_clock_speed",
name=f"{gpu.name} clock speed",
translation_key="gpu_core_clock_speed",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_core_clock_speed(data, k),
),
entry.data[CONF_PORT],
@@ -489,13 +489,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_clock_speed",
name=f"{gpu.name} memory clock speed",
translation_key="gpu_memory_clock_speed",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_memory_clock_speed(data, k),
),
entry.data[CONF_PORT],
@@ -504,12 +504,12 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_free",
name=f"{gpu.name} memory free",
translation_key="gpu_memory_free",
translation_placeholders={"gpu_name": gpu.name},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_free(data, k),
),
entry.data[CONF_PORT],
@@ -518,11 +518,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_used_percentage",
name=f"{gpu.name} memory used %",
translation_key="gpu_memory_used_percentage",
translation_placeholders={"gpu_name": gpu.name},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used_percentage(data, k),
),
entry.data[CONF_PORT],
@@ -531,13 +531,13 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_memory_used",
name=f"{gpu.name} memory used",
translation_key="gpu_memory_used",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used(data, k),
),
entry.data[CONF_PORT],
@@ -546,11 +546,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_fan_speed",
name=f"{gpu.name} fan speed",
translation_key="gpu_fan_speed",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
icon="mdi:fan",
value=lambda data, k=index: gpu_fan_speed(data, k),
),
entry.data[CONF_PORT],
@@ -559,7 +559,8 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_power_usage",
name=f"{gpu.name} power usage",
translation_key="gpu_power_usage",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@@ -571,7 +572,8 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_temperature",
name=f"{gpu.name} temperature",
translation_key="gpu_temperature",
translation_placeholders={"gpu_name": gpu.name},
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
@@ -585,11 +587,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"gpu_{gpu.id}_usage_percentage",
name=f"{gpu.name} usage %",
translation_key="gpu_usage_percentage",
translation_placeholders={"gpu_name": gpu.name},
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
icon="mdi:percent",
value=lambda data, k=index: gpu_usage_percentage(data, k),
),
entry.data[CONF_PORT],
@@ -605,11 +607,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"processes_load_cpu_{cpu.id}",
name=f"Load CPU {cpu.id}",
translation_key="processes_load_cpu",
translation_placeholders={"cpu_id": str(cpu.id)},
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:percent",
suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k),
),
@@ -619,11 +621,11 @@ async def async_setup_entry(
coordinator,
SystemBridgeSensorEntityDescription(
key=f"cpu_power_core_{cpu.id}",
name=f"CPU Core {cpu.id} Power",
translation_key="cpu_power_core",
translation_placeholders={"cpu_id": str(cpu.id)},
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:chip",
suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k),
),
@@ -653,8 +655,6 @@ class SystemBridgeSensor(SystemBridgeEntity, SensorEntity):
description.key,
)
self.entity_description = description
if description.name is not UNDEFINED:
self._attr_has_entity_name = False
@property
def native_value(self) -> StateType:
@@ -54,6 +54,9 @@
"boot_time": {
"name": "Boot time"
},
"cpu_power_core": {
"name": "CPU core {cpu_id} power"
},
"cpu_power_package": {
"name": "CPU package power"
},
@@ -66,9 +69,45 @@
"cpu_voltage": {
"name": "CPU voltage"
},
"display_refresh_rate": {
"name": "Display {display_id} refresh rate"
},
"display_resolution_x": {
"name": "Display {display_id} resolution x"
},
"display_resolution_y": {
"name": "Display {display_id} resolution y"
},
"displays_connected": {
"name": "Displays connected"
},
"gpu_core_clock_speed": {
"name": "{gpu_name} clock speed"
},
"gpu_fan_speed": {
"name": "{gpu_name} fan speed"
},
"gpu_memory_clock_speed": {
"name": "{gpu_name} memory clock speed"
},
"gpu_memory_free": {
"name": "{gpu_name} memory free"
},
"gpu_memory_used": {
"name": "{gpu_name} memory used"
},
"gpu_memory_used_percentage": {
"name": "{gpu_name} memory used %"
},
"gpu_power_usage": {
"name": "{gpu_name} power usage"
},
"gpu_temperature": {
"name": "{gpu_name} temperature"
},
"gpu_usage_percentage": {
"name": "{gpu_name} usage %"
},
"kernel": {
"name": "Kernel"
},
@@ -81,6 +120,9 @@
"memory_used": {
"name": "Memory used"
},
"memory_used_percentage": {
"name": "Memory used %"
},
"os": {
"name": "Operating system"
},
@@ -90,6 +132,12 @@
"processes": {
"name": "Processes"
},
"processes_load_cpu": {
"name": "Load CPU {cpu_id}"
},
"space_used": {
"name": "{partition} space used"
},
"version": {
"name": "Version"
},
@@ -32,6 +32,7 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
_attr_has_entity_name = True
_attr_title = "System Bridge"
_attr_name = None
def __init__(
self,
@@ -44,7 +45,6 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
api_port,
"update",
)
self._attr_name = coordinator.data.system.hostname
@property
def installed_version(self) -> str | None:
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.4.1"]
"requirements": ["uiprotect==10.5.0"]
}
+4 -2
View File
@@ -46,8 +46,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
try:
await client.connect()
except WebOsTvPairError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
# If pairing request accepted there will be no error
# Update the stored key without triggering reauth
@@ -6,6 +6,7 @@ from homeassistant.components.device_automation import (
DEVICE_TRIGGER_BASE_SCHEMA,
InvalidDeviceAutomationConfig,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -13,10 +14,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, trigger
from .helpers import (
async_get_client_by_device_entry,
async_get_device_entry_by_device_id,
)
from .helpers import async_get_device_entry_by_device_id
from .triggers.turn_on import (
PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE,
async_get_turn_on_trigger,
@@ -40,10 +38,31 @@ async def async_validate_trigger_config(
device_id = config[CONF_DEVICE_ID]
try:
device = async_get_device_entry_by_device_id(hass, device_id)
async_get_client_by_device_entry(hass, device)
except ValueError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise InvalidDeviceAutomationConfig(err) from err
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_not_valid",
translation_placeholders={"device_id": device_id},
) from err
for config_entry_id in device.config_entries:
if (
entry := hass.config_entries.async_get_entry(config_entry_id)
) and entry.domain == DOMAIN:
if entry.state is ConfigEntryState.LOADED:
break
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_config_entry_not_loaded",
translation_placeholders={"device_id": device.id},
)
else:
raise InvalidDeviceAutomationConfig(
translation_domain=DOMAIN,
translation_key="device_not_valid",
translation_placeholders={"device_id": device.id},
)
return config
+1 -26
View File
@@ -4,7 +4,7 @@ import logging
from aiowebostv import WebOsClient, WebOsTvState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -56,31 +56,6 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s
return entity_entry.device_id
@callback
def async_get_client_by_device_entry(
hass: HomeAssistant, device: DeviceEntry
) -> WebOsClient:
"""Get WebOsClient from Device Registry by device entry.
Raises ValueError if client is not found.
"""
for config_entry_id in device.config_entries:
entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry(
config_entry_id
)
if entry and entry.domain == DOMAIN:
if entry.state is ConfigEntryState.LOADED:
return entry.runtime_data
raise ValueError(
f"Device {device.id} is not from a loaded {DOMAIN} config entry"
)
raise ValueError(
f"Device {device.id} is not from an existing {DOMAIN} config entry"
)
def get_sources(tv_state: WebOsTvState) -> list[str]:
"""Construct sources list."""
sources = []
@@ -46,9 +46,18 @@
}
},
"exceptions": {
"auth_failed": {
"message": "Pairing failed, make sure to accept the pairing request on your TV."
},
"communication_error": {
"message": "Communication error while calling {func} for device {name}: {error}"
},
"device_config_entry_not_loaded": {
"message": "The LG webOS TV integration for device {device_id} is not loaded."
},
"device_not_valid": {
"message": "Device {device_id} is not a valid LG webOS TV device."
},
"device_off": {
"message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
},
+45
View File
@@ -0,0 +1,45 @@
"""The Yoto integration."""
import aiohttp
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Set up Yoto from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
raise ConfigEntryNotReady from err
coordinator = YotoDataUpdateCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Unload a Yoto config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,40 @@
"""Application credentials platform for the Yoto integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import YOTO_AUDIENCE, YOTO_SCOPES
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
async def async_get_auth_implementation(
hass: HomeAssistant,
auth_domain: str,
credential: ClientCredential,
) -> YotoOAuth2Implementation:
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
return YotoOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
AUTHORIZE_URL,
TOKEN_URL,
credential.client_secret,
)
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
@property
def extra_authorize_data(self) -> dict:
"""Append Yoto's audience and scopes to every authorize URL."""
return super().extra_authorize_data | {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
@@ -0,0 +1,35 @@
"""Config flow for the Yoto integration."""
import logging
from typing import Any
from yoto_api import YotoError, get_account_id
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import _LOGGER, DOMAIN
class YotoOAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Authorize Home Assistant with a Yoto account using OAuth2."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return the logger used for the OAuth2 flow."""
return _LOGGER
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Identify the Yoto account from the access token."""
try:
user_id = get_account_id(data["token"]["access_token"])
except YotoError:
return self.async_abort(reason="oauth_unauthorized")
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Yoto", data=data)
+26
View File
@@ -0,0 +1,26 @@
"""Constants for the Yoto integration."""
from datetime import timedelta
import logging
DOMAIN = "yoto"
_LOGGER = logging.getLogger(__package__)
YOTO_AUDIENCE = "https://api.yotoplay.com"
YOTO_SCOPES = [
"offline_access",
"family:view",
"family:devices:view",
"family:devices:control",
"family:devices:manage",
"family:library:view",
"user:content:view",
"user:icons:manage",
]
SCAN_INTERVAL = timedelta(minutes=5)
STATUS_PUSH_INTERVAL = timedelta(seconds=60)
MANUFACTURER = "Yoto"
@@ -0,0 +1,139 @@
"""Coordinator for the Yoto integration."""
from datetime import datetime
import aiohttp
from yoto_api import Token, YotoClient, YotoError, YotoPlayer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL
type YotoConfigEntry = ConfigEntry[YotoDataUpdateCoordinator]
class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
"""Coordinator that drives the Yoto cloud polling cycle."""
config_entry: YotoConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: YotoConfigEntry,
session: OAuth2Session,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self._session = session
self.client = YotoClient(session=async_get_clientsession(hass))
self._sync_token()
def _sync_token(self) -> None:
"""Sync the OAuth2 access token to the Yoto client."""
token = self._session.token
self.client.token = Token(
access_token=token[CONF_ACCESS_TOKEN],
refresh_token=token.get("refresh_token", ""),
token_type=token.get("token_type", "Bearer"),
valid_until=dt_util.utc_from_timestamp(token["expires_at"]),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.client.refresh()
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
await self._async_load_library()
try:
await self.client.connect_events(
list(self.client.players), self._mqtt_event
)
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
# The MQTT data/status topic is not pushed spontaneously; the firmware
# only emits it in response to a command/status/request publish.
self.config_entry.async_on_unload(
async_track_time_interval(
self.hass, self._async_status_push_tick, STATUS_PUSH_INTERVAL
)
)
async def _async_update_data(self) -> dict[str, YotoPlayer]:
"""Fetch fresh data from the Yoto cloud."""
# _async_setup already populated the client; skip the duplicate first fetch.
if self.data is None:
return self.client.players
try:
await self._session.async_ensure_token_valid()
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
self._sync_token()
try:
await self.client.refresh()
except YotoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
return self.client.players
async def _async_load_library(self) -> None:
"""Load the card library; failures only affect titles and artwork."""
try:
await self.client.update_library()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card library: %s", err)
async def _async_status_push_tick(self, _now: datetime) -> None:
"""Ask each player to push a fresh status snapshot over MQTT."""
if not self.client.is_mqtt_connected:
return
# Fire-and-forget: the data/status response lands via the on_update
# callback later, which already triggers async_set_updated_data.
for device_id in list(self.client.players):
await self.client.request_status_push(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
self.async_set_updated_data(self.client.players)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
await self.client.disconnect_events()
await super().async_shutdown()
+46
View File
@@ -0,0 +1,46 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import YotoDataUpdateCoordinator
class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
"""Base class for Yoto entities tied to a single player."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._player_id = player.id
device = player.device
mac = player.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, player.id)},
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
manufacturer=MANUFACTURER,
model=player.model,
model_id=device.device_type,
hw_version=device.generation,
name=player.name,
sw_version=player.info.firmware_version,
)
@property
def player(self) -> YotoPlayer:
"""Return the live player record from the client."""
return self.coordinator.data[self._player_id]
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
@@ -0,0 +1,14 @@
{
"domain": "yoto",
"name": "Yoto",
"codeowners": ["@cdnninja", "@piitaya"],
"config_flow": true,
"dependencies": ["application_credentials"],
"dhcp": [{ "hostname": "yoto-*" }],
"documentation": "https://www.home-assistant.io/integrations/yoto",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.1.0"]
}
@@ -0,0 +1,183 @@
"""Media player platform for the Yoto integration."""
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 0
# Yoto players expose 16 hardware volume steps.
VOLUME_STEP = 1 / 16
PLAYBACK_STATE_MAP = {
PlaybackStatus.PLAYING: MediaPlayerState.PLAYING,
PlaybackStatus.PAUSED: MediaPlayerState.PAUSED,
PlaybackStatus.STOPPED: MediaPlayerState.IDLE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto media player platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoMediaPlayer(coordinator, player)
for player in coordinator.client.players.values()
)
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
"""Representation of a Yoto Player."""
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_media_image_remotely_accessible = True
_attr_volume_step = VOLUME_STEP
_attr_supported_features = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
)
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.status.is_online)
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
return PLAYBACK_STATE_MAP.get(
self.player.last_event.playback_status, MediaPlayerState.IDLE
)
@property
def volume_level(self) -> float | None:
"""Return the current volume level."""
return self.player.last_event.volume_percentage
@property
def media_duration(self) -> int | None:
"""Return the current track duration in seconds."""
return self.player.last_event.track_length
@property
def media_position(self) -> int | None:
"""Return the current playback position in seconds."""
return self.player.last_event.position
@property
def media_position_updated_at(self) -> datetime | None:
"""Return the time the media position was last refreshed."""
return self.player.last_event_received_at
@property
def media_title(self) -> str | None:
"""Return the title of the currently playing track."""
event = self.player.last_event
return event.track_title or event.chapter_title
@property
def media_album_name(self) -> str | None:
"""Return the title of the active card."""
card = self._current_card()
return card.title if card else None
@property
def media_artist(self) -> str | None:
"""Return the author of the active card."""
card = self._current_card()
return card.author if card else None
@property
def media_image_url(self) -> str | None:
"""Return the cover image URL of the active card."""
card = self._current_card()
return card.cover_image_large if card else None
def _current_card(self) -> Card | None:
"""Return the cached library card for the currently active media."""
card_id = self.player.last_event.card_id
if not card_id:
return None
return self.coordinator.client.library.get(card_id)
async def async_media_play(self) -> None:
"""Resume playback."""
await self._async_run(self.coordinator.client.resume, self._player_id)
async def async_media_pause(self) -> None:
"""Pause playback."""
await self._async_run(self.coordinator.client.pause, self._player_id)
async def async_media_stop(self) -> None:
"""Stop playback."""
await self._async_run(self.coordinator.client.stop, self._player_id)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the playback volume (0.0 - 1.0)."""
await self._async_run(
self.coordinator.client.set_volume,
self._player_id,
round(volume * 100),
)
async def async_media_seek(self, position: float) -> None:
"""Seek to ``position`` seconds in the active track."""
await self._async_run(
self.coordinator.client.seek, self._player_id, int(position)
)
async def async_media_next_track(self) -> None:
"""Skip to the next track on the active card."""
await self._async_run(self.coordinator.client.next_track, self._player_id)
async def async_media_previous_track(self) -> None:
"""Skip to the previous track on the active card."""
await self._async_run(self.coordinator.client.previous_track, self._player_id)
async def _async_run(
self, func: Callable[..., Awaitable[Any]], /, *args: Any
) -> None:
"""Await a Yoto command and surface failures as HA errors."""
try:
await func(*args)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -0,0 +1,85 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register custom service actions.
appropriate-polling:
status: done
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Real-time updates are dispatched through the coordinator, not via per-entity event subscriptions.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register custom service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
entity-translations:
status: exempt
comment: The media_player uses the device name; no translatable strings yet.
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
repair-issues:
status: exempt
comment: No repair issues are raised yet.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,44 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"oauth_discovery": {
"description": "Home Assistant has found a Yoto player on your network. Press **Submit** to continue setting up Yoto."
},
"pick_implementation": {
"data": {
"implementation": "[%key:common::config_flow::data::implementation%]"
},
"data_description": {
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"command_failed": {
"message": "Yoto command failed: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_error": {
"message": "Error communicating with Yoto: {error}"
}
}
}
+1
View File
@@ -51,5 +51,6 @@ APPLICATION_CREDENTIALS = [
"xbox",
"yale",
"yolink",
"yoto",
"youtube",
]
+1
View File
@@ -860,6 +860,7 @@ FLOWS = {
"yardian",
"yeelight",
"yolink",
"yoto",
"youless",
"youtube",
"zamg",
+4
View File
@@ -1476,4 +1476,8 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "yeelight",
"hostname": "yeelink-*",
},
{
"domain": "yoto",
"hostname": "yoto-*",
},
]
@@ -8215,6 +8215,12 @@
"config_flow": true,
"iot_class": "cloud_push"
},
"yoto": {
"name": "Yoto",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"youless": {
"name": "YouLess",
"integration_type": "device",
+4 -4
View File
@@ -1,7 +1,7 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.1
aiodiscover==3.2.0
aiodiscover==3.2.3
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.1.1
@@ -30,12 +30,12 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
dbus-fast==5.0.0
dbus-fast==5.0.3
file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.1.0
habluetooth==6.2.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
@@ -133,7 +133,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.2
pydantic==2.13.4
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
+9 -6
View File
@@ -233,7 +233,7 @@ aiocomelit==2.0.3
aiodhcpwatcher==1.2.1
# homeassistant.components.dhcp
aiodiscover==3.2.0
aiodiscover==3.2.3
# homeassistant.components.dnsip
aiodns==4.0.4
@@ -794,7 +794,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==5.0.0
dbus-fast==5.0.3
# homeassistant.components.debugpy
debugpy==1.8.17
@@ -1183,7 +1183,7 @@ growattServer==2.1.0
gspread==5.5.0
# homeassistant.components.guntamatic
guntamatic==1.8.0
guntamatic==1.9.0
# homeassistant.components.profiler
guppy3==3.1.6
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.1.0
habluetooth==6.2.0
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1923,7 +1923,7 @@ py-nightscout==1.2.2
py-nymta==0.4.0
# homeassistant.components.opendisplay
py-opendisplay==5.9.0
py-opendisplay==7.2.3
# homeassistant.components.schluter
py-schluter==0.1.7
@@ -3224,7 +3224,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.4.1
uiprotect==10.5.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -3407,6 +3407,9 @@ yeelightsunflower==0.0.10
# homeassistant.components.yolink
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.1.0
# homeassistant.components.youless
youless-api==2.2.0
+1 -1
View File
@@ -18,7 +18,7 @@ license-expression==30.4.3
mock-open==1.4.0
mypy==2.1.0
prek==0.2.28
pydantic==2.13.2
pydantic==2.13.4
pylint==4.0.5
pylint-per-file-ignores==3.2.1
pipdeptree==2.26.1
+1 -1
View File
@@ -117,7 +117,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.2
pydantic==2.13.4
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
+75 -16
View File
@@ -2,13 +2,19 @@
"""Helper script to split test into n buckets."""
import argparse
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from math import ceil
import os
from pathlib import Path
import subprocess
import sys
from typing import Final
# tests/components has ~1000 sub-directories, which makes it the natural
# place to subdivide to keep each pytest invocation roughly equal in size.
_FAN_OUT_DIRS: Final = frozenset({"components"})
class Bucket:
"""Class to hold bucket."""
@@ -164,33 +170,86 @@ class TestFolder:
return result
def collect_tests(path: Path) -> TestFolder:
"""Collect all tests."""
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
"""Run pytest --collect-only on a batch of paths."""
result = subprocess.run(
["pytest", "--collect-only", "-qq", "-p", "no:warnings", path],
["pytest", "--collect-only", "-qq", "-p", "no:warnings", *map(str, paths)],
check=False,
capture_output=True,
text=True,
)
return result.stdout, result.stderr, result.returncode
if result.returncode != 0:
print("Failed to collect tests:")
print(result.stderr)
print(result.stdout)
def _iter_eligible_children(path: Path) -> list[Path]:
"""Return immediate children of ``path`` that pytest should collect.
Filters out hidden/dunder entries, non-``test_*.py`` files (so helper
modules like ``conftest.py`` and ``common.py`` are not passed as
explicit collection targets), and pycache-style directories.
"""
children: list[Path] = []
for entry in sorted(path.iterdir()):
if entry.name.startswith((".", "_")):
continue
if entry.is_dir() or (entry.suffix == ".py" and entry.name.startswith("test_")):
children.append(entry)
return children
def _enumerate_batch_paths(path: Path) -> list[Path]:
"""Return the child paths to run pytest --collect-only over.
Files are returned as-is. Directories are expanded one level deep, with
a second level of expansion for entries named in ``_FAN_OUT_DIRS`` so the
enormous ``tests/components`` tree fans out into per-integration paths.
"""
if path.is_file():
return [path]
paths: list[Path] = []
for entry in _iter_eligible_children(path):
if entry.is_dir() and entry.name in _FAN_OUT_DIRS:
paths.extend(_iter_eligible_children(entry))
else:
paths.append(entry)
return paths
def collect_tests(path: Path) -> TestFolder:
"""Collect all tests."""
batch_paths = _enumerate_batch_paths(path)
if not batch_paths:
print(f"No eligible test paths found under {path}")
sys.exit(1)
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
# Round-robin chunking keeps batches roughly balanced when path
# ordering correlates with test size.
batches = [batch_paths[i::workers] for i in range(workers)]
if workers == 1:
results = [_collect_batch(batches[0])]
else:
with ProcessPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(_collect_batch, batches))
folder = TestFolder(path)
for line in result.stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not path or not total_tests:
print(f"Unexpected line: {line}")
for stdout, stderr, returncode in results:
if returncode != 0:
print("Failed to collect tests:")
print(stderr)
print(stdout)
sys.exit(1)
for line in stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not file_path or not total_tests:
print(f"Unexpected line: {line}")
sys.exit(1)
file = TestFile(int(total_tests), Path(file_path))
folder.add_test_file(file)
file = TestFile(int(total_tests), Path(file_path))
folder.add_test_file(file)
return folder
@@ -182,8 +182,13 @@ async def test_diagnostics(
"scanners": [
{
"adapter": "hci0",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (00:00:00:00:00:01)",
@@ -202,6 +207,11 @@ async def test_diagnostics(
},
{
"adapter": "hci1",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"last_connect_completed_time": 0.0,
"discovered_devices_and_advertisement_data": [
{
"address": "44:44:33:11:23:45",
@@ -397,7 +407,12 @@ async def test_diagnostics_macos(
"scanners": [
{
"adapter": "Core Bluetooth",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"last_connect_completed_time": 0.0,
"discovered_devices_and_advertisement_data": [
{
"address": "44:44:33:11:23:45",
@@ -602,8 +617,13 @@ async def test_diagnostics_remote_adapter(
"scanners": [
{
"adapter": "hci0",
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (00:00:00:00:00:01)",
@@ -621,9 +641,14 @@ async def test_diagnostics_remote_adapter(
},
},
{
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"current_mode": None,
"requested_mode": None,
"last_connect_completed_time": 0.0,
"discovered_device_timestamps": {"44:44:33:11:23:45": ANY},
"discovered_devices_and_advertisement_data": [
{
+5 -3
View File
@@ -138,8 +138,10 @@ async def test_setup_and_stop_passive(
await hass.async_block_till_done()
assert init_kwargs == {
"adapter": "hci0",
"bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
"bluez": {
**scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
"adapter": "hci0",
},
"scanning_mode": "passive",
}
@@ -188,7 +190,7 @@ async def test_setup_and_stop_old_bluez(
await hass.async_block_till_done()
assert init_kwargs == {
"adapter": "hci0",
"bluez": {"adapter": "hci0"},
"scanning_mode": "active",
}
-4
View File
@@ -1,10 +1,8 @@
"""Test for DNS IP integration Init."""
import asyncio
from unittest.mock import patch
from aiodns.error import DNSError
from pycares import AresError
import pytest
from homeassistant.components.dnsip.const import (
@@ -180,8 +178,6 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
[
TimeoutError(),
DNSError(),
AresError(),
asyncio.CancelledError(),
],
)
async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
@@ -81,11 +81,16 @@ async def test_diagnostics_with_bluetooth(
"connections_free": 0,
"connections_limit": 0,
"scanner": {
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": True,
"current_mode": None,
"requested_mode": None,
"discovered_device_timestamps": {},
"discovered_devices_and_advertisement_data": [],
"last_connect_completed_time": 0.0,
"last_detection": ANY,
"monotonic_time": ANY,
"name": "test (AA:BB:CC:DD:EE:FC)",
+1 -2
View File
@@ -13,13 +13,12 @@ from homeassistant.components.application_credentials import (
async_import_client_credential,
)
from homeassistant.components.mcp.const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
+3
View File
@@ -31,6 +31,8 @@ MOCK_TEST_CONFIG = {
TEST_ENTRY = "portainer_test_entry_123"
TEST_INSTANCE_ID = "299ab403-70a8-4c05-92f7-bf7a994d50df"
TEST_CONTAINER_NAME = "practical_morse"
TEST_CONTAINER_ID = "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf"
@pytest.fixture
@@ -93,6 +95,7 @@ def mock_portainer_client() -> Generator[AsyncMock]:
client.stop_container = AsyncMock(return_value=None)
client.start_stack = AsyncMock(return_value=None)
client.stop_stack = AsyncMock(return_value=None)
client.container_recreate = AsyncMock(return_value=None)
yield client
+139 -4
View File
@@ -13,9 +13,13 @@ from voluptuous import MultipleInvalid
from homeassistant.components.portainer.const import DOMAIN
from homeassistant.components.portainer.services import (
ATTR_CONTAINER_DEVICE_ID,
ATTR_DANGLING,
ATTR_DATE_UNTIL,
ATTR_PULL_IMAGE,
ATTR_TIMEOUT,
SERVICE_PRUNE_IMAGES,
SERVICE_RECREATE_CONTAINER,
)
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
@@ -23,13 +27,17 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceRegistry
from . import setup_integration
from .conftest import TEST_ENTRY
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY
from tests.common import MockConfigEntry
TEST_ENDPOINT_ID = 1
TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}"
TEST_CONTAINER_DEVICE_IDENTIFIER = (
f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}_{TEST_CONTAINER_NAME}"
)
async def test_services(
hass: HomeAssistant,
@@ -102,6 +110,99 @@ async def test_service_prune_images(
)
@pytest.mark.parametrize(
("call_arguments", "extra_expected_kwargs"),
[
({}, {"pull_image": False}),
(
{ATTR_TIMEOUT: timedelta(minutes=10)},
{"pull_image": False, "timeout": timedelta(minutes=10)},
),
(
{ATTR_TIMEOUT: timedelta(minutes=12), ATTR_PULL_IMAGE: True},
{"pull_image": True, "timeout": timedelta(minutes=12)},
),
],
ids=["no optional", "with duration", "with duration and pull_image"],
)
async def test_service_recreate_container(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
call_arguments: dict,
extra_expected_kwargs: dict,
) -> None:
"""Test recreate container service with the variants."""
await setup_integration(hass, mock_config_entry)
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{
ATTR_CONTAINER_DEVICE_ID: container.id,
**call_arguments,
},
blocking=True,
)
mock_portainer_client.container_recreate.assert_called_once_with(
endpoint_id=TEST_ENDPOINT_ID,
container_id=TEST_CONTAINER_ID,
**extra_expected_kwargs,
)
@pytest.mark.parametrize(
("exception", "translation_key"),
[
(
PortainerAuthenticationError("auth"),
"invalid_auth_no_details",
),
(
PortainerConnectionError("conn"),
"cannot_connect_no_details",
),
(
PortainerTimeoutError("timeout"),
"timeout_connect_no_details",
),
],
)
async def test_service_recreate_container_portainer_exceptions(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: PortainerAuthenticationError
| PortainerConnectionError
| PortainerTimeoutError,
translation_key: str,
) -> None:
"""Test recreate container service handles Portainer exceptions."""
await setup_integration(hass, mock_config_entry)
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
mock_portainer_client.container_recreate.side_effect = exception
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: container.id},
blocking=True,
)
assert err.value.translation_key == translation_key
mock_portainer_client.container_recreate.assert_called_once()
async def test_service_validation_errors(
hass: HomeAssistant,
device_registry: DeviceRegistry,
@@ -115,8 +216,11 @@ async def test_service_validation_errors(
identifiers={(DOMAIN, TEST_DEVICE_IDENTIFIER)}
)
assert device is not None
container = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
)
assert container is not None
# Test missing device_id
with pytest.raises(MultipleInvalid, match="required key not provided"):
await hass.services.async_call(
DOMAIN,
@@ -126,7 +230,6 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
# Test invalid until (too short, needs to be at least 1 minute)
with pytest.raises(MultipleInvalid, match="value must be at least"):
await hass.services.async_call(
DOMAIN,
@@ -136,7 +239,6 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
# Test invalid device
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
@@ -146,6 +248,39 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: "invalid_device_id"},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
other_entry = MockConfigEntry(domain="well_no_portainer_for_sure")
other_entry.add_to_hass(hass)
non_portainer_device = device_registry.async_get_or_create(
config_entry_id=other_entry.entry_id,
identifiers={("well_no_portainer_for_sure", "some_identifier")},
)
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: non_portainer_device.id},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_RECREATE_CONTAINER,
{ATTR_CONTAINER_DEVICE_ID: device.id},
blocking=True,
)
mock_portainer_client.container_recreate.assert_not_called()
@pytest.mark.parametrize(
("exception", "message"),
@@ -113,6 +113,10 @@ async def test_rpc_config_entry_diagnostics(
"entry": entry_dict | {"discovery_keys": {}},
"bluetooth": {
"scanner": {
"connect_completed_total": 0,
"connect_failed_total": 0,
"connect_failures": {},
"connect_in_progress": {},
"connectable": False,
"current_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
@@ -122,6 +126,7 @@ async def test_rpc_config_entry_diagnostics(
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
},
"last_connect_completed_time": 0.0,
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
"discovered_devices_and_advertisement_data": [
{
+2 -7
View File
@@ -6,15 +6,10 @@ from asyncsleepiq import (
SleepIQTimeoutException,
)
from homeassistant.components.sleepiq.const import (
DOMAIN,
IS_IN_BED,
PRESSURE,
SLEEP_NUMBER,
)
from homeassistant.components.sleepiq.const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
from homeassistant.components.sleepiq.coordinator import UPDATE_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_USERNAME
from homeassistant.const import CONF_USERNAME, PRESSURE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
@@ -0,0 +1,203 @@
# serializer version: 1
# name: test_binary_sensor_platform[binary_sensor.hostname_camera_in_use-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.hostname_camera_in_use',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Camera in use',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Camera in use',
'platform': 'system_bridge',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'camera_in_use',
'unique_id': 'hostname_camera_in_use',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_camera_in_use-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'hostname Camera in use',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.hostname_camera_in_use',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.hostname_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
'original_icon': None,
'original_name': 'Charging',
'platform': 'system_bridge',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'hostname_battery_is_charging',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery_charging',
'friendly_name': 'hostname Charging',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.hostname_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_pending_reboot-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.hostname_pending_reboot',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Pending reboot',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Pending reboot',
'platform': 'system_bridge',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pending_reboot',
'unique_id': 'hostname_pending_reboot',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_pending_reboot-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'hostname Pending reboot',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.hostname_pending_reboot',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_update-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.hostname_update',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Update',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.UPDATE: 'update'>,
'original_icon': None,
'original_name': 'Update',
'platform': 'system_bridge',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'hostname_version_available',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_platform[binary_sensor.hostname_update-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'update',
'friendly_name': 'hostname Update',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.hostname_update',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_update_platform[update.hostname-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'update.hostname',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'system_bridge',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'hostname_update',
'unit_of_measurement': None,
})
# ---
# name: test_update_platform[update.hostname-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': '/api/brands/integration/system_bridge/icon.png',
'friendly_name': 'hostname',
'in_progress': False,
'installed_version': '1.0.0',
'latest_version': '4.99.0',
'release_summary': None,
'release_url': 'https://github.com/timmo001/system-bridge/releases/tag/4.99.0',
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': 'System Bridge',
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.hostname',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
@@ -0,0 +1,43 @@
"""Tests for the System Bridge binary sensor platform."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def binary_sensor_only() -> Generator[None]:
"""Enable only the binary sensor platform."""
with patch(
"homeassistant.components.system_bridge.PLATFORMS",
[Platform.BINARY_SENSOR],
):
yield
@pytest.mark.usefixtures(
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
)
async def test_binary_sensor_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup of the binary sensor platform."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -0,0 +1,44 @@
"""Tests for the System Bridge sensor platform."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def sensor_only() -> Generator[None]:
"""Enable only the sensor platform."""
with patch(
"homeassistant.components.system_bridge.PLATFORMS",
[Platform.SENSOR],
):
yield
@pytest.mark.usefixtures(
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
)
@pytest.mark.freeze_time("1970-01-01 00:00:00")
async def test_sensor_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup of the sensor platform."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -0,0 +1,43 @@
"""Tests for the System Bridge update platform."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def update_only() -> Generator[None]:
"""Enable only the update platform."""
with patch(
"homeassistant.components.system_bridge.PLATFORMS",
[Platform.UPDATE],
):
yield
@pytest.mark.usefixtures(
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
)
async def test_update_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup of the update platform."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -117,7 +117,7 @@ async def test_invalid_trigger_raises(
)
# Test invalid device id
with pytest.raises(InvalidDeviceAutomationConfig):
with pytest.raises(InvalidDeviceAutomationConfig) as exc_info:
await device_trigger.async_validate_trigger_config(
hass,
{
@@ -127,13 +127,23 @@ async def test_invalid_trigger_raises(
"device_id": "invalid_device_id",
},
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "device_not_valid"
@pytest.mark.parametrize(
("domain", "entry_state"),
("domain", "entry_state", "expected_translation_key"),
[
(DOMAIN, ConfigEntryState.NOT_LOADED),
("fake", ConfigEntryState.LOADED),
(
DOMAIN,
ConfigEntryState.NOT_LOADED,
"device_config_entry_not_loaded",
),
(
"fake",
ConfigEntryState.LOADED,
"device_not_valid",
),
],
)
async def test_invalid_entry_raises(
@@ -142,6 +152,7 @@ async def test_invalid_entry_raises(
client,
domain: str,
entry_state: ConfigEntryState,
expected_translation_key: str,
) -> None:
"""Test device id not loaded or from another domain raises."""
await setup_webostv(hass)
@@ -162,5 +173,7 @@ async def test_invalid_entry_raises(
}
# Test that device id from non webostv domain raises exception
with pytest.raises(InvalidDeviceAutomationConfig):
with pytest.raises(InvalidDeviceAutomationConfig) as exc_info:
await device_trigger.async_validate_trigger_config(hass, config)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == expected_translation_key
+12
View File
@@ -0,0 +1,12 @@
"""Tests for the Yoto integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the Yoto integration for testing."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+151
View File
@@ -0,0 +1,151 @@
"""Fixtures for the Yoto integration tests."""
from collections.abc import Generator
from datetime import UTC, datetime
import time
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from yoto_api import (
Card,
Device,
PlaybackEvent,
PlaybackStatus,
PlayerInfo,
PlayerStatus,
YotoPlayer,
)
from homeassistant.components.application_credentials import (
DOMAIN as APPLICATION_CREDENTIALS_DOMAIN,
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.yoto.const import DOMAIN, YOTO_SCOPES
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
USER_ID = "auth0|user-test"
PLAYER_ID = "player-test"
CARD_ID = "card-test"
SCOPES = " ".join(YOTO_SCOPES)
ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sha256")
def _build_card() -> Card:
"""Build a representative Yoto library card."""
return Card(
id=CARD_ID,
title="Outer Space",
author="Ladybird Audio Adventures",
cover_image_large="https://example.test/cover.jpg",
)
def _build_player() -> YotoPlayer:
"""Build a representative Yoto player for tests."""
now = datetime(2026, 5, 8, 12, 0, tzinfo=UTC)
player = YotoPlayer(
device=Device(
device_id=PLAYER_ID,
name="Nursery Yoto",
device_type="v3",
device_family="v3",
generation="gen3",
),
devices_refreshed_at=now,
info_refreshed_at=now,
last_event_received_at=now,
)
player.info = PlayerInfo(
device_id=PLAYER_ID,
firmware_version="v2.17.5",
mac="aa:bb:cc:dd:ee:ff",
)
player.status = PlayerStatus(device_id=PLAYER_ID, is_online=True)
player.last_event = PlaybackEvent(
player_id=PLAYER_ID,
playback_status=PlaybackStatus.PLAYING,
volume=8,
volume_max=16,
track_length=300,
position=120,
card_id=CARD_ID,
chapter_key="01",
chapter_title="Chapter 1",
track_key="01-INT",
track_title="Introduction",
)
return player
@pytest.fixture
def mock_token_hex() -> Generator[MagicMock]:
"""Pin the access token used for proxy URLs to keep snapshots stable."""
with patch("secrets.token_hex", return_value="abcdef") as token:
yield token
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Bypass the integration setup so the config flow can be tested in isolation."""
with patch(
"homeassistant.components.yoto.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_yoto_client() -> Generator[MagicMock]:
"""Patch YotoClient used by the runtime to a configurable mock."""
with patch(
"homeassistant.components.yoto.coordinator.YotoClient", autospec=True
) as client_class:
client = client_class.return_value
client.players = {PLAYER_ID: _build_player()}
client.library = {CARD_ID: _build_card()}
client.token = MagicMock(refresh_token="mock-refresh-token")
yield client
@pytest.fixture(name="expires_at")
def mock_expires_at() -> float:
"""Fixture to set the OAuth token expiration time."""
return time.time() + 3600
@pytest.fixture
def mock_config_entry(expires_at: float) -> MockConfigEntry:
"""Return a Yoto OAuth2 config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Yoto",
unique_id=USER_ID,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": ACCESS_TOKEN,
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"expires_in": 3600,
"token_type": "Bearer",
"scope": SCOPES,
},
},
entry_id="01J5TX5A0FF6G5V0QJX6HBC94T",
)
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Register fake OAuth2 client credentials for the Yoto integration."""
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("CLIENT_ID", "CLIENT_SECRET"),
DOMAIN,
)
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_entity_state[media_player.nursery_yoto-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.nursery_yoto',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': None,
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 21559>,
'translation_key': None,
'unique_id': 'player-test',
'unit_of_measurement': None,
})
# ---
# name: test_entity_state[media_player.nursery_yoto-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'entity_picture': 'https://example.test/cover.jpg',
'entity_picture_local': '/api/media_player_proxy/media_player.nursery_yoto?token=abcdef&cache=1cbba102718cbf3f',
'friendly_name': 'Nursery Yoto',
'media_album_name': 'Outer Space',
'media_artist': 'Ladybird Audio Adventures',
'media_duration': 300,
'media_position': 120,
'media_position_updated_at': datetime.datetime(2026, 5, 8, 12, 0, tzinfo=datetime.timezone.utc),
'media_title': 'Introduction',
'supported_features': <MediaPlayerEntityFeature: 21559>,
'volume_level': 0.5,
}),
'context': <ANY>,
'entity_id': 'media_player.nursery_yoto',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
+175
View File
@@ -0,0 +1,175 @@
"""Tests for the Yoto config flow."""
from http import HTTPStatus
from urllib.parse import parse_qs, urlparse
import jwt
import pytest
from homeassistant.components.yoto.const import DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import ACCESS_TOKEN, USER_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
REDIRECT_URI = "https://example.com/auth/external/callback"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
async def _initiate_user_flow(hass: HomeAssistant) -> dict:
"""Start the OAuth2 user flow and return the EXTERNAL_STEP result."""
return await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
async def _complete_callback(
hass: HomeAssistant,
result: dict,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
*,
refresh_token: str = "mock-refresh-token",
access_token: str = ACCESS_TOKEN,
) -> dict:
"""Drive the OAuth2 callback through the token exchange."""
state = config_entry_oauth2_flow._encode_jwt(
hass,
{"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI},
)
client = await hass_client_no_auth()
response = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert response.status == HTTPStatus.OK
aioclient_mock.clear_requests()
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": refresh_token,
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
},
)
return result
async def test_abort_if_no_credentials(hass: HomeAssistant) -> None:
"""The flow aborts when no application credentials are configured."""
result = await _initiate_user_flow(hass)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_credentials"
@pytest.mark.usefixtures(
"current_request_with_host", "setup_credentials", "mock_setup_entry"
)
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Walk a happy-path OAuth2 flow end to end."""
result = await _initiate_user_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
parsed = urlparse(result["url"])
query = {key: value[0] for key, value in parse_qs(parsed.query).items()}
assert parsed.scheme == "https"
assert parsed.netloc == "login.yotoplay.com"
assert parsed.path == "/authorize"
assert query["audience"] == YOTO_AUDIENCE
assert query["scope"] == " ".join(YOTO_SCOPES)
assert query["client_id"] == "CLIENT_ID"
assert query["redirect_uri"] == REDIRECT_URI
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Yoto"
assert result["result"].unique_id == USER_ID
assert result["data"]["auth_implementation"] == DOMAIN
assert result["data"]["token"]["access_token"] == ACCESS_TOKEN
@pytest.mark.usefixtures(
"current_request_with_host", "setup_credentials", "mock_setup_entry"
)
async def test_dhcp_discovery_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""A Yoto player found on the LAN walks through OAuth to a new entry."""
discovery = DhcpServiceInfo(
ip="10.0.0.42",
hostname="yoto-player",
macaddress="6825dd39c3fc",
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_DHCP}, data=discovery
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "oauth_discovery"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.EXTERNAL_STEP
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == USER_ID
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_already_configured(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Re-authorizing the same account aborts as already configured."""
mock_config_entry.add_to_hass(hass)
result = await _initiate_user_flow(hass)
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"access_token",
[
"not-a-jwt",
jwt.encode({"foo": "bar"}, "test-secret-long-enough-for-hmac-sha256"),
],
)
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_invalid_access_token(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
) -> None:
"""The flow aborts when the access token is not a usable JWT."""
result = await _initiate_user_flow(hass)
await _complete_callback(
hass, result, hass_client_no_auth, aioclient_mock, access_token=access_token
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_unauthorized"
+237
View File
@@ -0,0 +1,237 @@
"""Tests for the Yoto integration setup."""
from unittest.mock import MagicMock, Mock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import pytest
from yoto_api import YotoAPIError, YotoError
from homeassistant.components.yoto.const import (
DOMAIN,
SCAN_INTERVAL,
STATUS_PUSH_INTERVAL,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import OAuth2TokenRequestError
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = pytest.mark.usefixtures("setup_credentials")
async def test_setup_unload(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""The integration loads and unloads cleanly."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_yoto_client.disconnect_events.assert_called_once()
async def test_setup_retries_on_api_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A non-auth API failure surfaces as a setup retry."""
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_mqtt_event_updates_entity(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""An MQTT event published by the broker refreshes the entity state."""
await setup_integration(hass, mock_config_entry)
state_before = hass.states.get("media_player.nursery_yoto")
assert state_before is not None
# connect_events(device_ids, on_update) — invoke the registered on_update callback
on_update = mock_yoto_client.connect_events.call_args.args[1]
player = next(iter(mock_yoto_client.players.values()))
player.last_event.volume = 12
on_update(player)
await hass.async_block_till_done()
state_after = hass.states.get("media_player.nursery_yoto")
assert state_after is not None
assert state_after.attributes["volume_level"] == 12 / 16
assert state_after.last_updated > state_before.last_updated
async def test_status_push_tick(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""The status-push timer publishes a request every 60 s."""
mock_yoto_client.is_mqtt_connected = True
await setup_integration(hass, mock_config_entry)
mock_yoto_client.request_status_push.reset_mock()
freezer.tick(STATUS_PUSH_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.request_status_push.assert_called_once_with("player-test")
async def test_status_push_skipped_when_mqtt_disconnected(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""The status-push timer is a no-op while MQTT is reconnecting."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.request_status_push.reset_mock()
mock_yoto_client.is_mqtt_connected = False
freezer.tick(STATUS_PUSH_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.request_status_push.assert_not_called()
async def test_periodic_poll_refreshes_players(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""The coordinator refreshes the player list on every tick."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.refresh.reset_mock()
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.refresh.assert_called_once()
async def test_setup_retries_when_implementation_missing(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Missing OAuth2 implementation defers setup as not-ready."""
with patch(
"homeassistant.components.yoto.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError("gone"),
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"side_effect",
[
aiohttp.ClientError("boom"),
OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN),
],
)
async def test_setup_retries_on_token_validation_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""A failure refreshing the OAuth token defers setup."""
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=side_effect,
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_retries_when_mqtt_unavailable(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""MQTT connect failure surfaces as a setup retry."""
mock_yoto_client.connect_events.side_effect = YotoError("mqtt down")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_succeeds_without_card_library(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A library load failure doesn't block setup; titles and artwork stay empty."""
mock_yoto_client.update_library.side_effect = YotoError("library down")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
"side_effect",
[
aiohttp.ClientError("boom"),
OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN),
],
)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_periodic_poll_fails_on_token_validation_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
side_effect: Exception,
) -> None:
"""A failure refreshing the OAuth token marks the coordinator failed."""
await setup_integration(hass, mock_config_entry)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=side_effect,
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
coordinator = mock_config_entry.runtime_data
assert coordinator.last_update_success is False
async def test_periodic_poll_fails_on_api_error(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""A non-auth API error during periodic refresh marks the coordinator failed."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
coordinator = mock_config_entry.runtime_data
assert coordinator.last_update_success is False
+181
View File
@@ -0,0 +1,181 @@
"""Tests for the Yoto media player platform."""
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.media_player import (
ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_VOLUME_LEVEL,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_SEEK,
SERVICE_MEDIA_STOP,
SERVICE_VOLUME_SET,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "media_player.nursery_yoto"
pytestmark = pytest.mark.usefixtures("setup_credentials")
@pytest.mark.usefixtures("mock_token_hex", "mock_yoto_client")
async def test_entity_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
freezer: FrozenDateTimeFactory,
) -> None:
"""Snapshot the media player entity state."""
freezer.move_to("2026-05-08T12:00:00+00:00")
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "method"),
[
(SERVICE_MEDIA_PLAY, "resume"),
(SERVICE_MEDIA_PAUSE, "pause"),
(SERVICE_MEDIA_STOP, "stop"),
(SERVICE_MEDIA_NEXT_TRACK, "next_track"),
(SERVICE_MEDIA_PREVIOUS_TRACK, "previous_track"),
],
)
async def test_playback_commands(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
service: str,
method: str,
) -> None:
"""Playback service calls reach the client."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
getattr(mock_yoto_client, method).assert_called_once_with("player-test")
async def test_set_volume(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Volume is forwarded as an integer 0-100."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
blocking=True,
)
mock_yoto_client.set_volume.assert_called_once_with("player-test", 50)
async def test_seek(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Seek delegates to the client with the integer position."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_SEEK,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SEEK_POSITION: 30},
blocking=True,
)
mock_yoto_client.seek.assert_called_once_with("player-test", 30)
async def test_state_unavailable_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""When the player reports offline the entity is unavailable."""
player = next(iter(mock_yoto_client.players.values()))
player.status.is_online = False
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_no_card_metadata_when_card_id_missing(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Card metadata properties return None when no card is active."""
player = next(iter(mock_yoto_client.players.values()))
player.last_event.card_id = None
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert "media_album_name" not in state.attributes
assert "media_artist" not in state.attributes
assert "entity_picture" not in state.attributes
async def test_state_idle_before_first_event(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A freshly-online player with no playback event yet reports IDLE."""
player = next(iter(mock_yoto_client.players.values()))
player.last_event.playback_status = None
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == "idle"
async def test_command_error_raises(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Yoto command failures surface as HomeAssistantError."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.pause.side_effect = YotoError("nope")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PAUSE,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)