Compare commits

...

46 Commits

Author SHA1 Message Date
Joakim Sørensen f18291259e Merge branch 'dev' into Add-devcontainer-lock.json-file 2026-05-24 09:27:10 +02:00
J. Nick Koston c056242390 Bump habluetooth to 6.7.1 (#172000) 2026-05-24 08:52:21 +02:00
J. Nick Koston 9cbb14bbde Bump inkbird-ble to 1.1.2 (#172011) 2026-05-24 08:41:11 +02:00
Allen Porter 6634c4ce78 Replace duplicate constant ATTR_ELEVATION in fitbit (#172018) 2026-05-24 08:40:32 +02:00
Allen Porter ae1355666b Remove positional message strings from roborock exceptions (#172016) 2026-05-23 22:14:12 -07:00
Allen Porter 2d0d202b80 Fix exception translation placeholder mismatch in roborock (#172014) 2026-05-23 22:14:02 -07:00
skye-harris 9fd48344f8 Reorder device location context towards the end of the Assist LLM instructions (#165136) 2026-05-23 20:51:17 -07:00
J. Nick Koston 7b4ed59861 Change default ESPHome bluetooth proxy scanning mode to Auto (#171996) 2026-05-23 18:21:37 -05:00
J. Nick Koston fb8f82542e Use AlarmControlPanelEntityFeature from aioesphomeapi in esphome (#171961) 2026-05-23 19:08:52 -04:00
Robert Svensson af5583ba76 Axis bump to v72 (#171967) 2026-05-23 19:06:47 -04:00
J. Nick Koston 2a943369d5 Change default Bluetooth scanning mode to Auto (#171985) 2026-05-23 17:44:19 -05:00
J. Nick Koston 29425fd0ac Bump bleak-esphome to 3.9.1 (#171994) 2026-05-23 17:25:35 -05:00
Markus Adrario 271111fe75 Homee: Update quality-scale for current state. (#171981) 2026-05-23 22:17:02 +02:00
J. Nick Koston 37e9bdd36f Wire scan_interval and scan_duration into bluetooth.async_register_callback (#171806) 2026-05-23 15:34:31 -04:00
J. Nick Koston e1d1bdd377 Bump aioesphomeapi to 45.2.0 (#171986) 2026-05-23 14:34:01 -05:00
J. Nick Koston b3a60de487 Bump habluetooth to 6.5.0 (#171966) 2026-05-23 14:33:42 -05:00
Michael 0cb7ea5584 Improve switch definitions in FRITZ!Box Tools (#171862) 2026-05-23 21:19:22 +02:00
ludeeus 296d625121 Add devcontainer-lock.json file 2026-05-23 18:11:53 +00:00
Max Michels 7bc7694e14 Replace duplicate constants in ios with homeassistant.const imports (#171973) 2026-05-23 19:40:31 +02:00
Max Michels c45c949080 Replace duplicate constants in wiz with homeassistant.const imports (#171969) 2026-05-23 19:01:03 +02:00
SeifEddineMezned ec4f64172b Fix grammar and clarity in homekit_controller/strings.json (#169625) 2026-05-23 17:52:39 +02:00
Max Michels f88b7bcdf6 Replace duplicate constants in olama with homeassistant.const imports (#171949)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2026-05-23 17:03:44 +02:00
Max Michels 05009871aa Replace duplicate constants in numato with homeassistant.const imports (#171950) 2026-05-23 16:57:46 +02:00
Max Michels 4aa7323af2 Replace duplicate constants in nmbs with homeassistant.const imports (#171951) 2026-05-23 16:57:07 +02:00
Max Michels bcacf3a73c Remove unused duplicate constants in nice_go with homeassistant.const imports (#171952) 2026-05-23 16:56:16 +02:00
Maciej Bieniek 96a6babaef Remove Shelly temperature and humidity sensors with error (#170900) 2026-05-23 14:02:32 +02:00
Max Michels e856271a5a Replace duplicate constants in motioneye with homeassistant.const imports (#171954)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-05-23 12:05:02 +02:00
Max Michels add023ed74 Replace duplicate constants in openerz with homeassistant.const imports (#171946) 2026-05-23 11:58:51 +02:00
Max Michels 8d456cb24f Replace duplicate constants osoenergy with homeassistant.const imports (#171944) 2026-05-23 11:52:54 +02:00
Max Michels 5ebd95eb34 Replace duplicate constants in netatmo with homeassistant.const imports (#171953) 2026-05-23 11:47:58 +02:00
Max Michels 228d7189c3 Replace duplicate constants in profiler with homeassistant.const imports (#171943) 2026-05-23 11:01:02 +02:00
Max Michels a8e141a48a Replace duplicate constants in rainmachine with homeassistant.const imports (#171942)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2026-05-23 10:59:53 +02:00
Max Michels d42d52a0f7 Replace duplicate constants in onkyo with homeassistant.const imports (#171947) 2026-05-23 10:57:48 +02:00
Mick Vleeshouwer cee0fe071d Fix tilt-only DynamicPergola covers in Overkiz (#171898) 2026-05-23 10:52:16 +02:00
Josh Gustafson e3593c3076 Arcam reconfig flow (#171767) 2026-05-23 10:24:28 +02:00
Martin Hjelmare 5498de07ff Remove legacy Konnected integration (#171896) 2026-05-23 10:19:35 +02:00
J. Nick Koston ac3f973d7d Bump aioesphomeapi to 45.1.0 (#171935) 2026-05-23 09:47:15 +02:00
J. Nick Koston 6b8a2a4032 Bump bleak-esphome to 3.8.1 (#171936) 2026-05-23 09:46:49 +02:00
Artur Pragacz 74e40af4bb Remove CLOUD_NEVER_EXPOSED_ENTITIES (#171933) 2026-05-23 00:26:45 -04:00
J. Nick Koston 833e15d6f2 Bump habluetooth to 6.4.0 (#171918) 2026-05-23 00:10:50 -04:00
Matt ee56fd1eb0 Fix two HEOS bugs: host set construction and missing error decorator (#171913) 2026-05-22 18:42:43 -05:00
Felipe Santos e6528bae8a Add missing translation for connection failure on OpenRGB (#171892) 2026-05-22 21:59:39 +02:00
Joost Lekkerkerker a17eb65498 Refactor labs websocket API tests to use async_setup_component (#171891) 2026-05-22 21:53:52 +02:00
Joost Lekkerkerker 912a839d66 Don't call migrate entry in generic thermostat tests directly (#171887) 2026-05-22 21:44:10 +02:00
Martin Hjelmare 4306863729 Fix homekit test_reload flaky test (#171878) 2026-05-22 14:33:27 -05:00
Martin Hjelmare ba2f66e751 Remove not needed default force_update in flo (#171854) 2026-05-22 20:15:00 +02:00
119 changed files with 1811 additions and 6047 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
}
}
}
Generated
-2
View File
@@ -945,8 +945,6 @@ 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,7 +39,6 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_DESCRIPTION,
CONF_NAME,
UnitOfTemperature,
@@ -373,9 +372,6 @@ 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,6 +16,13 @@ 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."""
@@ -31,13 +38,22 @@ 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) -> None:
"""Verify the device is reachable."""
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
"""Verify the device is reachable; return errors keyed by reason."""
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
@@ -53,19 +69,10 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_HOST], user_input[CONF_PORT], uuid
)
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:
errors = await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
if not errors:
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
data={
@@ -74,16 +81,46 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
},
)
fields = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
schema = vol.Schema(fields)
schema = STEP_DATA_SCHEMA
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:
@@ -113,9 +150,7 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_set_unique_id_and_update(host, port, uuid)
try:
await self._async_try_connect(host, port)
except ConnectionFailed, OSError:
if await self._async_try_connect(host, port):
return self.async_abort(reason="cannot_connect")
self.host = host
@@ -3,7 +3,9 @@
"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%]"
"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."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -16,6 +18,13 @@
"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,8 +2,7 @@
import axis
from axis.errors import Unauthorized
from axis.interfaces.mqtt import mqtt_json_to_event
from axis.models.mqtt import ClientState
from axis.models.mqtt import ClientState, mqtt_json_to_event
from axis.stream_manager import Signal, State
from homeassistant.components import mqtt
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==71"],
"requirements": ["axis==72"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -11,6 +11,7 @@ from bluetooth_adapters import (
ADAPTER_CONNECTION_SLOTS,
ADAPTER_HW_VERSION,
ADAPTER_MANUFACTURER,
ADAPTER_PASSIVE_SCAN,
ADAPTER_SW_VERSION,
DEFAULT_ADDRESS,
DEFAULT_CONNECTION_SLOTS,
@@ -79,7 +80,6 @@ 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
from .util import adapter_title, resolve_scanning_mode
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
@@ -387,12 +387,15 @@ 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()
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
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
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)
+15 -6
View File
@@ -130,17 +130,26 @@ 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.
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.
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.
Returns a callback that can be used to cancel the registration.
"""
return _get_manager(hass).async_register_callback(callback, match_dict)
return _get_manager(hass).async_register_callback(
callback, match_dict, mode, scan_interval, scan_duration
)
async def async_process_advertisements(
@@ -161,7 +170,7 @@ async def async_process_advertisements(
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
try:
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
adapter_model,
get_adapters,
)
from habluetooth import get_manager
from habluetooth import BluetoothScanningMode, get_manager
import voluptuous as vol
from homeassistant.components import onboarding
@@ -24,14 +24,21 @@ 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,
@@ -40,15 +47,39 @@ from .const import (
CONF_SOURCE_MODEL,
DOMAIN,
)
from .util import adapter_title
from .util import adapter_title, resolve_scanning_mode
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSIVE, default=False): bool,
}
_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="mode",
mode=SelectSelectorMode.DROPDOWN,
)
)
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),
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
}
@@ -7,14 +7,21 @@ 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"
+20 -1
View File
@@ -202,6 +202,9 @@ 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)
@@ -216,15 +219,31 @@ 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):
if (address := callback_matcher.get(ADDRESS)) is not None:
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.2.0"
"habluetooth==6.7.1"
]
}
@@ -48,9 +48,21 @@
"step": {
"init": {
"data": {
"passive": "Passive scanning"
"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."
}
}
}
},
"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)"
}
}
}
}
+23 -1
View File
@@ -1,5 +1,9 @@
"""The bluetooth integration utilities."""
from collections.abc import Mapping
import logging
from typing import Any
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_MANUFACTURER,
@@ -9,14 +13,32 @@ from bluetooth_adapters import (
adapter_unique_name,
)
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import get_manager
from habluetooth import BluetoothScanningMode, 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,7 +32,6 @@ 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
@@ -275,9 +274,6 @@ 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)
@@ -308,8 +304,6 @@ 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,7 +22,6 @@ 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,
@@ -282,9 +281,6 @@ 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)
@@ -316,8 +312,6 @@ 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)
+2 -5
View File
@@ -29,7 +29,6 @@ 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
@@ -973,7 +972,7 @@ async def google_assistant_get(
return
entity = google_helpers.GoogleEntity(hass, gconf, state)
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
if not entity.is_supported():
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_SUPPORTED,
@@ -1075,9 +1074,7 @@ async def alexa_get(
"""Get data for a single alexa entity."""
entity_id: str = msg["entity_id"]
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
hass, entity_id
):
if 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,16 +50,28 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[
}
)
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
_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 EsphomeAlarmControlPanel(
@@ -73,20 +85,14 @@ class EsphomeAlarmControlPanel(
"""Set attrs from static info."""
super()._on_static_info_update(static_info)
static_info = self._static_info
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)
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
self._attr_code_format = (
CodeFormat.NUMBER if static_info.requires_code else None
)
+94 -18
View File
@@ -1,16 +1,33 @@
"""Bluetooth support for esphome."""
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import APIClient, DeviceInfo
from aioesphomeapi import (
APIClient,
APIVersion,
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerStateResponse,
DeviceInfo,
)
from bleak_esphome import connect_scanner
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_register_scanner,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .const import DOMAIN
from .entry_data import RuntimeEntryData
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}
@hass_callback
@@ -23,6 +40,7 @@ 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,
@@ -35,17 +53,75 @@ def async_connect_scanner(
scanner = client_data.scanner
if TYPE_CHECKING:
assert scanner is not None
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(),
],
)
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
+59 -12
View File
@@ -9,6 +9,7 @@ from typing import Any, cast
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -20,6 +21,7 @@ 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,
@@ -38,6 +40,11 @@ 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
@@ -47,10 +54,12 @@ 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,
@@ -68,6 +77,18 @@ _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."""
@@ -936,18 +957,44 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
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[
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=self.config_entry.options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
CONF_BLUETOOTH_SCANNING_MODE,
default=options.get(
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
] = _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
)
return self.async_show_form(step_id="init", data_schema=data_schema)
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
return False
@@ -4,6 +4,7 @@ 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:
@@ -18,9 +19,11 @@ 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
+1 -1
View File
@@ -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, entry_data, cli, device_info, self.device_id
hass, self.entry, entry_data, cli, device_info, self.device_id
)
)
else:
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.0.4",
"aioesphomeapi==45.2.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
"bleak-esphome==3.9.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -209,13 +209,24 @@
"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)"
}
}
}
}
-2
View File
@@ -13,8 +13,6 @@ 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"
-1
View File
@@ -10,7 +10,6 @@ from .coordinator import FloDeviceDataUpdateCoordinator
class FloEntity(Entity):
"""A base class for Flo entities."""
_attr_force_update = False
_attr_has_entity_name = True
_attr_should_poll = False
+2
View File
@@ -53,6 +53,8 @@ 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
-1
View File
@@ -76,7 +76,6 @@ 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__(
-1
View File
@@ -170,7 +170,6 @@ class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
+17 -52
View File
@@ -380,44 +380,18 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
"""Init Fritzbox base switch."""
super().__init__(avm_wrapper, device_friendly_name)
self._description = switch_info["description"]
self._friendly_name = switch_info["friendly_name"]
self._icon = switch_info["icon"]
description = switch_info["description"]
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._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
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
async def async_update(self) -> None:
"""Update data."""
@@ -438,7 +412,6 @@ 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."""
@@ -452,9 +425,6 @@ 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, ...}
@@ -464,7 +434,6 @@ 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,
@@ -483,11 +452,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
"Specific %s response: %s", SWITCH_TYPE_PORTFORWARD, self.port_mapping
)
if not self.port_mapping:
self._is_available = False
self._attr_available = False
return
self._attr_is_on = self.port_mapping["NewEnabled"] is True
self._is_available = True
self._attr_available = True
attributes_dict = {
"NewInternalClient": "internal_ip",
@@ -498,7 +467,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
}
for key, attr in attributes_dict.items():
self._attributes[attr] = self.port_mapping[key]
self._attr_extra_state_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"
@@ -605,7 +574,6 @@ 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."""
@@ -617,10 +585,8 @@ 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
@@ -632,14 +598,13 @@ 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__(self._avm_wrapper, device_friendly_name, switch_info)
super().__init__(avm_wrapper, device_friendly_name, switch_info)
async def _async_fetch_update(self) -> None:
"""Fetch updates."""
@@ -652,16 +617,16 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
)
if not wifi_info:
self._is_available = False
self._attr_available = False
return
self._attr_is_on = wifi_info["NewEnable"] is True
self._is_available = True
self._attr_available = True
std = wifi_info["NewStandard"]
self._attributes["standard"] = std or None
self._attributes["bssid"] = wifi_info["NewBSSID"]
self._attributes["mac_address_control"] = wifi_info[
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[
"NewMACAddressControlEnabled"
]
self._wifi_info = wifi_info
@@ -18,7 +18,6 @@ from homeassistant.components import webhook
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_NAME,
STATE_UNAVAILABLE,
)
@@ -803,8 +802,6 @@ 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,7 +12,6 @@ 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
@@ -167,9 +166,6 @@ 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:
+1 -1
View File
@@ -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 = set(entry.data[CONF_HOST])
hosts = {entry.data[CONF_HOST]}
if hasattr(entry, "runtime_data"):
hosts.update(
player.ip_address
@@ -473,6 +473,7 @@ 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,7 +10,6 @@ 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
@@ -246,9 +245,6 @@ 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)
@@ -406,19 +402,6 @@ 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: todo
discovery-update-info: todo
discovery: todo
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
@@ -62,9 +62,11 @@ rules:
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues: todo
repair-issues:
status: exempt
comment: |
The integration currently does not have any known issues.
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 continue to resume pairing.",
"description": "Abort pairing on all controllers, or try restarting the device, then try pairing again.",
"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 continue to resume pairing.",
"description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then try pairing again.",
"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 continue to resume pairing.",
"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.",
"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.1"]
"requirements": ["inkbird-ble==1.1.2"]
}
+1 -2
View File
@@ -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 Platform
from homeassistant.const import CONF_ACTIONS, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, discovery
@@ -41,7 +41,6 @@ from .const import (
CONF_ACTION_SHOW_IN_CARPLAY,
CONF_ACTION_SHOW_IN_WATCH,
CONF_ACTION_USE_CUSTOM_COLORS,
CONF_ACTIONS,
DOMAIN,
)
-2
View File
@@ -32,8 +32,6 @@ 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"
+30 -427
View File
@@ -1,450 +1,53 @@
"""Support for Konnected devices."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
"""The Konnected.io integration."""
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 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.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
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
from .const import DOMAIN
_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]
CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""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
)
)
"""Set up the Konnected.io integration."""
if DOMAIN in config:
_create_issue(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""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))
"""Set up Konnected.io from a config entry."""
_create_issue(hass)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
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)
if unload_ok:
hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
return unload_ok
return True
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)
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",
},
)
@@ -1,69 +0,0 @@
"""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,892 +1,11 @@
"""Config flow for konnected.io integration."""
# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas
"""Config flow for Konnected.io integration."""
import asyncio
import copy
import logging
import random
import string
from typing import Any
from urllib.parse import urlparse
from homeassistant.config_entries import ConfigFlow
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,
)
from .const import DOMAIN
class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Konnected Panels."""
"""Handle a config flow for Konnected.io."""
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,46 +1,3 @@
"""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"
@@ -1,11 +0,0 @@
"""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."""
@@ -1,57 +0,0 @@
"""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,17 +1,9 @@
{
"domain": "konnected",
"name": "Konnected.io (Legacy)",
"codeowners": ["@heythisisnate"],
"config_flow": true,
"dependencies": ["http"],
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/konnected",
"integration_type": "hub",
"integration_type": "system",
"iot_class": "local_push",
"loggers": ["konnected"],
"requirements": ["konnected==1.2.0"],
"ssdp": [
{
"manufacturer": "konnected.io"
}
]
"requirements": []
}
-398
View File
@@ -1,398 +0,0 @@
"""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
@@ -1,141 +0,0 @@
"""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()
+3 -110
View File
@@ -1,115 +1,8 @@
{
"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": {
"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"
}
"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"
}
}
}
@@ -1,135 +0,0 @@
"""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
)
)
+1 -1
View File
@@ -29,6 +29,7 @@ from homeassistant.components.mjpeg import (
MjpegCamera,
)
from homeassistant.const import (
CONF_ACTION,
CONF_AUTHENTICATION,
CONF_NAME,
CONF_PASSWORD,
@@ -43,7 +44,6 @@ 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,8 +29,6 @@ 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"
+1 -1
View File
@@ -10,6 +10,7 @@ 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
@@ -20,7 +21,6 @@ from .const import (
ATTR_CAMERA_LIGHT_MODE,
ATTR_EVENT_TYPE,
ATTR_PERSON,
ATTR_PERSONS,
CAMERA_LIGHT_MODES,
CAMERA_TRIGGERS,
CONF_URL_SECURITY,
@@ -92,8 +92,6 @@ 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"
+1 -2
View File
@@ -5,7 +5,7 @@ import logging
from aiohttp.web import Request
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -14,7 +14,6 @@ from .const import (
ATTR_FACE_URL,
ATTR_HOME_ID,
ATTR_IS_KNOWN,
ATTR_PERSONS,
DATA_DEVICE_IDS,
DATA_PERSONS,
DEFAULT_PERSON,
@@ -8,8 +8,6 @@ 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"
+2 -7
View File
@@ -7,6 +7,7 @@ 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,
@@ -16,13 +17,7 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
)
from .const import (
CONF_EXCLUDE_VIAS,
CONF_SHOW_ON_MAP,
CONF_STATION_FROM,
CONF_STATION_TO,
DOMAIN,
)
from .const import CONF_EXCLUDE_VIAS, CONF_STATION_FROM, CONF_STATION_TO, DOMAIN
class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
-2
View File
@@ -13,8 +13,6 @@ 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):
+1 -2
View File
@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.const import (
CONF_BINARY_SENSORS,
CONF_DEVICES,
CONF_ID,
CONF_NAME,
CONF_SENSORS,
@@ -28,8 +29,6 @@ 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 DEVICE_DEFAULT_NAME
from homeassistant.const import CONF_DEVICES, 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,7 +14,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_BINARY_SENSORS,
CONF_DEVICES,
CONF_ID,
CONF_INVERT_LOGIC,
CONF_PORTS,
+1 -2
View File
@@ -5,13 +5,12 @@ import logging
from numato_gpio import NumatoGpioError
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_ID, CONF_NAME, CONF_SENSORS
from homeassistant.const import CONF_DEVICES, 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,
+7 -3
View File
@@ -8,7 +8,13 @@ import httpx
import ollama
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.const import (
CONF_API_KEY,
CONF_MODEL,
CONF_PROMPT,
CONF_URL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -26,9 +32,7 @@ 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,7 +18,14 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.const import (
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_MODEL,
CONF_NAME,
CONF_PROMPT,
CONF_URL,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.selector import (
@@ -40,9 +47,7 @@ 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
View File
@@ -4,10 +4,6 @@ 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, MATCH_ALL
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OllamaConfigEntry
from .const import CONF_PROMPT, DOMAIN
from .const import DOMAIN
from .entity import OllamaBaseLLMEntity
+1 -1
View File
@@ -11,6 +11,7 @@ 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
@@ -20,7 +21,6 @@ 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_HOST
from homeassistant.const import CONF_DEVICE, CONF_HOST
from homeassistant.core import callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.selector import (
@@ -47,9 +47,6 @@ 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 = {
+1 -2
View File
@@ -9,6 +9,7 @@ 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
@@ -18,8 +19,6 @@ 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,6 +73,9 @@
}
},
"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 UnitOfTemperature
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, 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,10 +45,6 @@ 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,6 +429,9 @@ 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_SCAN_INTERVAL, CONF_TYPE
from homeassistant.const import CONF_ENABLED, 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,8 +70,6 @@ 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,6 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CONDITION,
CONF_DEVICE_ID,
CONF_IP_ADDRESS,
CONF_PASSWORD,
@@ -67,8 +68,6 @@ PLATFORMS = [
Platform.UPDATE,
]
# pylint: disable-next=home-assistant-duplicate-const
CONF_CONDITION = "condition"
CONF_DEWPOINT = "dewpoint"
CONF_ET = "et"
CONF_MAXRH = "maxrh"
@@ -95,9 +95,7 @@ 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
@@ -118,9 +116,7 @@ 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
@@ -178,9 +174,7 @@ 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,11 +161,9 @@ 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
+2
View File
@@ -343,3 +343,5 @@ TRV_CHANNEL = 0
ATTR_KEY = "key"
ATTR_VALUE = "value"
DRIVER_MISSING_ERROR = "Sensor driver missing from firmware"
+7 -1
View File
@@ -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, ROLE_GENERIC
from .const import CONF_SLEEP_PERIOD, DRIVER_MISSING_ERROR, ROLE_GENERIC
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -1225,6 +1225,9 @@ 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",
@@ -1253,6 +1256,9 @@ 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",
+1 -4
View File
@@ -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_HOST
from homeassistant.const import CONF_DEVICE, 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,9 +21,6 @@ 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."""
-4
View File
@@ -946,10 +946,6 @@ 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.
-1
View File
@@ -384,7 +384,6 @@ FLOWS = {
"knocki",
"knx",
"kodi",
"konnected",
"kostal_plenticore",
"kraken",
"kulersky",
@@ -3574,12 +3574,6 @@
"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,
-5
View File
@@ -201,11 +201,6 @@ SSDP = {
"manufacturer": "ZyXEL Communications Corp.",
},
],
"konnected": [
{
"manufacturer": "konnected.io",
},
],
"lametric": [
{
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1",
+39 -36
View File
@@ -71,6 +71,13 @@ 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"
@@ -496,27 +503,33 @@ 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),
]
# Filter out None and empty strings before joining
return "\n".join([part for part in prompt_parts if part])
@callback
def _async_get_preable(self, llm_context: LLMContext) -> list[str]:
"""Return the prompt for the API."""
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
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."
)
]
area: ar.AreaEntry | None = 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 = ""
if llm_context.device_id:
device_reg = dr.async_get(self.hass)
device = device_reg.async_get(llm_context.device_id)
@@ -535,28 +548,18 @@ class AssistAPI(API):
)
if floor and area:
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
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."
)
@callback
def _async_get_exposed_entities_prompt(
self, llm_context: LLMContext, exposed_entities: dict | None
self, exposed_entities: dict | None
) -> list[str]:
"""Return the prompt for the API for exposed entities."""
prompt = []
+1 -1
View File
@@ -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.2.0
habluetooth==6.7.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+5 -8
View File
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==45.0.4
aioesphomeapi==45.2.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -603,7 +603,7 @@ avea==1.8.0
# avion==0.10
# homeassistant.components.axis
axis==71
axis==72
# 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.7.3
bleak-esphome==3.9.1
# 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.2.0
habluetooth==6.7.1
# 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.1
inkbird-ble==1.1.2
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.2
@@ -1422,9 +1422,6 @@ 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
-25
View File
@@ -3662,31 +3662,6 @@ 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,3 +287,139 @@ 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
+40 -19
View File
@@ -1,5 +1,6 @@
"""Test the bluetooth config flow."""
from typing import Any
from unittest.mock import patch
from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails
@@ -10,6 +11,7 @@ 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,
@@ -347,10 +349,11 @@ 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) -> None:
async def test_options_flow_linux(hass: HomeAssistant, mode: str) -> None:
"""Test options on Linux."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -370,32 +373,50 @@ async def test_options_flow_linux(hass: HomeAssistant) -> None:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_PASSIVE: True,
},
user_input={CONF_MODE: mode},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PASSIVE] is True
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()
# Verify we can change it to False
@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()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
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
schema = result["data_schema"].schema
mode_key = next(k for k in schema if k == CONF_MODE)
assert mode_key.default() == expected_default
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
+30 -2
View File
@@ -178,6 +178,22 @@ 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": [
{
@@ -239,11 +255,11 @@ async def test_diagnostics(
"type": "FakeHaScanner",
"current_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
},
"requested_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
},
},
],
@@ -344,6 +360,12 @@ 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",
@@ -554,6 +576,12 @@ 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",
+156 -4
View File
@@ -26,6 +26,7 @@ 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,
@@ -95,15 +96,26 @@ 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
hass: HomeAssistant,
mock_bleak_scanner_start: MagicMock,
options: dict[str, Any],
) -> None:
"""Test we and setup and stop the scanner the passive scanner."""
"""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.
"""
entry = MockConfigEntry(
domain=bluetooth.DOMAIN,
data={},
options={CONF_PASSIVE: True},
options=options,
unique_id="00:00:00:00:00:01",
)
entry.add_to_hass(hass)
@@ -151,7 +163,7 @@ async def test_setup_and_stop_old_bluez(
mock_bleak_scanner_start: MagicMock,
one_adapter_old_bluez: None,
) -> None:
"""Test we and setup and stop the scanner the passive scanner with older bluez."""
"""Default AUTO falls back to active on adapters without passive scan support."""
entry = MockConfigEntry(
domain=bluetooth.DOMAIN,
data={},
@@ -1642,6 +1654,117 @@ 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
@@ -2536,6 +2659,35 @@ 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"]
# Should receive initial state for existing scanner
# hci0 has passive_scan=False so AUTO falls back to ACTIVE.
async with asyncio.timeout(1):
response = await client.receive_json()
assert response["event"] == {
-1
View File
@@ -104,7 +104,6 @@ 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,
-34
View File
@@ -1418,23 +1418,6 @@ 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"
)
@@ -1617,23 +1600,6 @@ 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,6 +4,7 @@ from unittest.mock import call
from aioesphomeapi import (
AlarmControlPanelCommand,
AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature,
AlarmControlPanelEntityState as ESPHomeAlarmEntityState,
AlarmControlPanelInfo,
AlarmControlPanelState as ESPHomeAlarmState,
@@ -22,7 +23,6 @@ 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=EspHomeACPFeatures.ARM_AWAY
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
| EspHomeACPFeatures.ARM_HOME
| EspHomeACPFeatures.ARM_NIGHT
| EspHomeACPFeatures.ARM_VACATION
| EspHomeACPFeatures.TRIGGER,
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
| ESPHomeAlarmControlPanelEntityFeature.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=EspHomeACPFeatures.ARM_AWAY
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
| EspHomeACPFeatures.ARM_HOME
| EspHomeACPFeatures.ARM_NIGHT
| EspHomeACPFeatures.ARM_VACATION
| EspHomeACPFeatures.TRIGGER,
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
| ESPHomeAlarmControlPanelEntityFeature.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=EspHomeACPFeatures.ARM_AWAY
| EspHomeACPFeatures.ARM_CUSTOM_BYPASS
| EspHomeACPFeatures.ARM_HOME
| EspHomeACPFeatures.ARM_NIGHT
| EspHomeACPFeatures.ARM_VACATION
| EspHomeACPFeatures.TRIGGER,
supported_features=ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY
| ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
| ESPHomeAlarmControlPanelEntityFeature.ARM_HOME
| ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT
| ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION
| ESPHomeAlarmControlPanelEntityFeature.TRIGGER,
requires_code=False,
requires_code_to_arm=False,
)
+253 -3
View File
@@ -1,12 +1,30 @@
"""Test the ESPHome bluetooth integration."""
from unittest.mock import patch
from collections.abc import Callable
from typing import Any
from unittest.mock import MagicMock, patch
from aioesphomeapi import (
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerState,
BluetoothScannerStateResponse,
)
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
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.helpers import device_registry as dr
from .conftest import MockESPHomeDevice
from .conftest import MockBluetoothEntryType, MockESPHomeDevice
_PROXY_WITH_STATE_AND_MODE = (
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
)
async def test_bluetooth_connect_with_raw_adv(
@@ -94,3 +112,235 @@ 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]
+107 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -22,9 +23,11 @@ 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,
)
@@ -43,7 +46,11 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import VALID_NOISE_PSK
from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType
from .conftest import (
MockBluetoothEntryType,
MockESPHomeDeviceType,
MockGenericDeviceEntryType,
)
from tests.common import MockConfigEntry
@@ -2078,6 +2085,105 @@ 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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping',
'object_id_base': 'Port forward Test Port Mapping',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
'object_id_base': 'Port forward Test Port Mapping 81',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping 81',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Guest',
'object_id_base': 'Wi-Fi Guest',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Guest',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
'object_id_base': 'Wi-Fi Main 2.4Ghz',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping',
'object_id_base': 'Port forward Test Port Mapping',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
'object_id_base': 'Port forward Test Port Mapping 81',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping 81',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Guest',
'object_id_base': 'Wi-Fi Guest',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Guest',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
'object_id_base': 'Wi-Fi Main 2.4Ghz',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping',
'object_id_base': 'Port forward Test Port Mapping',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
'object_id_base': 'Port forward Test Port Mapping 81',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping 81',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Guest',
'object_id_base': 'Wi-Fi Guest',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Guest',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
'object_id_base': 'Wi-Fi Main 2.4Ghz',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping',
'object_id_base': 'Port forward Test Port Mapping',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Port forward Test Port Mapping 81',
'object_id_base': 'Port forward Test Port Mapping 81',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:check-network',
'original_name': 'Mock Title Port forward Test Port Mapping 81',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Guest',
'object_id_base': 'Wi-Fi Guest',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Guest',
'original_name': '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': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz',
'object_id_base': 'Wi-Fi Main 2.4Ghz',
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:wifi',
'original_name': 'Mock Title Wi-Fi Main 2.4Ghz',
'original_name': 'Wi-Fi Main 2.4Ghz',
'platform': 'fritz',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -626,15 +626,20 @@ async def test_migration_from_future_version(
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_migration_1_2(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("sensor_device")
async def test_migration_1_2(
hass: HomeAssistant,
sensor_entity_entry: er.RegistryEntry,
switch_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from 1.2 to 1.3 copies CONF_MIN_DUR to CONF_DUR_COOLDOWN."""
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"name": "My generic thermostat",
"heater": "switch.test",
"target_sensor": "sensor.test",
"heater": switch_entity_entry.entity_id,
"target_sensor": sensor_entity_entry.entity_id,
CONF_MIN_DUR: {"hours": 0, "minutes": 5, "seconds": 0},
"ac_mode": False,
"cold_tolerance": 0.3,
@@ -646,9 +651,10 @@ async def test_migration_1_2(hass: HomeAssistant) -> None:
)
config_entry.add_to_hass(hass)
# Run migration
result = await generic_thermostat.async_migrate_entry(hass, config_entry)
assert result is True
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# After migration, cooldown should be set to min_cycle_duration
# and minor version bumped
@@ -657,4 +663,5 @@ async def test_migration_1_2(hass: HomeAssistant) -> None:
"minutes": 5,
"seconds": 0,
}
assert config_entry.version == 1
assert config_entry.minor_version == 3

Some files were not shown because too many files have changed in this diff Show More