Compare commits

..

11 Commits

Author SHA1 Message Date
J. Nick Koston 51cddb88f5 Merge remote-tracking branch 'upstream/dev' into ci-uv-managed-python
# Conflicts:
#	.github/workflows/ci.yaml
2026-05-26 18:24:40 -05:00
J. Nick Koston 748a9842af Merge branch 'ci-cache-postgres-mariadb-deps' into ci-uv-managed-python 2026-05-21 14:33:44 -05:00
J. Nick Koston 55786dbdfc Use dpkg-architecture to derive multiarch lib path
So the ldconfig workaround also works on non-x86_64 runners.
2026-05-21 14:32:58 -05:00
J. Nick Koston e88c03a437 Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 13:37:03 -05:00
J. Nick Koston dbc0dc1ea6 Install Python eagerly via setup-uv-python composite
setup-uv only sets UV_PYTHON, it does not actually fetch the
interpreter; uv installs it lazily on the first 'uv venv' or
'uv pip install'. When the venv cache hits, no uv command runs, so
the cached venv's bin/python3 symlink points at an interpreter that
was never installed in this job and the next step that activates the
venv aborts with 'broken symlink'.

Extract setup-uv plus an explicit 'uv python install' into the
.github/actions/setup-uv-python composite action so every job that
restores the venv ends up with a real Python at the expected path.
Enable cache-python in the wrapper so subsequent jobs reuse astral's
download instead of refetching it.
2026-05-21 13:16:39 -05:00
J. Nick Koston 31271876bf Pin uv version in every setup-uv call to skip manifest fetch
Mirrors esphome/esphome#16534. Without an explicit version, setup-uv
fetches uv.ndjson from raw.githubusercontent.com on every cache miss,
which periodically times out and fails the job. Expose the uv version
from requirements.txt via the info job and pass it to every setup-uv
call. Also set ignore-nothing-to-cache: true so jobs that do not touch
uv (e.g. gen-copilot-instructions) no longer fail on the post-step
cache save.
2026-05-21 13:08:57 -05:00
J. Nick Koston d5c31332b5 Switch CI to astral-managed Python via setup-uv
Replace actions/setup-python with astral-sh/setup-uv so every job uses
the python-build-standalone interpreter astral ships, which bakes in
the PEP 744 tail call interpreter on 3.14. setup-uv handles both
installing uv and provisioning the requested Python version, so the
venv bootstrap uses 'uv venv' instead of 'python -m venv' and there is
no longer a separate uv install step on cache miss.

