mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 09:45:13 +02:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf48806ee9 | |||
| 2c25c5ad26 | |||
| ff1177dde4 | |||
| 1cc91cd3b6 | |||
| ecac38a359 | |||
| 8301addc94 | |||
| 77bc932cf0 | |||
| 11903ac62e | |||
| 878761cb41 | |||
| d7bf7df59f | |||
| e5890172a0 | |||
| e9a58cdd20 | |||
| cab7c41a7f | |||
| 277a2d847a | |||
| 7835a4992a | |||
| 9dc37a2f46 | |||
| 7534c438c1 | |||
| 69efa8ee1a | |||
| 305b5d6e00 | |||
| d94226260b | |||
| ecc8e52f3e | |||
| 5771b0c86c | |||
| 3e289da366 | |||
| 944fb1ef67 | |||
| 1b6e9f5094 | |||
| b2257caeb7 | |||
| 0ec0ea30ac | |||
| 584b32c8b3 | |||
| 4033a8b83a | |||
| add8a5f799 | |||
| 7c137b5c73 | |||
| 4a6c5b5a22 | |||
| 1009ce4180 | |||
| 22fb68b7a1 | |||
| 81e06539e6 | |||
| 7c18b67b2e | |||
| a8bc244a7a | |||
| 5975f4b179 | |||
| 9ed16b63a3 | |||
| 8dadaa2f9e | |||
| 4f98c71586 |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,12 +917,49 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pytest test counts cache
|
||||
id: cache-pytest-counts
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
# Primary key is a sentinel; restore-keys pick the most recent
|
||||
# prefix match since the real (content-addressed) key isn't
|
||||
# known until split_tests.py runs below.
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-restore-sentinel
|
||||
restore-keys: |
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
python -m script.split_tests \
|
||||
--cache pytest_test_counts.json \
|
||||
${TEST_GROUP_COUNT} tests
|
||||
- name: Hash pytest test counts cache
|
||||
id: cache-pytest-counts-hash
|
||||
run: |
|
||||
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
- name: Save pytest test counts cache
|
||||
# Content-addressed key: identical content reuses the same entry.
|
||||
# Skip the save when the restore already matched that hash.
|
||||
if: >-
|
||||
!endsWith(
|
||||
steps.cache-pytest-counts.outputs.cache-matched-key,
|
||||
steps.cache-pytest-counts-hash.outputs.hash
|
||||
)
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-${{
|
||||
steps.cache-pytest-counts-hash.outputs.hash }}
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
Generated
+2
@@ -945,6 +945,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
/tests/components/kostal_plenticore/ @stegm
|
||||
/homeassistant/components/kraken/ @eifinger
|
||||
|
||||
@@ -39,6 +39,7 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_DESCRIPTION,
|
||||
CONF_NAME,
|
||||
UnitOfTemperature,
|
||||
@@ -372,6 +373,9 @@ def async_get_entities(
|
||||
"""Return all entities that are supported by Alexa."""
|
||||
entities: list[AlexaEntity] = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
if state.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn
|
||||
|
||||
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||
|
||||
STEP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow."""
|
||||
@@ -38,22 +31,13 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
|
||||
"""Verify the device is reachable; return errors keyed by reason."""
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except socket.gaierror:
|
||||
return {"base": "invalid_host"}
|
||||
except TimeoutError:
|
||||
return {"base": "timeout_connect"}
|
||||
except ConnectionRefusedError:
|
||||
return {"base": "connection_refused"}
|
||||
except ConnectionFailed, OSError:
|
||||
return {"base": "cannot_connect"}
|
||||
finally:
|
||||
await client.stop()
|
||||
return {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -69,10 +53,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
@@ -81,46 +74,16 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
schema = STEP_DATA_SCHEMA
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
)
|
||||
if uuid:
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
STEP_DATA_SCHEMA, user_input or reconfigure_entry.data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -150,7 +113,9 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
if await self._async_try_connect(host, port):
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"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%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -18,13 +16,6 @@
|
||||
"confirm": {
|
||||
"description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "[%key:component::arcam_fmj::config::step::user::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import axis
|
||||
from axis.errors import Unauthorized
|
||||
from axis.models.mqtt import ClientState, mqtt_json_to_event
|
||||
from axis.interfaces.mqtt import mqtt_json_to_event
|
||||
from axis.models.mqtt import ClientState
|
||||
from axis.stream_manager import Signal, State
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==72"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -11,7 +11,6 @@ from bluetooth_adapters import (
|
||||
ADAPTER_CONNECTION_SLOTS,
|
||||
ADAPTER_HW_VERSION,
|
||||
ADAPTER_MANUFACTURER,
|
||||
ADAPTER_PASSIVE_SCAN,
|
||||
ADAPTER_SW_VERSION,
|
||||
DEFAULT_ADDRESS,
|
||||
DEFAULT_CONNECTION_SLOTS,
|
||||
@@ -80,6 +79,7 @@ from .const import (
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
@@ -93,7 +93,7 @@ from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import BluetoothCallback, BluetoothChange
|
||||
from .storage import BluetoothStorage
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
from .util import adapter_title
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -387,15 +387,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Bluetooth adapter {adapter} with address {address} not found"
|
||||
)
|
||||
passive = entry.options.get(CONF_PASSIVE)
|
||||
adapters = await manager.async_get_bluetooth_adapters()
|
||||
details = adapters[adapter]
|
||||
mode = resolve_scanning_mode(entry.options)
|
||||
# AUTO needs passive scanning support to flip on demand; without it
|
||||
# the scanner would start passive on hardware that can't do passive.
|
||||
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
|
||||
mode = BluetoothScanningMode.ACTIVE
|
||||
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
||||
scanner = HaScanner(mode, adapter, address)
|
||||
scanner.async_setup()
|
||||
details = adapters[adapter]
|
||||
if entry.title == address:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, title=adapter_title(adapter, details)
|
||||
|
||||
@@ -130,26 +130,17 @@ def async_register_callback(
|
||||
callback: BluetoothCallback,
|
||||
match_dict: BluetoothCallbackMatcher | None,
|
||||
mode: BluetoothScanningMode,
|
||||
*,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register to receive a callback on bluetooth change.
|
||||
|
||||
When ``mode`` is not PASSIVE and ``match_dict["address"]`` is set,
|
||||
the address is registered with habluetooth's active-scan scheduler
|
||||
so AUTO-mode scanners flip ACTIVE on demand for that device.
|
||||
``scan_interval`` / ``scan_duration`` default to habluetooth's
|
||||
DEFAULT_ACTIVE_SCAN_* (5 minutes / 10 seconds) when not provided;
|
||||
integrations that need a different cadence can pass explicit
|
||||
values. Without an address in the matcher the active-scan request
|
||||
is skipped; the callback itself still fires normally.
|
||||
mode is currently not used as we only support active scanning.
|
||||
Passive scanning will be available in the future. The flag
|
||||
is required to be present to avoid a future breaking change
|
||||
when we support passive scanning.
|
||||
|
||||
Returns a callback that can be used to cancel the registration.
|
||||
"""
|
||||
return _get_manager(hass).async_register_callback(
|
||||
callback, match_dict, mode, scan_interval, scan_duration
|
||||
)
|
||||
return _get_manager(hass).async_register_callback(callback, match_dict)
|
||||
|
||||
|
||||
async def async_process_advertisements(
|
||||
@@ -170,7 +161,7 @@ async def async_process_advertisements(
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
_async_discovered_device, match_dict
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
|
||||
adapter_model,
|
||||
get_adapters,
|
||||
)
|
||||
from habluetooth import BluetoothScanningMode, get_manager
|
||||
from habluetooth import get_manager
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
@@ -24,21 +24,14 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaFlowFormStep,
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
@@ -47,39 +40,15 @@ from .const import (
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .util import adapter_title, resolve_scanning_mode
|
||||
from .util import adapter_title
|
||||
|
||||
_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
BluetoothScanningMode.AUTO.value,
|
||||
BluetoothScanningMode.ACTIVE.value,
|
||||
BluetoothScanningMode.PASSIVE.value,
|
||||
],
|
||||
translation_key="mode",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSIVE, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Build the options schema with the saved mode as the default."""
|
||||
current = resolve_scanning_mode(handler.options).value
|
||||
return vol.Schema({vol.Required(CONF_MODE, default=current): _MODE_SELECTOR})
|
||||
|
||||
|
||||
async def _validate_options(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Mirror CONF_MODE into the legacy CONF_PASSIVE for downgrade safety."""
|
||||
user_input[CONF_PASSIVE] = (
|
||||
user_input[CONF_MODE] == BluetoothScanningMode.PASSIVE.value
|
||||
)
|
||||
return user_input
|
||||
|
||||
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
|
||||
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,21 +7,14 @@ from habluetooth import ( # noqa: F401
|
||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||
SCANNER_WATCHDOG_INTERVAL,
|
||||
SCANNER_WATCHDOG_TIMEOUT,
|
||||
BluetoothScanningMode,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_MODE # noqa: F401
|
||||
|
||||
DOMAIN = "bluetooth"
|
||||
|
||||
CONF_ADAPTER = "adapter"
|
||||
CONF_DETAILS = "details"
|
||||
# CONF_PASSIVE is the legacy boolean option; we keep writing it alongside
|
||||
# CONF_MODE so a downgrade to a pre-AUTO release reads a sensible value.
|
||||
CONF_PASSIVE = "passive"
|
||||
|
||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
|
||||
@@ -202,9 +202,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
self,
|
||||
callback: BluetoothCallback,
|
||||
matcher: BluetoothCallbackMatcher | None,
|
||||
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
|
||||
scan_interval: float | None = None,
|
||||
scan_duration: float | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback."""
|
||||
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
|
||||
@@ -219,31 +216,15 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
connectable = callback_matcher[CONNECTABLE]
|
||||
self._callback_index.add_callback_matcher(callback_matcher)
|
||||
|
||||
# If the matcher targets a specific address and the caller
|
||||
# didn't explicitly ask for PASSIVE, wire it into habluetooth's
|
||||
# active-scan scheduler so AUTO-mode scanners flip ACTIVE on
|
||||
# demand for this device. ``scan_interval``/``scan_duration``
|
||||
# default to habluetooth's DEFAULT_ACTIVE_SCAN_* when None.
|
||||
cancel_active_scan: Callable[[], None] | None = None
|
||||
if (
|
||||
mode is not BluetoothScanningMode.PASSIVE
|
||||
and (address := callback_matcher.get(ADDRESS)) is not None
|
||||
):
|
||||
cancel_active_scan = self.async_register_active_scan(
|
||||
address, scan_interval, scan_duration
|
||||
)
|
||||
|
||||
def _async_remove_callback() -> None:
|
||||
self._callback_index.remove_callback_matcher(callback_matcher)
|
||||
if cancel_active_scan is not None:
|
||||
cancel_active_scan()
|
||||
|
||||
# If we have history for the subscriber, we can trigger the callback
|
||||
# immediately with the last packet so the subscriber can see the
|
||||
# device.
|
||||
history = self._connectable_history if connectable else self._all_history
|
||||
service_infos: Iterable[BluetoothServiceInfoBleak] = []
|
||||
if (address := callback_matcher.get(ADDRESS)) is not None:
|
||||
if address := callback_matcher.get(ADDRESS):
|
||||
if service_info := history.get(address):
|
||||
service_infos = [service_info]
|
||||
else:
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.7.1"
|
||||
"habluetooth==6.2.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -48,21 +48,9 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"mode": "Scanning mode"
|
||||
},
|
||||
"data_description": {
|
||||
"mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
|
||||
"passive": "Passive scanning"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"mode": {
|
||||
"options": {
|
||||
"active": "Active (uses more device battery, fastest updates)",
|
||||
"auto": "Auto (recommended, saves device battery)",
|
||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""The bluetooth integration utilities."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bluetooth_adapters import (
|
||||
ADAPTER_ADDRESS,
|
||||
ADAPTER_MANUFACTURER,
|
||||
@@ -13,32 +9,14 @@ from bluetooth_adapters import (
|
||||
adapter_unique_name,
|
||||
)
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
from habluetooth import BluetoothScanningMode, get_manager
|
||||
from habluetooth import get_manager
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import CONF_MODE, CONF_PASSIVE, DEFAULT_MODE
|
||||
from .models import BluetoothServiceInfoBleak
|
||||
from .storage import BluetoothStorage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_scanning_mode(options: Mapping[str, Any]) -> BluetoothScanningMode:
|
||||
"""Resolve CONF_MODE, falling back to legacy CONF_PASSIVE or DEFAULT_MODE."""
|
||||
if (mode_value := options.get(CONF_MODE)) is not None:
|
||||
try:
|
||||
return BluetoothScanningMode(mode_value)
|
||||
except TypeError, ValueError:
|
||||
_LOGGER.warning("Unknown bluetooth scanning mode %r", mode_value)
|
||||
return BluetoothScanningMode(DEFAULT_MODE)
|
||||
if (legacy_passive := options.get(CONF_PASSIVE)) is True:
|
||||
return BluetoothScanningMode.PASSIVE
|
||||
if legacy_passive is False:
|
||||
return BluetoothScanningMode.ACTIVE
|
||||
return BluetoothScanningMode(DEFAULT_MODE)
|
||||
|
||||
|
||||
class InvalidConfigEntryID(HomeAssistantError):
|
||||
"""Invalid config entry id."""
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, start
|
||||
@@ -274,6 +275,9 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.alexa_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -304,6 +308,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
Event,
|
||||
@@ -281,6 +282,9 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity ID should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -312,6 +316,8 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.system_health import get_info as get_system_health_info
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -972,7 +973,7 @@ async def google_assistant_get(
|
||||
return
|
||||
|
||||
entity = google_helpers.GoogleEntity(hass, gconf, state)
|
||||
if not entity.is_supported():
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
@@ -1074,7 +1075,9 @@ async def alexa_get(
|
||||
"""Get data for a single alexa entity."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not entity_supported_by_alexa(hass, entity_id):
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
|
||||
hass, entity_id
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
|
||||
@@ -4,10 +4,10 @@ from functools import partial
|
||||
|
||||
from aioesphomeapi import (
|
||||
AlarmControlPanelCommand,
|
||||
AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelEntityState as ESPHomeAlarmControlPanelEntityState,
|
||||
AlarmControlPanelInfo,
|
||||
AlarmControlPanelState as ESPHomeAlarmControlPanelState,
|
||||
APIIntEnum,
|
||||
EntityInfo,
|
||||
)
|
||||
|
||||
@@ -50,28 +50,16 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[
|
||||
}
|
||||
)
|
||||
|
||||
_FEATURES: dict[
|
||||
ESPHomeAlarmControlPanelEntityFeature, AlarmControlPanelEntityFeature
|
||||
] = {
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_HOME: (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY: (
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT: (
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.TRIGGER: (
|
||||
AlarmControlPanelEntityFeature.TRIGGER
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: (
|
||||
AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
),
|
||||
ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION: (
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
),
|
||||
}
|
||||
|
||||
class EspHomeACPFeatures(APIIntEnum):
|
||||
"""ESPHome AlarmControlPanel feature numbers."""
|
||||
|
||||
ARM_HOME = 1
|
||||
ARM_AWAY = 2
|
||||
ARM_NIGHT = 4
|
||||
TRIGGER = 8
|
||||
ARM_CUSTOM_BYPASS = 16
|
||||
ARM_VACATION = 32
|
||||
|
||||
|
||||
class EsphomeAlarmControlPanel(
|
||||
@@ -85,14 +73,20 @@ class EsphomeAlarmControlPanel(
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
esp_flags = ESPHomeAlarmControlPanelEntityFeature(
|
||||
static_info.supported_features
|
||||
)
|
||||
flags = AlarmControlPanelEntityFeature(0)
|
||||
for esp_flag in esp_flags:
|
||||
if (flag := _FEATURES.get(esp_flag)) is not None:
|
||||
flags |= flag
|
||||
self._attr_supported_features = flags
|
||||
feature = 0
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_HOME:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
if static_info.supported_features & EspHomeACPFeatures.TRIGGER:
|
||||
feature |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION:
|
||||
feature |= AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
self._attr_supported_features = AlarmControlPanelEntityFeature(feature)
|
||||
self._attr_code_format = (
|
||||
CodeFormat.NUMBER if static_info.requires_code else None
|
||||
)
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
"""Bluetooth support for esphome."""
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIVersion,
|
||||
BluetoothProxyFeature,
|
||||
BluetoothScannerMode,
|
||||
BluetoothScannerStateResponse,
|
||||
DeviceInfo,
|
||||
)
|
||||
from aioesphomeapi import APIClient, DeviceInfo
|
||||
from bleak_esphome import connect_scanner
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
async_register_scanner,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_register_scanner
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||
|
||||
from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak_esphome.backend.scanner import ESPHomeScanner
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode}
|
||||
from .const import DOMAIN
|
||||
from .entry_data import RuntimeEntryData
|
||||
|
||||
|
||||
@hass_callback
|
||||
@@ -40,7 +23,6 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None:
|
||||
@hass_callback
|
||||
def async_connect_scanner(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
entry_data: RuntimeEntryData,
|
||||
cli: APIClient,
|
||||
device_info: DeviceInfo,
|
||||
@@ -53,75 +35,17 @@ def async_connect_scanner(
|
||||
scanner = client_data.scanner
|
||||
if TYPE_CHECKING:
|
||||
assert scanner is not None
|
||||
api_version = cli.api_version or APIVersion()
|
||||
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version)
|
||||
state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
|
||||
# Pin mode before async_register_scanner so habluetooth spawns the AUTO worker.
|
||||
deferred_migration: CALLBACK_TYPE | None = None
|
||||
if state_and_mode:
|
||||
deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli)
|
||||
callbacks: list[CALLBACK_TYPE] = [
|
||||
async_register_scanner(
|
||||
hass,
|
||||
scanner,
|
||||
source_domain=DOMAIN,
|
||||
source_model=device_info.model,
|
||||
source_config_entry_id=entry_data.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
scanner.async_setup(),
|
||||
]
|
||||
if deferred_migration is not None:
|
||||
callbacks.append(deferred_migration)
|
||||
return partial(_async_unload, callbacks)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def _async_apply_scanning_mode(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
scanner: ESPHomeScanner,
|
||||
cli: APIClient,
|
||||
) -> CALLBACK_TYPE | None:
|
||||
"""Apply saved scanning mode synchronously; migrate from configured_mode later."""
|
||||
saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE)
|
||||
if saved is not None and saved not in _VALID_SCANNING_MODES:
|
||||
_LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved)
|
||||
saved = None
|
||||
initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE
|
||||
scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value))
|
||||
if saved is not None:
|
||||
return None
|
||||
|
||||
unsub_holder: list[CALLBACK_TYPE] = []
|
||||
|
||||
@hass_callback
|
||||
def _migrate(state: BluetoothScannerStateResponse) -> None:
|
||||
# proto3 unset enums decode to None; wait for a real value.
|
||||
if (configured_pb := state.configured_mode) is None:
|
||||
return
|
||||
if unsub_holder:
|
||||
unsub_holder.pop()()
|
||||
if configured_pb is BluetoothScannerMode.PASSIVE:
|
||||
new_mode = BluetoothScanningMode.PASSIVE
|
||||
else:
|
||||
new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={
|
||||
**entry.options,
|
||||
CONF_BLUETOOTH_SCANNING_MODE: new_mode.value,
|
||||
},
|
||||
)
|
||||
# AUTO -> AUTO is already pinned; only re-apply on a downgrade.
|
||||
if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE):
|
||||
scanner.async_set_scanning_mode(new_mode)
|
||||
|
||||
unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate))
|
||||
|
||||
@hass_callback
|
||||
def _unsubscribe() -> None:
|
||||
if unsub_holder:
|
||||
unsub_holder.pop()()
|
||||
|
||||
return _unsubscribe
|
||||
return partial(
|
||||
_async_unload,
|
||||
[
|
||||
async_register_scanner(
|
||||
hass,
|
||||
scanner,
|
||||
source_domain=DOMAIN,
|
||||
source_model=device_info.model,
|
||||
source_config_entry_id=entry_data.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
scanner.async_setup(),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any, cast
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
BluetoothProxyFeature,
|
||||
DeviceInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
@@ -21,7 +20,6 @@ import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_ESPHOME,
|
||||
SOURCE_IGNORE,
|
||||
@@ -40,11 +38,6 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.importlib import async_import_module
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
@@ -54,12 +47,10 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .const import (
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_BLUETOOTH_SCANNING_MODE,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_NOISE_PSK,
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
DEFAULT_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_BLUETOOTH_SCANNING_MODE,
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
@@ -77,18 +68,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
DEFAULT_NAME = "ESPHome"
|
||||
|
||||
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
BluetoothScanningMode.AUTO.value,
|
||||
BluetoothScanningMode.ACTIVE.value,
|
||||
BluetoothScanningMode.PASSIVE.value,
|
||||
],
|
||||
translation_key="bluetooth_scanning_mode",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a esphome config flow."""
|
||||
@@ -957,44 +936,18 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
options = self.config_entry.options
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
default=options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
default=options.get(CONF_SUBSCRIBE_LOGS, False),
|
||||
): bool,
|
||||
}
|
||||
if _entry_has_bluetooth_scanner(self.config_entry):
|
||||
schema[
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BLUETOOTH_SCANNING_MODE,
|
||||
default=options.get(
|
||||
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
),
|
||||
)
|
||||
] = _BLUETOOTH_SCANNING_MODE_SELECTOR
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema))
|
||||
|
||||
|
||||
@callback
|
||||
def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool:
|
||||
"""Return True if the entry exposes a bluetooth proxy scanner or has one saved."""
|
||||
# Keep showing the option if it was previously saved, even when the
|
||||
# device is offline or stops advertising the feature flag, so the
|
||||
# saved value isn't silently dropped on the next options save.
|
||||
if CONF_BLUETOOTH_SCANNING_MODE in entry.options:
|
||||
return True
|
||||
if entry.state is ConfigEntryState.LOADED and (
|
||||
device_info := entry.runtime_data.device_info
|
||||
):
|
||||
flags = device_info.bluetooth_proxy_feature_flags_compat(
|
||||
entry.runtime_data.api_version
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
|
||||
return False
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Final
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,11 +18,9 @@ CONF_SUBSCRIBE_LOGS = "subscribe_logs"
|
||||
CONF_DEVICE_NAME = "device_name"
|
||||
CONF_NOISE_PSK = "noise_psk"
|
||||
CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
|
||||
CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode"
|
||||
|
||||
DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
|
||||
@@ -669,7 +669,7 @@ class ESPHomeManager:
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||
entry_data.disconnect_callbacks.add(
|
||||
async_connect_scanner(
|
||||
hass, self.entry, entry_data, cli, device_info, self.device_id
|
||||
hass, entry_data, cli, device_info, self.device_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.2.0",
|
||||
"aioesphomeapi==45.0.4",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.9.1"
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -209,24 +209,13 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
|
||||
"bluetooth_scanning_mode": "Bluetooth scanning mode",
|
||||
"subscribe_logs": "Subscribe to logs from the device."
|
||||
},
|
||||
"data_description": {
|
||||
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
|
||||
"bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.",
|
||||
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"bluetooth_scanning_mode": {
|
||||
"options": {
|
||||
"active": "Active (uses more device battery, fastest updates)",
|
||||
"auto": "Auto (recommended, saves device battery)",
|
||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ ATTR_LAST_SAVED_AT: Final = "last_saved_at"
|
||||
|
||||
ATTR_DURATION: Final = "duration"
|
||||
ATTR_DISTANCE: Final = "distance"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ELEVATION: Final = "elevation"
|
||||
ATTR_HEIGHT: Final = "height"
|
||||
ATTR_WEIGHT: Final = "weight"
|
||||
ATTR_BODY: Final = "body"
|
||||
|
||||
@@ -53,8 +53,6 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
|
||||
class FritzBoxBaseEntity:
|
||||
"""Fritz host entity base class."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None:
|
||||
"""Init device info class."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
|
||||
@@ -76,6 +76,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
|
||||
|
||||
_attr_content_type = "image/png"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -170,6 +170,7 @@ class SwitchInfo(TypedDict):
|
||||
"""FRITZ!Box switch info class."""
|
||||
|
||||
description: str
|
||||
friendly_name: str
|
||||
icon: str
|
||||
type: str
|
||||
callback_update: Callable
|
||||
|
||||
@@ -380,18 +380,44 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
|
||||
"""Init Fritzbox base switch."""
|
||||
super().__init__(avm_wrapper, device_friendly_name)
|
||||
|
||||
description = switch_info["description"]
|
||||
|
||||
self._description = switch_info["description"]
|
||||
self._friendly_name = switch_info["friendly_name"]
|
||||
self._icon = switch_info["icon"]
|
||||
self._type = switch_info["type"]
|
||||
self._update = switch_info["callback_update"]
|
||||
self._switch = switch_info["callback_switch"]
|
||||
|
||||
self._attr_icon = switch_info["icon"]
|
||||
self._attr_is_on = switch_info["init_state"]
|
||||
self._attr_name = description
|
||||
self._attr_unique_id = f"{self._avm_wrapper.unique_id}-{slugify(description)}"
|
||||
self._attr_extra_state_attributes: dict[str, Any | None] = {}
|
||||
self._attr_available = True
|
||||
|
||||
self._name = f"{self._friendly_name} {self._description}"
|
||||
self._unique_id = f"{self._avm_wrapper.unique_id}-{slugify(self._description)}"
|
||||
|
||||
self._attributes: dict[str, str | None] = {}
|
||||
self._is_available = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return icon."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return unique id."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return availability."""
|
||||
return self._is_available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str | None]:
|
||||
"""Return device attributes."""
|
||||
return self._attributes
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update data."""
|
||||
@@ -412,6 +438,7 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
|
||||
self._attr_is_on = turn_on
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-missing-has-entity-name
|
||||
class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
"""Defines a FRITZ!Box Tools PortForward switch."""
|
||||
|
||||
@@ -425,6 +452,9 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
connection_type: str,
|
||||
) -> None:
|
||||
"""Init Fritzbox port switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
|
||||
self._attributes = {}
|
||||
self.connection_type = connection_type
|
||||
# dict in the format as it comes from fritzconnection,
|
||||
# eg: {"NewRemoteHost": "0.0.0.0", "NewExternalPort": 22, ...}
|
||||
@@ -434,6 +464,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
|
||||
switch_info = SwitchInfo(
|
||||
description=f"Port forward {port_name}",
|
||||
friendly_name=device_friendly_name,
|
||||
icon="mdi:check-network",
|
||||
type=SWITCH_TYPE_PORTFORWARD,
|
||||
callback_update=self._async_fetch_update,
|
||||
@@ -452,11 +483,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
"Specific %s response: %s", SWITCH_TYPE_PORTFORWARD, self.port_mapping
|
||||
)
|
||||
if not self.port_mapping:
|
||||
self._attr_available = False
|
||||
self._is_available = False
|
||||
return
|
||||
|
||||
self._attr_is_on = self.port_mapping["NewEnabled"] is True
|
||||
self._attr_available = True
|
||||
self._is_available = True
|
||||
|
||||
attributes_dict = {
|
||||
"NewInternalClient": "internal_ip",
|
||||
@@ -467,7 +498,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
}
|
||||
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attr_extra_state_attributes[attr] = self.port_mapping[key]
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
@@ -574,6 +605,7 @@ class FritzBoxProfileSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-missing-has-entity-name
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
"""Defines a FRITZ!Box Tools Wifi switch."""
|
||||
|
||||
@@ -585,8 +617,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
network_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
avm_wrapper.mesh_role is not MeshRoles.SLAVE
|
||||
@@ -598,13 +632,14 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
|
||||
switch_info = SwitchInfo(
|
||||
description=description,
|
||||
friendly_name=device_friendly_name,
|
||||
icon="mdi:wifi",
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["NewEnable"],
|
||||
)
|
||||
super().__init__(avm_wrapper, device_friendly_name, switch_info)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
async def _async_fetch_update(self) -> None:
|
||||
"""Fetch updates."""
|
||||
@@ -617,16 +652,16 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
)
|
||||
|
||||
if not wifi_info:
|
||||
self._attr_available = False
|
||||
self._is_available = False
|
||||
return
|
||||
|
||||
self._attr_is_on = wifi_info["NewEnable"] is True
|
||||
self._attr_available = True
|
||||
self._is_available = True
|
||||
|
||||
std = wifi_info["NewStandard"]
|
||||
self._attr_extra_state_attributes["standard"] = std or None
|
||||
self._attr_extra_state_attributes["bssid"] = wifi_info["NewBSSID"]
|
||||
self._attr_extra_state_attributes["mac_address_control"] = wifi_info[
|
||||
self._attributes["standard"] = std or None
|
||||
self._attributes["bssid"] = wifi_info["NewBSSID"]
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.components import webhook
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
@@ -802,6 +803,8 @@ def async_get_entities(
|
||||
is_supported_cache = config.is_supported_cache
|
||||
for state in hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
# Check check inlined for performance to avoid
|
||||
# function calls for every entity since we enumerate
|
||||
# the entire state machine here
|
||||
|
||||
@@ -12,6 +12,7 @@ import jwt
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -166,6 +167,9 @@ class GoogleConfig(AbstractConfig):
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
registry_entry = entity_registry.async_get(state.entity_id)
|
||||
if registry_entry:
|
||||
|
||||
@@ -116,7 +116,7 @@ async def _validate_auth(
|
||||
|
||||
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
|
||||
"""Get a set of current hosts from the entry."""
|
||||
hosts = {entry.data[CONF_HOST]}
|
||||
hosts = set(entry.data[CONF_HOST])
|
||||
if hasattr(entry, "runtime_data"):
|
||||
hosts.update(
|
||||
player.ip_address
|
||||
|
||||
@@ -473,7 +473,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
||||
await self.coordinator.heos.set_group(new_members)
|
||||
return
|
||||
|
||||
@catch_action_error("remove from queue")
|
||||
async def async_remove_from_queue(self, queue_ids: list[int]) -> None:
|
||||
"""Remove items from the queue."""
|
||||
await self._player.remove_from_queue(queue_ids)
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -245,6 +246,9 @@ class ExposedEntities:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
should_expose: bool
|
||||
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self._hass)
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
return self._async_should_expose_legacy_entity(assistant, entity_id)
|
||||
@@ -402,6 +406,19 @@ def ws_expose_entity(
|
||||
"""Expose an entity to an assistant."""
|
||||
entity_ids: list[str] = msg["entity_ids"]
|
||||
|
||||
if blocked := next(
|
||||
(
|
||||
entity_id
|
||||
for entity_id in entity_ids
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
),
|
||||
None,
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'"
|
||||
)
|
||||
return
|
||||
|
||||
for entity_id in entity_ids:
|
||||
for assistant in msg["assistants"]:
|
||||
async_expose_entity(hass, assistant, entity_id, msg["should_expose"])
|
||||
|
||||
@@ -44,13 +44,13 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
@@ -62,11 +62,9 @@ rules:
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration currently does not have any known issues.
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"already_configured": "Accessory is already configured with this controller.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.",
|
||||
"ignored_model": "HomeKit support for this model is blocked as a more feature-complete native integration is available.",
|
||||
"ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.",
|
||||
"invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting configuration entry for it in Home Assistant that must first be removed.",
|
||||
"invalid_properties": "Invalid properties announced by device.",
|
||||
"no_devices": "No unpaired devices could be found"
|
||||
@@ -22,11 +22,11 @@
|
||||
"flow_title": "{name} ({category})",
|
||||
"step": {
|
||||
"busy_error": {
|
||||
"description": "Abort pairing on all controllers, or try restarting the device, then try pairing again.",
|
||||
"description": "Abort pairing on all controllers, or try restarting the device, then continue to resume pairing.",
|
||||
"title": "The device is already pairing with another controller"
|
||||
},
|
||||
"max_tries_error": {
|
||||
"description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then try pairing again.",
|
||||
"description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then continue to resume pairing.",
|
||||
"title": "Maximum authentication attempts exceeded"
|
||||
},
|
||||
"pair": {
|
||||
@@ -38,7 +38,7 @@
|
||||
"title": "Pair with a device via HomeKit Accessory Protocol"
|
||||
},
|
||||
"protocol_error": {
|
||||
"description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then try pairing again.",
|
||||
"description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then continue to resume pairing.",
|
||||
"title": "Error communicating with the accessory"
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -63,5 +63,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==1.1.2"]
|
||||
"requirements": ["inkbird-ble==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CONF_ACTIONS, Platform
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
@@ -41,6 +41,7 @@ from .const import (
|
||||
CONF_ACTION_SHOW_IN_CARPLAY,
|
||||
CONF_ACTION_SHOW_IN_WATCH,
|
||||
CONF_ACTION_USE_CUSTOM_COLORS,
|
||||
CONF_ACTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ CONF_ACTION_LABEL_TEXT = "text"
|
||||
CONF_ACTION_ICON = "icon"
|
||||
CONF_ACTION_ICON_COLOR = "color"
|
||||
CONF_ACTION_ICON_ICON = "icon"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACTIONS = "actions"
|
||||
CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay"
|
||||
CONF_ACTION_SHOW_IN_WATCH = "show_in_watch"
|
||||
CONF_ACTION_USE_CUSTOM_COLORS = "use_custom_colors"
|
||||
|
||||
@@ -1,53 +1,450 @@
|
||||
"""The Konnected.io integration."""
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import copy
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
from aiohttp.web import Request, Response
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .config_flow import ( # Loading the config flow file will register the flow
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_IO,
|
||||
CONF_IO_BIN,
|
||||
CONF_IO_DIG,
|
||||
CONF_IO_SWI,
|
||||
OPTIONS_SCHEMA,
|
||||
)
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
PIN_TO_ZONE,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
UPDATE_ENDPOINT,
|
||||
ZONE_TO_PIN,
|
||||
ZONES,
|
||||
)
|
||||
from .handlers import HANDLERS
|
||||
from .panel import AlarmPanel
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_pin(value):
|
||||
"""Check if valid pin and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("pin value is None")
|
||||
|
||||
if PIN_TO_ZONE.get(str(value)) is None:
|
||||
raise vol.Invalid("pin not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def ensure_zone(value):
|
||||
"""Check if valid zone and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("zone value is None")
|
||||
|
||||
if str(value) not in ZONES:
|
||||
raise vol.Invalid("zone not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def import_device_validator(config):
|
||||
"""Validate zones and reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
io_cfgs = {}
|
||||
# Replace pins with zones
|
||||
for conf_platform, conf_io in (
|
||||
(CONF_BINARY_SENSORS, CONF_IO_BIN),
|
||||
(CONF_SENSORS, CONF_IO_DIG),
|
||||
(CONF_SWITCHES, CONF_IO_SWI),
|
||||
):
|
||||
for zone in config.get(conf_platform, []):
|
||||
if zone.get(CONF_PIN):
|
||||
zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]]
|
||||
del zone[CONF_PIN]
|
||||
io_cfgs[zone[CONF_ZONE]] = conf_io
|
||||
|
||||
# Migrate config_entry data into default_options structure
|
||||
config[CONF_IO] = io_cfgs
|
||||
config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config)
|
||||
|
||||
# clean up fields migrated to options
|
||||
config.pop(CONF_BINARY_SENSORS, None)
|
||||
config.pop(CONF_SENSORS, None)
|
||||
config.pop(CONF_SWITCHES, None)
|
||||
config.pop(CONF_BLINK, None)
|
||||
config.pop(CONF_DISCOVERY, None)
|
||||
config.pop(CONF_API_HOST, None)
|
||||
config.pop(CONF_IO, None)
|
||||
return config
|
||||
|
||||
|
||||
def import_validator(config):
|
||||
"""Reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
# push api_host into device configs
|
||||
for device in config.get(CONF_DEVICES, []):
|
||||
device[CONF_API_HOST] = config.get(CONF_API_HOST, "")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# configuration.yaml schemas (legacy)
|
||||
BINARY_SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
DEVICE_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]),
|
||||
vol.Inclusive(CONF_HOST, "host_info"): cv.string,
|
||||
vol.Inclusive(CONF_PORT, "host_info"): cv.port,
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
import_device_validator,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
import_validator,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||
vol.Optional(CONF_API_HOST): vol.Url(),
|
||||
vol.Optional(CONF_DEVICES): vol.All(
|
||||
cv.ensure_list, [DEVICE_SCHEMA_YAML]
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
YAML_CONFIGS = "yaml_configs"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Konnected.io integration."""
|
||||
if DOMAIN in config:
|
||||
_create_issue(hass)
|
||||
"""Set up the Konnected platform."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_firmware",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware",
|
||||
translation_placeholders={
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
if (cfg := config.get(DOMAIN)) is None:
|
||||
cfg = {}
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {
|
||||
CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN),
|
||||
CONF_API_HOST: cfg.get(CONF_API_HOST),
|
||||
CONF_DEVICES: {},
|
||||
}
|
||||
|
||||
hass.http.register_view(KonnectedView)
|
||||
|
||||
# Check if they have yaml configured devices
|
||||
if CONF_DEVICES not in cfg:
|
||||
return True
|
||||
|
||||
for device in cfg.get(CONF_DEVICES, []):
|
||||
# Attempt to importing the cfg. Use
|
||||
# hass.async_add_job to avoid a deadlock.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Konnected.io from a config entry."""
|
||||
_create_issue(hass)
|
||||
"""Set up panel from a config entry."""
|
||||
client = AlarmPanel(hass, entry)
|
||||
# creates a panel data store in hass.data[DOMAIN][CONF_DEVICES]
|
||||
await client.async_save_data()
|
||||
|
||||
# if the cfg entry was created we know we could connect to the panel at some point
|
||||
# async_connect will handle retries until it establishes a connection
|
||||
await client.async_connect()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_entry_updated))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
def _create_issue(hass: HomeAssistant) -> None:
|
||||
"""Create the integration removed repair issue."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/konnected",
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the config entry when options change."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class KonnectedView(HomeAssistantView):
|
||||
"""View creates an endpoint to receive push updates from the device."""
|
||||
|
||||
url = UPDATE_ENDPOINT
|
||||
name = "api:konnected"
|
||||
requires_auth = False # Uses access token from configuration
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the view."""
|
||||
|
||||
@staticmethod
|
||||
def binary_value(state, activation):
|
||||
"""Return binary value for GPIO based on state and activation."""
|
||||
if activation == STATE_HIGH:
|
||||
return 1 if state == STATE_ON else 0
|
||||
return 0 if state == STATE_ON else 1
|
||||
|
||||
async def update_sensor(self, request: Request, device_id) -> Response:
|
||||
"""Process a put or post."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
auth = request.headers.get(AUTHORIZATION)
|
||||
tokens = []
|
||||
if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN):
|
||||
tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]])
|
||||
tokens.extend(
|
||||
[
|
||||
entry.data[CONF_ACCESS_TOKEN]
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_ACCESS_TOKEN)
|
||||
]
|
||||
)
|
||||
if auth is None or not next(
|
||||
(True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)),
|
||||
False,
|
||||
):
|
||||
return self.json_message(
|
||||
"unauthorized", status_code=HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
|
||||
try: # Konnected 2.2.0 and above supports JSON payloads
|
||||
payload = await request.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error(
|
||||
"Your Konnected device software may be out of "
|
||||
"date. Visit https://help.konnected.io for "
|
||||
"updating instructions"
|
||||
)
|
||||
|
||||
if (device := data[CONF_DEVICES].get(device_id)) is None:
|
||||
return self.json_message(
|
||||
"unregistered device", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
try:
|
||||
zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]])
|
||||
payload[CONF_ZONE] = zone_num
|
||||
zone_data = (
|
||||
device[CONF_BINARY_SENSORS].get(zone_num)
|
||||
or next(
|
||||
(s for s in device[CONF_SWITCHES] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
or next(
|
||||
(s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
zone_data = None
|
||||
|
||||
if zone_data is None:
|
||||
return self.json_message(
|
||||
"unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
zone_data["device_id"] = device_id
|
||||
|
||||
for attr in ("state", "temp", "humi", "addr"):
|
||||
value = payload.get(attr)
|
||||
handler = HANDLERS.get(attr)
|
||||
if value is not None and handler:
|
||||
hass.async_create_task(handler(hass, zone_data, payload))
|
||||
|
||||
return self.json_message("ok")
|
||||
|
||||
async def get(self, request: Request, device_id) -> Response:
|
||||
"""Return the current binary state of a switch."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
if not (device := data[CONF_DEVICES].get(device_id)):
|
||||
return self.json_message(
|
||||
f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
# Our data model is based on zone ids but we convert from/to pin ids
|
||||
# based on whether they are specified in the request
|
||||
try:
|
||||
zone_num = str(
|
||||
request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]]
|
||||
)
|
||||
zone = next(
|
||||
switch
|
||||
for switch in device[CONF_SWITCHES]
|
||||
if switch[CONF_ZONE] == zone_num
|
||||
)
|
||||
|
||||
except StopIteration:
|
||||
zone = None
|
||||
except KeyError:
|
||||
zone = None
|
||||
zone_num = None
|
||||
|
||||
if not zone:
|
||||
target = request.query.get(
|
||||
CONF_ZONE, request.query.get(CONF_PIN, "unknown")
|
||||
)
|
||||
return self.json_message(
|
||||
f"Switch on zone or pin {target} not configured",
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
resp = {}
|
||||
if request.query.get(CONF_ZONE):
|
||||
resp[CONF_ZONE] = zone_num
|
||||
elif zone_num:
|
||||
resp[CONF_PIN] = ZONE_TO_PIN[zone_num]
|
||||
|
||||
# Make sure entity is setup
|
||||
if zone_entity_id := zone.get(ATTR_ENTITY_ID):
|
||||
resp["state"] = self.binary_value(
|
||||
hass.states.get(zone_entity_id).state, # type: ignore[union-attr]
|
||||
zone[CONF_ACTIVATION],
|
||||
)
|
||||
return self.json(resp)
|
||||
|
||||
_LOGGER.warning("Konnected entity not yet setup, returning default")
|
||||
resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION])
|
||||
return self.json(resp)
|
||||
|
||||
async def put(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via PUT request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
|
||||
async def post(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via POST request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Support for wired binary sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
sensors = [
|
||||
KonnectedBinarySensor(device_id, pin_num, pin_data)
|
||||
for pin_num, pin_data in data[CONF_DEVICES][device_id][
|
||||
CONF_BINARY_SENSORS
|
||||
].items()
|
||||
]
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class KonnectedBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Konnected binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected binary sensor."""
|
||||
self._data = data
|
||||
self._attr_is_on = data.get(ATTR_STATE)
|
||||
self._attr_device_class = data.get(CONF_TYPE)
|
||||
self._attr_unique_id = f"{device_id}-{zone_num}"
|
||||
self._attr_name = data.get(CONF_NAME)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data[ATTR_ENTITY_ID] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
@@ -1,11 +1,892 @@
|
||||
"""Config flow for Konnected.io integration."""
|
||||
"""Config flow for konnected.io integration."""
|
||||
# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import DOMAIN
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MANUFACTURER,
|
||||
ATTR_UPNP_MODEL_NAME,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
ZONES,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName
|
||||
CONF_IO = "io"
|
||||
CONF_IO_DIS = "Disabled"
|
||||
CONF_IO_BIN = "Binary Sensor"
|
||||
CONF_IO_DIG = "Digital Sensor"
|
||||
CONF_IO_SWI = "Switchable Output"
|
||||
|
||||
CONF_MORE_STATES = "more_states"
|
||||
CONF_YES = "Yes"
|
||||
CONF_NO = "No"
|
||||
|
||||
CONF_OVERRIDE_API_HOST = "override_api_host"
|
||||
|
||||
KONN_MANUFACTURER = "konnected.io"
|
||||
KONN_PANEL_MODEL_NAMES = {
|
||||
KONN_MODEL: "Konnected Alarm Panel",
|
||||
KONN_MODEL_PRO: "Konnected Alarm Panel Pro",
|
||||
}
|
||||
|
||||
OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI])
|
||||
OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN])
|
||||
OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI])
|
||||
|
||||
|
||||
# Config entry schemas
|
||||
IO_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(
|
||||
CONF_TYPE, default=BinarySensorDeviceClass.DOOR
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(CONF_TYPE, default="dht"): vol.All(
|
||||
vol.Lower, vol.In(["dht", "ds18b20"])
|
||||
),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.In([STATE_HIGH, STATE_LOW])
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IO): IO_SCHEMA,
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
CONFIG_ENTRY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES),
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"),
|
||||
vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Konnected.io."""
|
||||
"""Handle a config flow for Konnected Panels."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
# class variable to store/share discovered host information
|
||||
DISCOVERED_HOSTS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
unique_id: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Konnected flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.options = OPTIONS_SCHEMA({CONF_IO: {}})
|
||||
|
||||
async def async_gen_config(self, host, port):
|
||||
"""Populate self.data based on panel status.
|
||||
|
||||
This will raise CannotConnect if an error occurs
|
||||
"""
|
||||
self.data[CONF_HOST] = host
|
||||
self.data[CONF_PORT] = port
|
||||
try:
|
||||
status = await get_status(self.hass, host, port)
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
except (CannotConnect, KeyError) as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
self.data[CONF_ACCESS_TOKEN] = "".join(
|
||||
random.choices(f"{string.ascii_uppercase}{string.digits}", k=20)
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a configuration.yaml config.
|
||||
|
||||
This flow is triggered by `async_setup` for configured panels.
|
||||
"""
|
||||
_LOGGER.debug(import_data)
|
||||
|
||||
# save the data and confirm connection via user step
|
||||
await self.async_set_unique_id(import_data["id"])
|
||||
self.options = import_data[CONF_DEFAULT_OPTIONS]
|
||||
|
||||
# config schema ensures we have port if we have host
|
||||
if import_data.get(CONF_HOST):
|
||||
# automatically connect if we have host info
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: import_data[CONF_HOST],
|
||||
CONF_PORT: import_data[CONF_PORT],
|
||||
}
|
||||
)
|
||||
|
||||
# if we have no host info wait for it or abort if previously configured
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_import_confirm()
|
||||
|
||||
async def async_step_import_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the user wants to import the config entry."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="import_confirm",
|
||||
description_placeholders={"id": self.unique_id},
|
||||
)
|
||||
|
||||
# if we have ssdp discovered applicable host info use it
|
||||
if KonnectedFlowHandler.DISCOVERED_HOSTS.get(self.unique_id):
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_HOST
|
||||
],
|
||||
CONF_PORT: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_PORT
|
||||
],
|
||||
}
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered konnected panel.
|
||||
|
||||
This flow is triggered by the SSDP component. It will check if the
|
||||
device is already configured and attempt to finish the config if not.
|
||||
"""
|
||||
_LOGGER.debug(discovery_info)
|
||||
|
||||
try:
|
||||
if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER:
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
if not any(
|
||||
name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
|
||||
for name in KONN_PANEL_MODEL_NAMES
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Discovered unrecognized Konnected device %s",
|
||||
discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"),
|
||||
)
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
# If MAC is missing it is a bug in the device fw but we'll guard
|
||||
# against it since the field is so vital
|
||||
except KeyError:
|
||||
_LOGGER.error("Malformed Konnected SSDP info")
|
||||
else:
|
||||
# extract host/port from ssdp_location
|
||||
assert discovery_info.ssdp_location
|
||||
netloc = urlparse(discovery_info.ssdp_location).netloc.split(":")
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])}
|
||||
)
|
||||
|
||||
try:
|
||||
status = await get_status(self.hass, netloc[0], int(netloc[1]))
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.data[CONF_HOST] = netloc[0]
|
||||
self.data[CONF_PORT] = int(netloc[1])
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Connect to panel and get config."""
|
||||
errors = {}
|
||||
if user_input:
|
||||
# build config info and wait for user confirmation
|
||||
self.data[CONF_HOST] = user_input[CONF_HOST]
|
||||
self.data[CONF_PORT] = user_input[CONF_PORT]
|
||||
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
status = await get_status(
|
||||
self.hass, self.data[CONF_HOST], self.data[CONF_PORT]
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self.data[CONF_ID] = status.get(
|
||||
"chipId", status["mac"].replace(":", "")
|
||||
)
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
# save off our discovered host info
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
description_placeholders={
|
||||
"host": self.data.get(CONF_HOST, "Unknown"),
|
||||
"port": self.data.get(CONF_PORT, "Unknown"),
|
||||
},
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str,
|
||||
vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt to link with the Konnected panel.
|
||||
|
||||
Given a configured host, will ask the user to confirm and finalize
|
||||
the connection.
|
||||
"""
|
||||
if user_input is None:
|
||||
# abort and update an existing config entry if host info changes
|
||||
await self.async_set_unique_id(self.data[CONF_ID])
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=self.data, reload_on_update=False
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
"id": self.unique_id,
|
||||
"host": self.data[CONF_HOST],
|
||||
"port": self.data[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
# Create access token, attach default options and create entry
|
||||
self.data[CONF_DEFAULT_OPTIONS] = self.options
|
||||
self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get(
|
||||
CONF_ACCESS_TOKEN
|
||||
) or "".join(random.choices(f"{string.ascii_uppercase}{string.digits}", k=20))
|
||||
|
||||
return self.async_create_entry(
|
||||
title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Return the Options Flow."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle a option flow for a Konnected Panel."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.model = config_entry.data[CONF_MODEL]
|
||||
self.current_opt = (
|
||||
config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS]
|
||||
)
|
||||
|
||||
# as config proceeds we'll build up new options
|
||||
# and then replace what's in the config entry
|
||||
self.new_opt: dict[str, Any] = {CONF_IO: {}}
|
||||
self.active_cfg: str | None = None
|
||||
self.io_cfg: dict[str, Any] = {}
|
||||
self.current_states: list[dict[str, Any]] = []
|
||||
self.current_state = 1
|
||||
|
||||
@callback
|
||||
def get_current_cfg(self, io_type, zone):
|
||||
"""Get the current zone config."""
|
||||
return next(
|
||||
(
|
||||
cfg
|
||||
for cfg in self.current_opt.get(io_type, [])
|
||||
if cfg[CONF_ZONE] == zone
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
return await self.async_step_options_io()
|
||||
|
||||
async def async_step_options_io(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure legacy panel IO or first half of pro IO."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO][key] = value
|
||||
return await self.async_step_options_io_ext()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"out", default=current_io.get("out", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# configure the first half of the pro board io
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"7", default=current_io.get("7", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_io_ext(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the extended IO for pro."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO].update({key: value})
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io_ext",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"8", default=current_io.get("8", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"9", default=current_io.get("9", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"10", default=current_io.get("10", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"11", default=current_io.get("11", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"12", default=current_io.get("12", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm1", default=current_io.get("alarm1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"out1", default=current_io.get("out1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm2_out2",
|
||||
default=current_io.get("alarm2_out2", CONF_IO_DIS),
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_binary(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for binary sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_BINARY_SENSORS] = [
|
||||
*self.new_opt.get(CONF_BINARY_SENSORS, []),
|
||||
zone,
|
||||
]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False)
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured binary sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_BIN:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE,
|
||||
default=current_cfg.get(CONF_INVERSE, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_digital()
|
||||
|
||||
async def async_step_options_digital(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for digital sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_SENSORS] = [*self.new_opt.get(CONF_SENSORS, []), zone]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured digital sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_DIG:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_switch()
|
||||
|
||||
async def async_step_options_switch(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for switches."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
del zone[CONF_MORE_STATES]
|
||||
self.new_opt[CONF_SWITCHES] = [*self.new_opt.get(CONF_SWITCHES, []), zone]
|
||||
|
||||
# iterate through multiple switch states
|
||||
if self.current_states:
|
||||
self.current_states.pop(0)
|
||||
|
||||
# only go to next zone if all states are entered
|
||||
self.current_state += 1
|
||||
if user_input[CONF_MORE_STATES] == CONF_NO:
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured switch
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_SWI:
|
||||
self.active_cfg = key
|
||||
self.current_states = [
|
||||
cfg
|
||||
for cfg in self.current_opt.get(CONF_SWITCHES, [])
|
||||
if cfg[CONF_ZONE] == self.active_cfg
|
||||
]
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
self.current_state = 1
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.In(["low", "high"]),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_misc()
|
||||
|
||||
async def async_step_options_misc(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the LED behavior."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# config schema only does basic schema val so check url here
|
||||
try:
|
||||
if user_input[CONF_OVERRIDE_API_HOST]:
|
||||
cv.url(user_input.get(CONF_API_HOST, ""))
|
||||
else:
|
||||
user_input[CONF_API_HOST] = ""
|
||||
except vol.Invalid:
|
||||
errors["base"] = "bad_host"
|
||||
else:
|
||||
# no need to store the override - can infer
|
||||
del user_input[CONF_OVERRIDE_API_HOST]
|
||||
self.new_opt.update(user_input)
|
||||
return self.async_create_entry(title="", data=self.new_opt)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options_misc",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DISCOVERY,
|
||||
default=self.current_opt.get(CONF_DISCOVERY, True),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True)
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_OVERRIDE_API_HOST,
|
||||
default=bool(self.current_opt.get(CONF_API_HOST)),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_API_HOST, default=self.current_opt.get(CONF_API_HOST, "")
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
"""Konnected constants."""
|
||||
|
||||
DOMAIN = "konnected"
|
||||
|
||||
CONF_ACTIVATION = "activation"
|
||||
CONF_API_HOST = "api_host"
|
||||
CONF_DEFAULT_OPTIONS = "default_options"
|
||||
CONF_MOMENTARY = "momentary"
|
||||
CONF_PAUSE = "pause"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_INVERSE = "inverse"
|
||||
CONF_BLINK = "blink"
|
||||
CONF_DHT_SENSORS = "dht_sensors"
|
||||
CONF_DS18B20_SENSORS = "ds18b20_sensors"
|
||||
|
||||
STATE_LOW = "low"
|
||||
STATE_HIGH = "high"
|
||||
|
||||
ZONES = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"alarm1",
|
||||
"out1",
|
||||
"alarm2_out2",
|
||||
"out",
|
||||
]
|
||||
|
||||
# alarm panel pro only handles zones,
|
||||
# alarm panel allows specifying pins via configuration.yaml
|
||||
PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"}
|
||||
ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}
|
||||
|
||||
ENDPOINT_ROOT = "/api/konnected"
|
||||
UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}"
|
||||
SIGNAL_DS18B20_NEW = "konnected.ds18b20.new"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Errors for the Konnected component."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class KonnectedException(HomeAssistantError):
|
||||
"""Base class for Konnected exceptions."""
|
||||
|
||||
|
||||
class CannotConnect(KonnectedException):
|
||||
"""Unable to connect to the panel."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Handle Konnected messages."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util import decorator
|
||||
|
||||
from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
HANDLERS = decorator.Registry() # type: ignore[var-annotated]
|
||||
|
||||
|
||||
@HANDLERS.register("state")
|
||||
async def async_handle_state_update(hass, context, msg):
|
||||
"""Handle a binary sensor or switch state update."""
|
||||
_LOGGER.debug("[state handler] context: %s msg: %s", context, msg)
|
||||
entity_id = context.get(ATTR_ENTITY_ID)
|
||||
state = bool(int(msg.get(ATTR_STATE)))
|
||||
if context.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
|
||||
@HANDLERS.register("temp")
|
||||
async def async_handle_temp_update(hass, context, msg):
|
||||
"""Handle a temperature sensor state update."""
|
||||
_LOGGER.debug("[temp handler] context: %s msg: %s", context, msg)
|
||||
entity_id, temp = context.get(SensorDeviceClass.TEMPERATURE), msg.get("temp")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
|
||||
|
||||
@HANDLERS.register("humi")
|
||||
async def async_handle_humi_update(hass, context, msg):
|
||||
"""Handle a humidity sensor state update."""
|
||||
_LOGGER.debug("[humi handler] context: %s msg: %s", context, msg)
|
||||
entity_id, humi = context.get(SensorDeviceClass.HUMIDITY), msg.get("humi")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi)
|
||||
|
||||
|
||||
@HANDLERS.register("addr")
|
||||
async def async_handle_addr_update(hass, context, msg):
|
||||
"""Handle an addressable sensor update."""
|
||||
_LOGGER.debug("[addr handler] context: %s msg: %s", context, msg)
|
||||
addr, temp = msg.get("addr"), msg.get("temp")
|
||||
if entity_id := context.get(addr):
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
else:
|
||||
msg["device_id"] = context.get("device_id")
|
||||
msg["temperature"] = temp
|
||||
msg["addr"] = addr
|
||||
async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg)
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/konnected",
|
||||
"integration_type": "system",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": []
|
||||
"loggers": ["konnected"],
|
||||
"requirements": ["konnected==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "konnected.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import konnected
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_DHT_SENSORS,
|
||||
CONF_DS18B20_SENSORS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
ENDPOINT_ROOT,
|
||||
STATE_LOW,
|
||||
ZONE_TO_PIN,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
KONN_MODEL = "Konnected"
|
||||
KONN_MODEL_PRO = "Konnected Pro"
|
||||
|
||||
# Indicate how each unit is controlled (pin or zone)
|
||||
KONN_API_VERSIONS = {
|
||||
KONN_MODEL: CONF_PIN,
|
||||
KONN_MODEL_PRO: CONF_ZONE,
|
||||
}
|
||||
|
||||
|
||||
class AlarmPanel:
|
||||
"""A representation of a Konnected alarm panel."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the Konnected device."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.config = config_entry.data
|
||||
self.options = config_entry.options or config_entry.data.get(
|
||||
CONF_DEFAULT_OPTIONS, {}
|
||||
)
|
||||
self.host = self.config.get(CONF_HOST)
|
||||
self.port = self.config.get(CONF_PORT)
|
||||
self.client = None
|
||||
self.status = None
|
||||
self.api_version = KONN_API_VERSIONS[KONN_MODEL]
|
||||
self.connected = False
|
||||
self.connect_attempts = 0
|
||||
self.cancel_connect_retry = None
|
||||
|
||||
@property
|
||||
def device_id(self):
|
||||
"""Device id is the chipId (pro) or MAC address as string."""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def stored_configuration(self):
|
||||
"""Return the configuration stored in `hass.data` for this device."""
|
||||
return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return whether the device is available."""
|
||||
return self.connected
|
||||
|
||||
def format_zone(self, zone, other_items=None):
|
||||
"""Get zone or pin based dict based on the client type."""
|
||||
payload = {
|
||||
self.api_version: zone
|
||||
if self.api_version == CONF_ZONE
|
||||
else ZONE_TO_PIN[zone]
|
||||
}
|
||||
payload.update(other_items or {})
|
||||
return payload
|
||||
|
||||
async def async_connect(self, now=None):
|
||||
"""Connect to and setup a Konnected device."""
|
||||
if self.connected:
|
||||
return
|
||||
|
||||
if self.cancel_connect_retry:
|
||||
# cancel any pending connect attempt and try now
|
||||
self.cancel_connect_retry()
|
||||
|
||||
try:
|
||||
self.client = konnected.Client(
|
||||
host=self.host,
|
||||
port=str(self.port),
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass),
|
||||
)
|
||||
self.status = await self.client.get_status()
|
||||
self.api_version = KONN_API_VERSIONS.get(
|
||||
self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL]
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Connected to new %s device", self.status.get("model", "Konnected")
|
||||
)
|
||||
_LOGGER.debug(self.status)
|
||||
|
||||
await self.async_update_initial_states()
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
await self.async_sync_device_config()
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to connect to panel: %s", err)
|
||||
|
||||
# retry in a bit, never more than ~3 min
|
||||
self.connect_attempts += 1
|
||||
self.cancel_connect_retry = async_call_later(
|
||||
self.hass, 2 ** min(self.connect_attempts, 5) * 5, self.async_connect
|
||||
)
|
||||
return
|
||||
|
||||
self.connect_attempts = 0
|
||||
self.connected = True
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Set up Konnected device %s. Open http://%s:%s in a "
|
||||
"web browser to view device status"
|
||||
),
|
||||
self.device_id,
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))},
|
||||
identifiers={(DOMAIN, self.device_id)},
|
||||
manufacturer="Konnected.io",
|
||||
name=self.config_entry.title,
|
||||
model=self.config_entry.title,
|
||||
sw_version=self.status.get("swVersion"),
|
||||
)
|
||||
|
||||
async def update_switch(self, zone, state, momentary=None, times=None, pause=None):
|
||||
"""Update the state of a switchable output."""
|
||||
try:
|
||||
if self.client:
|
||||
if self.api_version == CONF_ZONE:
|
||||
return await self.client.put_zone(
|
||||
zone,
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
# device endpoint uses pin number instead of zone
|
||||
return await self.client.put_device(
|
||||
ZONE_TO_PIN[zone],
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to update panel: %s", err)
|
||||
|
||||
raise CannotConnect
|
||||
|
||||
async def async_save_data(self):
|
||||
"""Save the device configuration to `hass.data`."""
|
||||
binary_sensors = {}
|
||||
for entity in self.options.get(CONF_BINARY_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
binary_sensors[zone] = {
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}"
|
||||
),
|
||||
CONF_INVERSE: entity.get(CONF_INVERSE),
|
||||
ATTR_STATE: None,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Set up binary_sensor %s (initial state: %s)",
|
||||
binary_sensors[zone].get("name"),
|
||||
binary_sensors[zone].get(ATTR_STATE),
|
||||
)
|
||||
|
||||
actuators = []
|
||||
for entity in self.options.get(CONF_SWITCHES) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
act = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME,
|
||||
f"Konnected {self.device_id[6:]} Actuator {zone}",
|
||||
),
|
||||
ATTR_STATE: None,
|
||||
CONF_ACTIVATION: entity[CONF_ACTIVATION],
|
||||
CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
|
||||
CONF_PAUSE: entity.get(CONF_PAUSE),
|
||||
CONF_REPEAT: entity.get(CONF_REPEAT),
|
||||
}
|
||||
actuators.append(act)
|
||||
_LOGGER.debug("Set up switch %s", act)
|
||||
|
||||
sensors = []
|
||||
for entity in self.options.get(CONF_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
sensor = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}"
|
||||
),
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL),
|
||||
}
|
||||
sensors.append(sensor)
|
||||
_LOGGER.debug(
|
||||
"Set up %s sensor %s (initial state: %s)",
|
||||
sensor.get(CONF_TYPE),
|
||||
sensor.get(CONF_NAME),
|
||||
sensor.get(ATTR_STATE),
|
||||
)
|
||||
|
||||
device_data = {
|
||||
CONF_BINARY_SENSORS: binary_sensors,
|
||||
CONF_SENSORS: sensors,
|
||||
CONF_SWITCHES: actuators,
|
||||
CONF_BLINK: self.options.get(CONF_BLINK),
|
||||
CONF_DISCOVERY: self.options.get(CONF_DISCOVERY),
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
"panel": self,
|
||||
}
|
||||
|
||||
if CONF_DEVICES not in self.hass.data[DOMAIN]:
|
||||
self.hass.data[DOMAIN][CONF_DEVICES] = {}
|
||||
|
||||
_LOGGER.debug(
|
||||
"Storing data in hass.data[%s][%s][%s]: %s",
|
||||
DOMAIN,
|
||||
CONF_DEVICES,
|
||||
self.device_id,
|
||||
device_data,
|
||||
)
|
||||
self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data
|
||||
|
||||
@callback
|
||||
def async_binary_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing binary sensors."""
|
||||
return [
|
||||
self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_actuator_configuration(self):
|
||||
"""Return the configuration map for syncing actuators."""
|
||||
return [
|
||||
self.format_zone(
|
||||
data[CONF_ZONE],
|
||||
{"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)},
|
||||
)
|
||||
for data in self.stored_configuration[CONF_SWITCHES]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_dht_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DHT sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_ds18b20_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DS18B20 sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "ds18b20"
|
||||
]
|
||||
|
||||
async def async_update_initial_states(self):
|
||||
"""Update the initial state of each sensor from status poll."""
|
||||
for sensor_data in self.status.get("sensors"):
|
||||
sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get(
|
||||
sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {}
|
||||
)
|
||||
entity_id = sensor_config.get(ATTR_ENTITY_ID)
|
||||
|
||||
state = bool(sensor_data.get(ATTR_STATE))
|
||||
if sensor_config.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
@callback
|
||||
def async_desired_settings_payload(self):
|
||||
"""Return a dict representing the desired device configuration."""
|
||||
# keeping self.hass.data check for backwards compatibility
|
||||
# newly configured integrations store this in the config entry
|
||||
desired_api_host = self.options.get(CONF_API_HOST) or (
|
||||
self.hass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hass)
|
||||
)
|
||||
desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
|
||||
|
||||
return {
|
||||
"sensors": self.async_binary_sensor_configuration(),
|
||||
"actuators": self.async_actuator_configuration(),
|
||||
"dht_sensors": self.async_dht_sensor_configuration(),
|
||||
"ds18b20_sensors": self.async_ds18b20_sensor_configuration(),
|
||||
"auth_token": self.config.get(CONF_ACCESS_TOKEN),
|
||||
"endpoint": desired_api_endpoint,
|
||||
"blink": self.options.get(CONF_BLINK, True),
|
||||
"discovery": self.options.get(CONF_DISCOVERY, True),
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_current_settings_payload(self):
|
||||
"""Return a dict of configuration currently stored on the device."""
|
||||
settings = self.status["settings"] or {}
|
||||
|
||||
return {
|
||||
"sensors": [
|
||||
{self.api_version: s[self.api_version]}
|
||||
for s in self.status.get("sensors")
|
||||
],
|
||||
"actuators": self.status.get("actuators"),
|
||||
"dht_sensors": self.status.get(CONF_DHT_SENSORS),
|
||||
"ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS),
|
||||
"auth_token": settings.get("token"),
|
||||
"endpoint": settings.get("endpoint"),
|
||||
"blink": settings.get(CONF_BLINK),
|
||||
"discovery": settings.get(CONF_DISCOVERY),
|
||||
}
|
||||
|
||||
async def async_sync_device_config(self):
|
||||
"""Sync the new zone configuration to the Konnected device if needed."""
|
||||
_LOGGER.debug(
|
||||
"Device %s settings payload: %s",
|
||||
self.device_id,
|
||||
self.async_desired_settings_payload(),
|
||||
)
|
||||
if (
|
||||
self.async_desired_settings_payload()
|
||||
!= self.async_current_settings_payload()
|
||||
):
|
||||
_LOGGER.debug("Pushing settings to device %s", self.device_id)
|
||||
await self.client.put_settings(**self.async_desired_settings_payload())
|
||||
|
||||
|
||||
async def get_status(hass, host, port):
|
||||
"""Get the status of a Konnected Panel."""
|
||||
client = konnected.Client(
|
||||
host, str(port), aiohttp_client.async_get_clientsession(hass)
|
||||
)
|
||||
try:
|
||||
return await client.get_status()
|
||||
|
||||
except client.ClientError as err:
|
||||
_LOGGER.error("Exception trying to get panel status: %s", err)
|
||||
raise CannotConnect from err
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Support for DHT and DS18B20 sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_SENSORS,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SIGNAL_DS18B20_NEW
|
||||
|
||||
SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
"temperature": SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"humidity": SensorEntityDescription(
|
||||
key="humidity",
|
||||
name="Humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
|
||||
# Initialize all DHT sensors.
|
||||
dht_sensors = [
|
||||
sensor
|
||||
for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
entities = [
|
||||
KonnectedSensor(device_id, data=sensor_config, description=description)
|
||||
for sensor_config in dht_sensors
|
||||
for description in SENSOR_TYPES.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def async_add_ds18b20(attrs):
|
||||
"""Add new KonnectedSensor representing a ds18b20 sensor."""
|
||||
sensor_config = next(
|
||||
(
|
||||
s
|
||||
for s in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
KonnectedSensor(
|
||||
device_id,
|
||||
sensor_config,
|
||||
SENSOR_TYPES["temperature"],
|
||||
addr=attrs.get("addr"),
|
||||
initial_state=attrs.get("temp"),
|
||||
)
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
# DS18B20 sensors entities are initialized when they report for the first
|
||||
# time. Set up a listener for that signal from the Konnected component.
|
||||
async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20)
|
||||
|
||||
|
||||
class KonnectedSensor(SensorEntity):
|
||||
"""Represents a Konnected DHT Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id,
|
||||
data,
|
||||
description: SensorEntityDescription,
|
||||
addr=None,
|
||||
initial_state=None,
|
||||
) -> None:
|
||||
"""Initialize the entity for a single sensor_type."""
|
||||
self.entity_description = description
|
||||
self._addr = addr
|
||||
self._data = data
|
||||
self._zone_num = self._data.get(CONF_ZONE)
|
||||
self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}"
|
||||
|
||||
# set initial state if known at initialization
|
||||
self._attr_native_value = initial_state
|
||||
if initial_state:
|
||||
self._attr_native_value = round(float(initial_state), 1)
|
||||
|
||||
# set entity name if given
|
||||
if name := self._data.get(CONF_NAME):
|
||||
name += f" {description.name}"
|
||||
self._attr_name = name
|
||||
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
entity_id_key = self._addr or self.entity_description.key
|
||||
self._data[entity_id_key] = self.entity_id
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
if self.entity_description.key == "humidity":
|
||||
self._attr_native_value = int(float(state))
|
||||
else:
|
||||
self._attr_native_value = round(float(state), 1)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,8 +1,115 @@
|
||||
{
|
||||
"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%]",
|
||||
"not_konn_panel": "Not a recognized Konnected.io device",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings.",
|
||||
"title": "Konnected device ready"
|
||||
},
|
||||
"import_confirm": {
|
||||
"description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.",
|
||||
"title": "Import Konnected device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "Please enter the host information for your Konnected panel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The Konnected.io (Legacy) integration relied on Konnected's deprecated firmware and has been removed from Home Assistant. Konnected recommends migrating to their ESPHome based firmware and the corresponding Home Assistant integration by following the [migration guide]({kb_page_url}).\n\nTo resolve this issue, migrate your Konnected device(s) to the ESPHome based firmware, then remove any `konnected:` YAML configuration from your `configuration.yaml` file, and remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Konnected.io (Legacy) integration entries]({entries}).",
|
||||
"title": "The Konnected.io (Legacy) integration has been removed"
|
||||
"deprecated_firmware": {
|
||||
"description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant.",
|
||||
"title": "Konnected firmware is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_host": "Invalid custom API host URL"
|
||||
},
|
||||
"step": {
|
||||
"options_binary": {
|
||||
"data": {
|
||||
"inverse": "Invert the open/close state",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"type": "Binary sensor type"
|
||||
},
|
||||
"description": "{zone} options",
|
||||
"title": "Configure binary sensor"
|
||||
},
|
||||
"options_digital": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"poll_interval": "Poll interval (minutes)",
|
||||
"type": "Sensor type"
|
||||
},
|
||||
"description": "[%key:component::konnected::options::step::options_binary::description%]",
|
||||
"title": "Configure digital sensor"
|
||||
},
|
||||
"options_io": {
|
||||
"data": {
|
||||
"1": "Zone 1",
|
||||
"2": "Zone 2",
|
||||
"3": "Zone 3",
|
||||
"4": "Zone 4",
|
||||
"5": "Zone 5",
|
||||
"6": "Zone 6",
|
||||
"7": "Zone 7",
|
||||
"out": "OUT"
|
||||
},
|
||||
"description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure I/O"
|
||||
},
|
||||
"options_io_ext": {
|
||||
"data": {
|
||||
"8": "Zone 8",
|
||||
"9": "Zone 9",
|
||||
"10": "Zone 10",
|
||||
"11": "Zone 11",
|
||||
"12": "Zone 12",
|
||||
"alarm1": "ALARM1",
|
||||
"alarm2_out2": "OUT2/ALARM2",
|
||||
"out1": "OUT1"
|
||||
},
|
||||
"description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure extended I/O"
|
||||
},
|
||||
"options_misc": {
|
||||
"data": {
|
||||
"api_host": "Custom API host URL",
|
||||
"blink": "Blink panel LED on when sending state change",
|
||||
"discovery": "Respond to discovery requests on your network",
|
||||
"override_api_host": "Override default Home Assistant API host URL"
|
||||
},
|
||||
"description": "Please select the desired behavior for your panel",
|
||||
"title": "Configure misc"
|
||||
},
|
||||
"options_switch": {
|
||||
"data": {
|
||||
"activation": "Output when on",
|
||||
"momentary": "Pulse duration (ms)",
|
||||
"more_states": "Configure additional states for this zone",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"pause": "Pause between pulses (ms)",
|
||||
"repeat": "Times to repeat (-1=infinite)"
|
||||
},
|
||||
"description": "{zone} options: state {state}",
|
||||
"title": "Configure switchable output"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Support for wired switches attached to a Konnected device."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_STATE,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_REPEAT,
|
||||
CONF_SWITCHES,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches attached to a Konnected device from a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
switches = [
|
||||
KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data)
|
||||
for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES]
|
||||
]
|
||||
async_add_entities(switches)
|
||||
|
||||
|
||||
class KonnectedSwitch(SwitchEntity):
|
||||
"""Representation of a Konnected switch."""
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected switch."""
|
||||
self._data = data
|
||||
self._device_id = device_id
|
||||
self._zone_num = zone_num
|
||||
self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH)
|
||||
self._momentary = self._data.get(CONF_MOMENTARY)
|
||||
self._pause = self._data.get(CONF_PAUSE)
|
||||
self._repeat = self._data.get(CONF_REPEAT)
|
||||
self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE))
|
||||
self._attr_name = self._data.get(CONF_NAME)
|
||||
self._attr_unique_id = (
|
||||
f"{device_id}-{self._zone_num}-{self._momentary}-"
|
||||
f"{self._pause}-{self._repeat}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
@property
|
||||
def panel(self):
|
||||
"""Return the Konnected HTTP client."""
|
||||
device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id]
|
||||
return device_data.get("panel")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the panel is available."""
|
||||
return self.panel.available
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn on the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num,
|
||||
int(self._activation == STATE_HIGH),
|
||||
self._momentary,
|
||||
self._repeat,
|
||||
self._pause,
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(True)
|
||||
|
||||
if self._momentary and resp.get(ATTR_STATE) != -1:
|
||||
# Immediately set the state back off for momentary switches
|
||||
self._set_state(False)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn off the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num, int(self._activation == STATE_LOW)
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(self._boolean_state(resp.get(ATTR_STATE)))
|
||||
|
||||
def _boolean_state(self, int_state: int | None) -> bool | None:
|
||||
if int_state == 0:
|
||||
return self._activation == STATE_LOW
|
||||
if int_state == 1:
|
||||
return self._activation == STATE_HIGH
|
||||
return None
|
||||
|
||||
def _set_state(self, state):
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Setting status of %s actuator zone %s to %s",
|
||||
self._device_id,
|
||||
self.name,
|
||||
state,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the switch state."""
|
||||
self._set_state(state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data["entity_id"] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
@@ -29,7 +29,6 @@ from homeassistant.components.mjpeg import (
|
||||
MjpegCamera,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ACTION,
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -44,6 +43,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras
|
||||
from .const import (
|
||||
CONF_ACTION,
|
||||
CONF_STREAM_URL_TEMPLATE,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
|
||||
@@ -29,6 +29,8 @@ DOMAIN: Final = "motioneye"
|
||||
ATTR_EVENT_TYPE: Final = "event_type"
|
||||
ATTR_WEBHOOK_ID: Final = "webhook_id"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACTION: Final = "action"
|
||||
CONF_ADMIN_PASSWORD: Final = "admin_password"
|
||||
CONF_ADMIN_USERNAME: Final = "admin_username"
|
||||
CONF_MORE_OPTIONS: Final = "more_options"
|
||||
|
||||
@@ -10,7 +10,6 @@ from pyatmo.event import Event as NaEvent
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.const import ATTR_PERSONS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
@@ -21,6 +20,7 @@ from .const import (
|
||||
ATTR_CAMERA_LIGHT_MODE,
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_PERSON,
|
||||
ATTR_PERSONS,
|
||||
CAMERA_LIGHT_MODES,
|
||||
CAMERA_TRIGGERS,
|
||||
CONF_URL_SECURITY,
|
||||
|
||||
@@ -92,6 +92,8 @@ ATTR_HOME_ID = "home_id"
|
||||
ATTR_HOME_NAME = "home_name"
|
||||
ATTR_IS_KNOWN = "is_known"
|
||||
ATTR_PERSON = "person"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_PERSONS = "persons"
|
||||
ATTR_PSEUDO = "pseudo"
|
||||
ATTR_SCHEDULE_ID = "schedule_id"
|
||||
ATTR_SCHEDULE_NAME = "schedule_name"
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -14,6 +14,7 @@ from .const import (
|
||||
ATTR_FACE_URL,
|
||||
ATTR_HOME_ID,
|
||||
ATTR_IS_KNOWN,
|
||||
ATTR_PERSONS,
|
||||
DATA_DEVICE_IDS,
|
||||
DATA_PERSONS,
|
||||
DEFAULT_PERSON,
|
||||
|
||||
@@ -8,6 +8,8 @@ DOMAIN = "nice_go"
|
||||
|
||||
# Configuration
|
||||
CONF_SITE_ID = "site_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from pyrail.models import StationDetails
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_SHOW_ON_MAP
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
@@ -17,7 +16,13 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_EXCLUDE_VIAS, CONF_STATION_FROM, CONF_STATION_TO, DOMAIN
|
||||
from .const import (
|
||||
CONF_EXCLUDE_VIAS,
|
||||
CONF_SHOW_ON_MAP,
|
||||
CONF_STATION_FROM,
|
||||
CONF_STATION_TO,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -13,6 +13,8 @@ CONF_STATION_FROM = "station_from"
|
||||
CONF_STATION_TO = "station_to"
|
||||
CONF_STATION_LIVE = "station_live"
|
||||
CONF_EXCLUDE_VIAS = "exclude_vias"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SHOW_ON_MAP = "show_on_map"
|
||||
|
||||
|
||||
def find_station_by_name(hass: HomeAssistant, station_name: str):
|
||||
|
||||
@@ -8,7 +8,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_SENSORS,
|
||||
@@ -29,6 +28,8 @@ DOMAIN = "numato"
|
||||
|
||||
CONF_INVERT_LOGIC = "invert_logic"
|
||||
CONF_DISCOVER = "discover"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICES = "devices"
|
||||
CONF_DEVICE_ID = "id"
|
||||
CONF_PORTS = "ports"
|
||||
CONF_SRC_RANGE = "source_range"
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from numato_gpio import NumatoGpioError
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_DEVICES, DEVICE_DEFAULT_NAME
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_ID,
|
||||
CONF_INVERT_LOGIC,
|
||||
CONF_PORTS,
|
||||
|
||||
@@ -5,12 +5,13 @@ import logging
|
||||
from numato_gpio import NumatoGpioError
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME, CONF_SENSORS
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_SENSORS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_DEVICES,
|
||||
CONF_DST_RANGE,
|
||||
CONF_DST_UNIT,
|
||||
CONF_PORTS,
|
||||
|
||||
@@ -8,13 +8,7 @@ import httpx
|
||||
import ollama
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_MODEL,
|
||||
CONF_PROMPT,
|
||||
CONF_URL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@@ -32,7 +26,9 @@ from homeassistant.util.ssl import get_default_context
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_NAME,
|
||||
|
||||
@@ -18,14 +18,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LLM_HASS_API,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PROMPT,
|
||||
CONF_URL,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, llm
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -47,7 +40,9 @@ from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
|
||||
@@ -4,6 +4,10 @@ DOMAIN = "ollama"
|
||||
|
||||
DEFAULT_NAME = "Ollama"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_MODEL = "model"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_THINK = "think"
|
||||
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
|
||||
@@ -4,12 +4,12 @@ from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OllamaBaseLLMEntity
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -21,6 +20,7 @@ from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_THINK,
|
||||
DEFAULT_KEEP_ALIVE,
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -47,6 +47,9 @@ from .util import get_meaning
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
INPUT_SOURCES_DEFAULT: list[InputSource] = []
|
||||
LISTENING_MODES_DEFAULT: list[ListeningMode] = []
|
||||
INPUT_SOURCES_ALL_MEANINGS = {
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,6 +18,8 @@ SCAN_INTERVAL = timedelta(hours=12)
|
||||
|
||||
CONF_ZIP = "zip"
|
||||
CONF_WASTE_TYPE = "waste_type"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_NAME = "name"
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
|
||||
@@ -73,9 +73,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Failed to connect to OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -45,6 +45,10 @@ SERVICE_GET_PROFILE = "get_profile"
|
||||
SERVICE_SET_PROFILE = "set_profile"
|
||||
SERVICE_SET_V40MIN = "set_v40_min"
|
||||
SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_OFF = "turn_off"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_ON = "turn_on"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -429,9 +429,6 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
close_command=OverkizCommand.CLOSE,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
),
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.ROLLER_SHUTTER,
|
||||
|
||||
@@ -19,7 +19,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -70,6 +70,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
DEFAULT_MAX_OBJECTS = 5
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_SECONDS = "seconds"
|
||||
CONF_MAX_OBJECTS = "max_objects"
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CONDITION,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -68,6 +67,8 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_CONDITION = "condition"
|
||||
CONF_DEWPOINT = "dewpoint"
|
||||
CONF_ET = "et"
|
||||
CONF_MAXRH = "maxrh"
|
||||
|
||||
@@ -95,7 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
prefer_cache=False,
|
||||
)
|
||||
except RoborockInvalidCredentials as err:
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid credentials",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
) from err
|
||||
@@ -116,7 +118,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
) from err
|
||||
except RoborockException as err:
|
||||
_LOGGER.debug("Failed to get Roborock home data: %s", err)
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ConfigEntryNotReady(
|
||||
"Failed to get Roborock home data",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="home_data_fail",
|
||||
) from err
|
||||
@@ -174,7 +178,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
len(v1_coords) + len(a01_coords) + len(b01_q7_coords) + len(b01_q10_coords) == 0
|
||||
and enabled_devices
|
||||
):
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ConfigEntryNotReady(
|
||||
"No devices were able to successfully setup",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_coordinators",
|
||||
)
|
||||
|
||||
@@ -161,9 +161,11 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
|
||||
_LOGGER.info("Home discovery skipped while device is busy/cleaning")
|
||||
except RoborockException as err:
|
||||
_LOGGER.debug("Failed to get maps: %s", err)
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="map_failure",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
else:
|
||||
# Force a map refresh on first setup
|
||||
|
||||
@@ -343,5 +343,3 @@ TRV_CHANNEL = 0
|
||||
|
||||
ATTR_KEY = "key"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
DRIVER_MISSING_ERROR = "Sensor driver missing from firmware"
|
||||
|
||||
@@ -42,7 +42,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, DRIVER_MISSING_ERROR, ROLE_GENERIC
|
||||
from .const import CONF_SLEEP_PERIOD, ROLE_GENERIC
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -1225,9 +1225,6 @@ RPC_SENSORS: Final = {
|
||||
suggested_display_precision=1,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda _, status, key: (
|
||||
DRIVER_MISSING_ERROR in status[key].get("errors", [])
|
||||
),
|
||||
),
|
||||
"rssi": RpcSensorDescription(
|
||||
key="wifi",
|
||||
@@ -1256,9 +1253,6 @@ RPC_SENSORS: Final = {
|
||||
suggested_display_precision=1,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda _, status, key: (
|
||||
DRIVER_MISSING_ERROR in status[key].get("errors", [])
|
||||
),
|
||||
),
|
||||
"battery": RpcSensorDescription(
|
||||
key="devicepower",
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.util.network import is_ip_address
|
||||
@@ -21,6 +21,9 @@ from .utils import _short_mac, name_from_bulb_type_and_mac
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
|
||||
class WizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for WiZ."""
|
||||
|
||||
@@ -946,6 +946,10 @@ PRECISION_WHOLE: Final = 1
|
||||
PRECISION_HALVES: Final = 0.5
|
||||
PRECISION_TENTHS: Final = 0.1
|
||||
|
||||
# Static list of entities that will never be exposed to
|
||||
# cloud, alexa, or google_home components
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"]
|
||||
|
||||
|
||||
class EntityCategory(StrEnum):
|
||||
"""Category of an entity.
|
||||
|
||||
Generated
+1
@@ -384,6 +384,7 @@ FLOWS = {
|
||||
"knocki",
|
||||
"knx",
|
||||
"kodi",
|
||||
"konnected",
|
||||
"kostal_plenticore",
|
||||
"kraken",
|
||||
"kulersky",
|
||||
|
||||
@@ -3574,6 +3574,12 @@
|
||||
"konnected": {
|
||||
"name": "Konnected",
|
||||
"integrations": {
|
||||
"konnected": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Konnected.io (Legacy)"
|
||||
},
|
||||
"konnected_esphome": {
|
||||
"integration_type": "virtual",
|
||||
"config_flow": false,
|
||||
|
||||
Generated
+5
@@ -201,6 +201,11 @@ SSDP = {
|
||||
"manufacturer": "ZyXEL Communications Corp.",
|
||||
},
|
||||
],
|
||||
"konnected": [
|
||||
{
|
||||
"manufacturer": "konnected.io",
|
||||
},
|
||||
],
|
||||
"lametric": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1",
|
||||
|
||||
@@ -71,13 +71,6 @@ NO_ENTITIES_PROMPT = (
|
||||
"to their voice assistant in Home Assistant."
|
||||
)
|
||||
|
||||
DEVICE_CONTROL_TOOL_USAGE_PROMPT = (
|
||||
"When controlling Home Assistant always call the intent tools. "
|
||||
"Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
|
||||
"When controlling a device, prefer passing just name and domain. "
|
||||
"When controlling an area, prefer passing just area name and domain."
|
||||
)
|
||||
|
||||
DYNAMIC_CONTEXT_PROMPT = (
|
||||
"You ARE equipped to answer questions about the"
|
||||
" current state of\n"
|
||||
@@ -503,33 +496,27 @@ class AssistAPI(API):
|
||||
) -> str:
|
||||
if not exposed_entities or not exposed_entities["entities"]:
|
||||
return NO_ENTITIES_PROMPT
|
||||
return "\n".join(
|
||||
[
|
||||
*self._async_get_preable(llm_context),
|
||||
*self._async_get_exposed_entities_prompt(llm_context, exposed_entities),
|
||||
]
|
||||
)
|
||||
|
||||
# Collect all parts, filtering out any None values
|
||||
prompt_parts = [
|
||||
DEVICE_CONTROL_TOOL_USAGE_PROMPT,
|
||||
DYNAMIC_CONTEXT_PROMPT,
|
||||
*self._async_get_exposed_entities_prompt(exposed_entities),
|
||||
self._async_get_voice_satellite_area_prompt(llm_context),
|
||||
self._async_get_no_timer_prompt(llm_context),
|
||||
@callback
|
||||
def _async_get_preable(self, llm_context: LLMContext) -> list[str]:
|
||||
"""Return the prompt for the API."""
|
||||
|
||||
prompt = [
|
||||
(
|
||||
"When controlling Home Assistant always call the intent tools. "
|
||||
"Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
|
||||
"When controlling a device, prefer passing just name and domain. "
|
||||
"When controlling an area, prefer passing just area name and domain."
|
||||
)
|
||||
]
|
||||
|
||||
# Filter out None and empty strings before joining
|
||||
return "\n".join([part for part in prompt_parts if part])
|
||||
|
||||
@callback
|
||||
def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None:
|
||||
if not llm_context.device_id or not async_device_supports_timers(
|
||||
self.hass, llm_context.device_id
|
||||
):
|
||||
return "This device is not able to start timers."
|
||||
return None
|
||||
|
||||
@callback
|
||||
def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str:
|
||||
"""Return the area prompt for the voice satellite."""
|
||||
floor: fr.FloorEntry | None = None
|
||||
area: ar.AreaEntry | None = None
|
||||
extra = ""
|
||||
floor: fr.FloorEntry | None = None
|
||||
if llm_context.device_id:
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device = device_reg.async_get(llm_context.device_id)
|
||||
@@ -548,18 +535,28 @@ class AssistAPI(API):
|
||||
)
|
||||
|
||||
if floor and area:
|
||||
return f"You are in area {area.name} (floor {floor.name}) {extra}".strip()
|
||||
if area:
|
||||
return f"You are in area {area.name} {extra}".strip()
|
||||
return (
|
||||
"When a user asks to turn on all devices of a specific type, "
|
||||
"ask the user to specify an area, unless there"
|
||||
" is only one device of that type."
|
||||
)
|
||||
prompt.append(f"You are in area {area.name} (floor {floor.name}) {extra}")
|
||||
elif area:
|
||||
prompt.append(f"You are in area {area.name} {extra}")
|
||||
else:
|
||||
prompt.append(
|
||||
"When a user asks to turn on all devices of a specific type, "
|
||||
"ask user to specify an area, unless there"
|
||||
" is only one device of that type."
|
||||
)
|
||||
|
||||
if not llm_context.device_id or not async_device_supports_timers(
|
||||
self.hass, llm_context.device_id
|
||||
):
|
||||
prompt.append("This device is not able to start timers.")
|
||||
|
||||
prompt.append(DYNAMIC_CONTEXT_PROMPT)
|
||||
|
||||
return prompt
|
||||
|
||||
@callback
|
||||
def _async_get_exposed_entities_prompt(
|
||||
self, exposed_entities: dict | None
|
||||
self, llm_context: LLMContext, exposed_entities: dict | None
|
||||
) -> list[str]:
|
||||
"""Return the prompt for the API for exposed entities."""
|
||||
prompt = []
|
||||
|
||||
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.7.1
|
||||
habluetooth==6.2.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
|
||||
Generated
+8
-5
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==45.2.0
|
||||
aioesphomeapi==45.0.4
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -603,7 +603,7 @@ avea==1.8.0
|
||||
# avion==0.10
|
||||
|
||||
# homeassistant.components.axis
|
||||
axis==72
|
||||
axis==71
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.7
|
||||
@@ -648,7 +648,7 @@ beautifulsoup4==4.13.3
|
||||
bizkaibus==0.1.1
|
||||
|
||||
# homeassistant.components.esphome
|
||||
bleak-esphome==3.9.1
|
||||
bleak-esphome==3.7.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.6.0
|
||||
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
|
||||
habiticalib==0.4.7
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==6.7.1
|
||||
habluetooth==6.2.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1359,7 +1359,7 @@ influxdb==5.3.1
|
||||
infrared-protocols==5.4.0
|
||||
|
||||
# homeassistant.components.inkbird
|
||||
inkbird-ble==1.1.2
|
||||
inkbird-ble==1.1.1
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.2
|
||||
@@ -1422,6 +1422,9 @@ knocki==0.4.2
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.4.30.60856
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
|
||||
# homeassistant.components.kraken
|
||||
krakenex==2.2.2
|
||||
|
||||
|
||||
+402
-62
@@ -2,9 +2,14 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
import hashlib
|
||||
import json
|
||||
from math import ceil
|
||||
from operator import attrgetter, itemgetter
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -15,13 +20,21 @@ from typing import Final
|
||||
# place to subdivide to keep each pytest invocation roughly equal in size.
|
||||
_FAN_OUT_DIRS: Final = frozenset({"components"})
|
||||
|
||||
# Cache file format version; bump on any incompatible schema change so old
|
||||
# caches are ignored rather than misread.
|
||||
_CACHE_VERSION: Final = 3
|
||||
|
||||
# Fall back from file-level to directory-level pytest collection when
|
||||
# misses make up more than this fraction of the tree; past that point
|
||||
# the per-file argv overhead pytest pays outweighs the cost of letting
|
||||
# it re-walk dirs and re-collect the hits.
|
||||
_DIR_LEVEL_MISS_RATIO: Final = 0.3
|
||||
|
||||
|
||||
class Bucket:
|
||||
"""Class to hold bucket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize bucket."""
|
||||
self.total_tests = 0
|
||||
self._paths: list[str] = []
|
||||
@@ -47,43 +60,56 @@ class BucketHolder:
|
||||
self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)]
|
||||
|
||||
def split_tests(self, test_folder: TestFolder) -> None:
|
||||
"""Split tests into buckets."""
|
||||
"""Place atomic units via best-fit; oversized ones go to the smallest bucket."""
|
||||
digits = len(str(test_folder.total_tests))
|
||||
sorted_tests = sorted(
|
||||
test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests
|
||||
)
|
||||
for tests in sorted_tests:
|
||||
if tests.added_to_bucket:
|
||||
# Already added to bucket
|
||||
continue
|
||||
by_load = attrgetter("total_tests")
|
||||
units = sorted(self._atomic_units(test_folder), key=itemgetter(0), reverse=True)
|
||||
for size, items in units:
|
||||
for item in items:
|
||||
tag = " (same bucket)" if item is not items[0] else ""
|
||||
print(f"{item.total_tests:>{digits}} tests in {item.path}{tag}")
|
||||
fits = [
|
||||
b
|
||||
for b in self._buckets
|
||||
if b.total_tests + size <= self._tests_per_bucket
|
||||
]
|
||||
bucket = max(fits, key=by_load) if fits else min(self._buckets, key=by_load)
|
||||
for item in items:
|
||||
bucket.add(item)
|
||||
|
||||
print(f"{tests.total_tests:>{digits}} tests in {tests.path}")
|
||||
smallest_bucket = min(self._buckets, key=lambda x: x.total_tests)
|
||||
is_file = isinstance(tests, TestFile)
|
||||
if (
|
||||
smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket
|
||||
) or is_file:
|
||||
smallest_bucket.add(tests)
|
||||
# Ensure all files from the same folder are in the same bucket
|
||||
# to ensure that syrupy correctly identifies unused snapshots
|
||||
if is_file:
|
||||
for other_test in tests.parent.children.values():
|
||||
if other_test is tests or isinstance(other_test, TestFolder):
|
||||
continue
|
||||
print(
|
||||
f"{other_test.total_tests:>{digits}}"
|
||||
f" tests in {other_test.path}"
|
||||
" (same bucket)"
|
||||
)
|
||||
smallest_bucket.add(other_test)
|
||||
|
||||
# verify that all tests are added to a bucket
|
||||
if not test_folder.added_to_bucket:
|
||||
raise ValueError("Not all tests are added to a bucket")
|
||||
|
||||
def create_ouput_file(self) -> None:
|
||||
def _atomic_units(
|
||||
self, folder: TestFolder
|
||||
) -> Iterator[tuple[int, list[TestFolder | TestFile]]]:
|
||||
"""Yield ``(size, items)`` placement units.
|
||||
|
||||
A folder that fits is one unit; otherwise same-dir files form
|
||||
a unit only when the folder has syrupy snapshots, else each
|
||||
file stands alone. Sub-folders recurse independently.
|
||||
"""
|
||||
if folder.total_tests <= self._tests_per_bucket:
|
||||
yield folder.total_tests, [folder]
|
||||
return
|
||||
|
||||
sibling_files = [c for c in folder.children.values() if isinstance(c, TestFile)]
|
||||
if sibling_files:
|
||||
if _has_snapshots(folder.path):
|
||||
yield (
|
||||
sum(f.total_tests for f in sibling_files),
|
||||
list(sibling_files),
|
||||
)
|
||||
else:
|
||||
for file in sibling_files:
|
||||
yield file.total_tests, [file]
|
||||
for child in folder.children.values():
|
||||
if isinstance(child, TestFolder):
|
||||
yield from self._atomic_units(child)
|
||||
|
||||
def create_output_file(self) -> None:
|
||||
"""Create output file."""
|
||||
with Path("pytest_buckets.txt").open("w") as file:
|
||||
with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file:
|
||||
for idx, bucket in enumerate(self._buckets):
|
||||
print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
|
||||
file.write(bucket.get_paths_line())
|
||||
@@ -170,6 +196,15 @@ class TestFolder:
|
||||
return result
|
||||
|
||||
|
||||
def _has_snapshots(folder_path: Path) -> bool:
|
||||
"""Return True when ``folder_path/snapshots`` holds ``.ambr`` files.
|
||||
|
||||
Same-dir tests must share a pytest run so syrupy can spot unused
|
||||
snapshots; without snapshots that constraint doesn't apply.
|
||||
"""
|
||||
return any((folder_path / "snapshots").glob("*.ambr"))
|
||||
|
||||
|
||||
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
|
||||
"""Run pytest --collect-only on a batch of paths."""
|
||||
result = subprocess.run(
|
||||
@@ -216,44 +251,343 @@ def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
return paths
|
||||
|
||||
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
if not batch_paths:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
|
||||
# Round-robin chunking keeps batches roughly balanced when path
|
||||
# ordering correlates with test size.
|
||||
batches = [batch_paths[i::workers] for i in range(workers)]
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return a short content hash for ``path``."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]:
|
||||
"""Walk ``root`` once and return (test files, fixture files).
|
||||
|
||||
Fixtures are every non-``test_*.py`` ``.py``: conftests and helpers
|
||||
like ``common.py`` that drive parametrize imports. Uses ``os.walk``
|
||||
(~2x faster than ``Path.rglob`` on this tree) and prunes ``.``/``_``
|
||||
subdirs.
|
||||
"""
|
||||
test_files: list[Path] = []
|
||||
fixtures: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))]
|
||||
base = Path(dirpath)
|
||||
for name in filenames:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
if name.startswith("test_"):
|
||||
test_files.append(base / name)
|
||||
else:
|
||||
fixtures.append(base / name)
|
||||
test_files.sort()
|
||||
fixtures.sort()
|
||||
return test_files, fixtures
|
||||
|
||||
|
||||
_PROJECT_ROOT_MARKERS: Final = frozenset(
|
||||
{"pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"}
|
||||
)
|
||||
|
||||
|
||||
def _find_ancestor_fixtures(root: Path) -> list[Path]:
|
||||
"""Return non-``test_*.py`` Python files above ``root``, up to the project root.
|
||||
|
||||
Includes conftests and helper modules (eg ``common.py``); subtree
|
||||
runs need both so shared ancestor helpers like
|
||||
``tests/components/common.py`` still invalidate descendants.
|
||||
Stops at the first ancestor containing a project-root marker so we
|
||||
don't read unrelated ``.py`` files outside the repo or trip on
|
||||
dirs we can't list.
|
||||
"""
|
||||
fixtures: list[Path] = []
|
||||
current = root.resolve().parent
|
||||
while True:
|
||||
with suppress(OSError):
|
||||
fixtures.extend(
|
||||
entry
|
||||
for entry in current.glob("*.py")
|
||||
if not entry.name.startswith("test_")
|
||||
)
|
||||
if any((current / marker).exists() for marker in _PROJECT_ROOT_MARKERS):
|
||||
break
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
return fixtures
|
||||
|
||||
|
||||
def _build_fixtures_by_dir(
|
||||
root: Path, descendants: list[Path]
|
||||
) -> dict[Path, list[Path]]:
|
||||
"""Bucket descendants plus ancestor fixtures by resolved parent dir."""
|
||||
by_dir: dict[Path, list[Path]] = {}
|
||||
for fixture in (*_find_ancestor_fixtures(root), *descendants):
|
||||
by_dir.setdefault(fixture.parent.resolve(), []).append(fixture)
|
||||
return by_dir
|
||||
|
||||
|
||||
def _file_fixture_hash(
|
||||
test_file: Path,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
blob_cache: dict[Path, bytes] | None = None,
|
||||
dir_cache: dict[Path, str] | None = None,
|
||||
) -> str:
|
||||
"""Hash every ``.py`` fixture on the test file's ancestor path.
|
||||
|
||||
Catches conftests and helper modules (``common.py`` etc.) at any
|
||||
level so parametrize imports from shared helpers invalidate
|
||||
descendants, while sibling subtrees stay warm. Pass shared
|
||||
``blob_cache``/``dir_cache`` dicts to memoize across many files.
|
||||
"""
|
||||
test_dir = test_file.parent.resolve()
|
||||
if dir_cache is not None and (cached := dir_cache.get(test_dir)) is not None:
|
||||
return cached
|
||||
relevant: list[Path] = []
|
||||
current = test_dir
|
||||
while True:
|
||||
relevant.extend(fixtures_by_dir.get(current, ()))
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
relevant.sort()
|
||||
digest = hashlib.sha256()
|
||||
for fixture in relevant:
|
||||
blob = blob_cache.get(fixture) if blob_cache is not None else None
|
||||
if blob is None:
|
||||
# relpath keeps the hash machine-stable across ancestor paths.
|
||||
blob = (
|
||||
os.path.relpath(fixture, root).encode()
|
||||
+ b"\0"
|
||||
+ fixture.read_bytes()
|
||||
+ b"\0"
|
||||
)
|
||||
if blob_cache is not None:
|
||||
blob_cache[fixture] = blob
|
||||
digest.update(blob)
|
||||
result = digest.hexdigest()
|
||||
if dir_cache is not None:
|
||||
dir_cache[test_dir] = result
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Cached test count plus its scope hash for a single file."""
|
||||
|
||||
hash: str
|
||||
fixture_hash: str
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cache:
|
||||
"""Mapping of test file path → cached entry."""
|
||||
|
||||
entries: dict[str, _CacheEntry]
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> _Cache:
|
||||
"""Load cache; any drift (missing, bad, version, malformed) returns empty."""
|
||||
try:
|
||||
raw = json.loads(path.read_bytes())
|
||||
except OSError, ValueError:
|
||||
raw = None
|
||||
if not (
|
||||
isinstance(raw, dict)
|
||||
and raw.get("version") == _CACHE_VERSION
|
||||
and isinstance(raw.get("files"), dict)
|
||||
):
|
||||
return cls(entries={})
|
||||
entries: dict[str, _CacheEntry] = {}
|
||||
for key, value in raw["files"].items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
hash_value = value.get("hash")
|
||||
fixture_hash = value.get("fixture_hash")
|
||||
count = value.get("count")
|
||||
# bool is an int subclass; reject true/false and negatives so
|
||||
# corrupted JSON can't feed bucket sizing a bogus weight.
|
||||
if (
|
||||
not isinstance(hash_value, str)
|
||||
or not isinstance(fixture_hash, str)
|
||||
or not isinstance(count, int)
|
||||
or isinstance(count, bool)
|
||||
or count < 0
|
||||
):
|
||||
continue
|
||||
entries[key] = _CacheEntry(
|
||||
hash=hash_value, fixture_hash=fixture_hash, count=count
|
||||
)
|
||||
return cls(entries=entries)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Write the cache to ``path``, creating parent dirs as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": _CACHE_VERSION,
|
||||
"files": {
|
||||
key: {
|
||||
"hash": entry.hash,
|
||||
"fixture_hash": entry.fixture_hash,
|
||||
"count": entry.count,
|
||||
}
|
||||
for key, entry in sorted(self.entries.items())
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entries(
|
||||
test_files: list[Path],
|
||||
cache: _Cache,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
) -> tuple[dict[Path, _CacheEntry], list[Path]]:
|
||||
"""Build an entry for every file; return ``(entries, misses)``.
|
||||
|
||||
Hits reuse the stored entry; misses get fresh hashes with a
|
||||
count=0 placeholder for the caller to fill in after pytest runs.
|
||||
Shared caches memoize fixture blobs and per-dir hashes so each
|
||||
fixture file is read once and each unique dir hashed once.
|
||||
"""
|
||||
blob_cache: dict[Path, bytes] = {}
|
||||
dir_cache: dict[Path, str] = {}
|
||||
entries: dict[Path, _CacheEntry] = {}
|
||||
misses: list[Path] = []
|
||||
for file in test_files:
|
||||
file_hash = _hash_file(file)
|
||||
fixture_hash = _file_fixture_hash(
|
||||
file, root, fixtures_by_dir, blob_cache, dir_cache
|
||||
)
|
||||
cached = cache.entries.get(str(file.relative_to(root)))
|
||||
if (
|
||||
cached is not None
|
||||
and cached.hash == file_hash
|
||||
and cached.fixture_hash == fixture_hash
|
||||
):
|
||||
entries[file] = cached
|
||||
else:
|
||||
entries[file] = _CacheEntry(
|
||||
hash=file_hash, fixture_hash=fixture_hash, count=0
|
||||
)
|
||||
misses.append(file)
|
||||
return entries, misses
|
||||
|
||||
|
||||
def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
"""Run pytest --collect-only across ``paths`` using a process pool."""
|
||||
workers = min(len(paths), os.cpu_count() or 1) or 1
|
||||
batches = [paths[i::workers] for i in range(workers)]
|
||||
if workers == 1:
|
||||
results = [_collect_batch(batches[0])]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
results = list(executor.map(_collect_batch, batches))
|
||||
return [_collect_batch(batches[0])]
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
return list(executor.map(_collect_batch, batches))
|
||||
|
||||
folder = TestFolder(path)
|
||||
for stdout, stderr, returncode in results:
|
||||
|
||||
def _parse_collect_output(stdout: str) -> dict[Path, int]:
|
||||
"""Parse ``pytest --collect-only -qq`` output into ``{path: count}``."""
|
||||
counts: dict[Path, int] = {}
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
raise ValueError(f"Unexpected line: {line}")
|
||||
counts[Path(file_path)] = int(total_tests)
|
||||
return counts
|
||||
|
||||
|
||||
def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]:
|
||||
"""Run pytest --collect-only across ``paths`` and parse the output."""
|
||||
counts: dict[Path, int] = {}
|
||||
for stdout, stderr, returncode in _run_collect_batches(paths):
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
sys.exit(1)
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
sys.exit(1)
|
||||
# Surface stderr from successful runs too; pytest puts deprecation
|
||||
# and import warnings here that would otherwise vanish.
|
||||
if stderr.strip():
|
||||
sys.stderr.write(stderr)
|
||||
try:
|
||||
counts.update(_parse_collect_output(stdout))
|
||||
except ValueError as err:
|
||||
print(err)
|
||||
sys.exit(1)
|
||||
return counts
|
||||
|
||||
file = TestFile(int(total_tests), Path(file_path))
|
||||
folder.add_test_file(file)
|
||||
|
||||
def _build_folder(root: Path, counts: dict[Path, int]) -> TestFolder:
|
||||
"""Build a ``TestFolder`` from ``{path: count}``; zero-count files are skipped."""
|
||||
folder = TestFolder(root)
|
||||
for file_path, count in counts.items():
|
||||
if count:
|
||||
folder.add_test_file(TestFile(count, file_path))
|
||||
return folder
|
||||
|
||||
|
||||
def _exit_if_empty(paths: list[Path], root: Path) -> None:
|
||||
"""Exit with a clear message when no eligible test paths were found."""
|
||||
if not paths:
|
||||
print(f"No eligible test paths found under {root}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _collect_tests_uncached(path: Path) -> TestFolder:
|
||||
"""Hand pytest the top-level dirs; the pre-cache path when ``--cache`` is unset."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
_exit_if_empty(batch_paths, path)
|
||||
return _build_folder(path, _run_pytest_collect(batch_paths))
|
||||
|
||||
|
||||
def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder:
|
||||
"""Collect tests using an on-disk cache for incremental updates."""
|
||||
all_test_files, fixtures = _walk_test_tree(path)
|
||||
_exit_if_empty(all_test_files, path)
|
||||
|
||||
fixtures_by_dir = _build_fixtures_by_dir(path, fixtures)
|
||||
cache = _Cache.load(cache_path)
|
||||
entries, misses = _resolve_entries(all_test_files, cache, path, fixtures_by_dir)
|
||||
hits = len(all_test_files) - len(misses)
|
||||
print(f"Cache: {hits} hits / {len(misses)} misses / {len(all_test_files)} total")
|
||||
|
||||
if misses:
|
||||
# Past _DIR_LEVEL_MISS_RATIO the per-file argv overhead beats
|
||||
# re-walking the dirs, so fall back to dir-level collection.
|
||||
if not hits or len(misses) > len(all_test_files) * _DIR_LEVEL_MISS_RATIO:
|
||||
collect_paths = _enumerate_batch_paths(path)
|
||||
else:
|
||||
collect_paths = misses
|
||||
new_counts = _run_pytest_collect(collect_paths)
|
||||
# Files pytest returned no count for stay at 0; cached so they
|
||||
# aren't re-collected next run.
|
||||
for file in misses:
|
||||
entries[file] = replace(entries[file], count=new_counts.get(file, 0))
|
||||
|
||||
_Cache(entries={str(f.relative_to(path)): e for f, e in entries.items()}).save(
|
||||
cache_path
|
||||
)
|
||||
return _build_folder(path, {f: e.count for f, e in entries.items()})
|
||||
|
||||
|
||||
def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder:
|
||||
"""Collect all tests, using an on-disk cache when ``cache_path`` is set."""
|
||||
if cache_path is None:
|
||||
return _collect_tests_uncached(path)
|
||||
if path.is_file():
|
||||
# No fixture tree to scope against; bypass cache to avoid stale hits.
|
||||
print(f"--cache ignored: {path} is a single file")
|
||||
return _collect_tests_uncached(path)
|
||||
return _collect_tests_cached(path, cache_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Execute script."""
|
||||
parser = argparse.ArgumentParser(description="Split tests into n buckets.")
|
||||
@@ -276,11 +610,17 @@ def main() -> None:
|
||||
help="Path to the test files to split into buckets",
|
||||
type=Path,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
help="Path to a JSON file used to cache per-file test counts",
|
||||
type=Path,
|
||||
default=None,
|
||||
)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
|
||||
print("Collecting tests...")
|
||||
tests = collect_tests(arguments.path)
|
||||
tests = collect_tests(arguments.path, arguments.cache)
|
||||
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
|
||||
|
||||
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
|
||||
@@ -290,7 +630,7 @@ def main() -> None:
|
||||
print(f"Total tests: {tests.total_tests}")
|
||||
print(f"Estimated tests per bucket: {tests_per_bucket}")
|
||||
|
||||
bucket_holder.create_ouput_file()
|
||||
bucket_holder.create_output_file()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3662,6 +3662,31 @@ async def test_include_filters(hass: HomeAssistant) -> None:
|
||||
assert len(msg["payload"]["endpoints"]) == 3
|
||||
|
||||
|
||||
async def test_never_exposed_entities(hass: HomeAssistant) -> None:
|
||||
"""Test never exposed locks do not get discovered."""
|
||||
request = get_new_request("Alexa.Discovery", "Discover")
|
||||
|
||||
# setup test devices
|
||||
hass.states.async_set("group.all_locks", "on", {"friendly_name": "Blocked locks"})
|
||||
|
||||
hass.states.async_set("group.allow", "off", {"friendly_name": "Allowed group"})
|
||||
|
||||
alexa_config = MockConfig(hass)
|
||||
alexa_config.should_expose = entityfilter.generate_filter(
|
||||
include_domains=["group"],
|
||||
include_entities=[],
|
||||
exclude_domains=[],
|
||||
exclude_entities=[],
|
||||
)
|
||||
|
||||
msg = await smart_home.async_handle_message(hass, alexa_config, request)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
msg = msg["event"]
|
||||
|
||||
assert len(msg["payload"]["endpoints"]) == 1
|
||||
|
||||
|
||||
async def test_api_entity_not_exists(hass: HomeAssistant) -> None:
|
||||
"""Test api turn on process without entity."""
|
||||
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#test")
|
||||
|
||||
@@ -287,139 +287,3 @@ async def test_user_unable_to_connect(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
|
||||
assert result["data"] == MOCK_CONFIG_ENTRY
|
||||
|
||||
|
||||
async def test_reconfigure(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test reconfiguring an existing entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT},
|
||||
title=MOCK_NAME,
|
||||
unique_id=MOCK_UUID,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
aioclient_mock.get(MOCK_UPNP_LOCATION, text=MOCK_UPNP_DEVICE)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
assert entry.data == MOCK_CONFIG_ENTRY
|
||||
assert entry.unique_id == MOCK_UUID
|
||||
|
||||
|
||||
async def test_reconfigure_no_ssdp(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test reconfiguring when the new host does not respond to ssdp."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT},
|
||||
title=MOCK_NAME,
|
||||
unique_id=MOCK_UUID,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
aioclient_mock.get(MOCK_UPNP_LOCATION, status=404)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
assert entry.data == MOCK_CONFIG_ENTRY
|
||||
assert entry.unique_id == MOCK_UUID
|
||||
|
||||
|
||||
async def test_reconfigure_unique_id_mismatch(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test reconfiguring against a different device aborts."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT},
|
||||
title=MOCK_NAME,
|
||||
unique_id="other_uuid",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
aioclient_mock.get(MOCK_UPNP_LOCATION, text=MOCK_UPNP_DEVICE)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unique_id_mismatch"
|
||||
|
||||
assert entry.data == {CONF_HOST: "old_host", CONF_PORT: MOCK_PORT}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("connect_exception", "expected_error"),
|
||||
[
|
||||
pytest.param(ConnectionFailed, "cannot_connect", id="connection_failed"),
|
||||
pytest.param(
|
||||
ConnectionRefusedError, "connection_refused", id="connection_refused"
|
||||
),
|
||||
pytest.param(OSError, "cannot_connect", id="os_error"),
|
||||
pytest.param(socket.gaierror, "invalid_host", id="invalid_host"),
|
||||
pytest.param(TimeoutError, "timeout_connect", id="timeout_connect"),
|
||||
],
|
||||
)
|
||||
async def test_reconfigure_unable_to_connect(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
dummy_client: MagicMock,
|
||||
connect_exception: type[Exception],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test reconfiguring when the device cannot be reached."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "old_host", CONF_PORT: MOCK_PORT},
|
||||
title=MOCK_NAME,
|
||||
unique_id=MOCK_UUID,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
dummy_client.start.side_effect = AsyncMock(side_effect=connect_exception)
|
||||
aioclient_mock.get(MOCK_UPNP_LOCATION, status=404)
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
user_input = {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
dummy_client.start.side_effect = AsyncMock(return_value=None)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
assert entry.data == MOCK_CONFIG_ENTRY
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Test the bluetooth config flow."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails
|
||||
@@ -11,7 +10,6 @@ from homeassistant.components.bluetooth import HaBluetoothConnector
|
||||
from homeassistant.components.bluetooth.const import (
|
||||
CONF_ADAPTER,
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
@@ -349,11 +347,10 @@ async def test_async_step_integration_discovery_already_exists(
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auto", "active", "passive"])
|
||||
@pytest.mark.usefixtures(
|
||||
"one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
|
||||
)
|
||||
async def test_options_flow_linux(hass: HomeAssistant, mode: str) -> None:
|
||||
async def test_options_flow_linux(hass: HomeAssistant) -> None:
|
||||
"""Test options on Linux."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
@@ -373,50 +370,32 @@ async def test_options_flow_linux(hass: HomeAssistant, mode: str) -> None:
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_MODE: mode},
|
||||
user_input={
|
||||
CONF_PASSIVE: True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_MODE] == mode
|
||||
assert result["data"][CONF_PASSIVE] is (mode == "passive")
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert result["data"][CONF_PASSIVE] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("options", "expected_default"),
|
||||
[
|
||||
({}, "auto"),
|
||||
({CONF_PASSIVE: True}, "passive"),
|
||||
({CONF_PASSIVE: False}, "active"),
|
||||
({CONF_MODE: "passive"}, "passive"),
|
||||
],
|
||||
ids=["fresh", "legacy_passive_true", "legacy_passive_false", "explicit_mode"],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
"one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
|
||||
)
|
||||
async def test_options_flow_default_reflects_existing_options(
|
||||
hass: HomeAssistant, options: dict[str, Any], expected_default: str
|
||||
) -> None:
|
||||
"""Options form preselects the current mode, including legacy CONF_PASSIVE."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options=options,
|
||||
unique_id="00:00:00:00:00:01",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
# Verify we can change it to False
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
schema = result["data_schema"].schema
|
||||
mode_key = next(k for k in schema if k == CONF_MODE)
|
||||
assert mode_key.default() == expected_default
|
||||
assert result["step_id"] == "init"
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PASSIVE: False,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_PASSIVE] is False
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -178,22 +178,6 @@ async def test_diagnostics(
|
||||
"timings": {},
|
||||
},
|
||||
"all_history": [],
|
||||
"auto_scheduler": {
|
||||
"monotonic_time": ANY,
|
||||
"requests": {},
|
||||
"running": True,
|
||||
"workers": {
|
||||
"00:00:00:00:00:02": {
|
||||
"failed_window": False,
|
||||
"name": "hci1 (00:00:00:00:00:02)",
|
||||
"next_event_at": ANY,
|
||||
"next_sweep_at": ANY,
|
||||
"sweep_last_completed": ANY,
|
||||
"warned_no_fallback": False,
|
||||
"window_end": 0.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"connectable_history": [],
|
||||
"scanners": [
|
||||
{
|
||||
@@ -255,11 +239,11 @@ async def test_diagnostics(
|
||||
"type": "FakeHaScanner",
|
||||
"current_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
|
||||
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
|
||||
},
|
||||
"requested_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
|
||||
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -360,12 +344,6 @@ async def test_diagnostics_macos(
|
||||
"sources": {"44:44:33:11:23:45": "local"},
|
||||
"timings": {"44:44:33:11:23:45": [ANY]},
|
||||
},
|
||||
"auto_scheduler": {
|
||||
"monotonic_time": ANY,
|
||||
"requests": {},
|
||||
"running": True,
|
||||
"workers": {},
|
||||
},
|
||||
"all_history": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
@@ -576,12 +554,6 @@ async def test_diagnostics_remote_adapter(
|
||||
"sources": {"44:44:33:11:23:45": "esp32"},
|
||||
"timings": {"44:44:33:11:23:45": [ANY]},
|
||||
},
|
||||
"auto_scheduler": {
|
||||
"monotonic_time": ANY,
|
||||
"requests": {},
|
||||
"running": True,
|
||||
"workers": {},
|
||||
},
|
||||
"all_history": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
|
||||
@@ -26,7 +26,6 @@ from homeassistant.components.bluetooth import (
|
||||
)
|
||||
from homeassistant.components.bluetooth.const import (
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
@@ -96,26 +95,15 @@ async def test_setup_and_stop(
|
||||
assert len(mock_bleak_scanner_start.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[{CONF_MODE: "passive"}, {CONF_PASSIVE: True}],
|
||||
ids=["mode_passive", "legacy_passive_true"],
|
||||
)
|
||||
@pytest.mark.usefixtures("one_adapter")
|
||||
async def test_setup_and_stop_passive(
|
||||
hass: HomeAssistant,
|
||||
mock_bleak_scanner_start: MagicMock,
|
||||
options: dict[str, Any],
|
||||
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
|
||||
) -> None:
|
||||
"""Test we set up and stop the scanner in passive mode.
|
||||
|
||||
Covers both the new CONF_MODE key and the legacy CONF_PASSIVE boolean
|
||||
so the fallback path in async_setup_entry stays exercised.
|
||||
"""
|
||||
"""Test we and setup and stop the scanner the passive scanner."""
|
||||
entry = MockConfigEntry(
|
||||
domain=bluetooth.DOMAIN,
|
||||
data={},
|
||||
options=options,
|
||||
options={CONF_PASSIVE: True},
|
||||
unique_id="00:00:00:00:00:01",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
@@ -163,7 +151,7 @@ async def test_setup_and_stop_old_bluez(
|
||||
mock_bleak_scanner_start: MagicMock,
|
||||
one_adapter_old_bluez: None,
|
||||
) -> None:
|
||||
"""Default AUTO falls back to active on adapters without passive scan support."""
|
||||
"""Test we and setup and stop the scanner the passive scanner with older bluez."""
|
||||
entry = MockConfigEntry(
|
||||
domain=bluetooth.DOMAIN,
|
||||
data={},
|
||||
@@ -1654,117 +1642,6 @@ async def test_register_callback_by_address(
|
||||
assert service_info.manufacturer_id == 89
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("matcher", "mode", "kwargs", "expected_args"),
|
||||
[
|
||||
pytest.param(
|
||||
{"address": "44:44:33:11:23:45"},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
{"scan_interval": 300.0, "scan_duration": 5.0},
|
||||
("44:44:33:11:23:45", 300.0, 5.0),
|
||||
id="active_with_interval_and_duration",
|
||||
),
|
||||
pytest.param(
|
||||
{"address": "44:44:33:11:23:45"},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
{"scan_interval": 300.0},
|
||||
("44:44:33:11:23:45", 300.0, None),
|
||||
id="active_with_interval_default_duration",
|
||||
),
|
||||
pytest.param(
|
||||
{"address": "44:44:33:11:23:45"},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
{},
|
||||
("44:44:33:11:23:45", None, None),
|
||||
id="active_with_address_default_cadence",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
|
||||
async def test_register_callback_registers_active_scan(
|
||||
hass: HomeAssistant,
|
||||
matcher: dict[str, str],
|
||||
mode: BluetoothScanningMode,
|
||||
kwargs: dict[str, float],
|
||||
expected_args: tuple[str, float | None, float | None],
|
||||
) -> None:
|
||||
"""An address matcher in non-PASSIVE mode registers an active-scan request."""
|
||||
mock_bt: list[Any] = []
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
):
|
||||
await async_setup_with_default_adapter(hass)
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init"):
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
|
||||
return None
|
||||
|
||||
mock_cancel = Mock()
|
||||
with patch.object(
|
||||
HomeAssistantBluetoothManager,
|
||||
"async_register_active_scan",
|
||||
return_value=mock_cancel,
|
||||
) as mock_register:
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass, _cb, matcher, mode, **kwargs
|
||||
)
|
||||
mock_register.assert_called_once_with(*expected_args)
|
||||
mock_cancel.assert_not_called()
|
||||
cancel()
|
||||
mock_cancel.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("matcher", "mode", "kwargs"),
|
||||
[
|
||||
pytest.param(
|
||||
{"address": "44:44:33:11:23:45"},
|
||||
BluetoothScanningMode.PASSIVE,
|
||||
{"scan_interval": 300.0},
|
||||
id="passive_mode",
|
||||
),
|
||||
pytest.param(
|
||||
{SERVICE_UUID: "cba20d00-224d-11e6-9fb8-0002a5d5c51b"},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
{"scan_interval": 300.0},
|
||||
id="no_address_in_matcher",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
|
||||
async def test_register_callback_skips_active_scan(
|
||||
hass: HomeAssistant,
|
||||
matcher: dict[str, str],
|
||||
mode: BluetoothScanningMode,
|
||||
kwargs: dict[str, float],
|
||||
) -> None:
|
||||
"""PASSIVE mode or a matcher without an address never registers an active scan."""
|
||||
mock_bt: list[Any] = []
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
||||
):
|
||||
await async_setup_with_default_adapter(hass)
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init"):
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
|
||||
return None
|
||||
|
||||
with patch.object(
|
||||
HomeAssistantBluetoothManager, "async_register_active_scan"
|
||||
) as mock_register:
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass, _cb, matcher, mode, **kwargs
|
||||
)
|
||||
mock_register.assert_not_called()
|
||||
cancel()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_register_callback_by_address_connectable_only(
|
||||
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
|
||||
@@ -2659,35 +2536,6 @@ async def test_process_advertisements_timeout(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
|
||||
async def test_process_advertisements_wires_timeout_as_scan_duration(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""async_process_advertisements forwards its timeout as scan_duration."""
|
||||
|
||||
def _callback(service_info: BluetoothServiceInfo) -> bool:
|
||||
return False
|
||||
|
||||
mock_cancel = Mock()
|
||||
with (
|
||||
patch.object(
|
||||
HomeAssistantBluetoothManager,
|
||||
"async_register_active_scan",
|
||||
return_value=mock_cancel,
|
||||
) as mock_register,
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
await async_process_advertisements(
|
||||
hass,
|
||||
_callback,
|
||||
{"address": "aa:44:33:11:23:45"},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
0,
|
||||
)
|
||||
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, 0)
|
||||
mock_cancel.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_wrapped_instance_with_filter(
|
||||
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
|
||||
|
||||
@@ -461,7 +461,7 @@ async def test_subscribe_scanner_state(
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
# hci0 has passive_scan=False so AUTO falls back to ACTIVE.
|
||||
# Should receive initial state for existing scanner
|
||||
async with asyncio.timeout(1):
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
|
||||
@@ -104,6 +104,7 @@ async def test_handler_google_actions(hass: HomeAssistant) -> None:
|
||||
"""Test handler Google Actions."""
|
||||
hass.states.async_set("switch.test", "on", {"friendly_name": "Test switch"})
|
||||
hass.states.async_set("switch.test2", "on", {"friendly_name": "Test switch 2"})
|
||||
hass.states.async_set("group.all_locks", "on", {"friendly_name": "Evil locks"})
|
||||
|
||||
await mock_cloud(
|
||||
hass,
|
||||
|
||||
@@ -1418,6 +1418,23 @@ async def test_get_google_entity(
|
||||
"message": "light.kitchen unknown",
|
||||
}
|
||||
|
||||
# Test getting a blocked entity
|
||||
entity_registry.async_get_or_create(
|
||||
"group", "test", "unique", suggested_object_id="all_locks"
|
||||
)
|
||||
hass.states.async_set("group.all_locks", "bla")
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_supported",
|
||||
"message": "group.all_locks not supported by Google assistant",
|
||||
}
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "test", "unique", suggested_object_id="kitchen"
|
||||
)
|
||||
@@ -1600,6 +1617,23 @@ async def test_get_alexa_entity(
|
||||
"message": "sensor.temperature not supported by Alexa",
|
||||
}
|
||||
|
||||
# Test getting a blocked entity
|
||||
entity_registry.async_get_or_create(
|
||||
"group", "test", "unique", suggested_object_id="all_locks"
|
||||
)
|
||||
hass.states.async_set("group.all_locks", "bla")
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_supported",
|
||||
"message": "group.all_locks not supported by Alexa",
|
||||
}
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "test", "unique", suggested_object_id="kitchen"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ from unittest.mock import call
|
||||
|
||||
from aioesphomeapi import (
|
||||
AlarmControlPanelCommand,
|
||||
AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelEntityState as ESPHomeAlarmEntityState,
|
||||
AlarmControlPanelInfo,
|
||||
AlarmControlPanelState as ESPHomeAlarmState,
|
||||
@@ -23,6 +22,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
SERVICE_ALARM_TRIGGER,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.components.esphome.alarm_control_panel import EspHomeACPFeatures
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -40,12 +40,12 @@ async def test_generic_alarm_control_panel_requires_code(
|
||||
object_id="myalarm_control_panel",
|
||||
key=1,
|
||||
name="my alarm_control_panel",
|
||||
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
|
||||
| ESPHomeAlarmControlPanelEntityFeature.TRIGGER,
|
||||
supported_features=EspHomeACPFeatures.ARM_AWAY
|
||||
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
|
||||
| EspHomeACPFeatures.ARM_HOME
|
||||
| EspHomeACPFeatures.ARM_NIGHT
|
||||
| EspHomeACPFeatures.ARM_VACATION
|
||||
| EspHomeACPFeatures.TRIGGER,
|
||||
requires_code=True,
|
||||
requires_code_to_arm=True,
|
||||
)
|
||||
@@ -172,12 +172,12 @@ async def test_generic_alarm_control_panel_no_code(
|
||||
object_id="myalarm_control_panel",
|
||||
key=1,
|
||||
name="my alarm_control_panel",
|
||||
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
|
||||
| ESPHomeAlarmControlPanelEntityFeature.TRIGGER,
|
||||
supported_features=EspHomeACPFeatures.ARM_AWAY
|
||||
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
|
||||
| EspHomeACPFeatures.ARM_HOME
|
||||
| EspHomeACPFeatures.ARM_NIGHT
|
||||
| EspHomeACPFeatures.ARM_VACATION
|
||||
| EspHomeACPFeatures.TRIGGER,
|
||||
requires_code=False,
|
||||
requires_code_to_arm=False,
|
||||
)
|
||||
@@ -217,12 +217,12 @@ async def test_generic_alarm_control_panel_missing_state(
|
||||
object_id="myalarm_control_panel",
|
||||
key=1,
|
||||
name="my alarm_control_panel",
|
||||
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
|
||||
| ESPHomeAlarmControlPanelEntityFeature.TRIGGER,
|
||||
supported_features=EspHomeACPFeatures.ARM_AWAY
|
||||
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
|
||||
| EspHomeACPFeatures.ARM_HOME
|
||||
| EspHomeACPFeatures.ARM_NIGHT
|
||||
| EspHomeACPFeatures.ARM_VACATION
|
||||
| EspHomeACPFeatures.TRIGGER,
|
||||
requires_code=False,
|
||||
requires_code_to_arm=False,
|
||||
)
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
"""Test the ESPHome bluetooth integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aioesphomeapi import (
|
||||
BluetoothProxyFeature,
|
||||
BluetoothScannerMode,
|
||||
BluetoothScannerState,
|
||||
BluetoothScannerStateResponse,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.components.esphome.const import CONF_BLUETOOTH_SCANNING_MODE
|
||||
from homeassistant.core import HomeAssistant, callback as hass_callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import MockBluetoothEntryType, MockESPHomeDevice
|
||||
|
||||
_PROXY_WITH_STATE_AND_MODE = (
|
||||
BluetoothProxyFeature.PASSIVE_SCAN
|
||||
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
|
||||
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
|
||||
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
|
||||
)
|
||||
from .conftest import MockESPHomeDevice
|
||||
|
||||
|
||||
async def test_bluetooth_connect_with_raw_adv(
|
||||
@@ -112,235 +94,3 @@ async def test_bluetooth_cleanup_on_remove_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
remove_mock.assert_called_once_with(hass, scanner.source)
|
||||
|
||||
|
||||
async def test_scanning_mode_saved_option_applied(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""A saved CONF_BLUETOOTH_SCANNING_MODE is applied immediately to the proxy."""
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
device.entry,
|
||||
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
|
||||
)
|
||||
set_mode_mock = MagicMock()
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
||||
|
||||
|
||||
async def test_scanning_mode_invalid_option_falls_back_to_default(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""A malformed saved value falls back to the AUTO default instead of raising."""
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
device.entry,
|
||||
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "bogus"},
|
||||
)
|
||||
set_mode_mock = MagicMock()
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# AUTO maps to PASSIVE on the firmware.
|
||||
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
||||
|
||||
|
||||
async def test_scanning_mode_migration_passive_is_honored(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""Proxy configured PASSIVE in YAML is honored on first state update."""
|
||||
set_mode_mock = MagicMock()
|
||||
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
||||
|
||||
def _subscribe(
|
||||
callback: Callable[[BluetoothScannerStateResponse], None],
|
||||
) -> Callable[[], None]:
|
||||
state_subscriptions.append(callback)
|
||||
return lambda: state_subscriptions.remove(callback)
|
||||
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert state_subscriptions
|
||||
for callback in state_subscriptions[:]:
|
||||
callback(
|
||||
BluetoothScannerStateResponse(
|
||||
state=BluetoothScannerState.RUNNING,
|
||||
mode=BluetoothScannerMode.PASSIVE,
|
||||
configured_mode=BluetoothScannerMode.PASSIVE,
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
|
||||
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
||||
|
||||
|
||||
async def test_scanning_mode_migration_waits_for_known_configured_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""An initial state with configured_mode=None must not commit a migration."""
|
||||
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
||||
set_mode_mock = MagicMock()
|
||||
|
||||
def _subscribe(
|
||||
callback: Callable[[BluetoothScannerStateResponse], None],
|
||||
) -> Callable[[], None]:
|
||||
state_subscriptions.append(callback)
|
||||
return lambda: state_subscriptions.remove(callback)
|
||||
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert state_subscriptions
|
||||
for callback in state_subscriptions[:]:
|
||||
callback(
|
||||
BluetoothScannerStateResponse(
|
||||
state=BluetoothScannerState.RUNNING,
|
||||
mode=None,
|
||||
configured_mode=None,
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert CONF_BLUETOOTH_SCANNING_MODE not in device.entry.options
|
||||
# A second response with a real configured_mode commits the migration.
|
||||
for callback in state_subscriptions[:]:
|
||||
callback(
|
||||
BluetoothScannerStateResponse(
|
||||
state=BluetoothScannerState.RUNNING,
|
||||
mode=BluetoothScannerMode.PASSIVE,
|
||||
configured_mode=BluetoothScannerMode.PASSIVE,
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
|
||||
|
||||
|
||||
async def test_scanning_mode_pending_subscription_unsubscribes_on_unload(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""Unloading before the first state update cancels the migration subscription."""
|
||||
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
||||
unsub_calls: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
||||
|
||||
def _subscribe(
|
||||
callback: Callable[[BluetoothScannerStateResponse], None],
|
||||
) -> Callable[[], None]:
|
||||
state_subscriptions.append(callback)
|
||||
|
||||
def _unsub() -> None:
|
||||
unsub_calls.append(callback)
|
||||
state_subscriptions.remove(callback)
|
||||
|
||||
return _unsub
|
||||
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
# The migration subscription is pending; tear the entry down without
|
||||
# firing a state update so _unsubscribe in bluetooth.py runs the
|
||||
# cancellation arm.
|
||||
assert state_subscriptions
|
||||
await hass.config_entries.async_unload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert unsub_calls
|
||||
|
||||
|
||||
async def test_scanning_mode_migration_active_becomes_auto(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""Proxy configured ACTIVE migrates to AUTO on first state update."""
|
||||
set_mode_mock = MagicMock()
|
||||
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
||||
|
||||
def _subscribe(
|
||||
callback: Callable[[BluetoothScannerStateResponse], None],
|
||||
) -> Callable[[], None]:
|
||||
state_subscriptions.append(callback)
|
||||
return lambda: state_subscriptions.remove(callback)
|
||||
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# AUTO was applied at setup before async_register_scanner so habluetooth's
|
||||
# scheduler spawns a worker; AUTO maps to PASSIVE on the firmware.
|
||||
assert set_mode_mock.call_args_list == [((BluetoothScannerMode.PASSIVE,), {})]
|
||||
set_mode_mock.reset_mock()
|
||||
assert state_subscriptions
|
||||
for callback in state_subscriptions[:]:
|
||||
callback(
|
||||
BluetoothScannerStateResponse(
|
||||
state=BluetoothScannerState.RUNNING,
|
||||
mode=BluetoothScannerMode.ACTIVE,
|
||||
configured_mode=BluetoothScannerMode.ACTIVE,
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "auto"
|
||||
# AUTO -> AUTO does not re-send a firmware command.
|
||||
set_mode_mock.assert_not_called()
|
||||
|
||||
|
||||
async def test_scanning_mode_default_pinned_before_register(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""The default AUTO is applied immediately so the AUTO worker spawns at register."""
|
||||
set_mode_mock = MagicMock()
|
||||
requested_at_register: list[BluetoothScanningMode | None] = []
|
||||
real_register = bluetooth.async_register_scanner
|
||||
|
||||
@hass_callback
|
||||
def _spy_register(*args: Any, **kwargs: Any) -> Callable[[], None]:
|
||||
requested_at_register.append(args[1].requested_mode)
|
||||
return real_register(*args, **kwargs)
|
||||
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
||||
)
|
||||
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
||||
with patch(
|
||||
"homeassistant.components.esphome.bluetooth.async_register_scanner",
|
||||
_spy_register,
|
||||
):
|
||||
await hass.config_entries.async_reload(device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# AUTO -> PASSIVE is sent before async_register_scanner, so the
|
||||
# habluetooth auto-mode worker is spawned at registration time.
|
||||
set_mode_mock.assert_called_once_with(BluetoothScannerMode.PASSIVE)
|
||||
assert requested_at_register == [BluetoothScanningMode.AUTO]
|
||||
|
||||
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
BluetoothProxyFeature,
|
||||
DeviceInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
@@ -23,11 +22,9 @@ from homeassistant import config_entries
|
||||
from homeassistant.components.esphome import dashboard
|
||||
from homeassistant.components.esphome.const import (
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_BLUETOOTH_SCANNING_MODE,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_NOISE_PSK,
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
DEFAULT_BLUETOOTH_SCANNING_MODE,
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -46,11 +43,7 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from . import VALID_NOISE_PSK
|
||||
from .conftest import (
|
||||
MockBluetoothEntryType,
|
||||
MockESPHomeDeviceType,
|
||||
MockGenericDeviceEntryType,
|
||||
)
|
||||
from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -2085,105 +2078,6 @@ async def test_option_flow_subscribe_logs(
|
||||
assert len(mock_reload.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_option_flow_shows_saved_scanning_mode_when_proxy_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""A previously-saved mode keeps surfacing even if the proxy feature flag is gone."""
|
||||
entry = await mock_generic_device_entry(mock_client=mock_client)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={**entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
|
||||
)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert CONF_BLUETOOTH_SCANNING_MODE in result["data_schema"].schema
|
||||
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_ALLOW_SERVICE_CALLS: False,
|
||||
CONF_SUBSCRIBE_LOGS: False,
|
||||
CONF_BLUETOOTH_SCANNING_MODE: "auto",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "auto"
|
||||
|
||||
|
||||
async def test_option_flow_unloaded_entry_without_saved_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""An unloaded entry without a saved scanning mode hides the option."""
|
||||
entry = await mock_generic_device_entry(mock_client=mock_client)
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
|
||||
|
||||
|
||||
async def test_option_flow_hides_bluetooth_scanning_mode_without_proxy(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""Devices without a bluetooth proxy must not see the scanning mode option."""
|
||||
entry = await mock_generic_device_entry(mock_client=mock_client)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
|
||||
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: False},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data"]
|
||||
|
||||
|
||||
async def test_option_flow_bluetooth_scanning_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_bluetooth_entry: MockBluetoothEntryType,
|
||||
) -> None:
|
||||
"""Bluetooth proxy devices with FEATURE_STATE_AND_MODE expose the option."""
|
||||
device = await mock_bluetooth_entry(
|
||||
bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN
|
||||
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
|
||||
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
|
||||
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
|
||||
)
|
||||
entry = device.entry
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["data_schema"]({}) == {
|
||||
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
CONF_SUBSCRIBE_LOGS: False,
|
||||
CONF_BLUETOOTH_SCANNING_MODE: DEFAULT_BLUETOOTH_SCANNING_MODE,
|
||||
}
|
||||
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_ALLOW_SERVICE_CALLS: False,
|
||||
CONF_SUBSCRIBE_LOGS: False,
|
||||
CONF_BLUETOOTH_SCANNING_MODE: "passive",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "passive"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
|
||||
async def test_user_discovers_name_no_dashboard(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -14,19 +14,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -65,19 +65,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping_81',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping 81',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping 81',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -116,19 +116,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_guest',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Guest',
|
||||
'object_id_base': 'Mock Title Wi-Fi Guest',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Guest',
|
||||
'original_name': 'Mock Title Wi-Fi Guest',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -167,19 +167,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Main 2.4Ghz',
|
||||
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Main 2.4Ghz',
|
||||
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -268,19 +268,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -319,19 +319,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping_81',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping 81',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping 81',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -370,19 +370,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_guest',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Guest',
|
||||
'object_id_base': 'Mock Title Wi-Fi Guest',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Guest',
|
||||
'original_name': 'Mock Title Wi-Fi Guest',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -421,19 +421,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Main 2.4Ghz',
|
||||
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Main 2.4Ghz',
|
||||
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -522,19 +522,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -573,19 +573,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping_81',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping 81',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping 81',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -624,19 +624,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_guest',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Guest',
|
||||
'object_id_base': 'Mock Title Wi-Fi Guest',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Guest',
|
||||
'original_name': 'Mock Title Wi-Fi Guest',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -675,19 +675,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Main 2.4Ghz',
|
||||
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Main 2.4Ghz',
|
||||
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -833,19 +833,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -884,19 +884,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_port_forward_test_port_mapping_81',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Port forward Test Port Mapping 81',
|
||||
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:check-network',
|
||||
'original_name': 'Port forward Test Port Mapping 81',
|
||||
'original_name': 'Mock Title Port forward Test Port Mapping 81',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -935,19 +935,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_guest',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Guest',
|
||||
'object_id_base': 'Mock Title Wi-Fi Guest',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Guest',
|
||||
'original_name': 'Mock Title Wi-Fi Guest',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -986,19 +986,19 @@
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz',
|
||||
'has_entity_name': True,
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Wi-Fi Main 2.4Ghz',
|
||||
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:wifi',
|
||||
'original_name': 'Wi-Fi Main 2.4Ghz',
|
||||
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
|
||||
'platform': 'fritz',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user