mirror of
https://github.com/home-assistant/core.git
synced 2025-10-05 09:49:26 +00:00
Compare commits
99 Commits
mqtt-suben
...
2025.10.0b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f616e5a4e3 | ||
![]() |
c0317f60cc | ||
![]() |
8abfe424e1 | ||
![]() |
8de200de0b | ||
![]() |
f242e294be | ||
![]() |
58cc7c8f84 | ||
![]() |
bd10f6ec08 | ||
![]() |
ed9cfb4c4b | ||
![]() |
a6b6e4c4b8 | ||
![]() |
36ff5c0d45 | ||
![]() |
de6d34fec5 | ||
![]() |
38f9067970 | ||
![]() |
53a8a250d0 | ||
![]() |
00f6d26ede | ||
![]() |
6d09411c07 | ||
![]() |
037e2bfd31 | ||
![]() |
c893552d4a | ||
![]() |
4fd10162c9 | ||
![]() |
392ee5ae7e | ||
![]() |
bf190609a0 | ||
![]() |
e982ac1e53 | ||
![]() |
b4747ea87b | ||
![]() |
df69bcecb7 | ||
![]() |
c75dca743a | ||
![]() |
00d667ed51 | ||
![]() |
51e098e807 | ||
![]() |
5e2b27699e | ||
![]() |
be942c2888 | ||
![]() |
584c1fbd97 | ||
![]() |
abc5c6e2b4 | ||
![]() |
d9de964035 | ||
![]() |
bb02158d1a | ||
![]() |
be10f097c7 | ||
![]() |
7084bca783 | ||
![]() |
cd6f3a0fe5 | ||
![]() |
af2888331d | ||
![]() |
b92e5d7131 | ||
![]() |
f7265c85d0 | ||
![]() |
8466dbf69f | ||
![]() |
2dd0d69bcd | ||
![]() |
6783c4ad83 | ||
![]() |
07d7f4e18d | ||
![]() |
54b1749986 | ||
![]() |
eaf264361f | ||
![]() |
d8f6f17a4f | ||
![]() |
9a969cea63 | ||
![]() |
ef16327b2b | ||
![]() |
a6a6261168 | ||
![]() |
a01eb48db8 | ||
![]() |
eb103a8d9a | ||
![]() |
2b5f989855 | ||
![]() |
4e247a6ebe | ||
![]() |
77f897a768 | ||
![]() |
4f0a6ef9a1 | ||
![]() |
66c6b0f5fc | ||
![]() |
dd01243391 | ||
![]() |
66c17e250a | ||
![]() |
723902e233 | ||
![]() |
59fdb9f3b5 | ||
![]() |
d83502514a | ||
![]() |
08e81b2ba6 | ||
![]() |
1e808c965d | ||
![]() |
563b58c9aa | ||
![]() |
cf223880e8 | ||
![]() |
4058ca59ed | ||
![]() |
1386c01733 | ||
![]() |
46504947f7 | ||
![]() |
0a44682014 | ||
![]() |
06a57473a9 | ||
![]() |
fbed66ef1f | ||
![]() |
99a0380ec5 | ||
![]() |
68c51dc7aa | ||
![]() |
3d945b0fc5 | ||
![]() |
7b26a93d38 | ||
![]() |
1b2eab00be | ||
![]() |
750e849f09 | ||
![]() |
6aaddad56b | ||
![]() |
a5af974209 | ||
![]() |
09e45f6f54 | ||
![]() |
d857d8850c | ||
![]() |
ccc50f2412 | ||
![]() |
3905723900 | ||
![]() |
cee88473a2 | ||
![]() |
cdf613d3f8 | ||
![]() |
156a0f1a3d | ||
![]() |
cc2a5b43dd | ||
![]() |
731064f7e9 | ||
![]() |
2f75661c20 | ||
![]() |
be6f056f30 | ||
![]() |
79599e1284 | ||
![]() |
a255585ab6 | ||
![]() |
e9bde225fe | ||
![]() |
d9521ac2a0 | ||
![]() |
d8b24ccccd | ||
![]() |
b4417a76d5 | ||
![]() |
274f6eb54a | ||
![]() |
21a5aaf35c | ||
![]() |
05820a49d0 | ||
![]() |
17b12d29af |
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.1
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
5
homeassistant/brands/eltako.json
Normal file
5
homeassistant/brands/eltako.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "eltako",
|
||||
"name": "Eltako",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
5
homeassistant/brands/konnected.json
Normal file
5
homeassistant/brands/konnected.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected",
|
||||
"integrations": ["konnected", "konnected_esphome"]
|
||||
}
|
5
homeassistant/brands/level.json
Normal file
5
homeassistant/brands/level.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "level",
|
||||
"name": "Level",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.2.1"]
|
||||
"requirements": ["accuweather==4.2.2"]
|
||||
}
|
||||
|
@@ -4,10 +4,18 @@ from __future__ import annotations
|
||||
|
||||
from airos.airos8 import AirOS8
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
@@ -21,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
|
||||
# By default airOS 8 comes with self-signed SSL certificates,
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(hass, verify_ssl=False)
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
airos_device = AirOS8(
|
||||
host=entry.data[CONF_HOST],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
|
||||
@@ -40,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_data = {**entry.data}
|
||||
advanced_data = {
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
}
|
||||
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_data,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
|
@@ -15,10 +15,17 @@ from airos.exceptions import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOS8
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -28,6 +35,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME, default="ubnt"): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
|
||||
vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -36,6 +52,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ubiquiti airOS."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self,
|
||||
@@ -46,13 +63,17 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
# By default airOS 8 comes with self-signed SSL certificates,
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(self.hass, verify_ssl=False)
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
airos_device = AirOS8(
|
||||
host=user_input[CONF_HOST],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
try:
|
||||
await airos_device.login()
|
||||
|
@@ -7,3 +7,8 @@ DOMAIN = "airos"
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
MANUFACTURER = "Ubiquiti"
|
||||
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
DEFAULT_SSL = True
|
||||
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
|
@@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_SSL
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOSDataUpdateCoordinator
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
|
||||
airos_data = self.coordinator.data
|
||||
url_schema = (
|
||||
"https"
|
||||
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
|
||||
else "http"
|
||||
)
|
||||
|
||||
configuration_url: str | None = (
|
||||
f"https://{coordinator.config_entry.data[CONF_HOST]}"
|
||||
f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}"
|
||||
)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
@@ -12,6 +12,18 @@
|
||||
"host": "IP address or hostname of the airOS device",
|
||||
"username": "Administrator username for the airOS device, normally 'ubnt'",
|
||||
"password": "Password configured through the UISP app or web interface"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"ssl": "Use HTTPS",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ssl": "Whether the connection should be encrypted (required for most devices)",
|
||||
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -22,6 +22,17 @@ class OAuth2FlowHandler(
|
||||
VERSION = CONFIG_FLOW_VERSION
|
||||
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Check we have the cloud integration set up."""
|
||||
if "cloud" not in self.hass.config.components:
|
||||
return self.async_abort(
|
||||
reason="cloud_not_enabled",
|
||||
description_placeholders={"default_config": "default_config"},
|
||||
)
|
||||
return await super().async_step_user(user_input)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
@@ -24,7 +24,8 @@
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
|
||||
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.",
|
||||
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
|
@@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
@@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_update_unique_id
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -31,6 +33,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
||||
|
||||
|
||||
BINARY_SENSORS: Final = (
|
||||
@@ -41,46 +44,15 @@ BINARY_SENSORS: Final = (
|
||||
is_on_fn=lambda device, _: device.online,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="bluetooth",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="bluetooth",
|
||||
is_on_fn=lambda device, _: device.bluetooth_state,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="babyCryDetectionState",
|
||||
translation_key="baby_cry_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="beepingApplianceDetectionState",
|
||||
translation_key="beeping_appliance_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="coughDetectionState",
|
||||
translation_key="cough_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="dogBarkDetectionState",
|
||||
translation_key="dog_bark_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="humanPresenceDetectionState",
|
||||
key="detectionState",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="waterSoundsDetectionState",
|
||||
translation_key="water_sounds_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_on_fn=lambda device, key: bool(
|
||||
device.sensors[key].value != SENSOR_STATE_OFF
|
||||
),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
is_available_fn=lambda device, key: (
|
||||
device.online and device.sensors[key].error is False
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -94,6 +66,15 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Replace unique id for "detectionState" binary sensor
|
||||
await async_update_unique_id(
|
||||
hass,
|
||||
coordinator,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
"humanPresenceDetectionState",
|
||||
"detectionState",
|
||||
)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -125,3 +106,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
return self.entity_description.is_on_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
self.entity_description.is_available_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
and super().available
|
||||
)
|
||||
|
@@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotAuthenticate, TypeError):
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
@@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotAuthenticate, TypeError):
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
|
@@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotAuthenticate, TypeError) as err:
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
@@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]:
|
||||
"online": device.online,
|
||||
"serial number": device.serial_number,
|
||||
"software version": device.software_version,
|
||||
"do not disturb": device.do_not_disturb,
|
||||
"response style": device.response_style,
|
||||
"bluetooth state": device.bluetooth_state,
|
||||
"sensors": device.sensors,
|
||||
}
|
||||
|
@@ -1,44 +1,4 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"default": "mdi:bluetooth-off",
|
||||
"state": {
|
||||
"on": "mdi:bluetooth"
|
||||
}
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"default": "mdi:account-voice-off",
|
||||
"state": {
|
||||
"on": "mdi:account-voice"
|
||||
}
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"default": "mdi:bell-off",
|
||||
"state": {
|
||||
"on": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"cough_detection": {
|
||||
"default": "mdi:blur-off",
|
||||
"state": {
|
||||
"on": "mdi:blur"
|
||||
}
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"default": "mdi:dog-side-off",
|
||||
"state": {
|
||||
"on": "mdi:dog-side"
|
||||
}
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"default": "mdi:water-pump-off",
|
||||
"state": {
|
||||
"on": "mdi:water-pump"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"send_sound": {
|
||||
"service": "mdi:cast-audio"
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==6.0.0"]
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.2.7"]
|
||||
}
|
||||
|
@@ -31,6 +31,9 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online and device.sensors[key].error is False
|
||||
)
|
||||
|
||||
|
||||
SENSORS: Final = (
|
||||
@@ -99,3 +102,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.device.sensors[self.entity_description.key].value
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
self.entity_description.is_available_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
and super().available
|
||||
)
|
||||
|
@@ -58,26 +58,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"name": "Baby crying"
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"name": "Beeping appliance"
|
||||
},
|
||||
"cough_detection": {
|
||||
"name": "Coughing"
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"name": "Dog barking"
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"name": "Water sounds"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"speak": {
|
||||
"name": "Speak"
|
||||
|
@@ -8,13 +8,17 @@ from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
from .utils import alexa_api_call, async_update_unique_id
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -24,16 +28,17 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Alexa Devices switch entity description."""
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice], bool]
|
||||
subkey: str
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online and device.sensors[key].error is False
|
||||
)
|
||||
method: str
|
||||
|
||||
|
||||
SWITCHES: Final = (
|
||||
AmazonSwitchEntityDescription(
|
||||
key="do_not_disturb",
|
||||
subkey="AUDIO_PLAYER",
|
||||
key="dnd",
|
||||
translation_key="do_not_disturb",
|
||||
is_on_fn=lambda _device: _device.do_not_disturb,
|
||||
is_on_fn=lambda device: bool(device.sensors["dnd"].value),
|
||||
method="set_do_not_disturb",
|
||||
),
|
||||
)
|
||||
@@ -48,6 +53,11 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Replace unique id for "DND" switch and remove from Speaker Group
|
||||
await async_update_unique_id(
|
||||
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
|
||||
)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -59,7 +69,7 @@ async def async_setup_entry(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in new_devices
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
if switch_desc.key in coordinator.data[serial_num].sensors
|
||||
)
|
||||
|
||||
_check_device()
|
||||
@@ -94,3 +104,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if switch is on."""
|
||||
return self.entity_description.is_on_fn(self.device)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
self.entity_description.is_available_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
and super().available
|
||||
)
|
||||
|
@@ -6,9 +6,12 @@ from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
|
||||
|
||||
@@ -38,3 +41,23 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
) from err
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
|
||||
async def async_update_unique_id(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
domain: str,
|
||||
old_key: str,
|
||||
new_key: str,
|
||||
) -> None:
|
||||
"""Update unique id for entities created with old format."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{old_key}"
|
||||
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
# Update the registry with the new unique_id
|
||||
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||
|
@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
@@ -513,6 +513,8 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
|
||||
removed_devices: set[str] = set()
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
@@ -525,6 +527,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
if config_entry is None:
|
||||
continue
|
||||
|
||||
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
|
||||
removed_devices.add(device_entry.id)
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
@@ -551,7 +557,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
for domain, integration_info in integration_inputs.items()
|
||||
if (integration := integrations.get(domain)) is not None
|
||||
and integration.is_built_in
|
||||
and integration.integration_type in ("device", "hub")
|
||||
and integration.manifest.get("integration_type") in ("device", "hub")
|
||||
}
|
||||
|
||||
# Call integrations that implement the analytics platform
|
||||
@@ -614,11 +620,12 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
|
||||
if device_config.remove:
|
||||
removed_devices.add(device_id)
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
device_id_mapping[device_id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
@@ -669,7 +676,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
@@ -690,15 +697,19 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
if (device_id_ := entity_entry.device_id) is not None:
|
||||
if device_id_ in removed_devices:
|
||||
# The device was removed, so we remove the entity too
|
||||
continue
|
||||
|
||||
if (
|
||||
new_device_id := device_id_mapping.get(device_id_)
|
||||
) is not None and (new_device_id[0] == integration_domain):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
continue
|
||||
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
|
@@ -1308,7 +1308,9 @@ class PipelineRun:
|
||||
# instead of a full response.
|
||||
all_targets_in_satellite_area = (
|
||||
self._get_all_targets_in_satellite_area(
|
||||
conversation_result.response, self._device_id
|
||||
conversation_result.response,
|
||||
self._satellite_id,
|
||||
self._device_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1337,39 +1339,62 @@ class PipelineRun:
|
||||
return (speech, all_targets_in_satellite_area)
|
||||
|
||||
def _get_all_targets_in_satellite_area(
|
||||
self, intent_response: intent.IntentResponse, device_id: str | None
|
||||
self,
|
||||
intent_response: intent.IntentResponse,
|
||||
satellite_id: str | None,
|
||||
device_id: str | None,
|
||||
) -> bool:
|
||||
"""Return true if all targeted entities were in the same area as the device."""
|
||||
if (
|
||||
(intent_response.response_type != intent.IntentResponseType.ACTION_DONE)
|
||||
or (not intent_response.matched_states)
|
||||
or (not device_id)
|
||||
):
|
||||
return False
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
if (not (device := device_registry.async_get(device_id))) or (
|
||||
not device.area_id
|
||||
intent_response.response_type != intent.IntentResponseType.ACTION_DONE
|
||||
or not intent_response.matched_states
|
||||
):
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for state in intent_response.matched_states:
|
||||
entity = entity_registry.async_get(state.entity_id)
|
||||
if not entity:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
area_id: str | None = None
|
||||
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (target_entity_entry := entity_registry.async_get(satellite_id))
|
||||
is not None
|
||||
):
|
||||
area_id = target_entity_entry.area_id
|
||||
device_id = target_entity_entry.device_id
|
||||
|
||||
if area_id is None:
|
||||
if device_id is None:
|
||||
return False
|
||||
|
||||
if (entity_area_id := entity.area_id) is None:
|
||||
if (entity.device_id is None) or (
|
||||
(entity_device := device_registry.async_get(entity.device_id))
|
||||
is None
|
||||
):
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
return False
|
||||
|
||||
area_id = device_entry.area_id
|
||||
if area_id is None:
|
||||
return False
|
||||
|
||||
for state in intent_response.matched_states:
|
||||
target_entity_entry = entity_registry.async_get(state.entity_id)
|
||||
if target_entity_entry is None:
|
||||
return False
|
||||
|
||||
target_area_id = target_entity_entry.area_id
|
||||
if target_area_id is None:
|
||||
if target_entity_entry.device_id is None:
|
||||
return False
|
||||
|
||||
entity_area_id = entity_device.area_id
|
||||
target_device_entry = device_registry.async_get(
|
||||
target_entity_entry.device_id
|
||||
)
|
||||
if target_device_entry is None:
|
||||
return False
|
||||
|
||||
if entity_area_id != device.area_id:
|
||||
target_area_id = target_device_entry.area_id
|
||||
|
||||
if target_area_id != area_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
@@ -272,6 +272,13 @@ async def async_setup_entry(
|
||||
observations: list[ConfigType] = [
|
||||
dict(subentry.data) for subentry in config_entry.subentries.values()
|
||||
]
|
||||
|
||||
for observation in observations:
|
||||
if observation[CONF_PLATFORM] == CONF_TEMPLATE:
|
||||
observation[CONF_VALUE_TEMPLATE] = Template(
|
||||
observation[CONF_VALUE_TEMPLATE], hass
|
||||
)
|
||||
|
||||
prior: float = config[CONF_PRIOR]
|
||||
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
||||
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
|
@@ -25,23 +25,27 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .utils import async_client_session
|
||||
|
||||
DEFAULT_HOST = "192.168.1.252"
|
||||
DEFAULT_PIN = 111111
|
||||
DEFAULT_PIN = "111111"
|
||||
|
||||
|
||||
pin_regex = r"^[0-9]{4,10}$"
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int})
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
|
||||
)
|
||||
STEP_RECONFIGURE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==0.12.3"]
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
@@ -88,8 +89,8 @@ class WanIpSensor(SensorEntity):
|
||||
self._attr_name = "IPv6" if ipv6 else None
|
||||
self._attr_unique_id = f"{hostname}_{ipv6}"
|
||||
self.hostname = hostname
|
||||
self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port)
|
||||
self.resolver.nameservers = [resolver]
|
||||
self.port = port
|
||||
self._resolver = resolver
|
||||
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
|
||||
self._retries = DEFAULT_RETRIES
|
||||
self._attr_extra_state_attributes = {
|
||||
@@ -103,14 +104,26 @@ class WanIpSensor(SensorEntity):
|
||||
model=aiodns.__version__,
|
||||
name=name,
|
||||
)
|
||||
self.resolver: aiodns.DNSResolver
|
||||
self.create_dns_resolver()
|
||||
|
||||
def create_dns_resolver(self) -> None:
|
||||
"""Create the DNS resolver."""
|
||||
self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port)
|
||||
self.resolver.nameservers = [self._resolver]
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the current DNS IP address for hostname."""
|
||||
if self.resolver._closed: # noqa: SLF001
|
||||
self.create_dns_resolver()
|
||||
response = None
|
||||
try:
|
||||
response = await self.resolver.query(self.hostname, self.querytype)
|
||||
async with asyncio.timeout(10):
|
||||
response = await self.resolver.query(self.hostname, self.querytype)
|
||||
except TimeoutError:
|
||||
await self.resolver.close()
|
||||
except DNSError as err:
|
||||
_LOGGER.warning("Exception while resolving host: %s", err)
|
||||
response = None
|
||||
|
||||
if response:
|
||||
sorted_ips = sort_ips(
|
||||
|
@@ -116,7 +116,11 @@ class EbusdData:
|
||||
try:
|
||||
_LOGGER.debug("Opening socket to ebusd %s", name)
|
||||
command_result = ebusdpy.write(self._address, self._circuit, name, value)
|
||||
if command_result is not None and "done" not in command_result:
|
||||
if (
|
||||
command_result is not None
|
||||
and "done" not in command_result
|
||||
and "empty" not in command_result
|
||||
):
|
||||
_LOGGER.warning("Write command failed: %s", name)
|
||||
except RuntimeError as err:
|
||||
_LOGGER.error(err)
|
||||
|
@@ -176,7 +176,7 @@
|
||||
"description": "Sets the participating sensors for a climate program.",
|
||||
"fields": {
|
||||
"preset_mode": {
|
||||
"name": "Climate Name",
|
||||
"name": "Climate program",
|
||||
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
|
||||
},
|
||||
"device_ids": {
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_preset": {
|
||||
"message": "Invalid climate name, available options are: {options}"
|
||||
"message": "Invalid climate program, available options are: {options}"
|
||||
},
|
||||
"invalid_sensor": {
|
||||
"message": "Invalid sensor for thermostat, available options are: {options}"
|
||||
|
@@ -69,7 +69,9 @@ class EcovacsMap(
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def on_info(event: CachedMapInfoEvent) -> None:
|
||||
self._attr_extra_state_attributes["map_name"] = event.name
|
||||
for map_obj in event.maps:
|
||||
if map_obj.using:
|
||||
self._attr_extra_state_attributes["map_name"] = map_obj.name
|
||||
|
||||
async def on_changed(event: MapChangedEvent) -> None:
|
||||
self._attr_image_last_updated = event.when
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.1"]
|
||||
"requirements": ["aioecowitt==2025.9.2"]
|
||||
}
|
||||
|
@@ -3,14 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyephember2.pyephember2 import (
|
||||
EphEmber,
|
||||
ZoneMode,
|
||||
boiler_state,
|
||||
zone_current_temperature,
|
||||
zone_is_active,
|
||||
zone_is_hotwater,
|
||||
zone_mode,
|
||||
zone_name,
|
||||
@@ -53,6 +54,15 @@ EPH_TO_HA_STATE = {
|
||||
"OFF": HVACMode.OFF,
|
||||
}
|
||||
|
||||
|
||||
class EPHBoilerStates(IntEnum):
|
||||
"""Boiler states for a zone given by the api."""
|
||||
|
||||
FIXME = 0
|
||||
OFF = 1
|
||||
ON = 2
|
||||
|
||||
|
||||
HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()}
|
||||
|
||||
|
||||
@@ -123,7 +133,7 @@ class EphEmberThermostat(ClimateEntity):
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return current HVAC action."""
|
||||
if zone_is_active(self._zone):
|
||||
if boiler_state(self._zone) == EPHBoilerStates.ON:
|
||||
return HVACAction.HEATING
|
||||
|
||||
return HVACAction.IDLE
|
||||
|
11
homeassistant/components/esphome/analytics.py
Normal file
11
homeassistant/components/esphome/analytics.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
return AnalyticsModifications(remove=True)
|
@@ -57,6 +57,7 @@ from .manager import async_replace_device
|
||||
|
||||
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
|
||||
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
|
||||
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
@@ -137,6 +138,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = ""
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
if error == ERROR_INVALID_PASSWORD_AUTH or (
|
||||
error is None and self._device_info and self._device_info.uses_password
|
||||
):
|
||||
return await self.async_step_authenticate()
|
||||
|
||||
if error is None and entry_data.get(CONF_NOISE_PSK):
|
||||
# Device was configured with encryption but now connects without it.
|
||||
# Check if it's the same device before offering to remove encryption.
|
||||
@@ -690,13 +696,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
cli = APIClient(
|
||||
host,
|
||||
port or DEFAULT_PORT,
|
||||
"",
|
||||
self._password or "",
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
)
|
||||
try:
|
||||
await cli.connect()
|
||||
self._device_info = await cli.device_info()
|
||||
except InvalidAuthAPIError:
|
||||
return ERROR_INVALID_PASSWORD_AUTH
|
||||
except RequiresEncryptionAPIError:
|
||||
return ERROR_REQUIRES_ENCRYPTION_KEY
|
||||
except InvalidEncryptionKeyAPIError as ex:
|
||||
|
@@ -372,6 +372,9 @@ class ESPHomeManager:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
try:
|
||||
await self._on_connect()
|
||||
except InvalidAuthAPIError as err:
|
||||
_LOGGER.warning("Authentication failed for %s: %s", self.host, err)
|
||||
await self._start_reauth_and_disconnect()
|
||||
except APIConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Error getting setting up connection for %s: %s", self.host, err
|
||||
@@ -641,7 +644,14 @@ class ESPHomeManager:
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
return
|
||||
await self._start_reauth_and_disconnect()
|
||||
|
||||
async def _start_reauth_and_disconnect(self) -> None:
|
||||
"""Start reauth flow and stop reconnection attempts."""
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
await self.cli.disconnect()
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
|
||||
async def _handle_dynamic_encryption_key(
|
||||
self, device_info: EsphomeDeviceInfo
|
||||
@@ -1063,7 +1073,7 @@ def _async_register_service(
|
||||
service_name,
|
||||
{
|
||||
"description": (
|
||||
f"Calls the service {service.name} of the node {device_info.name}"
|
||||
f"Performs the action {service.name} of the node {device_info.name}"
|
||||
),
|
||||
"fields": fields,
|
||||
},
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.9.0",
|
||||
"aioesphomeapi==41.11.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
@@ -26,11 +26,14 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
|
||||
super().__init__(coordinator)
|
||||
self._serial = serial
|
||||
self._camera_name = self.data["name"]
|
||||
|
||||
connections = set()
|
||||
if mac_address := self.data["mac_address"]:
|
||||
connections.add((CONNECTION_NETWORK_MAC, mac_address))
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
connections={
|
||||
(CONNECTION_NETWORK_MAC, self.data["mac_address"]),
|
||||
},
|
||||
connections=connections,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=self.data["device_sub_category"],
|
||||
name=self.data["name"],
|
||||
@@ -62,11 +65,14 @@ class EzvizBaseEntity(Entity):
|
||||
self._serial = serial
|
||||
self.coordinator = coordinator
|
||||
self._camera_name = self.data["name"]
|
||||
|
||||
connections = set()
|
||||
if mac_address := self.data["mac_address"]:
|
||||
connections.add((CONNECTION_NETWORK_MAC, mac_address))
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
connections={
|
||||
(CONNECTION_NETWORK_MAC, self.data["mac_address"]),
|
||||
},
|
||||
connections=connections,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=self.data["device_sub_category"],
|
||||
name=self.data["name"],
|
||||
|
@@ -46,6 +46,9 @@ async def async_get_config_entry_diagnostics(
|
||||
}
|
||||
for _, device in avm_wrapper.devices.items()
|
||||
],
|
||||
"cpu_temperatures": await hass.async_add_executor_job(
|
||||
avm_wrapper.fritz_status.get_cpu_temperatures
|
||||
),
|
||||
"wan_link_properties": await avm_wrapper.async_get_wan_link_properties(),
|
||||
},
|
||||
}
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250924.0"]
|
||||
"requirements": ["home-assistant-frontend==20251001.0"]
|
||||
}
|
||||
|
@@ -77,10 +77,10 @@ class GeniusDevice(GeniusEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update an entity's state data."""
|
||||
if "_state" in self._device.data: # only via v3 API
|
||||
self._last_comms = dt_util.utc_from_timestamp(
|
||||
self._device.data["_state"]["lastComms"]
|
||||
)
|
||||
if (state := self._device.data.get("_state")) and (
|
||||
last_comms := state.get("lastComms")
|
||||
) is not None: # only via v3 API
|
||||
self._last_comms = dt_util.utc_from_timestamp(last_comms)
|
||||
|
||||
|
||||
class GeniusZone(GeniusEntity):
|
||||
|
@@ -10,6 +10,7 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
@@ -67,6 +68,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
|
||||
context: ConfigFlowContext
|
||||
|
||||
# `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we
|
||||
# try them in this order is that on older adapters `baudrate` entered the ESP32-S3
|
||||
# bootloader instead of the MG24 bootloader.
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
@@ -27,6 +27,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -69,12 +75,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -129,14 +133,21 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"flow_title": "{model}",
|
||||
"step": {
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"pick_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
@@ -158,12 +169,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -215,9 +224,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -156,7 +157,7 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Connect ZBT-2 firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -39,6 +39,7 @@ from .util import (
|
||||
FirmwareInfo,
|
||||
OwningAddon,
|
||||
OwningIntegration,
|
||||
ResetTarget,
|
||||
async_flash_silabs_firmware,
|
||||
get_otbr_addon_manager,
|
||||
guess_firmware_info,
|
||||
@@ -61,6 +62,13 @@ class PickedFirmwareType(StrEnum):
|
||||
ZIGBEE = "zigbee"
|
||||
|
||||
|
||||
class ZigbeeFlowStrategy(StrEnum):
|
||||
"""Zigbee setup strategies that can be picked."""
|
||||
|
||||
ADVANCED = "advanced"
|
||||
RECOMMENDED = "recommended"
|
||||
|
||||
|
||||
class ZigbeeIntegration(StrEnum):
|
||||
"""Zigbee integrations that can be picked."""
|
||||
|
||||
@@ -72,7 +80,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Instantiate base flow."""
|
||||
@@ -147,34 +158,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def _probe_firmware_info(
|
||||
self,
|
||||
probe_methods: tuple[ApplicationType, ...] = (
|
||||
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
|
||||
ApplicationType.GECKO_BOOTLOADER,
|
||||
ApplicationType.EZSP,
|
||||
ApplicationType.SPINEL,
|
||||
ApplicationType.CPC,
|
||||
),
|
||||
) -> bool:
|
||||
"""Probe the firmware currently on the device."""
|
||||
assert self._device is not None
|
||||
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
probe_methods=probe_methods,
|
||||
)
|
||||
|
||||
return (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.firmware_type
|
||||
in (
|
||||
ApplicationType.EZSP,
|
||||
ApplicationType.SPINEL,
|
||||
ApplicationType.CPC,
|
||||
)
|
||||
)
|
||||
|
||||
async def _install_firmware_step(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
@@ -228,12 +211,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
) -> None:
|
||||
"""Install firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
raise AbortFlow(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
assert self._device is not None
|
||||
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
@@ -242,6 +219,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
)
|
||||
@@ -293,12 +272,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
await async_flash_silabs_firmware(
|
||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
@@ -306,15 +285,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
# Before we start the addon, confirm that the correct firmware is running
|
||||
# and populate `self._probed_firmware_info` with the correct information
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||
raise AbortFlow(
|
||||
"unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
@@ -395,12 +365,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
) -> ConfigFlowResult:
|
||||
"""Select recommended installation type."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
self._zigbee_flow_strategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_intent_custom(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select custom installation type."""
|
||||
self._zigbee_flow_strategy = ZigbeeFlowStrategy.ADVANCED
|
||||
return await self.async_step_zigbee_integration()
|
||||
|
||||
async def async_step_zigbee_integration(
|
||||
@@ -434,12 +406,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
return await self.async_step_prepare_thread_installation()
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_prepare_thread_installation(
|
||||
async def async_step_finish_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Prepare for Thread installation by stopping the OTBR addon if needed."""
|
||||
"""Finish Thread installation by starting the OTBR addon."""
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
@@ -449,22 +421,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# Stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_finish_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish Thread installation by starting the OTBR addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return await self.async_step_start_otbr_addon()
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
@@ -501,12 +463,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||
return self._async_flow_finished()
|
||||
|
||||
@@ -521,6 +477,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
"flow_strategy": self._zigbee_flow_strategy,
|
||||
},
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.0.32",
|
||||
"universal-silabs-flasher==0.0.34",
|
||||
"ha-silabs-firmware-client==0.2.0"
|
||||
]
|
||||
}
|
||||
|
@@ -23,12 +23,16 @@
|
||||
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "Installing OpenThread Border Router add-on",
|
||||
"description": "The OpenThread Border Router (OTBR) add-on is being installed."
|
||||
"title": "Configuring Thread"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "Updating adapter"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "Updating adapter"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "Starting OpenThread Border Router add-on",
|
||||
"description": "The OpenThread Border Router (OTBR) add-on is now starting."
|
||||
"title": "Configuring Thread"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "Failed to set up OpenThread Border Router",
|
||||
@@ -72,7 +76,9 @@
|
||||
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
|
||||
},
|
||||
"progress": {
|
||||
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
|
||||
"install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
|
||||
"install_otbr_addon": "Installing add-on",
|
||||
"start_otbr_addon": "Starting add-on"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -22,7 +22,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import FirmwareUpdateCoordinator
|
||||
from .helpers import async_register_firmware_info_callback
|
||||
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
async_flash_silabs_firmware,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity(
|
||||
|
||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||
entity_description: FirmwareUpdateEntityDescription
|
||||
bootloader_reset_type: str | None = None
|
||||
bootloader_reset_methods: list[ResetTarget] = []
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
@@ -268,7 +273,7 @@ class BaseFirmwareUpdateEntity(
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_type=self.bootloader_reset_type,
|
||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
finally:
|
||||
|
@@ -4,13 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
|
||||
from universal_silabs_flasher.const import (
|
||||
ApplicationType as FlasherApplicationType,
|
||||
ResetTarget as FlasherResetTarget,
|
||||
)
|
||||
from universal_silabs_flasher.firmware import parse_firmware_image
|
||||
from universal_silabs_flasher.flasher import Flasher
|
||||
|
||||
@@ -42,9 +45,9 @@ class ApplicationType(StrEnum):
|
||||
"""Application type running on a device."""
|
||||
|
||||
GECKO_BOOTLOADER = "bootloader"
|
||||
CPC = "cpc"
|
||||
EZSP = "ezsp"
|
||||
SPINEL = "spinel"
|
||||
CPC = "cpc"
|
||||
ROUTER = "router"
|
||||
|
||||
@classmethod
|
||||
@@ -59,6 +62,18 @@ class ApplicationType(StrEnum):
|
||||
return FlasherApplicationType(self.value)
|
||||
|
||||
|
||||
class ResetTarget(StrEnum):
|
||||
"""Methods to reset a device into bootloader mode."""
|
||||
|
||||
RTS_DTR = "rts_dtr"
|
||||
BAUDRATE = "baudrate"
|
||||
YELLOW = "yellow"
|
||||
|
||||
def as_flasher_reset_target(self) -> FlasherResetTarget:
|
||||
"""Convert the reset target enum into one compatible with USF."""
|
||||
return FlasherResetTarget(self.value)
|
||||
|
||||
|
||||
@singleton(OTBR_ADDON_MANAGER_DATA)
|
||||
@callback
|
||||
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
||||
@@ -342,7 +357,7 @@ async def async_flash_silabs_firmware(
|
||||
device: str,
|
||||
fw_data: bytes,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
bootloader_reset_type: str | None = None,
|
||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
@@ -359,7 +374,9 @@ async def async_flash_silabs_firmware(
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
),
|
||||
bootloader_reset=bootloader_reset_type,
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
|
@@ -27,6 +27,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -69,12 +75,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -129,9 +133,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -158,12 +163,16 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -215,9 +224,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -168,7 +168,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
# The ZBT-1 does not have a hardware bootloader trigger
|
||||
bootloader_reset_methods = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -27,6 +27,8 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
probe_silabs_firmware_info,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HARDWARE,
|
||||
@@ -82,6 +84,8 @@ else:
|
||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -141,8 +145,10 @@ class HomeAssistantYellowConfigFlow(
|
||||
self, data: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
assert self._device is not None
|
||||
|
||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||
await self._probe_firmware_info()
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
|
||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||
if (
|
||||
|
@@ -35,6 +35,12 @@
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"install_thread_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
|
||||
},
|
||||
"install_zigbee_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
|
||||
@@ -92,12 +98,10 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
},
|
||||
"install_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
|
||||
@@ -154,9 +158,10 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -173,7 +174,7 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_type = "yellow" # Triggers a GPIO reset
|
||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -145,7 +145,11 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity):
|
||||
if not self.resource.enabled:
|
||||
# Force None (unknown) if the sensor is set to disabled in Hue
|
||||
return None
|
||||
return self.resource.motion.value
|
||||
if not (motion_feature := self.resource.motion):
|
||||
return None
|
||||
if motion_feature.motion_report is not None:
|
||||
return motion_feature.motion_report.motion
|
||||
return motion_feature.motion
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
|
@@ -35,7 +35,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
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
|
||||
@@ -221,6 +221,19 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
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 = {}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
|
@@ -105,5 +105,11 @@
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_firmware": {
|
||||
"title": "Konnected firmware is deprecated",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1
homeassistant/components/konnected_esphome/__init__.py
Normal file
1
homeassistant/components/konnected_esphome/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Konnected ESPHome."""
|
6
homeassistant/components/konnected_esphome/manifest.json
Normal file
6
homeassistant/components/konnected_esphome/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "konnected_esphome",
|
||||
"name": "Konnected",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "esphome"
|
||||
}
|
@@ -142,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
)
|
||||
|
||||
coordinators = LaMarzoccoRuntimeData(
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client),
|
||||
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||
|
@@ -8,7 +8,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pylamarzocco import LaMarzoccoMachine
|
||||
from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
SETTINGS_UPDATE_INTERVAL = timedelta(hours=8)
|
||||
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15)
|
||||
@@ -51,6 +51,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
hass: HomeAssistant,
|
||||
entry: LaMarzoccoConfigEntry,
|
||||
device: LaMarzoccoMachine,
|
||||
cloud_client: LaMarzoccoCloudClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
@@ -61,6 +62,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
update_interval=self._default_update_interval,
|
||||
)
|
||||
self.device = device
|
||||
self.cloud_client = cloud_client
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Do the data update."""
|
||||
@@ -85,11 +87,17 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco API centrally."""
|
||||
|
||||
cloud_client: LaMarzoccoCloudClient
|
||||
|
||||
async def _internal_async_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
|
||||
# ensure token stays valid; does nothing if token is still valid
|
||||
await self.cloud_client.async_get_access_token()
|
||||
|
||||
if self.device.websocket.connected:
|
||||
return
|
||||
|
||||
await self.device.get_dashboard()
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
|
||||
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.1.0"]
|
||||
"requirements": ["pylamarzocco==2.1.1"]
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import PRECISION_WHOLE, EntityCategory
|
||||
from homeassistant.const import PRECISION_WHOLE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -72,6 +72,7 @@ NUMBERS: tuple[LetPotNumberEntityDescription, ...] = (
|
||||
LetPotNumberEntityDescription(
|
||||
key="plant_days",
|
||||
translation_key="plant_days",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
value_fn=lambda coordinator: coordinator.data.plant_days,
|
||||
set_value_fn=(
|
||||
lambda device_client, serial, value: device_client.set_plant_days(
|
||||
|
@@ -54,8 +54,7 @@
|
||||
"name": "Light brightness"
|
||||
},
|
||||
"plant_days": {
|
||||
"name": "Plants age",
|
||||
"unit_of_measurement": "days"
|
||||
"name": "Plants age"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.3.1"]
|
||||
"requirements": ["librehardwaremonitor-api==1.4.0"]
|
||||
}
|
||||
|
@@ -9,6 +9,18 @@
|
||||
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
|
||||
}
|
||||
},
|
||||
"credentials_choice": {
|
||||
"title": "Choose how to authenticate with the MCP server",
|
||||
"description": "You can either use existing credentials from another integration or set up new credentials.",
|
||||
"menu_options": {
|
||||
"new_credentials": "Set up new credentials",
|
||||
"pick_implementation": "Use existing credentials"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"new_credentials": "You will be guided through setting up a new OAuth Client ID and secret.",
|
||||
"pick_implementation": "You may use previously entered OAuth credentials."
|
||||
}
|
||||
},
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||
"data": {
|
||||
@@ -27,14 +39,21 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.09.23"],
|
||||
"requirements": ["yt-dlp[default]==2025.09.26"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -1235,6 +1235,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
@@ -1242,6 +1243,7 @@
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
|
@@ -2,10 +2,8 @@
|
||||
"domain": "mvglive",
|
||||
"name": "MVG",
|
||||
"codeowners": [],
|
||||
"disabled": "This integration is disabled because it uses non-open source code to operate.",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mvglive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["MVGLive"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["PyMVGLive==1.1.4"]
|
||||
"loggers": ["MVG"],
|
||||
"requirements": ["mvg==1.4.0"]
|
||||
}
|
||||
|
@@ -1,13 +1,14 @@
|
||||
"""Support for departure information for public transport in Munich."""
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import MVGLive
|
||||
from mvg import MvgApi, MvgApiError, TransportType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,53 +46,55 @@ ICONS = {
|
||||
"SEV": "mdi:checkbox-blank-circle-outline",
|
||||
"-": "mdi:clock",
|
||||
}
|
||||
ATTRIBUTION = "Data provided by MVG-live.de"
|
||||
|
||||
ATTRIBUTION = "Data provided by mvg.de"
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_NEXT_DEPARTURE): [
|
||||
{
|
||||
vol.Required(CONF_STATION): cv.string,
|
||||
vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(
|
||||
CONF_PRODUCTS, default=DEFAULT_PRODUCT
|
||||
): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
}
|
||||
]
|
||||
}
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_DIRECTIONS),
|
||||
SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_NEXT_DEPARTURE): [
|
||||
{
|
||||
vol.Required(CONF_STATION): cv.string,
|
||||
vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
|
||||
vol.Optional(
|
||||
CONF_PRODUCTS, default=DEFAULT_PRODUCT
|
||||
): cv.ensure_list_csv,
|
||||
vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the MVGLive sensor."""
|
||||
add_entities(
|
||||
(
|
||||
MVGLiveSensor(
|
||||
nextdeparture.get(CONF_STATION),
|
||||
nextdeparture.get(CONF_DESTINATIONS),
|
||||
nextdeparture.get(CONF_DIRECTIONS),
|
||||
nextdeparture.get(CONF_LINES),
|
||||
nextdeparture.get(CONF_PRODUCTS),
|
||||
nextdeparture.get(CONF_TIMEOFFSET),
|
||||
nextdeparture.get(CONF_NUMBER),
|
||||
nextdeparture.get(CONF_NAME),
|
||||
)
|
||||
for nextdeparture in config[CONF_NEXT_DEPARTURE]
|
||||
),
|
||||
True,
|
||||
)
|
||||
sensors = [
|
||||
MVGLiveSensor(
|
||||
hass,
|
||||
nextdeparture.get(CONF_STATION),
|
||||
nextdeparture.get(CONF_DESTINATIONS),
|
||||
nextdeparture.get(CONF_LINES),
|
||||
nextdeparture.get(CONF_PRODUCTS),
|
||||
nextdeparture.get(CONF_TIMEOFFSET),
|
||||
nextdeparture.get(CONF_NUMBER),
|
||||
nextdeparture.get(CONF_NAME),
|
||||
)
|
||||
for nextdeparture in config[CONF_NEXT_DEPARTURE]
|
||||
]
|
||||
add_entities(sensors, True)
|
||||
|
||||
|
||||
class MVGLiveSensor(SensorEntity):
|
||||
@@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
station,
|
||||
hass: HomeAssistant,
|
||||
station_name,
|
||||
destinations,
|
||||
directions,
|
||||
lines,
|
||||
products,
|
||||
timeoffset,
|
||||
number,
|
||||
name,
|
||||
):
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._station = station
|
||||
self._name = name
|
||||
self._station_name = station_name
|
||||
self.data = MVGLiveData(
|
||||
station, destinations, directions, lines, products, timeoffset, number
|
||||
hass, station_name, destinations, lines, products, timeoffset, number
|
||||
)
|
||||
self._state = None
|
||||
self._icon = ICONS["-"]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the sensor."""
|
||||
if self._name:
|
||||
return self._name
|
||||
return self._station
|
||||
return self._station_name
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the next departure time."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
if not (dep := self.data.departures):
|
||||
return None
|
||||
@@ -140,88 +144,114 @@ class MVGLiveSensor(SensorEntity):
|
||||
return attr
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
def icon(self) -> str | None:
|
||||
"""Icon to use in the frontend, if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit this state is expressed in."""
|
||||
return UnitOfTime.MINUTES
|
||||
|
||||
def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest data and update the state."""
|
||||
self.data.update()
|
||||
await self.data.update()
|
||||
if not self.data.departures:
|
||||
self._state = "-"
|
||||
self._state = None
|
||||
self._icon = ICONS["-"]
|
||||
else:
|
||||
self._state = self.data.departures[0].get("time", "-")
|
||||
self._icon = ICONS[self.data.departures[0].get("product", "-")]
|
||||
self._state = self.data.departures[0].get("time_in_mins", "-")
|
||||
self._icon = self.data.departures[0].get("icon", ICONS["-"])
|
||||
|
||||
|
||||
def _get_minutes_until_departure(departure_time: int) -> int:
|
||||
"""Calculate the time difference in minutes between the current time and a given departure time.
|
||||
|
||||
Args:
|
||||
departure_time: Unix timestamp of the departure time, in seconds.
|
||||
|
||||
Returns:
|
||||
The time difference in minutes, as an integer.
|
||||
|
||||
"""
|
||||
current_time = dt_util.utcnow()
|
||||
departure_datetime = dt_util.utc_from_timestamp(departure_time)
|
||||
time_difference = (departure_datetime - current_time).total_seconds()
|
||||
return int(time_difference / 60.0)
|
||||
|
||||
|
||||
class MVGLiveData:
|
||||
"""Pull data from the mvg-live.de web page."""
|
||||
"""Pull data from the mvg.de web page."""
|
||||
|
||||
def __init__(
|
||||
self, station, destinations, directions, lines, products, timeoffset, number
|
||||
):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
station_name,
|
||||
destinations,
|
||||
lines,
|
||||
products,
|
||||
timeoffset,
|
||||
number,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._station = station
|
||||
self._hass = hass
|
||||
self._station_name = station_name
|
||||
self._station_id = None
|
||||
self._destinations = destinations
|
||||
self._directions = directions
|
||||
self._lines = lines
|
||||
self._products = products
|
||||
self._timeoffset = timeoffset
|
||||
self._number = number
|
||||
self._include_ubahn = "U-Bahn" in self._products
|
||||
self._include_tram = "Tram" in self._products
|
||||
self._include_bus = "Bus" in self._products
|
||||
self._include_sbahn = "S-Bahn" in self._products
|
||||
self.mvg = MVGLive.MVGLive()
|
||||
self.departures = []
|
||||
self.departures: list[dict[str, Any]] = []
|
||||
|
||||
def update(self):
|
||||
async def update(self):
|
||||
"""Update the connection data."""
|
||||
if self._station_id is None:
|
||||
try:
|
||||
station = await MvgApi.station_async(self._station_name)
|
||||
self._station_id = station["id"]
|
||||
except MvgApiError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to resolve station %s: %s", self._station_name, err
|
||||
)
|
||||
self.departures = []
|
||||
return
|
||||
|
||||
try:
|
||||
_departures = self.mvg.getlivedata(
|
||||
station=self._station,
|
||||
timeoffset=self._timeoffset,
|
||||
ubahn=self._include_ubahn,
|
||||
tram=self._include_tram,
|
||||
bus=self._include_bus,
|
||||
sbahn=self._include_sbahn,
|
||||
_departures = await MvgApi.departures_async(
|
||||
station_id=self._station_id,
|
||||
offset=self._timeoffset,
|
||||
limit=self._number,
|
||||
transport_types=[
|
||||
transport_type
|
||||
for transport_type in TransportType
|
||||
if transport_type.value[0] in self._products
|
||||
]
|
||||
if self._products
|
||||
else None,
|
||||
)
|
||||
except ValueError:
|
||||
self.departures = []
|
||||
_LOGGER.warning("Returned data not understood")
|
||||
return
|
||||
self.departures = []
|
||||
for i, _departure in enumerate(_departures):
|
||||
# find the first departure meeting the criteria
|
||||
for _departure in _departures:
|
||||
if (
|
||||
"" not in self._destinations[:1]
|
||||
and _departure["destination"] not in self._destinations
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
"" not in self._directions[:1]
|
||||
and _departure["direction"] not in self._directions
|
||||
):
|
||||
if "" not in self._lines[:1] and _departure["line"] not in self._lines:
|
||||
continue
|
||||
|
||||
if "" not in self._lines[:1] and _departure["linename"] not in self._lines:
|
||||
time_to_departure = _get_minutes_until_departure(_departure["time"])
|
||||
|
||||
if time_to_departure < self._timeoffset:
|
||||
continue
|
||||
|
||||
if _departure["time"] < self._timeoffset:
|
||||
continue
|
||||
|
||||
# now select the relevant data
|
||||
_nextdep = {}
|
||||
for k in ("destination", "linename", "time", "direction", "product"):
|
||||
for k in ("destination", "line", "type", "cancelled", "icon"):
|
||||
_nextdep[k] = _departure.get(k, "")
|
||||
_nextdep["time"] = int(_nextdep["time"])
|
||||
_nextdep["time_in_mins"] = time_to_departure
|
||||
self.departures.append(_nextdep)
|
||||
if i == self._number - 1:
|
||||
break
|
||||
|
@@ -11,7 +11,7 @@
|
||||
"_r_to_u": "City/county (R-U)",
|
||||
"_v_to_z": "City/county (V-Z)",
|
||||
"slots": "Maximum warnings per city/county",
|
||||
"headline_filter": "Blacklist regex to filter warning headlines"
|
||||
"headline_filter": "Headline blocklist"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
"_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]",
|
||||
"slots": "[%key:component::nina::config::step::user::data::slots%]",
|
||||
"headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]",
|
||||
"area_filter": "Whitelist regex to filter warnings based on affected areas"
|
||||
"area_filter": "Affected area filter"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -124,7 +124,7 @@ class NumberDeviceClass(StrEnum):
|
||||
CO = "carbon_monoxide"
|
||||
"""Carbon Monoxide gas concentration.
|
||||
|
||||
Unit of measurement: `ppm` (parts per million), mg/m³
|
||||
Unit of measurement: `ppm` (parts per million)
|
||||
"""
|
||||
|
||||
CO2 = "carbon_dioxide"
|
||||
@@ -475,10 +475,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
|
||||
NumberDeviceClass.BATTERY: {PERCENTAGE},
|
||||
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
|
||||
NumberDeviceClass.CO: {
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
|
||||
NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent),
|
||||
|
@@ -112,6 +112,9 @@
|
||||
"pm1": {
|
||||
"name": "[%key:component::sensor::entity_component::pm1::name%]"
|
||||
},
|
||||
"pm4": {
|
||||
"name": "[%key:component::sensor::entity_component::pm4::name%]"
|
||||
},
|
||||
"pm10": {
|
||||
"name": "[%key:component::sensor::entity_component::pm10::name%]"
|
||||
},
|
||||
|
@@ -129,10 +129,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo
|
||||
raise ConfigEntryAuthFailed
|
||||
except HoleError as err:
|
||||
if str(err) == "Authentication failed: Invalid password":
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise UpdateFailed(f"Failed to communicate with API: {err}") from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Pi-hole {name} at host {host}, reported an invalid password"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
f"Pi-hole {name} at host {host}, update failed with HoleError: {err}"
|
||||
) from err
|
||||
if not isinstance(api.data, dict):
|
||||
raise ConfigEntryAuthFailed
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Pi-hole {name} at host {host}, returned an unexpected response: {api.data}, assuming authentication failed"
|
||||
)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
|
@@ -5,7 +5,14 @@ from __future__ import annotations
|
||||
from pyportainer import Portainer
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_TOKEN,
|
||||
CONF_HOST,
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
@@ -19,11 +26,12 @@ type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
|
||||
"""Set up Portainer from a config entry."""
|
||||
|
||||
session = async_create_clientsession(hass)
|
||||
client = Portainer(
|
||||
api_url=entry.data[CONF_HOST],
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
session=session,
|
||||
api_url=entry.data[CONF_URL],
|
||||
api_key=entry.data[CONF_API_TOKEN],
|
||||
session=async_create_clientsession(
|
||||
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
|
||||
),
|
||||
)
|
||||
|
||||
coordinator = PortainerCoordinator(hass, entry, client)
|
||||
@@ -38,3 +46,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if entry.version < 2:
|
||||
data = dict(entry.data)
|
||||
data[CONF_URL] = data.pop(CONF_HOST)
|
||||
data[CONF_API_TOKEN] = data.pop(CONF_API_KEY)
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data, version=2)
|
||||
|
||||
if entry.version < 3:
|
||||
data = dict(entry.data)
|
||||
data[CONF_VERIFY_SSL] = True
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data, version=3)
|
||||
|
||||
return True
|
||||
|
@@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
|
||||
# Container ID's are ephemeral, so use the container name for the unique ID
|
||||
# The first one, should always be unique, it's fine if users have aliases
|
||||
# According to Docker's API docs, the first name is unique
|
||||
device_identifier = (
|
||||
self._device_info.names[0].replace("/", " ").strip()
|
||||
if self._device_info.names
|
||||
else None
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
@@ -14,7 +14,7 @@ from pyportainer import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -24,8 +24,9 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_TOKEN): str,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -34,9 +35,11 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
client = Portainer(
|
||||
api_url=data[CONF_HOST],
|
||||
api_key=data[CONF_API_KEY],
|
||||
session=async_get_clientsession(hass),
|
||||
api_url=data[CONF_URL],
|
||||
api_key=data[CONF_API_TOKEN],
|
||||
session=async_get_clientsession(
|
||||
hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
|
||||
),
|
||||
)
|
||||
try:
|
||||
await client.get_endpoints()
|
||||
@@ -47,19 +50,21 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
except PortainerTimeoutError as err:
|
||||
raise PortainerTimeout from err
|
||||
|
||||
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST])
|
||||
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])
|
||||
|
||||
|
||||
class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Portainer."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
try:
|
||||
await _validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
@@ -72,10 +77,10 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_API_KEY])
|
||||
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
title=user_input[CONF_URL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
|
@@ -16,7 +16,7 @@ from pyportainer.models.docker import DockerContainer
|
||||
from pyportainer.models.portainer import Endpoint
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -87,7 +87,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
|
||||
"""Fetch data from Portainer API."""
|
||||
_LOGGER.debug(
|
||||
"Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST]
|
||||
"Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL]
|
||||
)
|
||||
|
||||
try:
|
||||
|
@@ -60,7 +60,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}")
|
||||
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}")
|
||||
},
|
||||
manufacturer=DEFAULT_NAME,
|
||||
model="Container",
|
||||
|
@@ -3,14 +3,16 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_token": "[%key:common::config_flow::data::api_token%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The host/URL, including the port, of your Portainer instance",
|
||||
"api_key": "The API key for authenticating with Portainer"
|
||||
"url": "The URL, including the port, of your Portainer instance",
|
||||
"api_token": "The API access token for authenticating with Portainer",
|
||||
"verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate"
|
||||
},
|
||||
"description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**"
|
||||
"description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -114,6 +114,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
|
@@ -46,7 +46,6 @@ from homeassistant.util.unit_conversion import (
|
||||
AreaConverter,
|
||||
BaseUnitConverter,
|
||||
BloodGlucoseConcentrationConverter,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
ConductivityConverter,
|
||||
DataRateConverter,
|
||||
DistanceConverter,
|
||||
@@ -205,10 +204,6 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
|
||||
**dict.fromkeys(
|
||||
MassVolumeConcentrationConverter.VALID_UNITS, MassVolumeConcentrationConverter
|
||||
),
|
||||
**dict.fromkeys(
|
||||
CarbonMonoxideConcentrationConverter.VALID_UNITS,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
**dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter),
|
||||
**dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter),
|
||||
**dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter),
|
||||
|
@@ -19,7 +19,6 @@ from homeassistant.util.unit_conversion import (
|
||||
ApparentPowerConverter,
|
||||
AreaConverter,
|
||||
BloodGlucoseConcentrationConverter,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
ConductivityConverter,
|
||||
DataRateConverter,
|
||||
DistanceConverter,
|
||||
@@ -67,9 +66,6 @@ UNIT_SCHEMA = vol.Schema(
|
||||
vol.Optional("blood_glucose_concentration"): vol.In(
|
||||
BloodGlucoseConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("carbon_monoxide"): vol.In(
|
||||
CarbonMonoxideConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("concentration"): vol.In(
|
||||
MassVolumeConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
|
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.15.2"]
|
||||
"requirements": ["reolink-aio==0.16.0"]
|
||||
}
|
||||
|
@@ -351,13 +351,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
def _set_current_map(self) -> None:
|
||||
if (
|
||||
self.roborock_device_info.props.status is not None
|
||||
and self.roborock_device_info.props.status.map_status is not None
|
||||
and self.roborock_device_info.props.status.current_map is not None
|
||||
):
|
||||
# The map status represents the map flag as flag * 4 + 3 -
|
||||
# so we have to invert that in order to get the map flag that we can use to set the current map.
|
||||
self.current_map = (
|
||||
self.roborock_device_info.props.status.map_status - 3
|
||||
) // 4
|
||||
self.current_map = self.roborock_device_info.props.status.current_map
|
||||
|
||||
async def set_current_map_rooms(self) -> None:
|
||||
"""Fetch all of the rooms for the current map and set on RoborockMapInfo."""
|
||||
@@ -440,7 +436,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
# If either of these fail, we don't care, and we want to continue.
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
if len(self.maps) != 1:
|
||||
if len(self.maps) > 1:
|
||||
# Set the map back to the map the user previously had selected so that it
|
||||
# does not change the end user's app.
|
||||
# Only needs to happen when we changed maps above.
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiorussound"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiorussound==4.8.1"],
|
||||
"requirements": ["aiorussound==4.8.2"],
|
||||
"zeroconf": ["_rio._tcp.local."]
|
||||
}
|
||||
|
@@ -197,6 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo
|
||||
def _close(*_):
|
||||
controller.close()
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -239,3 +240,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bo
|
||||
controller.close()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: SatelConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
@@ -171,6 +171,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
@@ -178,6 +179,7 @@
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
|
@@ -51,7 +51,6 @@ from homeassistant.util.unit_conversion import (
|
||||
AreaConverter,
|
||||
BaseUnitConverter,
|
||||
BloodGlucoseConcentrationConverter,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
ConductivityConverter,
|
||||
DataRateConverter,
|
||||
DistanceConverter,
|
||||
@@ -157,7 +156,7 @@ class SensorDeviceClass(StrEnum):
|
||||
CO = "carbon_monoxide"
|
||||
"""Carbon Monoxide gas concentration.
|
||||
|
||||
Unit of measurement: `ppm` (parts per million), `mg/m³`
|
||||
Unit of measurement: `ppm` (parts per million)
|
||||
"""
|
||||
|
||||
CO2 = "carbon_dioxide"
|
||||
@@ -544,7 +543,6 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
|
||||
SensorDeviceClass.AREA: AreaConverter,
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
|
||||
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter,
|
||||
SensorDeviceClass.CO: CarbonMonoxideConcentrationConverter,
|
||||
SensorDeviceClass.CONDUCTIVITY: ConductivityConverter,
|
||||
SensorDeviceClass.CURRENT: ElectricCurrentConverter,
|
||||
SensorDeviceClass.DATA_RATE: DataRateConverter,
|
||||
@@ -586,10 +584,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
|
||||
SensorDeviceClass.BATTERY: {PERCENTAGE},
|
||||
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
|
||||
SensorDeviceClass.CO: {
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
|
||||
SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent),
|
||||
|
@@ -245,6 +245,9 @@
|
||||
"pm1": {
|
||||
"name": "PM1"
|
||||
},
|
||||
"pm4": {
|
||||
"name": "PM4"
|
||||
},
|
||||
"pm10": {
|
||||
"name": "PM10"
|
||||
},
|
||||
|
@@ -20,6 +20,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"charger_state": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"detected_objects": {
|
||||
"default": "mdi:account-group"
|
||||
},
|
||||
|
@@ -33,6 +33,7 @@ from homeassistant.const import (
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
@@ -121,6 +122,23 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
|
||||
return self.option_map[attribute_value]
|
||||
|
||||
|
||||
class RpcConsumedEnergySensor(RpcSensor):
|
||||
"""Represent a RPC sensor."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return value of sensor."""
|
||||
total_energy = self.status["aenergy"]["total"]
|
||||
|
||||
if not isinstance(total_energy, float):
|
||||
return None
|
||||
|
||||
if not isinstance(self.attribute_value, float):
|
||||
return None
|
||||
|
||||
return total_energy - self.attribute_value
|
||||
|
||||
|
||||
class RpcPresenceSensor(RpcSensor):
|
||||
"""Represent a RPC presence sensor."""
|
||||
|
||||
@@ -884,7 +902,7 @@ RPC_SENSORS: Final = {
|
||||
"energy": RpcSensorDescription(
|
||||
key="switch",
|
||||
sub_key="aenergy",
|
||||
name="Energy",
|
||||
name="Total energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
@@ -902,7 +920,22 @@ RPC_SENSORS: Final = {
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
removal_condition=lambda _config, status, key: (
|
||||
status[key].get("ret_aenergy") is None
|
||||
),
|
||||
),
|
||||
"consumed_energy_switch": RpcSensorDescription(
|
||||
key="switch",
|
||||
sub_key="ret_aenergy",
|
||||
name="Consumed energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_class=RpcConsumedEnergySensor,
|
||||
removal_condition=lambda _config, status, key: (
|
||||
status[key].get("ret_aenergy") is None
|
||||
),
|
||||
@@ -921,7 +954,7 @@ RPC_SENSORS: Final = {
|
||||
"energy_pm1": RpcSensorDescription(
|
||||
key="pm1",
|
||||
sub_key="aenergy",
|
||||
name="Energy",
|
||||
name="Total energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
@@ -932,7 +965,18 @@ RPC_SENSORS: Final = {
|
||||
"ret_energy_pm1": RpcSensorDescription(
|
||||
key="pm1",
|
||||
sub_key="ret_aenergy",
|
||||
name="Total active returned energy",
|
||||
name="Returned energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
"consumed_energy_pm1": RpcSensorDescription(
|
||||
key="pm1",
|
||||
sub_key="ret_aenergy",
|
||||
name="Consumed energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
@@ -940,6 +984,7 @@ RPC_SENSORS: Final = {
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_class=RpcConsumedEnergySensor,
|
||||
),
|
||||
"energy_cct": RpcSensorDescription(
|
||||
key="cct",
|
||||
@@ -1489,6 +1534,41 @@ RPC_SENSORS: Final = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
role="water_temperature",
|
||||
),
|
||||
"number_work_state": RpcSensorDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
translation_key="charger_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"charger_charging",
|
||||
"charger_end",
|
||||
"charger_fault",
|
||||
"charger_free",
|
||||
"charger_free_fault",
|
||||
"charger_insert",
|
||||
"charger_pause",
|
||||
"charger_wait",
|
||||
],
|
||||
role="work_state",
|
||||
),
|
||||
"number_energy_charge": RpcSensorDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
role="energy_charge",
|
||||
),
|
||||
"number_time_charge": RpcSensorDescription(
|
||||
key="number",
|
||||
sub_key="value",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_display_precision=0,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
role="time_charge",
|
||||
),
|
||||
"presence_num_objects": RpcSensorDescription(
|
||||
key="presence",
|
||||
sub_key="num_objects",
|
||||
|
@@ -141,6 +141,18 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"charger_state": {
|
||||
"state": {
|
||||
"charger_charging": "[%key:common::state::charging%]",
|
||||
"charger_end": "Charge completed",
|
||||
"charger_fault": "Error while charging",
|
||||
"charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]",
|
||||
"charger_free_fault": "Can not release plug",
|
||||
"charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]",
|
||||
"charger_pause": "Charging paused by charger",
|
||||
"charger_wait": "Charging paused by vehicle"
|
||||
}
|
||||
},
|
||||
"detected_objects": {
|
||||
"unit_of_measurement": "objects"
|
||||
},
|
||||
|
@@ -30,5 +30,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.2.9"]
|
||||
"requirements": ["pysmartthings==3.3.0"]
|
||||
}
|
||||
|
@@ -59,17 +59,12 @@ async def async_setup_entry(
|
||||
for select_data in SELECT_TYPES:
|
||||
if select_data.speaker_model == speaker.model_name.upper():
|
||||
if (
|
||||
state := getattr(speaker.soco, select_data.soco_attribute, None)
|
||||
) is not None:
|
||||
try:
|
||||
setattr(speaker, select_data.speaker_attribute, int(state))
|
||||
features.append(select_data)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Invalid value for %s %s",
|
||||
select_data.speaker_attribute,
|
||||
state,
|
||||
)
|
||||
speaker.update_soco_int_attribute(
|
||||
select_data.soco_attribute, select_data.speaker_attribute
|
||||
)
|
||||
is not None
|
||||
):
|
||||
features.append(select_data)
|
||||
return features
|
||||
|
||||
async def _async_create_entities(speaker: SonosSpeaker) -> None:
|
||||
@@ -112,8 +107,9 @@ class SonosSelectEntity(SonosEntity, SelectEntity):
|
||||
@soco_error()
|
||||
def poll_state(self) -> None:
|
||||
"""Poll the device for the current state."""
|
||||
state = getattr(self.soco, self.soco_attribute)
|
||||
setattr(self.speaker, self.speaker_attribute, state)
|
||||
self.speaker.update_soco_int_attribute(
|
||||
self.soco_attribute, self.speaker_attribute
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
|
@@ -275,6 +275,29 @@ class SonosSpeaker:
|
||||
"""Write states for associated SonosEntity instances."""
|
||||
async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
|
||||
|
||||
def update_soco_int_attribute(
|
||||
self, soco_attribute: str, speaker_attribute: str
|
||||
) -> int | None:
|
||||
"""Update an integer attribute from SoCo and set it on the speaker.
|
||||
|
||||
Returns the integer value if successful, otherwise None. Do not call from
|
||||
async context as it is a blocking function.
|
||||
"""
|
||||
value: int | None = None
|
||||
if (state := getattr(self.soco, soco_attribute, None)) is None:
|
||||
_LOGGER.error("Missing value for %s", speaker_attribute)
|
||||
else:
|
||||
try:
|
||||
value = int(state)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.error(
|
||||
"Invalid value for %s %s",
|
||||
speaker_attribute,
|
||||
state,
|
||||
)
|
||||
setattr(self, speaker_attribute, value)
|
||||
return value
|
||||
|
||||
#
|
||||
# Properties
|
||||
#
|
||||
|
@@ -125,6 +125,7 @@
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user