Bumps CACHE_VERSION so the old setup-python venv caches are invalidated;
the venv symlinks would otherwise point at the absent hostedtoolcache
interpreter.
2026-05-21 13:05:30 -05:00
J. Nick Koston 3f0c93c26c Merge branch 'dev' into ci-cache-postgres-mariadb-deps 2026-05-21 12:48:19 -05:00
J. Nick Koston 07ed913ba2 Extract apt caching into composite action with alternatives workaround
Wrap awalsh128/cache-apt-pkgs-action in .github/actions/cache-apt-packages
so every job uses the same pattern, and route /usr/lib/x86_64-linux-gnu
subdirectories through ldconfig. The upstream action does not run postinst
on cache restore so update-alternatives symlinks (libblas, liblapack via
ffmpeg) never appear; adding the subdirs to ld.so.conf.d lets the linker
find the real libraries without those symlinks.
2026-05-21 10:45:13 -05:00
J. Nick Koston b7905b163f Run ldconfig after cache-apt-pkgs-action restore
The action restores cached .deb files to disk but skips dpkg-trigger so
/etc/ld.so.cache stays stale and ctypes-based loaders (eg opuslib)
cannot find libopus.so.0. Add an explicit ldconfig step after each
action call.
2026-05-21 10:02:39 -05:00
J. Nick Koston c712b07da3 Switch CI apt caching to awalsh128/cache-apt-pkgs-action 2026-05-21 09:42:20 -05:00
95 changed files with 494 additions and 3751 deletions
@@ -0,0 +1,42 @@
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}"
+45 -47
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 3
CACHE_VERSION: 4
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
@@ -89,6 +89,8 @@ jobs:
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 }}
@@ -235,6 +237,11 @@ 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}"
@@ -344,12 +351,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
- name: Set up uv and Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
@@ -397,21 +404,13 @@ jobs:
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Read uv version from requirements.txt
if: steps.cache-venv.outputs.cache-hit != 'true'
id: read-uv-version
run: |
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- 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: |
python -m venv venv
uv venv venv --python "${PYTHON_VERSION}"
. venv/bin/activate
python --version
uv pip install -r requirements.txt
@@ -419,7 +418,6 @@ jobs:
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
@@ -480,10 +478,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -517,10 +515,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -553,10 +551,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Run gen_copilot_instructions.py
run: |
python -m script.gen_copilot_instructions validate
@@ -608,10 +606,10 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
@@ -659,10 +657,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -712,10 +710,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -763,10 +761,10 @@ jobs:
persist-credentials: false
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
@@ -840,10 +838,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
with:
python-version-file: ".python-version"
check-latest: true
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -905,10 +903,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
@@ -1047,10 +1045,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
@@ -1203,10 +1201,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
@@ -1371,10 +1369,10 @@ jobs:
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: ./.github/actions/setup-uv-python
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
Generated
+2 -4
View File
@@ -453,8 +453,6 @@ CLAUDE.md @home-assistant/core
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/edifier_infrared/ @abmantis
/tests/components/edifier_infrared/ @abmantis
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
@@ -2056,8 +2054,8 @@ CLAUDE.md @home-assistant/core
/tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @aeon-matrix
/tests/components/yardian/ @aeon-matrix
/homeassistant/components/yardian/ @h3l1o5
/tests/components/yardian/ @h3l1o5
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
@@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
translation_key="invalid_bucket_name",
) from err
except ValueError as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_endpoint_url",
+1 -4
View File
@@ -7,7 +7,7 @@
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
},
"step": {
"user": {
@@ -48,9 +48,6 @@
},
"invalid_credentials": {
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
},
"invalid_endpoint_url": {
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
}
}
}
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.15",
"habluetooth==6.7.9"
"dbus-fast==5.0.14",
"habluetooth==6.7.4"
]
}
@@ -175,6 +175,7 @@ class ConfigManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.Any(str, list),
vol.Optional("show_advanced_options", default=False): cv.boolean,
vol.Optional("entry_id"): cv.string,
},
extra=vol.ALLOW_EXTRA,
@@ -301,6 +302,7 @@ class SubentryManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
@@ -221,8 +221,8 @@ class TrackerEntity(
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Takes precedence over latitude
and longitude when set (including when set to an empty list).
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@@ -252,7 +252,11 @@ class TrackerEntity(
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if (zones := self.in_zones) is not None:
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
@@ -266,12 +270,6 @@ class TrackerEntity(
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
elif (
self.available and self.latitude is not None and self.longitude is not None
):
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
@@ -38,9 +38,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
@@ -382,8 +379,8 @@ async def async_extract_config(
if platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform)
else:
async_create_platform_config_not_supported_issue(
hass, platform.name, DOMAIN
raise ValueError(
f"Unable to determine type for {platform.name}: {platform.type}"
)
return legacy
+17 -17
View File
@@ -64,23 +64,23 @@
"ventilation_state": {
"name": "Ventilation state",
"state": {
"aut1": "AUT1",
"aut2": "AUT2",
"aut3": "AUT3",
"auto": "AUTO",
"cnt1": "CNT1",
"cnt2": "CNT2",
"cnt3": "CNT3",
"empt": "EMPT",
"man1": "MAN1",
"man1x2": "MAN1x2",
"man1x3": "MAN1x3",
"man2": "MAN2",
"man2x2": "MAN2x2",
"man2x3": "MAN2x3",
"man3": "MAN3",
"man3x2": "MAN3x2",
"man3x3": "MAN3x3"
"aut1": "Automatic boost (15 min)",
"aut2": "Automatic boost (30 min)",
"aut3": "Automatic boost (45 min)",
"auto": "Automatic",
"cnt1": "Continuous low speed",
"cnt2": "Continuous medium speed",
"cnt3": "Continuous high speed",
"empt": "Empty house",
"man1": "Manual low speed (15 min)",
"man1x2": "Manual low speed (30 min)",
"man1x3": "Manual low speed (45 min)",
"man2": "Manual medium speed (15 min)",
"man2x2": "Manual medium speed (30 min)",
"man2x3": "Manual medium speed (45 min)",
"man3": "Manual high speed (15 min)",
"man3x2": "Manual high speed (30 min)",
"man3x3": "Manual high speed (45 min)"
}
}
}
@@ -1,18 +0,0 @@
"""Edifier infrared integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Edifier IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Edifier IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,82 +0,0 @@
"""Config flow for Edifier infrared integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MODEL
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
MODEL_TO_COMMAND_SET,
EdifierModel,
)
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Edifier IR."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - select IR entity and speaker model."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
model = EdifierModel(user_input[CONF_MODEL])
command_set = MODEL_TO_COMMAND_SET[model]
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
self._abort_if_unique_id_configured()
entity_name = infrared_entity_id
if state := self.hass.states.get(infrared_entity_id):
entity_name = state.name or infrared_entity_id
return self.async_create_entry(
title=f"Edifier {model.value} via {entity_name}",
data={
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
CONF_MODEL: model.value,
CONF_COMMAND_SET: command_set.value,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
)
),
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=[model.value for model in EdifierModel],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
@@ -1,76 +0,0 @@
"""Constants for the Edifier infrared integration."""
from enum import StrEnum
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
DOMAIN = "edifier_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_COMMAND_SET = "command_set"
type EdifierCode = (
EdifierR1700BTCode
| EdifierR1280DBCode
| EdifierR1280TCode
| EdifierS360DBCode
| EdifierRC20GCode
)
class EdifierCommandSets(StrEnum):
"""Edifier command set groupings."""
R1700BT = "r1700bt"
R1280DB = "r1280db"
R1280T = "r1280t"
S360DB = "s360db"
RC20G = "rc20g"
class EdifierModel(StrEnum):
"""Edifier speaker models."""
# R1700BT command set
R1700BT = "R1700BT"
R1700BTS = "R1700BTs"
RC17A = "RC17A"
RC80B = "RC80B"
R1855DB = "R1855DB"
# R1280DB command set
R1280DB = "R1280DB"
R2730DB = "R2730DB"
RC10D1 = "RC10D1"
R2000DB = "R2000DB"
# R1280T command set (basic)
R1280T = "R1280T"
# S360DB command set
S360DB = "S360DB"
RC31A = "RC31A"
# RC20G command set (unique left/right volume controls)
RC20G = "RC20G"
MODEL_TO_COMMAND_SET: dict[EdifierModel, EdifierCommandSets] = {
# R1700BT command set
EdifierModel.R1700BT: EdifierCommandSets.R1700BT,
EdifierModel.R1700BTS: EdifierCommandSets.R1700BT,
EdifierModel.RC17A: EdifierCommandSets.R1700BT,
EdifierModel.RC80B: EdifierCommandSets.R1700BT,
EdifierModel.R1855DB: EdifierCommandSets.R1700BT,
# R1280DB command set
EdifierModel.R1280DB: EdifierCommandSets.R1280DB,
EdifierModel.R2730DB: EdifierCommandSets.R1280DB,
EdifierModel.RC10D1: EdifierCommandSets.R1280DB,
EdifierModel.R2000DB: EdifierCommandSets.R1280DB,
# R1280T command set
EdifierModel.R1280T: EdifierCommandSets.R1280T,
# S360DB command set
EdifierModel.S360DB: EdifierCommandSets.S360DB,
EdifierModel.RC31A: EdifierCommandSets.S360DB,
# RC20G command set
EdifierModel.RC20G: EdifierCommandSets.RC20G,
}
@@ -1,25 +0,0 @@
"""Common entity for Edifier infrared integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EdifierModel
class EdifierIrEntity(Entity):
"""Edifier IR base entity providing common device info."""
_attr_has_entity_name = True
def __init__(
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
) -> None:
"""Initialize Edifier IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Edifier {model.value}",
manufacturer="Edifier",
model=model.value,
)
@@ -1,11 +0,0 @@
{
"domain": "edifier_infrared",
"name": "Edifier Infrared",
"codeowners": ["@abmantis"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -1,179 +0,0 @@
"""Media player platform for Edifier infrared integration."""
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
EdifierCode,
EdifierCommandSets,
EdifierModel,
)
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
COMMAND_SET_COMMANDS: dict[
EdifierCommandSets,
dict[
MediaPlayerEntityFeature,
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
],
] = {
EdifierCommandSets.R1700BT: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1700BTCode.VOLUME_UP,),
(EdifierR1700BTCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
},
EdifierCommandSets.R1280DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280DBCode.VOLUME_UP,),
(EdifierR1280DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
},
EdifierCommandSets.R1280T: {
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280TCode.VOLUME_UP,),
(EdifierR1280TCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
},
EdifierCommandSets.S360DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierS360DBCode.VOLUME_UP,),
(EdifierS360DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
},
EdifierCommandSets.RC20G: {
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
},
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR media player."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSets(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
)
class EdifierIrMediaPlayer(
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
):
"""Edifier IR media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
command_set: EdifierCommandSets,
) -> None:
"""Initialize Edifier IR media player."""
super().__init__(entry, model, unique_id_suffix="media_player")
self._infrared_emitter_entity_id = infrared_entity_id
self._commands = COMMAND_SET_COMMANDS[command_set]
self._attr_state = MediaPlayerState.ON
self._attr_supported_features = MediaPlayerEntityFeature(0)
for feature in self._commands:
self._attr_supported_features |= feature
async def _send_codes(self, *codes: EdifierCode) -> None:
"""Send one or more IR commands."""
for code in codes:
await self._send_command(code.to_command())
async def async_turn_on(self) -> None:
"""Turn on the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
async def async_turn_off(self) -> None:
"""Turn off the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
@@ -1,114 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
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 provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The media player entity is the primary entity and does not need a category.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
The media player entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration depends on infrared_protocols which provides only code
definitions with no I/O, so async dependency does not apply.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo
@@ -1,22 +0,0 @@
{
"config": {
"abort": {
"already_configured": "This Edifier device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_entity_id": "IR transmitter",
"model": "Speaker model"
},
"data_description": {
"infrared_entity_id": "Select the infrared transmitter entity to use.",
"model": "Choose your Edifier speaker model from the list."
},
"description": "Configure your Edifier speaker for IR control.",
"title": "Set up Edifier IR speaker"
}
}
}
}
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -97,13 +97,11 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property
@override
def is_on(self) -> bool:
"""Return the status of the sensor."""
return bool(self._heater.status[self.entity_description.value_key])
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
@@ -1,6 +1,6 @@
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
from typing import Any, override
from typing import Any
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
@@ -76,19 +76,16 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
)
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
return {"status": self._room.status}
@property
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._room.room_temp
@property
@override
def hvac_action(self) -> HVACAction | None:
"""Return the actual current HVAC action."""
if self._heater.is_burning and self._heater.is_pumping:
@@ -96,7 +93,6 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return HVACAction.IDLE
@property
@override
def target_temperature(self) -> float | None:
"""Return the (override)temperature we try to reach.
@@ -110,13 +106,11 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return self._room.setpoint
return self._room.override or self._room.setpoint
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature for this zone."""
temperature: float = kwargs[ATTR_TEMPERATURE]
await self._room.set_override(temperature)
await self.coordinator.async_refresh()
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import Any, override
from typing import Any
from incomfortclient import InvalidGateway, InvalidHeaterList
import voluptuous as vol
@@ -100,7 +100,6 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_host: str
@override
@staticmethod
@callback
def async_get_options_flow(
@@ -109,7 +108,6 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return InComfortOptionsFlowHandler()
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
@@ -171,7 +169,6 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={CONF_HOST: self._discovered_host},
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from typing import Any, override
from typing import Any
from aiohttp import ClientResponseError
from incomfortclient import (
@@ -74,7 +74,6 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
)
self.incomfort_data = incomfort_data
@override
async def _async_update_data(self) -> InComfortData:
"""Fetch data from API endpoint."""
try:
+1 -3
View File
@@ -1,7 +1,7 @@
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
from dataclasses import dataclass
from typing import Any, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -104,13 +104,11 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if (extra_key := self.entity_description.extra_key) is None:
@@ -1,7 +1,7 @@
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
import logging
from typing import Any, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -49,13 +49,11 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
self._attr_unique_id = heater.serial_no
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
@property
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self._heater.is_tapping:
@@ -69,7 +67,6 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
return max(self._heater.heater_temp, self._heater.tap_temp)
@property
@override
def current_operation(self) -> str | None:
"""Return the current operation mode."""
return self._heater.display_text
@@ -8,6 +8,8 @@ import datetime
from functools import partial
from random import random
import voluptuous as vol
from homeassistant.components.labs import (
EventLabsUpdatedData,
async_is_preview_feature_enabled,
@@ -32,7 +34,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import (
@@ -49,11 +51,9 @@ from homeassistant.util.unit_conversion import (
)
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .services import async_setup_services
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
@@ -69,6 +69,15 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment."""
@@ -78,7 +87,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
)
async_setup_services(hass)
@callback
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
"test_service_1",
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
return True
@@ -1,97 +0,0 @@
"""Demo platform that has a couple of fake device trackers."""
from homeassistant.components.device_tracker import (
BaseScannerEntity,
SourceType,
TrackerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
async_add_entities(
[
DemoTracker(
unique_id="kitchen_sink_tracker_001",
name="Demo tracker",
latitude=hass.config.latitude,
longitude=hass.config.longitude,
accuracy=10,
),
DemoScanner(
unique_id="kitchen_sink_scanner_001",
name="Demo scanner",
is_connected=True,
),
]
)
class DemoTracker(TrackerEntity):
"""Representation of a demo tracker."""
_attr_should_poll = False
_attr_source_type = SourceType.GPS
def __init__(
self,
*,
unique_id: str,
name: str,
latitude: float | None,
longitude: float | None,
accuracy: float,
) -> None:
"""Initialize the tracker."""
self._attr_unique_id = unique_id
self._attr_name = name
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
@callback
def async_set_tracker_location(
self, latitude: float, longitude: float, accuracy: float
) -> None:
"""Update the tracker location."""
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
self.async_write_ha_state()
class DemoScanner(BaseScannerEntity):
"""Representation of a demo scanner."""
_attr_should_poll = False
_attr_source_type = SourceType.ROUTER
def __init__(
self,
*,
unique_id: str,
name: str,
is_connected: bool,
) -> None:
"""Initialize the scanner."""
self._attr_unique_id = unique_id
self._attr_name = name
self._is_connected = is_connected
@property
def is_connected(self) -> bool:
"""Return true if the device is connected."""
return self._is_connected
@callback
def async_set_scanner_connected(self, connected: bool) -> None:
"""Update the scanner connected state."""
self._is_connected = connected
self.async_write_ha_state()
@@ -9,12 +9,6 @@
}
},
"services": {
"set_scanner_connected": {
"service": "mdi:lan-connect"
},
"set_tracker_location": {
"service": "mdi:map-marker"
},
"test_service_1": {
"sections": {
"additional_fields": "mdi:test-tube"
@@ -1,72 +0,0 @@
"""Services for the Everything but the Kitchen Sink integration."""
import voluptuous as vol
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
}
)
SERVICE_TEST_SERVICE_1 = "test_service_1"
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
ATTR_ACCURACY = "accuracy"
ATTR_CONNECTED = "connected"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Kitchen Sink integration."""
@callback
def service_handler(call: ServiceCall) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
SERVICE_TEST_SERVICE_1,
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TRACKER_LOCATION,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
},
func="async_set_tracker_location",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
func="async_set_scanner_connected",
)
@@ -30,44 +30,3 @@ test_service_1:
options:
- "forward"
- "reverse"
set_tracker_location:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
latitude:
required: true
example: 52.379189
selector:
number:
min: -90
max: 90
step: any
longitude:
required: true
example: 4.899431
selector:
number:
min: -180
max: 180
step: any
accuracy:
required: true
example: 10
selector:
number:
min: 0
max: 10000
unit_of_measurement: m
set_scanner_connected:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
connected:
required: true
example: true
selector:
boolean:
@@ -135,34 +135,6 @@
}
},
"services": {
"set_scanner_connected": {
"description": "Sets the connected state of a demo scanner entity.",
"fields": {
"connected": {
"description": "Whether the device should be reported as connected.",
"name": "Connected"
}
},
"name": "Set scanner connected"
},
"set_tracker_location": {
"description": "Sets the location and accuracy of a demo tracker entity.",
"fields": {
"accuracy": {
"description": "Location accuracy in meters.",
"name": "Accuracy"
},
"latitude": {
"description": "Latitude of the new location.",
"name": "Latitude"
},
"longitude": {
"description": "Longitude of the new location.",
"name": "Longitude"
}
},
"name": "Set tracker location"
},
"test_service_1": {
"description": "Fake action for testing {meep_2}",
"fields": {
+73 -103
View File
@@ -1,5 +1,7 @@
"""Support for OPNsense Routers."""
import logging
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
@@ -13,16 +15,22 @@ from aiopnsense import (
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
from .types import OPNsenseConfigEntry, OPNsenseRuntimeData
from .const import (
CONF_API_SECRET,
CONF_INTERFACE_CLIENT,
CONF_TRACKER_INTERFACES,
DOMAIN,
OPNSENSE_DATA,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
@@ -41,124 +49,86 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the OPNsense component."""
if DOMAIN not in config:
return True
"""Set up the opnsense component."""
hass.async_create_task(_async_setup(hass, config))
conf = config[DOMAIN]
url = conf[CONF_URL]
api_key = conf[CONF_API_KEY]
api_secret = conf[CONF_API_SECRET]
verify_ssl = conf[CONF_VERIFY_SSL]
tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
return True
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the OPNsense component from YAML."""
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
async def async_setup_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Set up the OPNsense component from a config entry."""
url = config_entry.data[CONF_URL]
session = async_get_clientsession(
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
)
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
client = OPNsenseClient(
url,
config_entry.data[CONF_API_KEY],
config_entry.data[CONF_API_SECRET],
api_key,
api_secret,
session,
opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]},
opts={"verify_ssl": verify_ssl},
)
tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, [])
try:
await client.validate()
if tracker_interfaces:
interfaces_resp = await client.get_interfaces()
except OPNsenseUnknownFirmware as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unknown_firmware",
translation_placeholders={"url": url},
) from err
except OPNsenseBelowMinFirmware as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="firmware_too_old",
translation_placeholders={"url": url},
) from err
except OPNsenseInvalidURL as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_url",
translation_placeholders={"url": url},
) from err
except OPNsenseTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connecting",
translation_placeholders={"url": url},
) from err
except OPNsenseSSLError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="ssl_error",
translation_placeholders={"url": url},
) from err
except OPNsenseInvalidAuth as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"url": url},
) from err
except OPNsensePrivilegeMissing as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="privilege_missing",
translation_placeholders={"url": url},
) from err
except OPNsenseConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"url": url},
) from err
except OPNsenseUnknownFirmware:
_LOGGER.error("Error checking the OPNsense firmware version at %s", url)
return False
except OPNsenseBelowMinFirmware:
_LOGGER.error(
"OPNsense Firmware is below the minimum supported version at %s", url
)
return False
except OPNsenseInvalidURL:
_LOGGER.error(
"Invalid URL while connecting to OPNsense API endpoint at %s", url
)
return False
except OPNsenseTimeoutError:
_LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url)
return False
except OPNsenseSSLError:
_LOGGER.error(
"Unable to verify SSL while connecting to OPNsense API endpoint at %s", url
)
return False
except OPNsenseInvalidAuth:
_LOGGER.error(
"Authentication failure while connecting to OPNsense API endpoint at %s",
url,
)
return False
except OPNsensePrivilegeMissing:
_LOGGER.error(
"Invalid Permissions while connecting to OPNsense API endpoint at %s",
url,
)
return False
except OPNsenseConnectionError:
_LOGGER.error(
"Connection failure while connecting to OPNsense API endpoint at %s",
url,
)
return False
if tracker_interfaces:
# Verify that specified tracker interfaces are valid
known_interfaces = [
name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name"))
ifinfo.get("name", "") for ifinfo in interfaces_resp.values()
]
for intf_description in tracker_interfaces:
if intf_description not in known_interfaces:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="tracker_interface_not_found",
translation_placeholders={
"interface": intf_description,
"known": ", ".join(known_interfaces),
},
_LOGGER.error(
"Specified OPNsense tracker interface %s is not found",
intf_description,
)
return False
config_entry.runtime_data = OPNsenseRuntimeData(
client=client,
tracker_interfaces=tracker_interfaces,
)
hass.data[OPNSENSE_DATA] = {
CONF_INTERFACE_CLIENT: client,
CONF_TRACKER_INTERFACES: tracker_interfaces,
}
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@@ -1,315 +0,0 @@
"""Config flow for OPNsense."""
import logging
from typing import Any
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_API_SECRET): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
)
def tracker_interfaces_schema(
interfaces: list[str], selected: list[str] | None = None
) -> vol.Schema:
"""Schema to display available interfaces for device tracking selection."""
return vol.Schema(
{
vol.Optional(
CONF_TRACKER_INTERFACES,
default=selected or [],
): SelectSelector(
SelectSelectorConfig(
options=interfaces, mode=SelectSelectorMode.DROPDOWN, multiple=True
)
),
}
)
class OPNsenseConfigFlow(ConfigFlow, domain=DOMAIN):
"""OPNsense config flow."""
def __init__(self) -> None:
"""Initialize OPNsense config flow."""
self.available_interfaces: list[str] | None = None
self._entry_data: dict[str, Any] = {}
async def _show_setup_form(
self,
user_input: dict[Any, Any] | None = None,
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
description_placeholders = {
"doc_url": "https://www.home-assistant.io/integrations/opnsense/"
}
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors or {},
description_placeholders=description_placeholders,
)
async def _show_interfaces_form(
self,
user_input: dict[Any, Any],
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the tracker interfaces selection form to the user."""
return self.async_show_form(
step_id="interfaces",
data_schema=self.add_suggested_values_to_schema(
tracker_interfaces_schema(
self.available_interfaces or [],
user_input.get(CONF_TRACKER_INTERFACES),
),
user_input,
),
errors=errors or {},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user step: credentials and connection test."""
errors = {}
if user_input is None:
return await self._show_setup_form(user_input, None)
verify_ssl = user_input[CONF_VERIFY_SSL]
session = async_get_clientsession(self.hass, verify_ssl=verify_ssl)
client = OPNsenseClient(
user_input[CONF_URL],
user_input[CONF_API_KEY],
user_input[CONF_API_SECRET],
session,
opts={"verify_ssl": verify_ssl},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = list(known_interfaces)
except OPNsenseInvalidAuth:
errors["base"] = "invalid_auth"
except OPNsensePrivilegeMissing:
errors["base"] = "privilege_missing"
except OPNsenseInvalidURL:
errors["base"] = "invalid_url"
except OPNsenseSSLError:
errors["base"] = "ssl_error"
except OPNsenseConnectionError, OPNsenseTimeoutError:
errors["base"] = "cannot_connect"
except OPNsenseUnknownFirmware:
errors["base"] = "unknown_version"
except OPNsenseBelowMinFirmware:
errors["base"] = "invalid_version"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
unique_id = await client.get_device_unique_id()
if not unique_id:
return self.async_abort(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
self._entry_data = user_input
return await self.async_step_interfaces()
return await self._show_setup_form(user_input, errors)
async def async_step_interfaces(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle tracker interface selection step."""
if user_input is None:
return await self._show_interfaces_form({}, None)
if user_input.get(CONF_TRACKER_INTERFACES):
self._entry_data[CONF_TRACKER_INTERFACES] = user_input[
CONF_TRACKER_INTERFACES
]
return self.async_create_entry(
title=self._entry_data[CONF_URL], data=self._entry_data
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a Yaml config."""
# Test connection
session = async_get_clientsession(
self.hass, verify_ssl=import_data[CONF_VERIFY_SSL]
)
client = OPNsenseClient(
import_data[CONF_URL],
import_data[CONF_API_KEY],
import_data[CONF_API_SECRET],
session,
opts={"verify_ssl": import_data[CONF_VERIFY_SSL]},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
except OPNsenseInvalidURL:
return self._abort_import(reason="invalid_url")
except OPNsenseInvalidAuth:
return self._abort_import(reason="invalid_auth")
except OPNsensePrivilegeMissing:
return self._abort_import(reason="privilege_missing")
except OPNsenseSSLError:
return self._abort_import(reason="ssl_error")
except OPNsenseConnectionError, OPNsenseTimeoutError:
return self._abort_import(reason="cannot_connect")
except OPNsenseUnknownFirmware:
return self._abort_import(reason="unknown_version")
except OPNsenseBelowMinFirmware:
return self._abort_import(reason="invalid_version")
except Exception: # Allowed in config flows
_LOGGER.exception("Unexpected exception during import")
return self._abort_import(reason="unknown")
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
unique_id = await client.get_device_unique_id()
if not unique_id:
return self._abort_import(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Validate CONF_TRACKER_INTERFACES if present and not empty
verified_data = dict(import_data)
if CONF_TRACKER_INTERFACES in verified_data:
if not verified_data[CONF_TRACKER_INTERFACES]:
verified_data.pop(CONF_TRACKER_INTERFACES)
else:
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = sorted(known_interfaces)
# Abort import if any specified tracker interface is not found
missing = [
intf_description
for intf_description in verified_data[CONF_TRACKER_INTERFACES]
if intf_description not in known_interfaces
]
if missing:
# Create a repair to guide the user
async_create_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="import_failed_missing_interfaces",
translation_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason="import_failed_missing_interfaces",
description_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
# Clear any previous import issues if interfaces are now valid
async_delete_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
)
return self.async_create_entry(
title=verified_data[CONF_URL], data=verified_data
)
def _abort_import(self, reason: str) -> ConfigFlowResult:
"""Create an issue for import errors and abort the import."""
async_create_issue(
self.hass,
DOMAIN,
f"import_failed_{reason}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"import_failed_{reason}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason=reason,
description_placeholders={
"integration_title": "OPNsense",
},
)
+2 -5
View File
@@ -1,11 +1,8 @@
"""Constants for OPNsense component."""
from datetime import timedelta
DOMAIN = "opnsense"
OPNSENSE_DATA = DOMAIN
CONF_API_SECRET = "api_secret"
CONF_INTERFACE_CLIENT = "interface_client"
CONF_TRACKER_INTERFACES = "tracker_interfaces"
# Update interval for device scanning
SCAN_INTERVAL = timedelta(seconds=30)
@@ -1,80 +0,0 @@
"""Coordinator for OPNsense device tracker updates."""
import logging
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import SCAN_INTERVAL
from .types import DeviceDetails, DeviceDetailsByMAC, OPNsenseConfigEntry
_LOGGER = logging.getLogger(__name__)
class OPNsenseDeviceTrackerCoordinator(DataUpdateCoordinator[DeviceDetailsByMAC]):
"""Coordinator for OPNsense device tracker updates."""
def __init__(
self,
hass: HomeAssistant,
config_entry: OPNsenseConfigEntry,
client: OPNsenseClient,
interfaces: list[str],
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="OPNsense Device Tracker",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
self.interfaces = interfaces
self.tracked_devices: set[str] = set()
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC:
"""Create dict with mac address keys from list of devices."""
out_devices: DeviceDetailsByMAC = {}
for device in devices:
if not self.interfaces or device["intf_description"] in self.interfaces:
formatted_mac = format_mac(device["mac"])
out_devices[formatted_mac] = device
return out_devices
async def _async_update_data(self) -> DeviceDetailsByMAC:
"""Fetch data from OPNsense."""
try:
devices = await self.client.get_arp_table(True)
except (
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseBelowMinFirmware,
OPNsenseUnknownFirmware,
) as err:
raise ConfigEntryError(f"Error with OPNsense configuration: {err}") from err
except (
OPNsenseConnectionError,
OPNsenseTimeoutError,
) as err:
raise UpdateFailed(
f"Error communicating with OPNsense router: {err}"
) from err
return self._get_mac_addrs(devices)
@@ -1,117 +1,71 @@
"""Device tracker support for OPNsense routers."""
from typing import Any
from typing import Any, NewType
from homeassistant.components.device_tracker import ScannerEntity
from aiopnsense import OPNsenseClient
from homeassistant.components.device_tracker import DeviceScanner
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.typing import ConfigType
from .coordinator import OPNsenseDeviceTrackerCoordinator
from .types import DeviceDetails, OPNsenseConfigEntry
from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA
DeviceDetails = NewType("DeviceDetails", dict[str, Any])
DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails])
async def async_setup_entry(
hass: HomeAssistant,
entry: OPNsenseConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for OPNsense component."""
client = entry.runtime_data.client
interfaces = entry.runtime_data.tracker_interfaces
coordinator = OPNsenseDeviceTrackerCoordinator(hass, entry, client, interfaces)
def _async_add_new_entities() -> None:
"""Add entities for newly discovered devices."""
if not coordinator.data:
return
entities = []
for mac_address in coordinator.data:
if mac_address in coordinator.tracked_devices:
continue
entity = OPNsenseDeviceTrackerEntity(coordinator, mac_address)
coordinator.tracked_devices.add(mac_address)
entities.append(entity)
if entities:
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
# Initial data fetch
await coordinator.async_config_entry_first_refresh()
_async_add_new_entities()
async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
) -> DeviceScanner | None:
"""Configure the OPNsense device_tracker."""
return OPNsenseDeviceScanner(
hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT],
hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES],
)
class OPNsenseDeviceTrackerEntity(
CoordinatorEntity[OPNsenseDeviceTrackerCoordinator], ScannerEntity
):
"""Representation of a tracked device."""
class OPNsenseDeviceScanner(DeviceScanner):
"""This class queries a router running OPNsense."""
def __init__(
self,
coordinator: OPNsenseDeviceTrackerCoordinator,
mac_address: str,
) -> None:
"""Initialize the device tracker entity."""
super().__init__(coordinator)
self._attr_mac_address = mac_address
def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None:
"""Initialize the scanner."""
self.last_results: dict[str, Any] = {}
self.client = client
self.interfaces = interfaces
@property
def device_data(self) -> DeviceDetails | None:
"""Return device data for current device."""
if self.coordinator.data and self.mac_address in self.coordinator.data:
return self.coordinator.data[self.mac_address]
return None
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict:
"""Create dict with mac address keys from list of devices."""
out_devices = {}
for device in devices:
if not self.interfaces or device["intf_description"] in self.interfaces:
out_devices[device["mac"]] = device
return out_devices
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return (
self.coordinator.data is not None
and self.mac_address in self.coordinator.data
)
async def async_scan_devices(self) -> list[str]:
"""Scan for new devices and return a list with found device IDs."""
await self._async_update_info()
return list(self.last_results)
@property
def name(self) -> str:
"""Return device name."""
device_data = self.device_data
if device_data and device_data.get("hostname"):
return str(device_data["hostname"])
return f"OPNsense {self.mac_address}"
def get_device_name(self, device: str) -> str | None:
"""Return the name of the given device or None if we don't know."""
if device not in self.last_results:
return None
return self.last_results[device].get("hostname") or None
@property
def ip_address(self) -> str | None:
"""Return the primary IP address of the device."""
device_data = self.device_data
if device_data:
return device_data.get("ip")
return None
async def _async_update_info(self) -> bool:
"""Ensure the information from the OPNsense router is up to date.
@property
def hostname(self) -> str | None:
"""Return hostname of the device."""
device_data = self.device_data
if device_data:
hostname = device_data.get("hostname")
return hostname or None
return None
Return boolean if scanning successful.
"""
devices = await self.client.get_arp_table(True)
self.last_results = self._get_mac_addrs(devices)
return True
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
device_data = self.device_data
if not device_data:
def get_extra_attributes(self, device: str) -> dict[Any, Any]:
"""Return the extra attrs of the given device."""
if device not in self.last_results:
return {}
attrs = {}
if manufacturer := device_data.get("manufacturer"):
attrs["manufacturer"] = manufacturer
if interface := device_data.get("intf_description"):
attrs["interface"] = interface
if expires := device_data.get("expires"):
attrs["expires"] = expires
return attrs
mfg = self.last_results[device].get("manufacturer")
if not mfg:
return {}
return {"manufacturer": mfg}
@@ -2,7 +2,6 @@
"domain": "opnsense",
"name": "OPNsense",
"codeowners": ["@HarlemSquirrel", "@Snuffy2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/opnsense",
"integration_type": "hub",
"iot_class": "local_polling",
@@ -1,117 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"import_failed_missing_interfaces": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_url": "URL is invalid or unreachable",
"invalid_version": "Unsupported OPNsense firmware version",
"no_unique_id": "Could not determine a unique identifier for this OPNsense router. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
"privilege_missing": "The API key used does not have sufficient privileges. Please check the integration documentation for required permissions",
"ssl_error": "SSL certificate verification failed",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_interface": "Interface(s) do not exist",
"invalid_url": "URL is invalid or unreachable",
"invalid_version": "Unsupported OPNsense firmware version",
"privilege_missing": "[%key:component::opnsense::config::abort::privilege_missing%]",
"ssl_error": "SSL certificate verification failed",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
},
"step": {
"interfaces": {
"data": {
"tracker_interfaces": "Interface(s) to use for tracking devices"
},
"description": "Select the OPNsense interfaces to use for tracking devices. If no interfaces are selected then all interfaces will be used for tracking."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_secret": "API secret",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Set required parameters to connect to your router. For more information, please refer to the [integration documentation]({doc_url})"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Connection failure while connecting to OPNsense API endpoint at {url}"
},
"firmware_too_old": {
"message": "OPNsense firmware at {url} is below the minimum supported version"
},
"invalid_auth": {
"message": "Authentication failure while connecting to OPNsense API endpoint at {url}"
},
"invalid_url": {
"message": "Invalid URL while connecting to OPNsense API endpoint at {url}"
},
"privilege_missing": {
"message": "The API user connecting to {url} does not have sufficient privileges"
},
"ssl_error": {
"message": "Unable to verify SSL certificate while connecting to OPNsense API endpoint at {url}"
},
"timeout_connecting": {
"message": "Timeout while connecting to OPNsense API endpoint at {url}"
},
"tracker_interface_not_found": {
"message": "Configured tracker interface {interface} is not present on the OPNsense router. Known interfaces: {known}"
},
"unknown_firmware": {
"message": "Could not determine the OPNsense firmware version at {url}"
}
},
"issues": {
"import_failed_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an authentication error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_url": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the URL provided is invalid or unreachable. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_version": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unsupported. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_missing_interfaces": {
"description": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
"title": "The {integration_title} YAML import failed: Missing tracker interfaces"
},
"import_failed_no_unique_id": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a unique identifier for the router could not be determined. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_privilege_missing": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the API key used does not have sufficient privileges. Please check the integration documentation for required permissions, correct your YAML configuration, and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_ssl_error": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_unknown": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_unknown_version": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unknown. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
}
}
}
@@ -1,21 +0,0 @@
"""Types for OPNsense routers."""
from dataclasses import dataclass
from typing import Any
from aiopnsense import OPNsenseClient
from homeassistant.config_entries import ConfigEntry
@dataclass(slots=True)
class OPNsenseRuntimeData:
"""Runtime data for OPNsense config entries."""
client: OPNsenseClient
tracker_interfaces: list[str]
type DeviceDetails = dict[str, Any]
type DeviceDetailsByMAC = dict[str, DeviceDetails]
type OPNsenseConfigEntry = ConfigEntry[OPNsenseRuntimeData]
@@ -111,11 +111,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
raise ConfigEntryAuthFailed from err
except CannotConnect as err:
_LOGGER.error("Error during login: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="login_error",
translation_placeholders={"error": str(err)},
) from err
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error during login: {err}") from err
try:
accounts = await self.api.async_get_accounts()
@@ -124,11 +124,6 @@
}
}
},
"exceptions": {
"login_error": {
"message": "Error during login: {error}"
}
},
"issues": {
"return_to_grid_migration": {
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.",
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/qingping",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["qingping-ble==1.1.5"]
"requirements": ["qingping-ble==1.1.4"]
}
@@ -24,7 +24,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_FIRMWARE_CHECK_TIME,
@@ -103,8 +102,6 @@ async def async_setup_entry(
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
or host.api.baichuan.connection_type.value
!= config_entry.data.get(CONF_BC_CONNECT)
):
if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning(
@@ -129,7 +126,6 @@ async def async_setup_entry(
CONF_USE_HTTPS: host.api.use_https,
CONF_BC_PORT: host.api.baichuan.port,
CONF_BC_ONLY: host.api.baichuan_only,
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
}
hass.config_entries.async_update_entry(config_entry, data=data)
@@ -37,7 +37,6 @@ from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -311,7 +310,6 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_USE_HTTPS] = host.api.use_https
user_input[CONF_BC_PORT] = host.api.baichuan.port
user_input[CONF_BC_ONLY] = host.api.baichuan_only
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode"
)
@@ -7,7 +7,6 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port"
CONF_BC_ONLY = "baichuan_only"
CONF_BC_CONNECT = "baichuan_connection"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
+1 -9
View File
@@ -11,7 +11,7 @@ import aiohttp
from aiohttp.web import Request
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
from reolink_aio.baichuan import DEFAULT_BC_PORT
from reolink_aio.enums import ConnectionEnum, SubType
from reolink_aio.enums import SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components import webhook
@@ -36,7 +36,6 @@ from .const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
BATTERY_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -78,12 +77,6 @@ class ReolinkHost:
self._config_entry = config_entry
self._config = config
self._unique_id: str = ""
try:
bc_connection = ConnectionEnum(
config.get(CONF_BC_CONNECT, ConnectionEnum.unknown.value)
)
except ValueError:
bc_connection = ConnectionEnum.unknown
def get_aiohttp_session() -> aiohttp.ClientSession:
"""Return the HA aiohttp session."""
@@ -103,7 +96,6 @@ class ReolinkHost:
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=get_aiohttp_session,
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
bc_connection=bc_connection,
bc_only=config.get(CONF_BC_ONLY, False),
)
@@ -217,9 +217,9 @@
"home": "[%key:common::entity::button::home::name%]",
"insert": "Insert",
"left": "[%key:common::entity::button::left::name%]",
"lights_kbd_down": "Keyboard backlight brightness down",
"lights_kbd_down": "Keyboasrd backlight brightness down",
"lights_kbd_toggle": "Toggle keyboard backlight",
"lights_kbd_up": "Keyboard backlight brightness up",
"lights_kbd_up": "Keyboard backlight brighness up",
"lights_mon_down": "Display brightness down",
"lights_mon_up": "Display brightness up",
"numpad_0": "NumPad 0",
+1 -1
View File
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pyvlx"],
"quality_scale": "silver",
"requirements": ["pyvlx==0.2.35"]
"requirements": ["pyvlx==0.2.34"]
}
@@ -16,5 +16,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"quality_scale": "bronze",
"requirements": ["pyvesync==3.4.2"]
"requirements": ["pyvesync==3.4.1"]
}
@@ -1,7 +1,7 @@
{
"domain": "yardian",
"name": "Yardian",
"codeowners": ["@aeon-matrix"],
"codeowners": ["@h3l1o5"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/yardian",
"integration_type": "device",
+1
View File
@@ -118,6 +118,7 @@ class AbortFlow(FlowError):
class FlowContext(TypedDict, total=False):
"""Typed context dict."""
show_advanced_options: bool
source: str
-2
View File
@@ -185,7 +185,6 @@ FLOWS = {
"econet",
"ecovacs",
"ecowitt",
"edifier_infrared",
"edl21",
"efergy",
"egauge",
@@ -542,7 +541,6 @@ FLOWS = {
"opentherm_gw",
"openuv",
"openweathermap",
"opnsense",
"opower",
"oralb",
"orvibo",
+1 -7
View File
@@ -1654,12 +1654,6 @@
"config_flow": true,
"iot_class": "local_push"
},
"edifier_infrared": {
"name": "Edifier Infrared",
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state"
},
"edimax": {
"name": "Edimax",
"integration_type": "hub",
@@ -5125,7 +5119,7 @@
"opnsense": {
"name": "OPNsense",
"integration_type": "hub",
"config_flow": true,
"config_flow": false,
"iot_class": "local_polling"
},
"opower": {
+2 -1
View File
@@ -60,6 +60,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]):
vol.Schema(
{
vol.Required("handler"): str,
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
@@ -92,7 +93,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]):
def get_context(self, data: dict[str, Any]) -> dict[str, Any]:
"""Return context."""
return {}
return {"show_advanced_options": data["show_advanced_options"]}
class FlowManagerResourceView(_BaseFlowManagerView[_FlowManagerT]):
+2 -2
View File
@@ -30,12 +30,12 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
dbus-fast==5.0.15
dbus-fast==5.0.14
file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.7.9
habluetooth==6.7.4
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+5 -5
View File
@@ -797,7 +797,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==5.0.15
dbus-fast==5.0.14
# homeassistant.components.debugpy
debugpy==1.8.17
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.7.9
habluetooth==6.7.4
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -2785,13 +2785,13 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.4.2
pyvesync==3.4.1
# homeassistant.components.vizio
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.35
pyvlx==0.2.34
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2839,7 +2839,7 @@ qbittorrent-api==2026.5.1
qbusmqttapi==1.5.0
# homeassistant.components.qingping
qingping-ble==1.1.5
qingping-ble==1.1.4
# homeassistant.components.qnap
qnapstats==0.4.0
+6
View File
@@ -1176,6 +1176,8 @@ class MockConfigEntry(config_entries.ConfigEntry):
async def start_reconfigure_flow(
self,
hass: HomeAssistant,
*,
show_advanced_options: bool = False,
) -> ConfigFlowResult:
"""Start a reconfiguration flow."""
if self.entry_id not in hass.config_entries._entries:
@@ -1187,6 +1189,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
context={
"source": config_entries.SOURCE_RECONFIGURE,
"entry_id": self.entry_id,
"show_advanced_options": show_advanced_options,
},
)
@@ -1194,6 +1197,8 @@ class MockConfigEntry(config_entries.ConfigEntry):
self,
hass: HomeAssistant,
subentry_id: str,
*,
show_advanced_options: bool = False,
) -> ConfigFlowResult:
"""Start a subentry reconfiguration flow."""
if self.entry_id not in hass.config_entries._entries:
@@ -1207,6 +1212,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
context={
"source": config_entries.SOURCE_RECONFIGURE,
"subentry_id": subentry_id,
"show_advanced_options": show_advanced_options,
},
)
-3
View File
@@ -2,7 +2,6 @@
import asyncio
from datetime import timedelta
import sys
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@@ -100,7 +99,6 @@ async def test_setup_and_stop(
assert len(mock_bleak_scanner_start.mock_calls) == 1
@pytest.mark.skipif(sys.platform != "linux", reason="Requires Linux BlueZ scanner")
@pytest.mark.parametrize(
"options",
[{CONF_MODE: "passive"}, {CONF_PASSIVE: True}],
@@ -163,7 +161,6 @@ async def test_setup_and_stop_passive(
}
@pytest.mark.skipif(sys.platform != "linux", reason="Requires Linux BlueZ scanner")
async def test_setup_and_stop_old_bluez(
hass: HomeAssistant,
mock_bleak_scanner_start: MagicMock,
+68 -86
View File
@@ -1,6 +1,5 @@
"""Tests for the Cast config flow."""
from typing import Any
from unittest.mock import ANY, patch
import pytest
@@ -14,17 +13,6 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry, get_schema_suggested_value
def _get_schema_suggested_values(data_schema, keys: list[str]) -> dict[str, Any]:
"""Get suggested values from a data schema."""
suggested_values = {}
for key in keys:
if (
suggested_value := get_schema_suggested_value(data_schema, key)
) is not None:
suggested_values[key] = suggested_value
return suggested_values
async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None:
"""Test setting up Cast loads the media player."""
with (
@@ -154,89 +142,50 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("initial", "expected_suggested_values", "user_input", "updated"),
("parameter", "initial", "suggested", "user_input", "updated"),
[
(
{},
{},
{"more_options": {}},
{"ignore_cec": [], "known_hosts": [], "user_id": ANY, "uuid": []},
"known_hosts",
["192.168.0.10", "192.168.0.11"],
["192.168.0.10", "192.168.0.11"],
["192.168.0.1", " ", " 192.168.0.2 "],
["192.168.0.1", "192.168.0.2"],
),
(
{"ignore_cec": [], "known_hosts": [], "uuid": []},
{"ignore_cec": [], "known_hosts": [], "uuid": []},
{"more_options": {}},
{"ignore_cec": [], "known_hosts": [], "user_id": ANY, "uuid": []},
"uuid",
["bla", "blu"],
["bla", "blu"],
["foo", " ", " bar "],
["foo", "bar"],
),
(
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{
"known_hosts": ["192.168.0.1", " ", " 192.168.0.2 "],
"more_options": {
"ignore_cec": ["other_cast", " ", " some_cast "],
"uuid": ["foo", " ", " bar "],
},
},
{
"ignore_cec": ["other_cast", "some_cast"],
"known_hosts": ["192.168.0.1", "192.168.0.2"],
"user_id": ANY,
"uuid": ["foo", "bar"],
},
),
# Implicit clearing of the lists when not passing values
(
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{"more_options": {}},
{"ignore_cec": [], "known_hosts": [], "user_id": ANY, "uuid": []},
),
# Explicit clearing of the lists
(
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{
"ignore_cec": ["cast1", "cast2"],
"known_hosts": ["192.168.0.10", "192.168.0.11"],
"uuid": ["bla", "blu"],
},
{"known_hosts": [], "more_options": {"ignore_cec": [], "uuid": []}},
{"ignore_cec": [], "known_hosts": [], "user_id": ANY, "uuid": []},
"ignore_cec",
["cast1", "cast2"],
["cast1", "cast2"],
["other_cast", " ", " some_cast "],
["other_cast", "some_cast"],
),
],
)
async def test_option_flow(
hass: HomeAssistant,
initial: dict[str, Any],
expected_suggested_values: dict[str, Any],
user_input: dict[str, Any],
updated: dict[str, Any],
parameter: str,
initial: list[str],
suggested: str | list[str],
user_input: str | list[str],
updated: list[str],
) -> None:
"""Test config flow options."""
basic_parameters = ["known_hosts"]
extra_parameters = ["ignore_cec", "uuid"]
config_entry = MockConfigEntry(domain="cast", data=initial)
data = {
"ignore_cec": [],
"known_hosts": [],
"uuid": [],
}
data[parameter] = initial
config_entry = MockConfigEntry(domain="cast", data=data)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -250,21 +199,54 @@ async def test_option_flow(
more_options_schema = data_schema["more_options"].schema.schema
assert set(more_options_schema) == {"ignore_cec", "uuid"}
orig_data = dict(config_entry.data)
# Check suggested values
suggested_values = _get_schema_suggested_values(data_schema, basic_parameters)
suggested_values |= _get_schema_suggested_values(
more_options_schema, extra_parameters
)
assert suggested_values == expected_suggested_values
for other_param in basic_parameters:
if other_param == parameter:
continue
assert get_schema_suggested_value(data_schema, other_param) == []
if parameter in basic_parameters:
assert get_schema_suggested_value(data_schema, parameter) == suggested
for other_param in extra_parameters:
if other_param == parameter:
continue
assert get_schema_suggested_value(more_options_schema, other_param) == []
if parameter in extra_parameters:
assert get_schema_suggested_value(more_options_schema, parameter) == suggested
# Reconfigure
user_input_dict = {"more_options": {}}
if parameter in basic_parameters:
user_input_dict[parameter] = user_input
if parameter in extra_parameters:
user_input_dict["more_options"][parameter] = user_input
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=user_input,
user_input=user_input_dict,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert config_entry.data == updated
for other_param in basic_parameters:
if other_param == parameter:
continue
assert config_entry.data[other_param] == []
for other_param in extra_parameters:
if other_param == parameter:
continue
assert config_entry.data[other_param] == []
assert config_entry.data[parameter] == updated
# Clear lists
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"more_options": {}},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
expected_data = {**orig_data, "ignore_cec": [], "known_hosts": [], "uuid": []}
assert dict(config_entry.data) == expected_data
async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None:
@@ -419,7 +419,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None:
with mock_config_flow("test", TestFlow):
resp = await client.post(
"/api/config/config_entries/flow",
json={"handler": "test"},
json={"handler": "test", "show_advanced_options": True},
)
assert resp.status == HTTPStatus.OK
@@ -469,7 +469,7 @@ async def test_initialize_flow_unmet_dependency(
with mock_config_flow("test2", TestFlow):
resp = await client.post(
"/api/config/config_entries/flow",
json={"handler": "test2"},
json={"handler": "test2", "show_advanced_options": True},
)
assert resp.status == HTTPStatus.BAD_REQUEST
+3 -19
View File
@@ -683,31 +683,15 @@ async def test_load_unload_entry_tracker(
None,
1.0,
2.0,
STATE_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_GPS_ACCURACY: 0,
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
},
id="in_zones_wins_over_lat_long",
),
pytest.param(
None,
[],
None,
50.0,
60.0,
STATE_NOT_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_GPS_ACCURACY: 0,
ATTR_IN_ZONES: [],
ATTR_LATITUDE: 50.0,
ATTR_LONGITUDE: 60.0,
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
},
id="empty_in_zones_wins_over_lat_long",
id="in_zones_ignored_when_lat_long_set",
),
pytest.param(
None,
+1 -63
View File
@@ -28,7 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery, issue_registry as ir
from homeassistant.helpers import discovery
from homeassistant.helpers.discovery import DiscoveryInfoType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import JSONEncoder
@@ -821,65 +821,3 @@ async def test_modern_platform_setup(hass: HomeAssistant) -> None:
"in_zones": [],
"source_type": SourceType.ROUTER,
}
async def test_unsupported_legacy_config_creates_issue(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test unsupported legacy config creates issue."""
integration_domain = "test"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.async_create_task(
discovery.async_load_platform(
hass, "device_tracker", integration_domain, {}, config
)
)
return True
mock_integration(
hass,
MockModule(integration_domain, async_setup=async_setup),
)
mock_platform(
hass,
f"{integration_domain}.device_tracker",
MockPlatform(),
)
await async_setup_component(hass, "homeassistant", {})
await async_setup_component(
hass,
device_tracker.DOMAIN,
{device_tracker.DOMAIN: {"platform": integration_domain, "something": "value"}},
)
await async_setup_component(hass, integration_domain, {})
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
assert len(hass.states.async_all(device_tracker.DOMAIN)) == 0
assert (
f"The {integration_domain} platform for the {device_tracker.DOMAIN} integration does not support platform"
" setup, please remove it from your config" in caplog.text
)
issue = issue_registry.async_get_issue(
"homeassistant",
f"platform_integration_no_support_{device_tracker.DOMAIN}_{integration_domain}",
)
assert issue
assert issue.issue_domain == integration_domain
assert issue.learn_more_url is None
assert issue.translation_key == "platform_setup_not_supported"
assert issue.severity == ir.IssueSeverity.ERROR
assert issue.translation_placeholders == {
"platform_domain": device_tracker.DOMAIN,
"integration_domain": integration_domain,
"platform_key": f"platform: {integration_domain}",
"yaml_example": f"```yaml\n{device_tracker.DOMAIN}:\n - platform: {integration_domain}\n```",
}
+3 -3
View File
@@ -71,12 +71,12 @@ async def test_form(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_with_advanced_options(hass: HomeAssistant) -> None:
"""Test we can submit the form with custom resolver and port options."""
async def test_form_adv(hass: HomeAssistant) -> None:
"""Test we get the form with advanced options on."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
)
assert result["data_schema"] == DATA_SCHEMA
@@ -1 +0,0 @@
"""Tests for the Edifier Infrared integration."""
@@ -1,100 +0,0 @@
"""Common fixtures for the Edifier Infrared tests."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.edifier_infrared import PLATFORMS
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.infrared import (
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
)
from tests.components.infrared.common import MockInfraredEmitterEntity
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000000",
title="Edifier R1700BT via Test IR emitter",
data={
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
CONF_COMMAND_SET: EdifierCommandSets.R1700BT.value,
},
unique_id=f"r1700bt_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return PLATFORMS
@pytest.fixture
def mock_edifier_code_to_command() -> Generator[None]:
"""Patch Edifier *Code.to_command to return the code enum directly.
This allows tests to assert on the high-level code enum value
rather than the raw NEC timings.
"""
with (
patch(
"infrared_protocols.codes.edifier.r1700bt.EdifierR1700BTCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.r1280db.EdifierR1280DBCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.r1280t.EdifierR1280TCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.s360db.EdifierS360DBCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.rc20g.EdifierRC20GCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
):
yield
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_edifier_code_to_command: None,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Edifier Infrared integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.edifier_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -1,55 +0,0 @@
# serializer version: 1
# name: test_entities[media_player.edifier_r1700bt-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.edifier_r1700bt',
'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': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 17849>,
'translation_key': None,
'unique_id': '01JTEST0000000000000000000_media_player',
'unit_of_measurement': None,
})
# ---
# name: test_entities[media_player.edifier_r1700bt-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'device_class': 'speaker',
'friendly_name': 'Edifier R1700BT',
'supported_features': <MediaPlayerEntityFeature: 17849>,
}),
'context': <ANY>,
'entity_id': 'media_player.edifier_r1700bt',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
@@ -1,132 +0,0 @@
"""Tests for the Edifier Infrared config flow."""
import pytest
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.infrared import EMITTER_ENTITY_ID
@pytest.mark.parametrize(
("model", "expected_command_set"),
[
(EdifierModel.R1700BT, EdifierCommandSets.R1700BT),
(EdifierModel.R1280DB, EdifierCommandSets.R1280DB),
(EdifierModel.R1280T, EdifierCommandSets.R1280T),
(EdifierModel.S360DB, EdifierCommandSets.S360DB),
(EdifierModel.RC20G, EdifierCommandSets.RC20G),
],
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_success(
hass: HomeAssistant,
model: EdifierModel,
expected_command_set: EdifierCommandSets,
) -> None:
"""Test successful user config flow for each command set."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: model.value,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Edifier {model.value} via Test IR emitter"
assert result["data"] == {
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: model.value,
CONF_COMMAND_SET: expected_command_set.value,
}
assert (
result["result"].unique_id
== f"{expected_command_set.value}_{EMITTER_ENTITY_ID}"
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test user flow aborts when entry is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("init_infrared")
async def test_user_flow_no_emitters(hass: HomeAssistant) -> None:
"""Test user flow aborts when no infrared emitters exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_emitters"
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.parametrize(
("entity_name", "expected_title"),
[
(None, "Edifier R1700BT via Test IR emitter"),
("Living room IR", "Edifier R1700BT via Living room IR"),
],
)
async def test_user_flow_title_from_entity_name(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_name: str | None,
expected_title: str,
) -> None:
"""Test config entry title uses the entity name."""
entity_registry.async_update_entity(EMITTER_ENTITY_ID, name=entity_name)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
@@ -1,19 +0,0 @@
"""Tests for the Edifier Infrared integration setup."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_setup_and_unload_entry(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
"""Test setting up and unloading a config entry."""
entry = init_integration
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -1,144 +0,0 @@
"""Tests for the Edifier Infrared media player platform."""
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.components.media_player import (
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_UP,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import MockInfraredEmitterEntity
MEDIA_PLAYER_ENTITY_ID = "media_player.edifier_r1700bt"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.MEDIA_PLAYER]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the media player entity is created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "service_data", "expected_code"),
[
(SERVICE_TURN_ON, {}, EdifierR1700BTCode.POWER),
(SERVICE_TURN_OFF, {}, EdifierR1700BTCode.POWER),
(SERVICE_VOLUME_UP, {}, EdifierR1700BTCode.VOLUME_UP),
(SERVICE_VOLUME_DOWN, {}, EdifierR1700BTCode.VOLUME_DOWN),
(SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, EdifierR1700BTCode.MUTE),
(SERVICE_MEDIA_PLAY, {}, EdifierR1700BTCode.PLAY_PAUSE),
(SERVICE_MEDIA_PAUSE, {}, EdifierR1700BTCode.PLAY_PAUSE),
(SERVICE_MEDIA_NEXT_TRACK, {}, EdifierR1700BTCode.FORWARD),
(SERVICE_MEDIA_PREVIOUS_TRACK, {}, EdifierR1700BTCode.BACK),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_media_player_action_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
service: str,
service_data: dict[str, bool],
expected_code: EdifierR1700BTCode,
) -> None:
"""Test each media player action sends the correct IR code."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
@pytest.mark.parametrize(
"mock_config_entry",
[
MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000001",
title="Edifier RC20G via Test IR emitter",
data={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.RC20G.value,
CONF_COMMAND_SET: EdifierCommandSets.RC20G.value,
},
unique_id=f"rc20g_{EMITTER_ENTITY_ID}",
)
],
)
@pytest.mark.parametrize(
("service", "expected_codes"),
[
(
SERVICE_VOLUME_UP,
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
),
(
SERVICE_VOLUME_DOWN,
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_rc20g_volume_sends_left_and_right_codes(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
service: str,
expected_codes: tuple[EdifierRC20GCode, ...],
) -> None:
"""Test that RC20G volume up/down send both left and right channel codes."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.edifier_rc20g"},
blocking=True,
)
assert tuple(mock_infrared_emitter_entity.send_command_calls) == expected_codes
@pytest.mark.usefixtures("init_integration")
async def test_media_player_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test media player becomes unavailable when IR entity is unavailable."""
await assert_availability_follows_source_entity(
hass, MEDIA_PLAYER_ENTITY_ID, EMITTER_ENTITY_ID
)
@@ -18,7 +18,7 @@
}),
'ulid-conversation': dict({
'data': dict({
'chat_model': 'models/gemini-3.1-flash-lite',
'chat_model': 'models/gemini-2.5-flash',
'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE',
@@ -21,7 +21,7 @@
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-3.1-flash-lite',
'model': 'gemini-2.5-flash',
'model_id': None,
'name': 'Google AI Conversation',
'name_by_user': None,
@@ -50,7 +50,7 @@
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-3.1-flash-lite',
'model': 'gemini-2.5-flash',
'model_id': None,
'name': 'Google AI STT',
'name_by_user': None,
@@ -108,7 +108,7 @@
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-3.1-flash-lite',
'model': 'gemini-2.5-flash',
'model_id': None,
'name': 'Google AI Task',
'name_by_user': None,
@@ -51,7 +51,7 @@ def get_models_pager():
model_25_flash = Mock(
supported_actions=["generateContent"],
)
model_25_flash.name = "models/gemini-3.1-flash-lite"
model_25_flash.name = "models/gemini-2.5-flash"
model_20_flash = Mock(
supported_actions=["generateContent"],
@@ -21,7 +21,7 @@ from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST
from tests.common import MockConfigEntry
TEST_CHAT_MODEL = "models/gemini-3.1-flash-lite"
TEST_CHAT_MODEL = "models/gemini-2.5-flash"
TEST_PROMPT = "Please transcribe the audio."
+13 -7
View File
@@ -317,16 +317,20 @@ async def test_options(
@pytest.mark.parametrize(
("group_type", "extra_options", "extra_options_after"),
("group_type", "extra_options", "extra_options_after", "advanced"),
[
("light", {"all": False}, {"all": False}),
("light", {"all": True}, {"all": False}),
("switch", {"all": False}, {"all": False}),
("switch", {"all": True}, {"all": False}),
("light", {"all": False}, {"all": False}, False),
("light", {"all": True}, {"all": False}, False),
("light", {"all": False}, {"all": False}, True),
("light", {"all": True}, {"all": False}, True),
("switch", {"all": False}, {"all": False}, False),
("switch", {"all": True}, {"all": False}, False),
("switch", {"all": False}, {"all": False}, True),
("switch", {"all": True}, {"all": False}, True),
],
)
async def test_all_options(
hass: HomeAssistant, group_type, extra_options, extra_options_after
hass: HomeAssistant, group_type, extra_options, extra_options_after, advanced
) -> None:
"""Test reconfiguring."""
members1 = [f"{group_type}.one", f"{group_type}.two"]
@@ -352,7 +356,9 @@ async def test_all_options(
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(
config_entry.entry_id, context={"show_advanced_options": advanced}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == group_type
@@ -1,57 +0,0 @@
# serializer version: 1
# name: test_states
set({
StateSnapshot({
'attributes': ReadOnlyDict({
'editable': True,
'friendly_name': 'test home',
'icon': 'mdi:home',
'latitude': 32.87336,
'longitude': -117.22743,
'passive': False,
'persons': list([
]),
'radius': 100,
}),
'context': <ANY>,
'entity_id': 'zone.home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Demo scanner',
'in_zones': list([
'zone.home',
]),
'source_type': <SourceType.ROUTER: 'router'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.demo_scanner',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'home',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Demo tracker',
'gps_accuracy': 10,
'in_zones': list([
'zone.home',
]),
'latitude': 32.87336,
'longitude': -117.22743,
'source_type': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.demo_tracker',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'home',
}),
})
# ---
@@ -1,146 +0,0 @@
"""The tests for the kitchen_sink device_tracker platform."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.device_tracker import ATTR_SOURCE_TYPE, SourceType
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.kitchen_sink.services import (
ATTR_ACCURACY,
ATTR_CONNECTED,
SERVICE_SET_SCANNER_CONNECTED,
SERVICE_SET_TRACKER_LOCATION,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
TRACKER_ENTITY_ID = "device_tracker.demo_tracker"
SCANNER_ENTITY_ID = "device_tracker.demo_scanner"
@pytest.fixture
def device_tracker_only() -> Generator[None]:
"""Enable only the device_tracker platform."""
with patch(
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
[Platform.DEVICE_TRACKER],
):
yield
@pytest.fixture(autouse=True)
async def setup_comp(hass: HomeAssistant, device_tracker_only: None) -> None:
"""Set up demo component."""
hass.config.latitude = 32.87336
hass.config.longitude = -117.22743
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test the expected device_tracker entities are added."""
states = hass.states.async_all()
assert set(states) == snapshot
async def test_set_tracker_location(hass: HomeAssistant) -> None:
"""Test the set_tracker_location service updates tracker attributes."""
state = hass.states.get(TRACKER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_SOURCE_TYPE] == SourceType.GPS
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TRACKER_LOCATION,
{
ATTR_ENTITY_ID: TRACKER_ENTITY_ID,
ATTR_LATITUDE: 12.34,
ATTR_LONGITUDE: 56.78,
ATTR_ACCURACY: 42,
},
blocking=True,
)
state = hass.states.get(TRACKER_ENTITY_ID)
assert state.attributes[ATTR_LATITUDE] == 12.34
assert state.attributes[ATTR_LONGITUDE] == 56.78
assert state.attributes[ATTR_GPS_ACCURACY] == 42
assert state.state == STATE_NOT_HOME
async def test_set_scanner_connected(hass: HomeAssistant) -> None:
"""Test the set_scanner_connected service updates scanner state."""
state = hass.states.get(SCANNER_ENTITY_ID)
assert state is not None
assert state.state == STATE_HOME
assert state.attributes[ATTR_SOURCE_TYPE] == SourceType.ROUTER
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
{ATTR_ENTITY_ID: SCANNER_ENTITY_ID, ATTR_CONNECTED: False},
blocking=True,
)
state = hass.states.get(SCANNER_ENTITY_ID)
assert state.state == STATE_NOT_HOME
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
{ATTR_ENTITY_ID: SCANNER_ENTITY_ID, ATTR_CONNECTED: True},
blocking=True,
)
state = hass.states.get(SCANNER_ENTITY_ID)
assert state.state == STATE_HOME
async def test_set_tracker_location_on_scanner_raises(hass: HomeAssistant) -> None:
"""Calling set_tracker_location on the scanner surfaces an AttributeError.
The service is registered for the device_tracker domain and dispatches by
method name, so targeting the scanner (which has no async_set_tracker_location)
bubbles up the missing-attribute error from the entity.
"""
with pytest.raises(
AttributeError,
match="'DemoScanner' object has no attribute 'async_set_tracker_location'",
):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TRACKER_LOCATION,
{
ATTR_ENTITY_ID: SCANNER_ENTITY_ID,
ATTR_LATITUDE: 12.34,
ATTR_LONGITUDE: 56.78,
ATTR_ACCURACY: 42,
},
blocking=True,
)
async def test_set_scanner_connected_on_tracker_raises(hass: HomeAssistant) -> None:
"""Calling set_scanner_connected on the tracker surfaces an AttributeError."""
with pytest.raises(
AttributeError,
match="'DemoTracker' object has no attribute 'async_set_scanner_connected'",
):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
{ATTR_ENTITY_ID: TRACKER_ENTITY_ID, ATTR_CONNECTED: False},
blocking=True,
)
+1 -1
View File
@@ -369,7 +369,7 @@ async def test_service(
await hass.services.async_call(
DOMAIN,
"test_service_1",
{"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forward"},
{"field_1": 1, "field_2": "auto", "field_3": 1, "field_4": "forwards"},
blocking=True,
)
@@ -172,16 +172,16 @@ async def setup_zone(hass: HomeAssistant) -> None:
{"in_zones": []},
"not_home",
),
# in_zones + gps: in_zones wins, gps coordinates still reported as attributes
# in_zones + gps: gps wins, in_zones recomputed from coordinates
(
{"gps": [10, 20], "in_zones": ["zone.school"]},
{
"latitude": 10,
"longitude": 20,
"gps_accuracy": 30,
"in_zones": ["zone.school"],
"in_zones": ["zone.home"],
},
"School",
"home",
),
],
)
-59
View File
@@ -1,59 +0,0 @@
"""OPNsense session fixtures."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.opnsense.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import ARP, CONFIG_DATA, INTERFACES
from tests.common import MockConfigEntry
CONF_OPNSENSE_CLIENT = "opnsense_client"
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant, mock_opnsense_client: AsyncMock
) -> MockConfigEntry:
"""Return the default mocked config entry."""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_DATA,
unique_id="mocked_unique_id",
)
mock_config_entry.add_to_hass(hass)
return mock_config_entry
@pytest.fixture
def mock_opnsense_client() -> Generator[AsyncMock]:
"""Override OPNsenseClient in both config_flow and component."""
with (
patch(
"homeassistant.components.opnsense.config_flow.OPNsenseClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.opnsense.OPNsenseClient",
new=mock_client,
),
):
client = mock_client.return_value
client.get_host_firmware_version.return_value = "25.7.8"
client.get_arp_table.return_value = ARP
client.get_interfaces.return_value = INTERFACES
client.get_device_unique_id.return_value = "mocked_unique_id"
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.opnsense.async_setup_entry", return_value=True
):
yield
-42
View File
@@ -1,42 +0,0 @@
"""Constants for opnsense tests."""
from homeassistant.components.opnsense.const import (
CONF_API_SECRET,
CONF_TRACKER_INTERFACES,
)
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
TITLE = "OPNsense"
CONFIG_DATA = {
CONF_URL: "http://router.lan/api",
CONF_API_KEY: "key",
CONF_API_SECRET: "secret",
CONF_VERIFY_SSL: False,
}
CONFIG_DATA_IMPORT = {
CONF_URL: "http://router.lan/api",
CONF_API_KEY: "key",
CONF_API_SECRET: "secret",
CONF_VERIFY_SSL: False,
CONF_TRACKER_INTERFACES: ["LAN"],
}
ARP = [
{
"hostname": "",
"intf": "igb1",
"intf_description": "LAN",
"ip": "192.168.0.123",
"mac": "ff:ff:ff:ff:ff:ff",
"manufacturer": "",
},
{
"hostname": "Desktop",
"intf": "igb1",
"intf_description": "LAN",
"ip": "192.168.0.167",
"mac": "ff:ff:ff:ff:ff:fe",
"manufacturer": "OEM",
},
]
INTERFACES = {"igb0": {"name": "WAN"}, "igb1": {"name": "LAN"}}
@@ -1,325 +0,0 @@
"""Tests for the OPNsense config flow."""
from unittest.mock import AsyncMock
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseTimeoutError,
)
import pytest
from homeassistant.components.opnsense import OPNsenseSSLError, OPNsenseUnknownFirmware
from homeassistant.components.opnsense.const import CONF_TRACKER_INTERFACES, DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import issue_registry as ir
from .const import CONFIG_DATA, CONFIG_DATA_IMPORT
# Constants for test values
TEST_URL = "http://router.lan/api"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user(hass: HomeAssistant, mock_opnsense_client: AsyncMock) -> None:
"""Test user config."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
# Submit user step, should go to interfaces step
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONFIG_DATA,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "interfaces"
# Submit interfaces step (simulate user selecting all interfaces)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_TRACKER_INTERFACES: []},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == CONFIG_DATA[CONF_URL]
assert result.get("data") == CONFIG_DATA
assert result["result"].unique_id == "mocked_unique_id"
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("exc", "expected"),
[
(OPNsenseInvalidAuth, "invalid_auth"),
(OPNsensePrivilegeMissing, "privilege_missing"),
(OPNsenseInvalidURL, "invalid_url"),
(OPNsenseSSLError, "ssl_error"),
(OPNsenseConnectionError, "cannot_connect"),
(OPNsenseTimeoutError, "cannot_connect"),
(OPNsenseUnknownFirmware, "unknown_version"),
(OPNsenseBelowMinFirmware, "invalid_version"),
],
)
async def test_user_exceptions(
hass: HomeAssistant,
mock_opnsense_client: AsyncMock,
exc: type[Exception],
expected: str,
) -> None:
"""Test all exception branches in async_step_user."""
mock_opnsense_client.validate.side_effect = exc
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_DATA
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": expected}
mock_opnsense_client.validate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_DATA
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry", "mock_config_entry")
async def test_user_unique_id_already_configured(
hass: HomeAssistant, mock_opnsense_client: AsyncMock
) -> None:
"""Test user flow aborts when unique ID is already configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG_DATA
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_no_unique_id_aborts(
hass: HomeAssistant, mock_opnsense_client: AsyncMock
) -> None:
"""Test that the user flow aborts if the router has no unique id."""
mock_opnsense_client.get_device_unique_id.return_value = None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={**CONFIG_DATA, CONF_URL: TEST_URL},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_unique_id"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_on_unknown_error(
hass: HomeAssistant, mock_opnsense_client: AsyncMock
) -> None:
"""Test when we have unknown errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
mock_opnsense_client.validate.side_effect = TypeError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONFIG_DATA,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": "unknown"}
mock_opnsense_client.validate.side_effect = None
# Submit user step, should go to interfaces step
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONFIG_DATA,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "interfaces"
# Submit interfaces step
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_TRACKER_INTERFACES: []},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_interfaces_step_with_tracker_interfaces(
hass: HomeAssistant, mock_opnsense_client: AsyncMock
) -> None:
"""Test interfaces step with tracker_interfaces in user_input (covering the missing branch)."""
# Patch the client to return interfaces
mock_opnsense_client.return_value.get_device_unique_id.return_value = (
"unique_id_789"
)
mock_opnsense_client.return_value.get_interfaces.return_value = {
"LAN": {"name": "LAN"},
"WAN": {"name": "WAN"},
}
# Go through user step
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={**CONFIG_DATA, CONF_VERIFY_SSL: True},
)
# Now submit interfaces step with tracker_interfaces
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_TRACKER_INTERFACES: ["LAN", "WAN"]},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TRACKER_INTERFACES] == ["LAN", "WAN"]
@pytest.mark.usefixtures("mock_setup_entry")
async def test_import(hass: HomeAssistant, mock_opnsense_client: AsyncMock) -> None:
"""Test import step."""
mock_opnsense_client.return_value.get_device_unique_id.return_value = (
"unique_id_123"
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_DATA_IMPORT,
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == CONFIG_DATA_IMPORT[CONF_URL]
@pytest.mark.usefixtures(
"mock_opnsense_client", "mock_setup_entry", "mock_config_entry"
)
async def test_import_unique_id_already_configured(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test import step when unique ID is already configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_DATA_IMPORT,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
# The deprecation issue must still be created so the YAML block gets removed
issue = issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
assert issue is not None
assert issue.translation_key == "deprecated_yaml"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_import_no_unique_id_aborts(
hass: HomeAssistant,
mock_opnsense_client: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that the import flow aborts and raises a repair if no unique id."""
mock_opnsense_client.get_device_unique_id.return_value = None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_DATA_IMPORT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_unique_id"
assert issue_registry.async_get_issue(DOMAIN, "import_failed_no_unique_id")
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("exc", "reason"),
[
(OPNsenseInvalidURL, "invalid_url"),
(OPNsenseInvalidAuth, "invalid_auth"),
(OPNsensePrivilegeMissing, "privilege_missing"),
(OPNsenseSSLError, "ssl_error"),
(OPNsenseConnectionError, "cannot_connect"),
(OPNsenseTimeoutError, "cannot_connect"),
(OPNsenseUnknownFirmware, "unknown_version"),
(OPNsenseBelowMinFirmware, "invalid_version"),
(Exception, "unknown"),
],
)
async def test_import_exceptions(
hass: HomeAssistant,
mock_opnsense_client: AsyncMock,
issue_registry: ir.IssueRegistry,
exc: type[Exception],
reason: str,
) -> None:
"""Test all exception branches in async_step_import."""
mock_opnsense_client.validate.side_effect = exc
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONFIG_DATA_IMPORT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
assert issue_registry.async_get_issue(DOMAIN, f"import_failed_{reason}")
@pytest.mark.usefixtures("mock_opnsense_client", "mock_setup_entry")
async def test_import_empty_tracker_interfaces(hass: HomeAssistant) -> None:
"""Test import with empty CONF_TRACKER_INTERFACES (should pop the key)."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={**CONFIG_DATA_IMPORT, CONF_TRACKER_INTERFACES: []},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert CONF_TRACKER_INTERFACES not in result["data"]
@pytest.mark.usefixtures("mock_setup_entry")
async def test_import_missing_interfaces(
hass: HomeAssistant,
mock_opnsense_client: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test import with missing tracker interfaces (should create issue and abort)."""
mock_opnsense_client.get_interfaces.return_value = {"LAN": {"name": "LAN"}}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={**CONFIG_DATA_IMPORT, CONF_TRACKER_INTERFACES: ["MISSING"]},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "import_failed_missing_interfaces"
assert issue_registry.async_get_issue(DOMAIN, "import_failed_missing_interfaces")
+53 -152
View File
@@ -1,169 +1,70 @@
"""The tests for the opnsense device tracker platform."""
from datetime import timedelta
from unittest import mock
from aiopnsense import OPNsenseConnectionError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components import device_tracker
from homeassistant.components.opnsense import OPNsenseRuntimeData
from homeassistant.components.opnsense.const import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.components import opnsense
from homeassistant.components.device_tracker import legacy
from homeassistant.components.opnsense import CONF_API_SECRET, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed
from homeassistant.setup import async_setup_component
@pytest.mark.usefixtures("mock_opnsense_client")
async def test_device_tracker_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
@pytest.fixture(name="mocked_opnsense")
def mocked_opnsense():
"""Mock for aiopnsense.OPNsenseClient."""
with mock.patch.object(opnsense, "OPNsenseClient") as mocked_opn:
yield mocked_opn
async def test_get_scanner(
hass: HomeAssistant, mocked_opnsense, mock_device_tracker_conf: list[legacy.Device]
) -> None:
"""Test device tracker platform setup."""
# Setup the integration
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Check that device tracker entities are created
device_tracker_entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
device_tracker_entities = [
entity
for entity in device_tracker_entities
if entity.domain == device_tracker.DOMAIN
"""Test creating an opnsense scanner."""
opnsense_client = mock.AsyncMock()
mocked_opnsense.return_value = opnsense_client
opnsense_client.get_arp_table.return_value = [
{
"hostname": "",
"intf": "igb1",
"intf_description": "LAN",
"ip": "192.168.0.123",
"mac": "ff:ff:ff:ff:ff:ff",
"manufacturer": "",
},
{
"hostname": "Desktop",
"intf": "igb1",
"intf_description": "LAN",
"ip": "192.168.0.167",
"mac": "ff:ff:ff:ff:ff:fe",
"manufacturer": "OEM",
},
]
# Should have 2 devices from ARP table
assert len(device_tracker_entities) == 2
# Check the unique IDs are correct
entity_unique_ids = {entity.unique_id for entity in device_tracker_entities}
assert "ff:ff:ff:ff:ff:ff" in entity_unique_ids
assert "ff:ff:ff:ff:ff:fe" in entity_unique_ids
@pytest.mark.usefixtures("mock_opnsense_client")
async def test_device_tracker_states(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test device tracker entity states and attributes."""
# Setup the integration
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device_tracker_entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
device_tracker_entities = [
entity
for entity in device_tracker_entities
if entity.domain == device_tracker.DOMAIN
]
entity_ids_by_unique_id = {
entity.unique_id: entity.entity_id for entity in device_tracker_entities
opnsense_client.get_interfaces.return_value = {
"wan": {"name": "WAN"},
"lan": {"name": "LAN"},
}
# Enable entities (device trackers are disabled by default)
entity_registry.async_update_entity(
entity_ids_by_unique_id["ff:ff:ff:ff:ff:ff"], disabled_by=None
)
entity_registry.async_update_entity(
entity_ids_by_unique_id["ff:ff:ff:ff:ff:fe"], disabled_by=None
)
# Reload the config entry to activate the enabled entities
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Test first device (no hostname)
entity_id_1 = entity_ids_by_unique_id["ff:ff:ff:ff:ff:ff"]
state_1 = hass.states.get(entity_id_1)
assert state_1 is not None
assert state_1.state == "home" # Should be connected since it's in ARP table
assert state_1.attributes.get("ip") == "192.168.0.123"
assert state_1.attributes.get("mac") == "ff:ff:ff:ff:ff:ff"
assert state_1.attributes.get("interface") == "LAN"
# Test second device (with hostname and manufacturer)
entity_id_2 = entity_ids_by_unique_id["ff:ff:ff:ff:ff:fe"]
state_2 = hass.states.get(entity_id_2)
assert state_2 is not None
assert state_2.state == "home" # Should be connected since it's in ARP table
assert state_2.attributes.get("ip") == "192.168.0.167"
assert state_2.attributes.get("mac") == "ff:ff:ff:ff:ff:fe"
assert state_2.attributes.get("interface") == "LAN"
assert state_2.attributes.get("manufacturer") == "OEM"
async def test_device_tracker_with_interfaces_filter(
hass: HomeAssistant,
mock_opnsense_client: mock.AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test device tracker with interface filtering."""
# Create config entry with interface filtering
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"url": "http://router.lan/api",
"api_key": "key",
"api_secret": "secret",
"verify_ssl": False,
"tracker_interfaces": ["WAN"], # Filter to only WAN interface
result = await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
CONF_URL: "https://fake_host_fun/api",
CONF_API_KEY: "fake_key",
CONF_API_SECRET: "fake_secret",
CONF_VERIFY_SSL: False,
}
},
)
mock_config_entry.runtime_data = OPNsenseRuntimeData(
client=mock_opnsense_client.return_value,
tracker_interfaces=["WAN"],
)
mock_config_entry.add_to_hass(hass)
# Setup the integration
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Check that no device tracker entities are created (since all devices are on LAN)
device_tracker_entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
device_tracker_entities = [
entity
for entity in device_tracker_entities
if entity.domain == device_tracker.DOMAIN
]
assert len(device_tracker_entities) == 0
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_device_tracker_coordinator_update_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_opnsense_client: mock.AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator wraps client errors as UpdateFailed."""
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("device_tracker.desktop").state != STATE_UNAVAILABLE
mock_opnsense_client.get_arp_table.side_effect = OPNsenseConnectionError(
"connection failed"
)
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("device_tracker.desktop").state == STATE_UNAVAILABLE
assert mock_opnsense_client.get_arp_table.call_count == 2
assert result
device_1 = hass.states.get("device_tracker.desktop")
assert device_1 is not None
assert device_1.state == "home"
device_2 = hass.states.get("device_tracker.ff_ff_ff_ff_ff_ff")
assert device_2.state == "home"
-88
View File
@@ -1,88 +0,0 @@
"""Tests for the opnsense integration setup."""
from unittest import mock
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
import pytest
from homeassistant.components.opnsense.const import (
CONF_API_SECRET,
CONF_TRACKER_INTERFACES,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("exc", "expected_state", "expected_translation_key"),
[
(OPNsenseUnknownFirmware, ConfigEntryState.SETUP_ERROR, "unknown_firmware"),
(OPNsenseBelowMinFirmware, ConfigEntryState.SETUP_ERROR, "firmware_too_old"),
(OPNsenseInvalidURL, ConfigEntryState.SETUP_ERROR, "invalid_url"),
(OPNsenseTimeoutError, ConfigEntryState.SETUP_RETRY, "timeout_connecting"),
(OPNsenseSSLError, ConfigEntryState.SETUP_ERROR, "ssl_error"),
(OPNsenseInvalidAuth, ConfigEntryState.SETUP_ERROR, "invalid_auth"),
(OPNsensePrivilegeMissing, ConfigEntryState.SETUP_ERROR, "privilege_missing"),
(OPNsenseConnectionError, ConfigEntryState.SETUP_RETRY, "cannot_connect"),
],
)
async def test_setup_entry_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_opnsense_client: mock.AsyncMock,
exc: type[Exception],
expected_state: ConfigEntryState,
expected_translation_key: str,
) -> None:
"""Test async_setup_entry surfaces translation-keyed errors."""
mock_opnsense_client.validate.side_effect = exc
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
assert mock_config_entry.error_reason_translation_key == expected_translation_key
assert mock_config_entry.error_reason_translation_placeholders == {
"url": mock_config_entry.data[CONF_URL]
}
async def test_setup_entry_tracker_interface_not_found(
hass: HomeAssistant,
mock_opnsense_client: mock.AsyncMock,
) -> None:
"""Test async_setup_entry rejects unknown tracker interfaces."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_URL: "http://router.lan/api",
CONF_API_KEY: "key",
CONF_API_SECRET: "secret",
CONF_VERIFY_SSL: False,
CONF_TRACKER_INTERFACES: ["NOPE"],
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_ERROR
assert entry.error_reason_translation_key == "tracker_interface_not_found"
assert entry.error_reason_translation_placeholders == {
"interface": "NOPE",
"known": "WAN, LAN",
}
-3
View File
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from reolink_aio.api import Chime
from reolink_aio.enums import ConnectionEnum
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
@@ -51,7 +50,6 @@ TEST_CAM_MODEL = "RLC-123"
TEST_DUO_MODEL = "Reolink Duo PoE"
TEST_PRIVACY = True
TEST_BC_PORT = 5678
TEST_BC_CON = ConnectionEnum.tcp.value
@pytest.fixture
@@ -164,7 +162,6 @@ def _init_host_mock(host_mock: MagicMock) -> None:
host_mock.baichuan_only = False
# Disable tcp push by default for tests
host_mock.baichuan.port = TEST_BC_PORT
host_mock.baichuan.connection_type = ConnectionEnum(TEST_BC_CON)
host_mock.baichuan.events_active = False
host_mock.baichuan.login_sucess = True
host_mock.baichuan.subscribe_events = AsyncMock()
@@ -7,7 +7,6 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
from aiohttp import ClientSession
from freezegun.api import FrozenDateTimeFactory
import pytest
from reolink_aio.enums import ConnectionEnum
from reolink_aio.exceptions import (
ApiError,
CredentialsInvalidError,
@@ -19,7 +18,6 @@ from reolink_aio.exceptions import (
from homeassistant import config_entries
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import (
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -44,7 +42,6 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import (
DHCP_FORMATTED_MAC,
TEST_BC_CON,
TEST_BC_PORT,
TEST_HOST,
TEST_HOST2,
@@ -94,7 +91,6 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None:
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -150,7 +146,6 @@ async def test_config_flow_privacy_success(
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -193,7 +188,6 @@ async def test_config_flow_baichuan_only(
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: True,
}
assert result["options"] == {
@@ -356,7 +350,6 @@ async def test_config_flow_errors(hass: HomeAssistant, reolink_host: MagicMock)
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -377,7 +370,6 @@ async def test_options_flow(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -419,7 +411,6 @@ async def test_reauth(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -468,7 +459,6 @@ async def test_reauth_abort_unique_id_mismatch(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -539,7 +529,6 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None:
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -564,7 +553,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -607,7 +595,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -690,7 +677,6 @@ async def test_dhcp_ip_update(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -734,7 +720,6 @@ async def test_dhcp_ip_update(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -768,7 +753,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -802,7 +786,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -832,7 +815,6 @@ async def test_reconfig(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -882,7 +864,6 @@ async def test_reconfig_abort_unique_id_mismatch(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
+5 -61
View File
@@ -7,10 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientResponseError
from freezegun.api import FrozenDateTimeFactory
import pytest
from reolink_aio.enums import ConnectionEnum, SubType
from reolink_aio.enums import SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components.reolink.const import CONF_BC_CONNECT, DOMAIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.host import (
FIRST_ONVIF_LONG_POLL_TIMEOUT,
@@ -22,39 +21,14 @@ from homeassistant.components.reolink.host import (
)
from homeassistant.components.webhook import async_handle_webhook
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_USERNAME,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.util.aiohttp import MockRequest
from .conftest import (
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DEFAULT_PROTOCOL,
TEST_BC_PORT,
TEST_CAM_NAME,
TEST_HOST,
TEST_MAC,
TEST_NVR_NAME,
TEST_PASSWORD,
TEST_PORT,
TEST_PRIVACY,
TEST_USE_HTTPS,
TEST_USERNAME,
)
from .conftest import TEST_CAM_NAME
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -108,6 +82,7 @@ async def test_webhook_callback(
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test webhook callback with motion sensor."""
reolink_host.motion_detected.return_value = False
@@ -203,37 +178,6 @@ async def test_no_mac(
reolink_host.mac_address = original
async def test_invalid_bc_connection(
hass: HomeAssistant,
reolink_host: MagicMock,
) -> None:
"""Test setup of host with an outdated, invalid bc_connection."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=format_mac(TEST_MAC),
data={
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_ONLY: False,
CONF_BC_CONNECT: "invalid_test",
},
options={
CONF_PROTOCOL: DEFAULT_PROTOCOL,
},
title=TEST_NVR_NAME,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data[CONF_BC_CONNECT] == ConnectionEnum.tcp.value
async def test_subscribe_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+1 -1
View File
@@ -119,7 +119,7 @@ async def create_device(hass: HomeAssistant, mock_device_code: str) -> CustomerD
if device.update_time:
device.update_time = int(dt_util.as_timestamp(device.update_time))
device.support_local = details.get("support_local")
device.local_strategy = details.get("local_strategy") or {}
device.local_strategy = details.get("local_strategy")
device.mqtt_connected = details.get("mqtt_connected")
device.function = {
@@ -198,8 +198,7 @@
'name_by_user': None,
}),
'id': '2pxfek1jjrtctiyglam',
'local_strategy': dict({
}),
'local_strategy': None,
'mqtt_connected': True,
'name': 'Multifunction alarm',
'online': True,
@@ -389,8 +388,7 @@
'name_by_user': None,
}),
'id': 'cwwk68dyfsh2eqi4jbqr',
'local_strategy': dict({
}),
'local_strategy': None,
'mqtt_connected': True,
'name': 'Gas sensor',
'online': True,
@@ -541,8 +539,7 @@
'name_by_user': None,
}),
'id': 'vrhdtr5fawoiyth9qdt',
'local_strategy': dict({
}),
'local_strategy': None,
'mqtt_connected': True,
'name': 'Framboisiers',
'online': True,
@@ -685,8 +682,7 @@
'name_by_user': None,
}),
'id': 'cwwk68dyfsh2eqi4jbqr',
'local_strategy': dict({
}),
'local_strategy': None,
'name': 'Gas sensor',
'online': True,
'product_id': '4iqe2hsfyd86kwwc',
+1 -1
View File
@@ -56,7 +56,7 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = {
("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json")
],
"Dimmable Light": [
("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmable-light-detail.json")
("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json")
],
"Temperature Light": [
("post", "/cloud/v1/deviceManaged/bypass", "light-detail.json")
@@ -1,22 +0,0 @@
{
"traceId": "1234",
"code": 0,
"msg": "request success",
"module": null,
"stacktrace": null,
"result": {
"deviceName": "Dimmable",
"name": "Dimmable",
"brightNess": "80",
"deviceStatus": "on",
"activeTime": 0,
"defaultDeviceImg": "https://image.vesync.com/defaultImages/ESL100_Series/icon_dimmable_bulb_80.png",
"timer": null,
"scheduleCount": 0,
"away": null,
"schedule": null,
"ownerShip": "1",
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifismartbulb_240.png",
"connectionStatus": "online"
}
}
@@ -300,8 +300,6 @@
# name: test_light_state[Dimmable Light][light.dimmable_light]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 204,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'friendly_name': 'Dimmable Light',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
@@ -313,7 +311,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
'state': 'unavailable',
})
# ---
# name: test_light_state[Dimmer Switch][devices]
@@ -662,27 +662,17 @@
# name: test_update_state[Dimmable Light][update.dimmable_light_firmware]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Dimmable Light Firmware',
'in_progress': False,
'installed_version': '1.0.0',
'latest_version': '1.0.1',
'release_summary': None,
'release_url': None,
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.dimmable_light_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
'state': 'unavailable',
})
# ---
# name: test_update_state[Dimmer Switch][devices]
-57
View File
@@ -3326,63 +3326,6 @@ async def _setup_numerical_condition(
"90",
False,
),
# outside (inverse of between) — limits are non-inclusive, so a value
# equal to either bound is treated as "not inside" and matches
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"50",
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"20",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"80",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"10",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"90",
True,
),
],
)
async def test_numerical_condition_thresholds(
-371
View File
@@ -1937,188 +1937,6 @@ async def test_numerical_state_attribute_changed_error_handling(
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "new_value", "expected_fires"),
[
# above — limit is non-inclusive
({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True),
({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False),
# below — limit is non-inclusive
({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True),
({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False),
# between — both limits are non-inclusive
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
True,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
False,
),
# outside — values equal to either bound are treated as "not inside"
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
True,
),
# any — fires on every numerical change regardless of value
({"threshold": {"type": "any"}}, 0, True),
({"threshold": {"type": "any"}}, 50, True),
({"threshold": {"type": "any"}}, 1000, True),
],
)
async def test_numerical_state_attribute_changed_trigger_thresholds(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_options: dict[str, Any],
new_value: float,
expected_fires: bool,
) -> None:
"""Test numerical changed trigger above/below/between/outside/any thresholds.
Verifies that the threshold limits are non-inclusive: a tracked value
exactly equal to a limit is treated as "not inside" the range.
"""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"attribute_changed": make_entity_numerical_state_changed_trigger(
{"test": DomainSpec(value_source="test_attribute")}
),
}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Seed the entity with a starting value that differs from new_value so
# the changed-transition is always satisfied; the test then exercises
# the is_valid_state boundary semantics for the new value.
initial_value = -1 if new_value != -1 else -2
hass.states.async_set("test.test_entity", "on", {"test_attribute": initial_value})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.attribute_changed",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
},
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value})
await hass.async_block_till_done()
assert len(service_calls) == (1 if expected_fires else 0)
async def test_numerical_state_attribute_changed_entity_limit_unit_validation(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
@@ -3027,195 +2845,6 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "new_value", "expected_fires"),
[
# above — limit is non-inclusive, crossing exactly onto the limit does
# not enter the range
({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True),
({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False),
# below — limit is non-inclusive
({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True),
({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False),
# between — both limits are non-inclusive
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
True,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
False,
),
# outside — values equal to either bound are treated as "not inside"
# and therefore enter the "outside" range from the inside seed value
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
True,
),
],
)
async def test_numerical_state_attribute_crossed_threshold_trigger_thresholds(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_options: dict[str, Any],
new_value: float,
expected_fires: bool,
) -> None:
"""Test crossed-threshold trigger above/below/between/outside thresholds.
Verifies the threshold limits are non-inclusive: transitioning to a value
exactly equal to a limit does not enter the range, so the trigger does
not fire. For "outside", values equal to either bound are considered
outside and therefore do cause the trigger to fire.
"""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{"test": DomainSpec(value_source="test_attribute")}
),
}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Seed the entity with a value that is NOT in the target range so the
# transition into the new value is a potential "cross". The seed is
# chosen per threshold type to ensure is_valid_state(from_state) is
# False and the seed value differs from any parametrized new_value.
seed_values = {
"above": 0, # 0 is not above 50
"below": 100, # 100 is not below 50
"between": 0, # 0 is not inside (20, 80)
"outside": 30, # 30 is inside (20, 80), i.e. not "outside"
}
seed_value = seed_values[trigger_options["threshold"]["type"]]
hass.states.async_set("test.test_entity", "on", {"test_attribute": seed_value})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.crossed_threshold",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
},
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value})
await hass.async_block_till_done()
assert len(service_calls) == (1 if expected_fires else 0)
async def test_numerical_state_attribute_crossed_threshold_entity_limit_unit_validation(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
+15 -3
View File
@@ -3,6 +3,7 @@
import asyncio
import dataclasses
import logging
from typing import Any
from unittest.mock import Mock, patch
import pytest
@@ -1274,21 +1275,32 @@ def test_nested_section_in_serializer() -> None:
)
@pytest.mark.parametrize(
("context", "expected_show_advanced"),
[
# The property is deprecated and now unconditionally returns True
({}, True),
({"show_advanced_options": False}, True),
({"show_advanced_options": True}, True),
],
)
async def test_show_advanced_options(
manager: MockFlowManager,
context: dict[str, Any],
expected_show_advanced: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test FlowHandler show_advanced_options property is deprecated and always True."""
"""Test FlowHandler show_advanced_options property."""
@manager.mock_reg_handler("test")
class TestFlow(data_entry_flow.FlowHandler):
VERSION = 5
async def async_step_init(self, info):
assert self.show_advanced_options is True
assert self.show_advanced_options == expected_show_advanced
return self.async_create_entry(title="hello", data={})
await manager.async_init("test", context={}, data={})
await manager.async_init("test", context=context, data={})
assert len(manager.async_progress()) == 0
assert len(manager.mock_created_entries) == 1