Merge branch 'dev' into gj-20260705-04

This commit is contained in:
G Johansson
2026-07-28 10:47:18 +02:00
committed by GitHub
669 changed files with 28873 additions and 2477 deletions
+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -130,7 +130,7 @@ jobs:
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -474,7 +474,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
@@ -497,7 +497,7 @@ jobs:
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
skip-existing: true
@@ -550,7 +550,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -41,7 +41,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
check-latest: true
+2 -2
View File
@@ -34,7 +34,7 @@
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6
#
@@ -1301,7 +1301,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
with:
extra-args: --all-files zizmor
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
category: "/language:python"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
package_json_file: tests/e2e/package.json
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Set up Python
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: ".python-version"
check-latest: true
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.21
rev: v0.15.22
hooks:
- id: ruff-check
args:
Generated
+7 -4
View File
@@ -441,6 +441,8 @@ CLAUDE.md @home-assistant/core
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/dyson_infrared/ @elax46
/tests/components/dyson_infrared/ @elax46
/homeassistant/components/eafm/ @Jc2k
/tests/components/eafm/ @Jc2k
/homeassistant/components/earn_e_p1/ @Miggets7
@@ -649,6 +651,8 @@ CLAUDE.md @home-assistant/core
/tests/components/geonetnz_quakes/ @exxamalte
/homeassistant/components/geonetnz_volcano/ @exxamalte
/tests/components/geonetnz_volcano/ @exxamalte
/homeassistant/components/geosphere_austria_warnings/ @tklecka
/tests/components/geosphere_austria_warnings/ @tklecka
/homeassistant/components/ghost/ @johnonolan
/tests/components/ghost/ @johnonolan
/homeassistant/components/gios/ @bieniu
@@ -899,6 +903,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/tests/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
/homeassistant/components/ios/ @robbiet480
@@ -1363,8 +1368,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/overseerr/ @joostlek @AmGarera @felixschndr
/tests/components/overseerr/ @joostlek @AmGarera @felixschndr
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001
@@ -1999,8 +2004,6 @@ CLAUDE.md @home-assistant/core
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos @svrooij
/tests/components/volvooncall/ @molobrakos @svrooij
/homeassistant/components/wake_on_lan/ @ntilley905
/tests/components/wake_on_lan/ @ntilley905
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
@@ -0,0 +1,5 @@
{
"domain": "geosphere_austria",
"name": "GeoSphere Austria",
"integrations": ["zamg", "geosphere_austria_warnings"]
}
@@ -7,7 +7,7 @@ from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
@@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
LOGGER.debug("Setting up coordinator for system: %s", system.serial)
await coordinator.async_config_entry_first_refresh()
system_coordinators[system.serial] = coordinator
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -36,12 +36,12 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
if self._api is None:
_LOGGER.debug("Initiating device authorization")
LOGGER.debug("Initiating device authorization")
self._api = ActronAirAPI()
try:
device_code_response = await self._api.request_device_code()
except ActronAirAuthError as err:
_LOGGER.error("OAuth2 flow failed: %s", err)
LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response.device_code
@@ -53,21 +53,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Wait for the user to authorize the device."""
assert self._api is not None
assert self._device_code is not None
_LOGGER.debug("Waiting for device authorization")
LOGGER.debug("Waiting for device authorization")
try:
await self._api.poll_for_token(self._device_code)
_LOGGER.debug("Authorization successful")
LOGGER.debug("Authorization successful")
except ActronAirAuthError as ex:
_LOGGER.exception("Error while waiting for device authorization")
LOGGER.exception("Error while waiting for device authorization")
raise CannotConnect from ex
_LOGGER.debug("Checking login task")
LOGGER.debug("Checking login task")
if self.login_task is None:
_LOGGER.debug("Creating task for device authorization")
LOGGER.debug("Creating task for device authorization")
self.login_task = self.hass.async_create_task(_wait_for_authorization())
if self.login_task.done():
_LOGGER.debug("Login task is done, checking results")
LOGGER.debug("Login task is done, checking results")
if exception := self.login_task.exception():
if isinstance(exception, CannotConnect):
return self.async_show_progress_done(
@@ -91,13 +91,13 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the finalization of login."""
_LOGGER.debug("Finalizing authorization")
LOGGER.debug("Finalizing authorization")
assert self._api is not None
try:
user_data = await self._api.get_user_info()
except ActronAirAuthError as err:
_LOGGER.error("Error getting user info: %s", err)
LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = user_data.sub
+1 -1
View File
@@ -2,5 +2,5 @@
import logging
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "actron_air"
@@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
@@ -50,7 +50,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
LOGGER,
name="Actron Air Status",
update_interval=SCAN_INTERVAL,
config_entry=entry,
@@ -6,7 +6,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
from .const import CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN, LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services
@@ -92,9 +92,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) ->
hass.config_entries.async_update_entry(entry, version=1, minor_version=3)
return True
_LOGGER.debug(
"Migrating from version %s.%s", entry.version, entry.minor_version
)
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
# Convert country in domain
country = entry.data[CONF_COUNTRY].lower()
@@ -108,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) ->
entry, data=new_data, version=1, minor_version=3
)
_LOGGER.info(
LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_update_unique_id
@@ -120,7 +120,7 @@ async def async_setup_entry(
if entity_id := entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set()
@@ -2,7 +2,7 @@
import logging
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
CONF_LOGIN_DATA = "login_data"
@@ -35,7 +35,7 @@ from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
from .const import CONF_LOGIN_DATA, DOMAIN, LOGGER
SCAN_INTERVAL = 300
@@ -115,12 +115,12 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Initialize the scanner."""
super().__init__(
hass,
_LOGGER,
LOGGER,
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=SCAN_INTERVAL, immediate=False
hass, LOGGER, cooldown=SCAN_INTERVAL, immediate=False
),
)
self.api = AmazonEchoApi(
@@ -232,7 +232,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
device_registry = dr.async_get(self.hass)
for serial_num in stale_devices:
_LOGGER.debug(
LOGGER.debug(
"Detected change in devices: serial %s removed",
serial_num,
)
@@ -259,7 +259,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
routine_unique_id,
)
if entity_id:
_LOGGER.debug(
LOGGER.debug(
"Detected change in routines: routine %s removed",
routine_unique_id.replace(
f"{slugify(self.config_entry.unique_id)}-", ""
@@ -281,7 +281,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
todo_list_unique_id,
)
if entity_id:
_LOGGER.debug(
LOGGER.debug(
"Detected change in todo lists: todo list entity %s removed",
entity_id,
)
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .const import LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import async_remove_entity_from_virtual_group
@@ -78,7 +78,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
self.device.serial_number
)
):
_LOGGER.debug(
LOGGER.debug(
"No vocal record found for device %s [%s]",
self.device.account_name,
self.device.serial_number,
@@ -21,7 +21,7 @@ from homeassistant.components.media_player import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .const import LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonEntity
@@ -239,7 +239,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
LOGGER.debug(
"Setting volume for %s to %s%%",
self.device.serial_number,
volume,
@@ -278,7 +278,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self._prev_volume = None
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
async with alexa_api_call(self.coordinator):
+11 -11
View File
@@ -16,7 +16,7 @@ from homeassistant.components.todo import (
)
from homeassistant.helpers.entity import EntityDescription
from .const import _LOGGER
from .const import LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonServiceEntity
@@ -83,7 +83,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
super().__init__(coordinator, entity_description)
_LOGGER.debug(
LOGGER.debug(
"Created todo entity for list: %s (ID: %s)", self._list.name, self._list.id
)
@@ -108,7 +108,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
@override
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list."""
_LOGGER.debug(
LOGGER.debug(
"Creating todo item: %s for list: %s", item.summary, self._list.name
)
@@ -120,7 +120,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
async with alexa_api_call(self.coordinator):
await self.coordinator.api.add_todo_list_item(self._list.id, item.summary)
_LOGGER.debug(
LOGGER.debug(
"Successfully created todo item: %s for list: %s",
item.summary,
self._list.name,
@@ -129,14 +129,14 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
@override
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete items from the to-do list."""
_LOGGER.debug("Called async_delete_todo_items for %s item(s)", len(uids))
LOGGER.debug("Called async_delete_todo_items for %s item(s)", len(uids))
list_items_lookup = self.coordinator.todo_list_items[self._list.id]
for uid in uids:
existing_item = list_items_lookup[uid]
_LOGGER.debug(
LOGGER.debug(
"Deleting item %s (ID: %s) with version %s",
existing_item.name,
uid,
@@ -146,7 +146,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
await self.coordinator.api.delete_todo_list_item(
self._list.id, uid, existing_item.version
)
_LOGGER.debug(
LOGGER.debug(
"Successfully deleted item %s (ID: %s) with version %s",
existing_item.name,
uid,
@@ -170,7 +170,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
existing_item.status == AmazonListItemStatus.COMPLETE
) != (item.status == TodoItemStatus.COMPLETED):
# Update the checked status
_LOGGER.debug(
LOGGER.debug(
"Updating item %s with checked status %s", item.uid, item.status
)
@@ -182,7 +182,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
existing_item.version,
)
_LOGGER.debug(
LOGGER.debug(
"Successfully updated item %s with checked status %s",
item.uid,
item.status,
@@ -190,7 +190,7 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
if existing_item.name != item.summary:
# Name has changed, update it
_LOGGER.debug("Updating item %s with new name %s", item.uid, item.summary)
LOGGER.debug("Updating item %s with new name %s", item.uid, item.summary)
# If both have changed -> Increase item version by 1
version = existing_item.version + int(has_completed_changed)
@@ -199,6 +199,6 @@ class AlexaToDoList(AmazonServiceEntity, TodoListEntity):
await self.coordinator.api.rename_todo_list_item(
self._list.id, item.uid, item.summary, version
)
_LOGGER.debug(
LOGGER.debug(
"Successfully updated item %s with new name %s", item.uid, item.summary
)
@@ -11,7 +11,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .const import DOMAIN, LOGGER
from .coordinator import AmazonDevicesCoordinator
@@ -30,7 +30,7 @@ async def async_update_unique_id(
if entity_id := entity_registry.async_get_entity_id(
platform, DOMAIN, unique_id
):
_LOGGER.debug("Updating unique_id for %s", entity_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
@@ -52,7 +52,7 @@ async def async_remove_entity_from_virtual_group(
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
async def async_remove_unsupported_notification_sensors(
@@ -76,4 +76,4 @@ async def async_remove_unsupported_notification_sensors(
if entity_id and is_unsupported:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
"quality_scale": "legacy",
"requirements": ["boto3==1.37.1"]
"requirements": ["boto3==1.42.97"]
}
@@ -6,6 +6,7 @@ from pyanglianwater.auth import MSOB2CAuth
from pyanglianwater.exceptions import (
ConsentRequiredError,
ExpiredAccessTokenError,
InvalidGrantError,
SelfAssertedError,
SmartMeterUnavailableError,
)
@@ -17,11 +18,16 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
from .helpers import async_create_consent_required_issue
_PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -41,8 +47,17 @@ async def async_setup_entry(
)
try:
await auth.send_refresh_request()
except (ConsentRequiredError, ExpiredAccessTokenError, SelfAssertedError) as err:
raise ConfigEntryAuthFailed from err
except ConsentRequiredError as err:
async_create_consent_required_issue(hass, entry.data[CONF_ACCOUNT_NUMBER])
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="consent_required",
) from err
except (ExpiredAccessTokenError, InvalidGrantError, SelfAssertedError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_expired",
) from err
_aw = AnglianWater(authenticator=auth)
@@ -37,7 +37,9 @@ async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
"""Validate the provided credentials."""
try:
await auth.send_login_request()
except ConsentRequiredError, SelfAssertedError:
except ConsentRequiredError:
return "consent_required"
except SelfAssertedError:
return "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
@@ -8,6 +8,7 @@ from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import (
ConsentRequiredError,
ExpiredAccessTokenError,
InvalidGrantError,
UnknownEndpointError,
)
@@ -25,11 +26,16 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
from .helpers import (
async_create_consent_required_issue,
async_delete_consent_required_issue,
)
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
@@ -64,12 +70,30 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
try:
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
except (
ConsentRequiredError,
ExpiredAccessTokenError,
UnknownEndpointError,
) as err:
raise UpdateFailed from err
except ConsentRequiredError as err:
async_create_consent_required_issue(
self.hass, self.config_entry.data[CONF_ACCOUNT_NUMBER]
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="consent_required",
retry_after=900.0,
) from err
except (ExpiredAccessTokenError, InvalidGrantError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_expired",
) from err
except UnknownEndpointError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="service_unavailable",
retry_after=60.0,
) from err
else:
async_delete_consent_required_issue(
self.hass, self.config_entry.data[CONF_ACCOUNT_NUMBER]
)
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
@@ -0,0 +1,42 @@
"""Helpers for the Anglian Water integration."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
LEARN_MORE_URL = "https://myaccount.anglianwater.co.uk/"
def consent_required_issue_id(account_number: str) -> str:
"""Return the repair issue id for a consent-required condition."""
return f"consent_required_{account_number}"
def async_create_consent_required_issue(
hass: HomeAssistant, account_number: str
) -> None:
"""Create a repair issue directing the user to accept updated terms."""
ir.async_create_issue(
hass,
DOMAIN,
consent_required_issue_id(account_number),
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="consent_required",
translation_placeholders={
CONF_ACCOUNT_NUMBER: account_number,
},
learn_more_url=LEARN_MORE_URL,
)
def async_delete_consent_required_issue(
hass: HomeAssistant, account_number: str
) -> None:
"""Delete the consent-required repair issue if it exists."""
ir.async_delete_issue(
hass,
DOMAIN,
consent_required_issue_id(account_number),
)
@@ -44,7 +44,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
test-coverage: done
# Gold
devices: done
@@ -78,10 +78,7 @@ rules:
comment: |
Entities do not require different icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
Read-only integration and no repairs are possible.
repair-issues: done
stale-devices: todo
# Platinum
async-dependency: done
@@ -5,6 +5,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"consent_required": "You need to accept the terms and conditions for your Anglian Water account before using this integration. Log in to their website for further information.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"smart_meter_unavailable": "This account does not have any smart meters associated with it. If this is unexpected, enter your Billing Account Number found at the top of your latest bill.",
"unknown": "[%key:common::config_flow::error::unknown%]"
@@ -55,11 +56,20 @@
"auth_expired": {
"message": "Authentication token expired"
},
"consent_required": {
"message": "The terms and conditions for your Anglian Water account have been updated. Please visit and log in to your Anglian Water account to accept the new terms and conditions."
},
"service_unavailable": {
"message": "Anglian Water services are currently unavailable for maintenance."
"message": "Anglian Water web services are currently unavailable, check the logs for more details."
},
"smart_meter_unavailable": {
"message": "This account no longer has a smart meter associated with it."
}
},
"issues": {
"consent_required": {
"description": "The terms and conditions for account {account_number} have been updated. Please visit and log in to your Anglian Water account to accept the new terms and conditions.\n\nThis issue will automatically resolve once the new conditions have been accepted and on the next refresh cycle.",
"title": "Anglian Water terms and conditions updated"
}
}
}
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
"quality_scale": "legacy",
"requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"]
"requirements": ["aiobotocore==3.7.0"]
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiobotocore"],
"quality_scale": "bronze",
"requirements": ["aiobotocore==2.21.1"]
"requirements": ["aiobotocore==3.7.0"]
}
@@ -52,16 +52,25 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
return f"{base_name}.tar", f"{base_name}.metadata.json"
# Other tools may write unrelated files ending in `.metadata.json` into the
# bucket; such files must be skipped rather than crashing the backup listing.
REQUIRED_METADATA_KEYS = frozenset({"metadata_version", "backup_id", "backup_metadata"})
def _parse_metadata(raw_content: str) -> dict[str, Any]:
"""Parse metadata content from JSON."""
"""Parse metadata content from JSON and validate its schema."""
try:
data = json.loads(raw_content)
except json.JSONDecodeError as err:
raise ValueError(f"Invalid JSON format: {err}") from err
else:
if not isinstance(data, dict):
raise TypeError("JSON content is not a dictionary")
return data
if not isinstance(data, dict):
raise TypeError("JSON content is not a dictionary")
missing = REQUIRED_METADATA_KEYS - data.keys()
if missing:
raise ValueError(
f"Missing required metadata keys: {', '.join(sorted(missing))}"
)
return data
def _find_backup_file_for_metadata(
@@ -557,7 +566,13 @@ class BackblazeBackupAgent(BackupAgent):
metadata_content = _parse_metadata(
download_response.content.decode("utf-8")
)
except ValueError:
except (TypeError, ValueError) as err:
_LOGGER.warning(
"Skipping metadata file %s: not a valid Backblaze B2 backup "
"metadata file (%s)",
file_name,
err,
)
return None, None
if metadata_content["backup_id"] != target_backup_id:
@@ -633,7 +648,13 @@ class BackblazeBackupAgent(BackupAgent):
metadata_content = _parse_metadata(
download_response.content.decode("utf-8")
)
except ValueError:
except (TypeError, ValueError) as err:
_LOGGER.warning(
"Skipping metadata file %s: not a valid Backblaze B2 backup "
"metadata file (%s)",
file_name,
err,
)
return None
found_backup_file = _find_backup_file_for_metadata(
@@ -1,6 +1,7 @@
"""The Bosch Smart Home Controller integration."""
import logging
from typing import TYPE_CHECKING
from boschshcpy import SHCSession
from boschshcpy.exceptions import SHCAuthenticationError, SHCConnectionError
@@ -47,6 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
raise ConfigEntryNotReady from err
shc_info = session.information
if TYPE_CHECKING:
assert shc_info is not None and shc_info.unique_id is not None
if shc_info.updateState.name == "UPDATE_AVAILABLE":
_LOGGER.warning("Please check for software updates in the Bosch Smart Home App")
@@ -1,8 +1,13 @@
"""Platform for binarysensor integration."""
from typing import override
from typing import TYPE_CHECKING, override
from boschshcpy import SHCBatteryDevice, SHCShutterContact
from boschshcpy import (
BatteryLevelService,
SHCBatteryDevice,
SHCShutterContact,
ShutterContactService,
)
from boschshcpy.device import SHCDevice
from homeassistant.components.binary_sensor import (
@@ -15,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschConfigEntry
from .entity import SHCEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -24,34 +31,38 @@ async def async_setup_entry(
"""Set up the SHC binary sensor platform."""
session = config_entry.runtime_data
shc_info = session.information
if TYPE_CHECKING:
assert shc_info is not None and shc_info.unique_id is not None
entities: list[BinarySensorEntity] = [
ShutterContactSensor(
device=binary_sensor,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
)
for binary_sensor in (
session.device_helper.shutter_contacts
+ session.device_helper.shutter_contacts2
*session.device_helper.shutter_contacts,
*session.device_helper.shutter_contacts2,
)
]
entities.extend(
BatterySensor(
device=binary_sensor,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
)
for binary_sensor in (
session.device_helper.motion_detectors
+ session.device_helper.shutter_contacts
+ session.device_helper.shutter_contacts2
+ session.device_helper.smoke_detectors
+ session.device_helper.thermostats
+ session.device_helper.twinguards
+ session.device_helper.universal_switches
+ session.device_helper.wallthermostats
+ session.device_helper.water_leakage_detectors
*session.device_helper.motion_detectors,
*session.device_helper.shutter_contacts,
*session.device_helper.shutter_contacts2,
*session.device_helper.smoke_detectors,
*session.device_helper.thermostats,
*session.device_helper.twinguards,
*session.device_helper.universal_switches,
*session.device_helper.wallthermostats,
*session.device_helper.water_leakage_detectors,
)
)
@@ -62,11 +73,12 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
"""Representation of an SHC shutter contact sensor."""
_attr_name = None
_device: SHCShutterContact
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
"""Initialize an SHC shutter contact sensor.."""
"""Initialize an SHC shutter contact sensor."""
super().__init__(device, parent_id, entry_id)
switcher = {
switcher: dict[str | None, BinarySensorDeviceClass] = {
"ENTRANCE_DOOR": BinarySensorDeviceClass.DOOR,
"REGULAR_WINDOW": BinarySensorDeviceClass.WINDOW,
"FRENCH_WINDOW": BinarySensorDeviceClass.DOOR,
@@ -80,13 +92,14 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
@override
def is_on(self) -> bool:
"""Return the state of the sensor."""
return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN
return self._device.state is ShutterContactService.State.OPEN
class BatterySensor(SHCEntity, BinarySensorEntity):
"""Representation of an SHC battery reporting sensor."""
_attr_device_class = BinarySensorDeviceClass.BATTERY
_device: SHCBatteryDevice
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
"""Initialize an SHC battery reporting sensor."""
@@ -97,6 +110,4 @@ class BatterySensor(SHCEntity, BinarySensorEntity):
@override
def is_on(self) -> bool:
"""Return the state of the sensor."""
return (
self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK
)
return self._device.batterylevel is not BatteryLevelService.State.OK
+12 -11
View File
@@ -1,8 +1,8 @@
"""Platform for cover integration."""
from typing import Any, override
from typing import TYPE_CHECKING, Any, override
from boschshcpy import SHCShutterControl
from boschshcpy import SHCShutterControl, ShutterControlService
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -16,6 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschConfigEntry
from .entity import SHCEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
@@ -25,10 +27,14 @@ async def async_setup_entry(
"""Set up the SHC cover platform."""
session = config_entry.runtime_data
shc_info = session.information
if TYPE_CHECKING:
assert shc_info is not None and shc_info.unique_id is not None
async_add_entities(
ShutterControlCover(
device=cover,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
)
for cover in session.device_helper.shutter_controls
@@ -40,6 +46,7 @@ class ShutterControlCover(SHCEntity, CoverEntity):
_attr_name = None
_attr_device_class = CoverDeviceClass.SHUTTER
_device: SHCShutterControl
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
@@ -68,19 +75,13 @@ class ShutterControlCover(SHCEntity, CoverEntity):
@override
def is_opening(self) -> bool:
"""Return if the cover is opening or not."""
return (
self._device.operation_state
== SHCShutterControl.ShutterControlService.State.OPENING
)
return self._device.operation_state is ShutterControlService.State.OPENING
@property
@override
def is_closing(self) -> bool:
"""Return if the cover is closing or not."""
return (
self._device.operation_state
== SHCShutterControl.ShutterControlService.State.CLOSING
)
return self._device.operation_state is ShutterControlService.State.CLOSING
@override
def open_cover(self, **kwargs: Any) -> None:
@@ -65,6 +65,8 @@ class SHCBaseEntity(Entity):
class SHCEntity(SHCBaseEntity):
"""Representation of a SHC device entity."""
_device: SHCDevice
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
"""Initialize generic SHC device."""
self._attr_unique_id = device.serial
@@ -105,6 +107,8 @@ class SHCEntity(SHCBaseEntity):
class SHCDomainEntity(SHCBaseEntity):
"""Representation of a SHC domain service entity."""
_device: SHCIntrusionSystem
def __init__(
self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str
) -> None:
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.3.5"],
"requirements": ["boschshcpy==0.6.4"],
"zeroconf": [
{
"name": "bosch shc*",
+188 -93
View File
@@ -2,8 +2,16 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from typing import TYPE_CHECKING, Any, override
from boschshcpy import (
SHCLightSwitchBSM,
SHCSmartPlug,
SHCSmartPlugCompact,
SHCThermostat,
SHCTwinguard,
SHCWallThermostat,
)
from boschshcpy.device import SHCDevice
from homeassistant.components.sensor import (
@@ -25,14 +33,21 @@ from homeassistant.helpers.typing import StateType
from . import BoschConfigEntry
from .entity import SHCEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class SHCSensorEntityDescription(SensorEntityDescription):
"""Describes a SHC sensor."""
class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription):
"""Describes a SHC sensor.
value_fn: Callable[[SHCDevice], StateType]
attributes_fn: Callable[[SHCDevice], dict[str, Any]] | None = None
Never share one instance across descriptions for different device types.
"""
value_fn: Callable[[_DeviceT], StateType]
attributes_fn: Callable[[_DeviceT], dict[str, Any]] | None = None
_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM
TEMPERATURE_SENSOR = "temperature"
HUMIDITY_SENSOR = "humidity"
@@ -46,68 +61,17 @@ POWER_SENSOR = "power"
ENERGY_SENSOR = "energy"
COMMUNICATION_QUALITY_SENSOR = "communication_quality"
SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
TEMPERATURE_SENSOR: SHCSensorEntityDescription(
_THERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = (
SHCSensorEntityDescription(
key=TEMPERATURE_SENSOR,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda device: device.temperature,
),
HUMIDITY_SENSOR: SHCSensorEntityDescription(
key=HUMIDITY_SENSOR,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
value_fn=lambda device: device.humidity,
),
PURITY_SENSOR: SHCSensorEntityDescription(
key=PURITY_SENSOR,
translation_key=PURITY_SENSOR,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
value_fn=lambda device: device.purity,
),
AIR_QUALITY_SENSOR: SHCSensorEntityDescription(
key=AIR_QUALITY_SENSOR,
translation_key="air_quality",
value_fn=lambda device: device.combined_rating.name,
attributes_fn=lambda device: {
"rating_description": device.description,
},
),
TEMPERATURE_RATING_SENSOR: SHCSensorEntityDescription(
key=TEMPERATURE_RATING_SENSOR,
translation_key=TEMPERATURE_RATING_SENSOR,
value_fn=lambda device: device.temperature_rating.name,
),
COMMUNICATION_QUALITY_SENSOR: SHCSensorEntityDescription(
key=COMMUNICATION_QUALITY_SENSOR,
translation_key=COMMUNICATION_QUALITY_SENSOR,
value_fn=lambda device: device.communicationquality.name,
),
HUMIDITY_RATING_SENSOR: SHCSensorEntityDescription(
key=HUMIDITY_RATING_SENSOR,
translation_key=HUMIDITY_RATING_SENSOR,
value_fn=lambda device: device.humidity_rating.name,
),
PURITY_RATING_SENSOR: SHCSensorEntityDescription(
key=PURITY_RATING_SENSOR,
translation_key=PURITY_RATING_SENSOR,
value_fn=lambda device: device.purity_rating.name,
),
POWER_SENSOR: SHCSensorEntityDescription(
key=POWER_SENSOR,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda device: device.powerconsumption,
),
ENERGY_SENSOR: SHCSensorEntityDescription(
key=ENERGY_SENSOR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda device: device.energyconsumption / 1000.0,
),
VALVE_TAPPET_SENSOR: SHCSensorEntityDescription(
)
)
_VALVE_TAPPET_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = (
SHCSensorEntityDescription(
key=VALVE_TAPPET_SENSOR,
translation_key=VALVE_TAPPET_SENSOR,
state_class=SensorStateClass.MEASUREMENT,
@@ -116,8 +80,122 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
attributes_fn=lambda device: {
"valve_tappet_state": device.valvestate.name,
},
),
}
)
)
_WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[
SHCWallThermostat
] = SHCSensorEntityDescription(
key=TEMPERATURE_SENSOR,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda device: device.temperature,
)
_WALLTHERMOSTAT_HUMIDITY_DESCRIPTION: SHCSensorEntityDescription[SHCWallThermostat] = (
SHCSensorEntityDescription(
key=HUMIDITY_SENSOR,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
value_fn=lambda device: device.humidity,
)
)
_TWINGUARD_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=TEMPERATURE_SENSOR,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda device: device.temperature,
)
)
_TWINGUARD_HUMIDITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=HUMIDITY_SENSOR,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
value_fn=lambda device: device.humidity,
)
)
_PURITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=PURITY_SENSOR,
translation_key=PURITY_SENSOR,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
value_fn=lambda device: device.purity,
)
)
_AIR_QUALITY_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=AIR_QUALITY_SENSOR,
translation_key="air_quality",
value_fn=lambda device: device.combined_rating.name,
attributes_fn=lambda device: {
"rating_description": device.description,
},
)
)
_TEMPERATURE_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=TEMPERATURE_RATING_SENSOR,
translation_key=TEMPERATURE_RATING_SENSOR,
value_fn=lambda device: device.temperature_rating.name,
)
)
_HUMIDITY_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=HUMIDITY_RATING_SENSOR,
translation_key=HUMIDITY_RATING_SENSOR,
value_fn=lambda device: device.humidity_rating.name,
)
)
_PURITY_RATING_DESCRIPTION: SHCSensorEntityDescription[SHCTwinguard] = (
SHCSensorEntityDescription(
key=PURITY_RATING_SENSOR,
translation_key=PURITY_RATING_SENSOR,
value_fn=lambda device: device.purity_rating.name,
)
)
_POWER_DESCRIPTION: SHCSensorEntityDescription[_PowerMeterDevice] = (
SHCSensorEntityDescription(
key=POWER_SENSOR,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda device: device.powerconsumption,
)
)
_ENERGY_DESCRIPTION: SHCSensorEntityDescription[_PowerMeterDevice] = (
SHCSensorEntityDescription(
key=ENERGY_SENSOR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda device: device.energyconsumption / 1000.0,
)
)
_COMPACT_POWER_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
SHCSensorEntityDescription(
key=POWER_SENSOR,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda device: device.powerconsumption,
)
)
_COMPACT_ENERGY_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
SHCSensorEntityDescription(
key=ENERGY_SENSOR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda device: device.energyconsumption / 1000.0,
)
)
_COMMUNICATION_QUALITY_DESCRIPTION: SHCSensorEntityDescription[SHCSmartPlugCompact] = (
SHCSensorEntityDescription(
key=COMMUNICATION_QUALITY_SENSOR,
translation_key=COMMUNICATION_QUALITY_SENSOR,
value_fn=lambda device: device.communicationquality.name,
)
)
async def async_setup_entry(
@@ -128,88 +206,105 @@ async def async_setup_entry(
"""Set up the SHC sensor platform."""
session = config_entry.runtime_data
shc_info = session.information
if TYPE_CHECKING:
assert shc_info is not None and shc_info.unique_id is not None
entities: list[SensorEntity] = [
SHCSensor(
device,
SENSOR_DESCRIPTIONS[sensor_type],
session.information.unique_id,
description,
shc_info.unique_id,
config_entry.entry_id,
)
for device in session.device_helper.thermostats
for sensor_type in (TEMPERATURE_SENSOR, VALVE_TAPPET_SENSOR)
for description in (
_THERMOSTAT_TEMPERATURE_DESCRIPTION,
_VALVE_TAPPET_DESCRIPTION,
)
]
entities.extend(
SHCSensor(
device,
SENSOR_DESCRIPTIONS[sensor_type],
session.information.unique_id,
description,
shc_info.unique_id,
config_entry.entry_id,
)
for device in session.device_helper.wallthermostats
for sensor_type in (TEMPERATURE_SENSOR, HUMIDITY_SENSOR)
for description in (
_WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION,
_WALLTHERMOSTAT_HUMIDITY_DESCRIPTION,
)
)
entities.extend(
SHCSensor(
device,
SENSOR_DESCRIPTIONS[sensor_type],
session.information.unique_id,
description,
shc_info.unique_id,
config_entry.entry_id,
)
for device in session.device_helper.twinguards
for sensor_type in (
TEMPERATURE_SENSOR,
HUMIDITY_SENSOR,
PURITY_SENSOR,
AIR_QUALITY_SENSOR,
TEMPERATURE_RATING_SENSOR,
HUMIDITY_RATING_SENSOR,
PURITY_RATING_SENSOR,
for description in (
_TWINGUARD_TEMPERATURE_DESCRIPTION,
_TWINGUARD_HUMIDITY_DESCRIPTION,
_PURITY_DESCRIPTION,
_AIR_QUALITY_DESCRIPTION,
_TEMPERATURE_RATING_DESCRIPTION,
_HUMIDITY_RATING_DESCRIPTION,
_PURITY_RATING_DESCRIPTION,
)
)
power_meter_devices: list[_PowerMeterDevice] = [
*session.device_helper.smart_plugs,
*session.device_helper.light_switches_bsm,
]
entities.extend(
SHCSensor(
device,
SENSOR_DESCRIPTIONS[sensor_type],
session.information.unique_id,
description,
shc_info.unique_id,
config_entry.entry_id,
)
for device in (
session.device_helper.smart_plugs + session.device_helper.light_switches_bsm
)
for sensor_type in (POWER_SENSOR, ENERGY_SENSOR)
for device in power_meter_devices
for description in (_POWER_DESCRIPTION, _ENERGY_DESCRIPTION)
)
entities.extend(
SHCSensor(
device,
SENSOR_DESCRIPTIONS[sensor_type],
session.information.unique_id,
description,
shc_info.unique_id,
config_entry.entry_id,
)
for device in session.device_helper.smart_plugs_compact
for sensor_type in (POWER_SENSOR, ENERGY_SENSOR, COMMUNICATION_QUALITY_SENSOR)
for description in (
_COMPACT_POWER_DESCRIPTION,
_COMPACT_ENERGY_DESCRIPTION,
_COMMUNICATION_QUALITY_DESCRIPTION,
)
)
async_add_entities(entities)
class SHCSensor(SHCEntity, SensorEntity):
class SHCSensor[_DeviceT: SHCDevice](SHCEntity, SensorEntity):
"""Representation of a SHC sensor."""
entity_description: SHCSensorEntityDescription
entity_description: SHCSensorEntityDescription[_DeviceT]
def __init__(
self,
device: SHCDevice,
entity_description: SHCSensorEntityDescription,
device: _DeviceT,
entity_description: SHCSensorEntityDescription[_DeviceT],
parent_id: str,
entry_id: str,
) -> None:
"""Initialize sensor."""
super().__init__(device, parent_id, entry_id)
self._device: _DeviceT = device
self.entity_description = entity_description
self._attr_unique_id = f"{device.serial}_{entity_description.key}"
+26 -20
View File
@@ -1,14 +1,14 @@
"""Platform for switch integration."""
from dataclasses import dataclass
from typing import Any, override
from enum import Enum
from typing import TYPE_CHECKING, Any, override
from boschshcpy import (
SHCCamera360,
SHCCameraEyes,
SHCLightSwitch,
CameraLightService,
PowerSwitchService,
PrivacyModeService,
SHCSmartPlug,
SHCSmartPlugCompact,
)
from boschshcpy.device import SHCDevice
@@ -20,18 +20,19 @@ from homeassistant.components.switch import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BoschConfigEntry
from .entity import SHCEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class SHCSwitchEntityDescription(SwitchEntityDescription):
"""Class describing SHC switch entities."""
on_key: str
on_value: StateType
on_value: Enum
should_poll: bool
@@ -40,35 +41,35 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = {
key="smartplug",
device_class=SwitchDeviceClass.OUTLET,
on_key="switchstate",
on_value=SHCSmartPlug.PowerSwitchService.State.ON,
on_value=PowerSwitchService.State.ON,
should_poll=False,
),
"smartplugcompact": SHCSwitchEntityDescription(
key="smartplugcompact",
device_class=SwitchDeviceClass.OUTLET,
on_key="switchstate",
on_value=SHCSmartPlugCompact.PowerSwitchService.State.ON,
on_value=PowerSwitchService.State.ON,
should_poll=False,
),
"lightswitch": SHCSwitchEntityDescription(
key="lightswitch",
device_class=SwitchDeviceClass.SWITCH,
on_key="switchstate",
on_value=SHCLightSwitch.PowerSwitchService.State.ON,
on_value=PowerSwitchService.State.ON,
should_poll=False,
),
"cameraeyes": SHCSwitchEntityDescription(
key="cameraeyes",
device_class=SwitchDeviceClass.SWITCH,
on_key="cameralight",
on_value=SHCCameraEyes.CameraLightService.State.ON,
on_value=CameraLightService.State.ON,
should_poll=True,
),
"camera360": SHCSwitchEntityDescription(
key="camera360",
device_class=SwitchDeviceClass.SWITCH,
on_key="privacymode",
on_value=SHCCamera360.PrivacyModeService.State.DISABLED,
on_value=PrivacyModeService.State.DISABLED,
should_poll=True,
),
}
@@ -82,10 +83,14 @@ async def async_setup_entry(
"""Set up the SHC switch platform."""
session = config_entry.runtime_data
shc_info = session.information
if TYPE_CHECKING:
assert shc_info is not None and shc_info.unique_id is not None
entities: list[SwitchEntity] = [
SHCSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["smartplug"],
)
@@ -95,7 +100,7 @@ async def async_setup_entry(
entities.extend(
SHCRoutingSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
)
for switch in session.device_helper.smart_plugs
@@ -104,7 +109,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["lightswitch"],
)
@@ -114,7 +119,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["smartplugcompact"],
)
@@ -124,7 +129,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["cameraeyes"],
)
@@ -134,7 +139,7 @@ async def async_setup_entry(
entities.extend(
SHCSwitch(
device=switch,
parent_id=session.information.unique_id,
parent_id=shc_info.unique_id,
entry_id=config_entry.entry_id,
description=SWITCH_TYPES["camera360"],
)
@@ -166,7 +171,7 @@ class SHCSwitch(SHCEntity, SwitchEntity):
"""Return the state of the switch."""
return (
getattr(self._device, self.entity_description.on_key)
== self.entity_description.on_value
is self.entity_description.on_value
)
@override
@@ -195,9 +200,10 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity):
_attr_translation_key = "routing"
_attr_entity_category = EntityCategory.CONFIG
_device: SHCSmartPlug
def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None:
"""Initialize an SHC communication quality reporting sensor."""
"""Initialize an SHC routing switch."""
super().__init__(device, parent_id, entry_id)
self._attr_unique_id = f"{device.serial}_routing"
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aiobotocore"],
"quality_scale": "bronze",
"requirements": ["aiobotocore==2.21.1"]
"requirements": ["aiobotocore==3.7.0"]
}
+3 -3
View File
@@ -6,7 +6,7 @@ from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platf
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN
from .const import CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -104,7 +104,7 @@ async def async_migrate_entry(
):
return None
_LOGGER.debug(
LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
@@ -119,7 +119,7 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(config_entry, version=1, minor_version=2)
_LOGGER.info(
LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
@@ -1,6 +1,5 @@
"""Support for Comelit VEDO system."""
import logging
from typing import TYPE_CHECKING, cast, override
from aiocomelit.api import ComelitVedoAreaObject
@@ -16,13 +15,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import LOGGER
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
AWAY = "away"
DISABLE = "disable"
HOME = "home"
@@ -120,7 +118,7 @@ class ComelitAlarmEntity(
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the alarm."""
_LOGGER.debug(
LOGGER.debug(
"Area %s status is: %s. Armed is %s",
self._area.name,
self._area.human_status,
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .const import CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN, LOGGER
from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
@@ -118,7 +118,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
@@ -161,7 +161,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
@@ -214,7 +214,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data_updates = {
+1 -1
View File
@@ -9,7 +9,7 @@ from aiocomelit.api import (
)
from aiocomelit.const import BRIDGE, VEDO
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
type ObjectClassType = (
ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject
@@ -32,7 +32,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, ObjectClassType
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, ObjectClassType
type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
@@ -63,7 +63,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
super().__init__(
hass=hass,
logger=_LOGGER,
logger=LOGGER,
config_entry=entry,
name=f"{DOMAIN}-{host}-coordinator",
update_interval=timedelta(seconds=SCAN_INTERVAL),
@@ -103,7 +103,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
@override
async def _async_update_data(self) -> T:
"""Update device data."""
_LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host)
LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host)
try:
await self.api.login()
return await self._async_update_system_data()
@@ -139,7 +139,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
for i in previous_list:
if i not in current_list:
_LOGGER.debug(
LOGGER.debug(
"Detected change in %s devices: index %s removed",
dev_type,
i,
+3 -3
View File
@@ -22,7 +22,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
from .const import _LOGGER, DOMAIN, ObjectClassType
from .const import DOMAIN, LOGGER, ObjectClassType
from .coordinator import ComelitBaseCoordinator
from .entity import ComelitBridgeBaseEntity
@@ -63,7 +63,7 @@ async def cleanup_stale_entity(
for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id):
if entry.unique_id == entry_unique_id:
entry_name = entry.name or entry.original_name
_LOGGER.info("Removing entity: %s [%s]", entry.entity_id, entry_name)
LOGGER.info("Removing entity: %s [%s]", entry.entity_id, entry_name)
entity_reg.async_remove(entry.entity_id)
identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}")
@@ -82,7 +82,7 @@ def _async_remove_state_config_entry_from_devices(
(DOMAIN, identifier), config_entry.entry_id
)
if device:
_LOGGER.info(
LOGGER.info(
"Removing config entry %s from device %s",
config_entry.title,
device.name,
@@ -37,6 +37,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.NUMBER,
Platform.REMOTE,
Platform.SELECT,
Platform.SENSOR,
Platform.SIREN,
@@ -52,6 +53,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
]
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.GEO_LOCATION,
Platform.TTS,
Platform.IMAGE_PROCESSING,
Platform.DEVICE_TRACKER,
+26 -19
View File
@@ -18,24 +18,24 @@ _LOGGER = logging.getLogger(__name__)
AVG_KM_PER_DEGREE = 111.0
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
MAX_RADIUS_IN_KM = 50
NUMBER_OF_DEMO_DEVICES = 5
NUMBER_OF_DEMO_DEVICES = 2
EVENT_NAMES = [
"Bushfire",
"Hazard Reduction",
"Grass Fire",
"Burn off",
"Structure Fire",
"Fire Alarm",
"Thunderstorm",
"Tornado",
"Cyclone",
"Waterspout",
"Dust Storm",
"Blizzard",
"Ice Storm",
"Earthquake",
"Tsunami",
EVENTS = [
{"name": "Bushfire", "icon": "mdi:fire"},
{"name": "Hazard Reduction", "icon": "mdi:alarm-light"},
{"name": "Grass Fire", "icon": "mdi:fire"},
{"name": "Burn off", "icon": "mdi:fire"},
{"name": "Structure Fire", "icon": "mdi:wall-fire"},
{"name": "Fire Alarm", "icon": "mdi:fire-truck"},
{"name": "Thunderstorm", "icon": "mdi:weather-lightning-rainy"},
{"name": "Tornado", "icon": "mdi:weather-tornado"},
{"name": "Cyclone", "icon": "mdi:weather-tornado"},
{"name": "Waterspout", "icon": "mdi:weather-tornado"},
{"name": "Dust Storm", "icon": "mdi:weather-dust"},
{"name": "Blizzard", "icon": "mdi:weather-snowy-heavy"},
{"name": "Ice Storm", "icon": "mdi:snowflake-alert"},
{"name": "Earthquake", "icon": "mdi:earth"},
{"name": "Tsunami", "icon": "mdi:waves-arrow-right"},
]
SOURCE = "demo"
@@ -78,9 +78,14 @@ class DemoManager:
radians(home_latitude)
)
event_name = random.choice(EVENT_NAMES)
event = random.choice(EVENTS)
return DemoGeolocationEvent(
event_name, radius_in_km, latitude, longitude, UnitOfLength.KILOMETERS
event["name"],
event["icon"],
radius_in_km,
latitude,
longitude,
UnitOfLength.KILOMETERS,
)
def _init_regular_updates(self) -> None:
@@ -120,6 +125,7 @@ class DemoGeolocationEvent(GeolocationEvent):
def __init__(
self,
name: str,
icon: str,
distance: float,
latitude: float,
longitude: float,
@@ -131,6 +137,7 @@ class DemoGeolocationEvent(GeolocationEvent):
self._latitude = latitude
self._longitude = longitude
self._unit_of_measurement = unit_of_measurement
self._attr_icon = icon
@property
@override
@@ -36,7 +36,11 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
@@ -226,6 +230,21 @@ async def async_setup_integration(
SERVICE_SEE,
)
warned_called_see = True
# Recreate the issue on every call so it reappears if the user
# confirmed (deleted) it while still using the deprecated action.
docs_url = "https://www.home-assistant.io/integrations/template/#device-tracker"
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_see_action",
breaks_in_ha_version="2027.5.0",
is_fixable=True,
is_persistent=True,
learn_more_url=docs_url,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_see_action",
translation_placeholders={"docs_url": docs_url},
)
# Temp workaround for iOS, introduced in 0.65
data = dict(call.data)
data.pop("hostname", None)
@@ -55,6 +55,17 @@
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
},
"deprecated_see_action": {
"fix_flow": {
"step": {
"confirm": {
"description": "The `device_tracker.see` action is deprecated and will be removed in Home Assistant Core 2027.5.\n\nUse a [template device tracker]({docs_url}) instead, then remove any use of this action from your automations and scripts and select **Submit** to close this issue.",
"title": "Detected use of deprecated action device_tracker.see"
}
}
},
"title": "Detected use of deprecated action device_tracker.see"
}
},
"services": {
+11 -10
View File
@@ -2,20 +2,19 @@
from collections.abc import Iterable
from datetime import timedelta
import logging
from typing import Any, override
from directv import DIRECTV, DIRECTVError
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DirecTVConfigEntry
from .const import DOMAIN
from .entity import DIRECTVEntity
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=2)
@@ -93,10 +92,12 @@ class DIRECTVRemote(DIRECTVEntity, RemoteEntity):
for single_command in command:
try:
await self.dtv.remote(single_command, self._address)
# pylint: disable-next=home-assistant-action-swallowed-exception
except DIRECTVError:
_LOGGER.exception(
"Sending command %s to device %s failed",
single_command,
self._device_id,
)
except DIRECTVError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_command_failed",
translation_placeholders={
"command": single_command,
"device": self._device_id,
},
) from err
@@ -21,5 +21,10 @@
}
}
}
},
"exceptions": {
"send_command_failed": {
"message": "Sending command {command} to device {device} failed."
}
}
}
@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.47.1", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.47.0"],
"requirements": ["async-upnp-client==0.47.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
@@ -5,7 +5,7 @@ import os
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import _LOGGER, CONF_DOWNLOAD_DIR
from .const import CONF_DOWNLOAD_DIR, LOGGER
from .services import async_setup_services
@@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
if not await hass.async_add_executor_job(os.path.isdir, download_path):
_LOGGER.error(
LOGGER.error(
"Download path %s does not exist. File Downloader not active", download_path
)
return False
@@ -9,7 +9,7 @@ from homeassistant import exceptions
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, CONF_DOWNLOAD_DIR, DEFAULT_NAME, DOMAIN
from .const import CONF_DOWNLOAD_DIR, DEFAULT_NAME, DOMAIN, LOGGER
class DownloaderConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -49,7 +49,7 @@ class DownloaderConfigFlow(ConfigFlow, domain=DOMAIN):
download_path = self.hass.config.path(download_path)
if not await self.hass.async_add_executor_job(os.path.isdir, download_path):
_LOGGER.error(
LOGGER.error(
"Download path %s does not exist. File Downloader not active",
download_path,
)
+1 -1
View File
@@ -2,7 +2,7 @@
import logging
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "downloader"
DEFAULT_NAME = "Downloader"
@@ -14,7 +14,6 @@ from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
from .const import (
_LOGGER,
ATTR_FILENAME,
ATTR_HEADERS,
ATTR_OVERWRITE,
@@ -24,6 +23,7 @@ from .const import (
DOMAIN,
DOWNLOAD_COMPLETED_EVENT,
DOWNLOAD_FAILED_EVENT,
LOGGER,
SERVICE_DOWNLOAD_FILE,
)
@@ -64,7 +64,7 @@ async def download_file(service: ServiceCall) -> None:
req = requests.get(url, stream=True, headers=headers, timeout=10)
if req.status_code != HTTPStatus.OK:
_LOGGER.warning(
LOGGER.warning(
"Downloading '%s' failed, status_code=%d", url, req.status_code
)
service.hass.bus.fire(
@@ -112,12 +112,12 @@ async def download_file(service: ServiceCall) -> None:
final_path = f"{path}_{tries}.{ext}"
_LOGGER.debug("%s -> %s", url, final_path)
LOGGER.debug("%s -> %s", url, final_path)
with open(final_path, "wb") as fil:
fil.writelines(req.iter_content(1024))
_LOGGER.debug("Downloading of %s done", url)
LOGGER.debug("Downloading of %s done", url)
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}",
{"url": url, "filename": filename},
+8 -2
View File
@@ -55,9 +55,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> b
try:
await client.get_account_info()
except DropboxAuthException as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from err
except (DropboxUnknownException, TimeoutError) as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = client
@@ -51,11 +51,13 @@ def handle_backup_errors[_R, **P](
try:
return await func(self, *args, **kwargs)
except DropboxFileOrFolderNotFoundException as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupNotFound(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
except DropboxAuthException as err:
self._entry.async_start_reauth(self._hass)
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupAgentError("Authentication error") from err
except DropboxUnknownException as err:
_LOGGER.error(
@@ -64,6 +66,7 @@ def handle_backup_errors[_R, **P](
err,
)
_LOGGER.debug("Full error: %s", err, exc_info=True)
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupAgentError(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
@@ -198,6 +201,7 @@ class DropboxBackupAgent(BackupAgent):
if backup.backup_id == backup_id:
return self._api.download_file(f"/{filename}")
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
@@ -214,6 +218,7 @@ class DropboxBackupAgent(BackupAgent):
if backup.backup_id == backup_id:
return backup
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
@@ -232,4 +237,5 @@ class DropboxBackupAgent(BackupAgent):
await self._api.delete_file(f"/{metadata_filename}")
return
# pylint: disable-next=home-assistant-exception-not-translated
raise BackupNotFound(f"Backup {backup_id} not found")
@@ -6,7 +6,11 @@ from typing import Any, override
from python_dropbox_api import DropboxAPIClient
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
@@ -46,16 +50,25 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
account_info = await client.get_account_info()
await self.async_set_unique_id(account_info.account_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
entry = (
self._get_reauth_entry()
if self.source == SOURCE_REAUTH
else self._get_reconfigure_entry()
)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(entry, data=data)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=account_info.email, data=data)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -102,11 +102,11 @@ rules:
entity-translations:
status: exempt
comment: Integration does not have any entities.
exception-translations: todo
exception-translations: done
icon-translations:
status: exempt
comment: Integration does not have any entities.
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: Integration does not have any repairs.
@@ -11,6 +11,7 @@
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with the correct account."
},
@@ -32,6 +33,12 @@
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication with Dropbox failed"
},
"cannot_connect": {
"message": "Error communicating with Dropbox"
},
"missing_refresh_token": {
"message": "[%key:component::dropbox::config::step::reauth_confirm::description%]"
},
+86 -6
View File
@@ -2,6 +2,7 @@
import asyncio
from functools import partial
import re
from typing import Any, override
from dsmr_parser import obis_references as obis_ref
@@ -23,6 +24,7 @@ from homeassistant.helpers.selector import SerialPortSelector
from .const import (
CONF_DSMR_VERSION,
CONF_ENCRYPTION_KEY,
CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE,
@@ -30,24 +32,36 @@ from .const import (
DOMAIN,
DSMR_PROTOCOL,
DSMR_VERSIONS,
DSMR_VERSIONS_WITHOUT_EQUIPMENT_ID,
ENCRYPTED_DSMR_VERSIONS,
LOGGER,
RFXTRX_DSMR_PROTOCOL,
)
ENCRYPTION_KEY_PATTERN = re.compile(r"[0-9a-fA-F]{32}")
class DSMRConnection:
"""Test the connection to DSMR and receive telegram to read serial ids."""
def __init__(self, port: str, dsmr_version: str, protocol: str) -> None:
def __init__(
self,
port: str,
dsmr_version: str,
protocol: str,
encryption_key: str = "",
) -> None:
"""Initialize."""
self._port = port
self._dsmr_version = dsmr_version
self._protocol = protocol
self._encryption_key = encryption_key
self._decryption_failed = False
self._telegram: dict[str, DSMRObject] = {}
self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER
if dsmr_version == "5B":
self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER
if dsmr_version in ("5L", "5EONHU"):
if dsmr_version in ("5L", "5EONHU", "MSn"):
self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER
if dsmr_version == "Q3D":
self._equipment_identifier = obis_ref.Q3D_EQUIPMENT_IDENTIFIER
@@ -75,13 +89,24 @@ class DSMRConnection:
if self._equipment_identifier in telegram:
self._telegram = telegram
transport.close()
# Swedish meters have no equipment identifier
if self._dsmr_version == "5S" and obis_ref.P1_MESSAGE_TIMESTAMP in telegram:
# Meters without an equipment identifier fall back to the timestamp
if (
self._dsmr_version in DSMR_VERSIONS_WITHOUT_EQUIPMENT_ID
and obis_ref.P1_MESSAGE_TIMESTAMP in telegram
):
self._telegram = telegram
transport.close()
# Only the standard DSMR reader supports encryption. authentication_key=
# None decrypts without verifying the GCM authentication tag; the
# telegram CRC still catches transmission errors (but not tampering).
key_kwargs: dict[str, Any] = {}
if self._protocol == DSMR_PROTOCOL:
create_reader = create_dsmr_reader
key_kwargs = {
"encryption_key": self._encryption_key,
"authentication_key": None,
}
else:
create_reader = create_rfxtrx_dsmr_reader
reader_factory = partial(
@@ -90,6 +115,7 @@ class DSMRConnection:
self._dsmr_version,
update_telegram,
loop=hass.loop,
**key_kwargs,
)
try:
@@ -108,8 +134,15 @@ class DSMRConnection:
# result in CannotCommunicate error)
transport.close()
await protocol.wait_closed()
# A wrong key closes the transport with a DecryptionError
if getattr(protocol, "decryption_error", None) is not None:
self._decryption_failed = True
return True
def decryption_failed(self) -> bool:
"""Return whether decryption failed (wrong key)."""
return self._decryption_failed
async def _validate_dsmr_connection(
hass: HomeAssistant, data: dict[str, Any], protocol: str
@@ -119,16 +152,23 @@ async def _validate_dsmr_connection(
data[CONF_PORT],
data[CONF_DSMR_VERSION],
protocol,
data.get(CONF_ENCRYPTION_KEY, ""),
)
if not await conn.validate_connect(hass):
raise CannotConnect
if conn.decryption_failed():
raise InvalidKey
equipment_identifier = conn.equipment_identifier()
equipment_identifier_gas = conn.equipment_identifier_gas()
# Check only for equipment identifier in case no gas meter is connected
if equipment_identifier is None and data[CONF_DSMR_VERSION] != "5S":
if (
equipment_identifier is None
and data[CONF_DSMR_VERSION] not in DSMR_VERSIONS_WITHOUT_EQUIPMENT_ID
):
raise CannotCommunicate
return {
@@ -142,6 +182,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_pending_data: dict[str, Any]
_pending_title: str
@staticmethod
@callback
@override
@@ -163,6 +206,11 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
"""
errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_DSMR_VERSION] in ENCRYPTED_DSMR_VERSIONS:
self._pending_data = user_input
self._pending_title = user_input[CONF_PORT]
return await self.async_step_encryption_key()
data = await self.async_validate_dsmr(user_input, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
@@ -179,6 +227,29 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_encryption_key(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask for the encryption key of an encrypted meter."""
errors: dict[str, str] = {}
if user_input is not None:
# DLMS uses an AES-128 key: 32 hex characters. Reject malformed keys
# here so the user gets immediate feedback instead of a timeout.
if ENCRYPTION_KEY_PATTERN.fullmatch(user_input[CONF_ENCRYPTION_KEY]):
data = await self.async_validate_dsmr(
{**self._pending_data, **user_input}, errors
)
if not errors:
return self.async_create_entry(title=self._pending_title, data=data)
else:
errors["base"] = "invalid_key"
return self.async_show_form(
step_id="encryption_key",
data_schema=vol.Schema({vol.Required(CONF_ENCRYPTION_KEY): str}),
errors=errors,
)
async def async_validate_dsmr(
self, input_data: dict[str, Any], errors: dict[str, str]
) -> dict[str, Any]:
@@ -190,6 +261,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
protocol = DSMR_PROTOCOL
info = await _validate_dsmr_connection(self.hass, data, protocol)
except CannotCommunicate:
# Encrypted meters don't support the RFXtrx fallback
if data[CONF_DSMR_VERSION] in ENCRYPTED_DSMR_VERSIONS:
raise
protocol = RFXTRX_DSMR_PROTOCOL
info = await _validate_dsmr_connection(self.hass, data, protocol)
@@ -202,6 +276,8 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except CannotCommunicate:
errors["base"] = "cannot_communicate"
except InvalidKey:
errors["base"] = "invalid_key"
return data
@@ -236,4 +312,8 @@ class CannotConnect(HomeAssistantError):
class CannotCommunicate(HomeAssistantError):
"""Error to indicate we cannot connect."""
"""Error to indicate we cannot communicate with the device."""
class InvalidKey(HomeAssistantError):
"""Error to indicate the decryption key is invalid."""
+23 -1
View File
@@ -11,6 +11,7 @@ LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.SENSOR]
CONF_DSMR_VERSION = "dsmr_version"
CONF_TIME_BETWEEN_UPDATE = "time_between_update"
CONF_ENCRYPTION_KEY = "encryption_key"
CONF_SERIAL_ID = "serial_id"
CONF_SERIAL_ID_GAS = "serial_id_gas"
@@ -26,7 +27,28 @@ DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter"
DEVICE_NAME_HEAT = "Heat Meter"
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D", "5EONHU"}
# Maps each dsmr_version token to the label shown in the config-flow picker; the
# label disambiguates the Luxembourg Smarty (MSn) from the Austrian Sagemcom.
# Labels are hardcoded rather than translated because the tokens (e.g. "2.2",
# "MSn") are not valid translation keys.
DSMR_VERSIONS = {
"5": "DSMR 5",
"MSn": "Luxembourg Smarty / Sagemcom T210-D, encrypted (Creos)",
"SAGEMCOM_T210_D_R": "Sagemcom T210-D-R, encrypted (Austria, Energienetze Steiermark)",
"5B": "DSMR 5B (Belgium, Fluvius)",
"5L": "DSMR 5L (Luxembourg, unencrypted)",
"5S": "DSMR 5S (Sweden)",
"Q3D": "Q3D (Austria)",
"5EONHU": "DSMR 5 (E.ON Hungary)",
"4": "DSMR 4",
"2.2": "DSMR 2.2",
}
# Versions with AES-128-GCM encrypted telegrams that require an encryption key.
ENCRYPTED_DSMR_VERSIONS = {"MSn", "SAGEMCOM_T210_D_R"}
# Versions whose telegrams carry no equipment identifier.
DSMR_VERSIONS_WITHOUT_EQUIPMENT_ID = {"5S", "SAGEMCOM_T210_D_R"}
DSMR_PROTOCOL = "dsmr_protocol"
RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol"
+5 -3
View File
@@ -2,10 +2,14 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from homeassistant.util.json import json_loads
from . import DsmrConfigEntry
from .const import CONF_ENCRYPTION_KEY
TO_REDACT = {CONF_ENCRYPTION_KEY}
async def async_get_config_entry_diagnostics(
@@ -15,9 +19,7 @@ async def async_get_config_entry_diagnostics(
return {
"entry": {
"data": {
**config_entry.data,
},
"data": async_redact_data(config_entry.data, TO_REDACT),
"unique_id": config_entry.unique_id,
},
"data": json_loads(config_entry.runtime_data.telegram.to_json())
+67 -16
View File
@@ -49,6 +49,7 @@ from homeassistant.util import Throttle
from . import DsmrConfigEntry
from .const import (
CONF_DSMR_VERSION,
CONF_ENCRYPTION_KEY,
CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE,
@@ -116,7 +117,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_active_tariff",
translation_key="electricity_active_tariff",
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
dsmr_versions={
"2.2",
"4",
"5",
"5B",
"5L",
"5EONHU",
"MSn",
"SAGEMCOM_T210_D_R",
},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
),
@@ -124,7 +134,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_used_tariff_1",
translation_key="electricity_used_tariff_1",
obis_reference="ELECTRICITY_USED_TARIFF_1",
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
dsmr_versions={
"2.2",
"4",
"5",
"5B",
"5L",
"5EONHU",
"MSn",
"SAGEMCOM_T210_D_R",
},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -132,7 +151,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_used_tariff_2",
translation_key="electricity_used_tariff_2",
obis_reference="ELECTRICITY_USED_TARIFF_2",
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
dsmr_versions={
"2.2",
"4",
"5",
"5B",
"5L",
"5EONHU",
"MSn",
"SAGEMCOM_T210_D_R",
},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -158,7 +186,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_delivered_tariff_1",
translation_key="electricity_delivered_tariff_1",
obis_reference="ELECTRICITY_DELIVERED_TARIFF_1",
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
dsmr_versions={
"2.2",
"4",
"5",
"5B",
"5L",
"5EONHU",
"MSn",
"SAGEMCOM_T210_D_R",
},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -166,7 +203,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_delivered_tariff_2",
translation_key="electricity_delivered_tariff_2",
obis_reference="ELECTRICITY_DELIVERED_TARIFF_2",
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
dsmr_versions={
"2.2",
"4",
"5",
"5B",
"5L",
"5EONHU",
"MSn",
"SAGEMCOM_T210_D_R",
},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -240,7 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="short_power_failure_count",
translation_key="short_power_failure_count",
obis_reference="SHORT_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -249,7 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="long_power_failure_count",
translation_key="long_power_failure_count",
obis_reference="LONG_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -258,7 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_sag_l1_count",
translation_key="voltage_sag_l1_count",
obis_reference="VOLTAGE_SAG_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -267,7 +313,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_sag_l2_count",
translation_key="voltage_sag_l2_count",
obis_reference="VOLTAGE_SAG_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -276,7 +322,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_sag_l3_count",
translation_key="voltage_sag_l3_count",
obis_reference="VOLTAGE_SAG_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -285,7 +331,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_swell_l1_count",
translation_key="voltage_swell_l1_count",
obis_reference="VOLTAGE_SWELL_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -294,7 +340,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_swell_l2_count",
translation_key="voltage_swell_l2_count",
obis_reference="VOLTAGE_SWELL_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -303,7 +349,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="voltage_swell_l3_count",
translation_key="voltage_swell_l3_count",
obis_reference="VOLTAGE_SWELL_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
dsmr_versions={"2.2", "4", "5", "5L", "MSn"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -386,7 +432,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_imported_total",
translation_key="electricity_imported_total",
obis_reference="ELECTRICITY_IMPORTED_TOTAL",
dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
dsmr_versions={"5L", "5S", "Q3D", "5EONHU", "MSn", "SAGEMCOM_T210_D_R"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -394,7 +440,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_exported_total",
translation_key="electricity_exported_total",
obis_reference="ELECTRICITY_EXPORTED_TOTAL",
dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
dsmr_versions={"5L", "5S", "Q3D", "5EONHU", "MSn", "SAGEMCOM_T210_D_R"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -418,7 +464,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="hourly_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference="HOURLY_GAS_METER_READING",
dsmr_versions={"4", "5", "5L"},
dsmr_versions={"4", "5", "5L", "MSn"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -815,12 +861,17 @@ async def async_setup_entry(
# create_dsmr_reader opens both local devices and any URL (socket://,
# esphome://, ...); the only difference is the keep-alive watchdog.
keep_alive = {} if port.startswith("/") else {"keep_alive_interval": 60}
# authentication_key=None decrypts without verifying the GCM
# authentication tag; an empty encryption key leaves plain telegrams
# untouched.
reader_factory = partial(
create_dsmr_reader,
port,
dsmr_version,
update_entities_telegram,
loop=hass.loop,
encryption_key=entry.data.get(CONF_ENCRYPTION_KEY, ""),
authentication_key=None,
**keep_alive,
)
+11 -1
View File
@@ -8,9 +8,19 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_communicate": "Failed to communicate",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_key": "Failed to decrypt the telegram, check the encryption key"
},
"step": {
"encryption_key": {
"data": {
"encryption_key": "Encryption key"
},
"data_description": {
"encryption_key": "The encryption key of your meter, provided by your grid operator (32 hexadecimal characters)"
},
"title": "Encryption key"
},
"user": {
"data": {
"dsmr_version": "Select DSMR version",
@@ -0,0 +1,18 @@
"""Dyson Infrared integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.FAN]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Dyson Infrared from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a Dyson Infrared config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,84 @@
"""Config flow for Dyson Infrared integration."""
from typing import Any, override
import voluptuous as vol
from homeassistant.components import infrared
from homeassistant.components.infrared import DOMAIN as INFRARED_DOMAIN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_EMITTER_ENTITY_ID,
DOMAIN,
DysonDeviceType,
)
DEVICE_TYPE_NAMES: dict[DysonDeviceType, str] = {
DysonDeviceType.FAN: "Fan",
}
class DysonIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Dyson Infrared."""
VERSION = 1
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step where the user configures the integration."""
emitter_entity_ids = infrared.async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID]
device_type = user_input[CONF_DEVICE_TYPE]
await self.async_set_unique_id(f"{device_type}_{entity_id}")
self._abort_if_unique_id_configured()
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(entity_id)
entity_name = (
entry.name or entry.original_name or entity_id if entry else entity_id
)
device_type_name = DEVICE_TYPE_NAMES[DysonDeviceType(device_type)]
title = f"Dyson {device_type_name} via {entity_name}"
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
SelectSelectorConfig(
options=[
device_type.value for device_type in DysonDeviceType
],
translation_key=CONF_DEVICE_TYPE,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
}
),
)
@@ -0,0 +1,13 @@
"""Constants for the Dyson Infrared integration."""
from enum import StrEnum
DOMAIN = "dyson_infrared"
CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id"
CONF_DEVICE_TYPE = "device_type"
class DysonDeviceType(StrEnum):
"""Supported Dyson device types."""
FAN = "fan"
@@ -0,0 +1,141 @@
"""Support for Dyson infrared fans."""
import asyncio
from typing import Any, override
from infrared_protocols.codes.dyson.cool import DysonCoolCode
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_INFRARED_EMITTER_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
_SPEED_STEP_DELAY = 0.2
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Dyson infrared fan platform from a config entry."""
infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
async_add_entities(
[DysonInfraredFan(infrared_emitter_entity_id, entry.entry_id, entry.title)]
)
class DysonInfraredFan(InfraredEmitterConsumerEntity, FanEntity):
"""Representation of a Dyson infrared fan entity."""
_attr_translation_key = "fan"
_attr_has_entity_name = True
_attr_speed_count = 10
_attr_assumed_state = True
_attr_supported_features = (
FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.SET_SPEED
| FanEntityFeature.OSCILLATE
)
def __init__(
self, infrared_emitter_entity_id: str, unique_id: str, name: str
) -> None:
"""Initialize the Dyson infrared fan entity."""
self._infrared_emitter_entity_id = infrared_emitter_entity_id
self._attr_unique_id = unique_id
self._attr_percentage = 50
self._attr_is_on = False
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=name,
)
@property
@override
def is_on(self) -> bool:
"""Return true if the fan is on."""
return self._attr_is_on or False
async def _async_send_dyson_action(self, code: DysonCoolCode) -> None:
command = code.to_command()
await self._send_command(command)
def _percentage_to_speed(self, percentage: int) -> int:
"""Convert a percentage into a discrete speed level (1..speed_count)."""
step_size = 100 / self._attr_speed_count
return max(1, min(self._attr_speed_count, round(percentage / step_size)))
def _speed_to_percentage(self, speed: int) -> int:
"""Convert a discrete speed level back to its normalized percentage."""
step_size = 100 / self._attr_speed_count
return round(speed * step_size)
@override
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
await self._async_send_dyson_action(DysonCoolCode.ON)
self._attr_is_on = True
self.async_write_ha_state()
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._async_send_dyson_action(DysonCoolCode.OFF)
self._attr_is_on = False
self.async_write_ha_state()
@override
async def async_set_percentage(self, percentage: int) -> None:
"""Set the fan speed percentage."""
if percentage == 0:
await self.async_turn_off()
return
target_speed = self._percentage_to_speed(percentage)
normalized_percentage = self._speed_to_percentage(target_speed)
current_speed = self._percentage_to_speed(self._attr_percentage or 0)
if target_speed == current_speed and self._attr_is_on:
return
if target_speed == current_speed:
# Fan is off but already at the requested speed level: just turn it on.
await self._async_send_dyson_action(DysonCoolCode.ON)
else:
code = (
DysonCoolCode.SPEED_UP
if target_speed > current_speed
else DysonCoolCode.SPEED_DOWN
)
for _ in range(abs(target_speed - current_speed)):
await self._async_send_dyson_action(code)
await asyncio.sleep(_SPEED_STEP_DELAY)
self._attr_percentage = normalized_percentage
self._attr_is_on = True
self.async_write_ha_state()
@override
async def async_oscillate(self, oscillating: bool) -> None:
"""Set the oscillation state of the fan."""
await self._async_send_dyson_action(DysonCoolCode.SWING)
self._attr_oscillating = oscillating
self.async_write_ha_state()
@@ -0,0 +1,11 @@
{
"domain": "dyson_infrared",
"name": "Dyson Infrared",
"codeowners": ["@elax46"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/dyson_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -0,0 +1,119 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
Conditions are inherited from the infrared dependency.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
docs-conditions:
status: exempt
comment: |
Conditions are inherited from the infrared dependency.
docs-triggers:
status: exempt
comment: |
Triggers are inherited from the infrared dependency.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
Triggers are inherited from the infrared dependency.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no connection to test in the config flow.
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No entities should be disabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration has no external dependencies.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo
@@ -0,0 +1,36 @@
{
"config": {
"abort": {
"already_configured": "This device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"device_type": "Device type",
"infrared_emitter_entity_id": "Infrared transmitter"
},
"data_description": {
"device_type": "The type of device to control.",
"infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands."
},
"description": "Select the device type and the infrared transmitter entity to use for controlling your device.",
"title": "Set up Infrared Remote"
}
}
},
"entity": {
"fan": {
"fan": {
"name": "Dyson Fan"
}
}
},
"selector": {
"device_type": {
"options": {
"fan": "Fan"
}
}
}
}
+22 -11
View File
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -119,23 +119,34 @@ async def async_setup_entry(
) -> None:
"""Set up EARN-E P1 sensor entities."""
coordinator = entry.runtime_data
added_keys: set[str] = set()
pending_keys = {description.key for description in SENSOR_DESCRIPTIONS}
remove_listener: CALLBACK_TYPE | None = None
@callback
def _async_remove_listener() -> None:
nonlocal remove_listener
if remove_listener is not None:
remove_listener()
remove_listener = None
@callback
def _async_add_sensors() -> None:
if coordinator.data is None:
return
new_entities = [
EarnEP1Sensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if description.key in coordinator.data and description.key not in added_keys
]
if new_entities:
added_keys.update(entity.entity_description.key for entity in new_entities)
async_add_entities(new_entities)
if new_keys := pending_keys & coordinator.data.keys():
pending_keys.difference_update(new_keys)
async_add_entities(
EarnEP1Sensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if description.key in new_keys
)
if not pending_keys:
_async_remove_listener()
entry.async_on_unload(coordinator.async_add_listener(_async_add_sensors))
_async_add_sensors()
if pending_keys:
remove_listener = coordinator.async_add_listener(_async_add_sensors)
entry.async_on_unload(_async_remove_listener)
class EarnEP1Sensor(EarnEP1Entity, SensorEntity):
+7 -7
View File
@@ -26,7 +26,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import _LOGGER, CONF_REFRESH_TOKEN, DOMAIN, PLATFORMS
from .const import CONF_REFRESH_TOKEN, DOMAIN, LOGGER, PLATFORMS
from .services import async_setup_services
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
@@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bo
await runtime_data.update()
if runtime_data.ecobee.thermostats is None:
_LOGGER.error("No ecobee devices found to set up")
LOGGER.error("No ecobee devices found to set up")
return False
entry.runtime_data = runtime_data
@@ -116,14 +116,14 @@ class EcobeeData:
"""Get the latest data from ecobee.com."""
try:
await self._hass.async_add_executor_job(self.ecobee.update)
_LOGGER.debug("Updating ecobee")
LOGGER.debug("Updating ecobee")
except ExpiredTokenError:
_LOGGER.debug("Refreshing expired ecobee tokens")
LOGGER.debug("Refreshing expired ecobee tokens")
await self.refresh()
async def refresh(self) -> bool:
"""Refresh ecobee tokens and update config entry."""
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
LOGGER.debug("Refreshing ecobee tokens and updating config entry")
try:
success = await self._hass.async_add_executor_job(
self.ecobee.refresh_tokens
@@ -144,7 +144,7 @@ class EcobeeData:
translation_key="credentials_rejected",
) from err
except EcobeeAuthUnknownError:
_LOGGER.exception("Unexpected error refreshing ecobee tokens")
LOGGER.exception("Unexpected error refreshing ecobee tokens")
return False
if success:
@@ -167,7 +167,7 @@ class EcobeeData:
data=data,
)
return True
_LOGGER.error("Error refreshing ecobee tokens")
LOGGER.error("Error refreshing ecobee tokens")
return False
+13 -13
View File
@@ -40,12 +40,12 @@ from homeassistant.util.unit_conversion import TemperatureConverter
from . import EcobeeConfigEntry, EcobeeData
from .const import (
_LOGGER,
ATTR_ACTIVE_SENSORS,
ATTR_AVAILABLE_SENSORS,
DOMAIN,
ECOBEE_AUX_HEAT_ONLY,
ECOBEE_MODEL_TO_NAME,
LOGGER,
MANUFACTURER,
)
from .services import (
@@ -154,7 +154,7 @@ async def async_setup_entry(
for index in range(len(data.ecobee.thermostats)):
thermostat = data.ecobee.get_thermostat(index)
if thermostat["modelNumber"] not in ECOBEE_MODEL_TO_NAME:
_LOGGER.error(
LOGGER.error(
(
"Model number for ecobee thermostat %s not recognized. "
"Please visit this link to open a new issue: "
@@ -565,7 +565,7 @@ class Thermostat(ClimateEntity):
preset_mode = climate_ref
break
else:
_LOGGER.warning("Received unknown preset mode: %s", preset_mode)
LOGGER.warning("Received unknown preset mode: %s", preset_mode)
self.data.ecobee.set_climate_hold(
self.thermostat_index,
@@ -612,7 +612,7 @@ class Thermostat(ClimateEntity):
self.hold_preference(),
self.hold_hours(),
)
_LOGGER.debug(
LOGGER.debug(
"Setting ecobee hold_temp to: heat=%s, is=%s, cool=%s, is=%s",
heat_temp,
isinstance(heat_temp, (int, float)),
@@ -634,7 +634,7 @@ class Thermostat(ClimateEntity):
"""
if fan_mode.lower() not in (FAN_ON, FAN_AUTO):
error = "Invalid fan_mode value: Valid values are 'on' or 'auto'"
_LOGGER.error(error)
LOGGER.error(error)
return
cool_temp = self.thermostat["runtime"]["desiredCool"] / 10.0
@@ -649,7 +649,7 @@ class Thermostat(ClimateEntity):
heatHoldTemp=heat_temp,
)
_LOGGER.debug(
LOGGER.debug(
"Setting fan mode to: %s (preserving heat=%s cool=%s)",
fan_mode,
heat_temp,
@@ -691,7 +691,7 @@ class Thermostat(ClimateEntity):
elif temp is not None:
self.set_temp_hold(temp)
else:
_LOGGER.error("Missing valid arguments for set_temperature in %s", kwargs)
LOGGER.error("Missing valid arguments for set_temperature in %s", kwargs)
@override
def set_humidity(self, humidity: int) -> None:
@@ -709,7 +709,7 @@ class Thermostat(ClimateEntity):
"""Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
ecobee_value = HASS_TO_ECOBEE_HVAC.get(hvac_mode)
if ecobee_value is None:
_LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode)
LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode)
return
self.data.ecobee.set_hvac_mode(self.thermostat_index, ecobee_value)
self.update_without_throttle = True
@@ -795,7 +795,7 @@ class Thermostat(ClimateEntity):
# Check if sensors are currently used on the climate for the thermostat.
current_sensors_in_climate = self._sensors_in_preset_mode(preset_mode)
if set(sensor_names) == set(current_sensors_in_climate):
_LOGGER.debug(
LOGGER.debug(
"This action would not be an update, current sensors"
" on climate (%s) are: %s",
preset_mode,
@@ -803,7 +803,7 @@ class Thermostat(ClimateEntity):
)
return
_LOGGER.debug(
LOGGER.debug(
"Setting sensors %s to be used on thermostat %s for program %s",
sensor_names,
self.device_info.get("name"),
@@ -899,7 +899,7 @@ class Thermostat(ClimateEntity):
if value is not None
}
_LOGGER.debug(
LOGGER.debug(
(
"Creating a vacation on thermostat %s with name %s, cool temp %s, heat"
" temp %s, and the following other parameters: %s"
@@ -916,7 +916,7 @@ class Thermostat(ClimateEntity):
def delete_vacation(self, vacation_name):
"""Delete a vacation with the specified name."""
_LOGGER.debug(
LOGGER.debug(
"Deleting a vacation on thermostat %s with name %s",
self.name,
vacation_name,
@@ -926,7 +926,7 @@ class Thermostat(ClimateEntity):
@override
def turn_on(self) -> None:
"""Set the thermostat to the last active HVAC mode."""
_LOGGER.debug(
LOGGER.debug(
"Turning on ecobee thermostat %s in %s mode",
self.name,
self._last_active_hvac_mode,
+1 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.components.weather import (
)
from homeassistant.const import Platform
_LOGGER = logging.getLogger(__package__)
LOGGER = logging.getLogger(__package__)
DOMAIN = "ecobee"
ATTR_AVAILABLE_SENSORS = "available_sensors"
+6
View File
@@ -172,6 +172,9 @@ class BatterySourceType(TypedDict):
# statistic_id of a sensor (unit %) reporting the battery state of charge
stat_soc: NotRequired[str]
# usable capacity in kWh, used to weight the combined state of charge
capacity: NotRequired[float]
# An optional custom name for display in energy graphs
name: NotRequired[str]
@@ -506,6 +509,9 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Optional("stat_soc"): str,
vol.Optional("capacity"): vol.All(
vol.Coerce(float), vol.Range(min=0, min_included=False)
),
vol.Optional("name"): str,
}
)
@@ -57,6 +57,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b
serial_number=envoy.serial_number,
)
# register the ACB aggregate device before the individual batteries reference
# it as via_device, so they nest under it in the device hierarchy
if (envoy_data := coordinator.envoy.data) and envoy_data.acb_inventory:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{envoy.serial_number}_acb")},
manufacturer="Enphase",
model="ACB",
name=f"ACB {envoy.serial_number}",
via_device=(DOMAIN, envoy.serial_number),
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -95,4 +107,10 @@ async def async_remove_config_entry_device(
if envoy_data.enpower:
if str(envoy_data.enpower.serial_number) in dev_ids:
return False
if envoy_data.acb_inventory:
if f"{envoy_serial_num}_acb" in dev_ids:
return False
for acb_serial in envoy_data.acb_inventory:
if str(acb_serial) in dev_ids:
return False
return True
@@ -6,6 +6,7 @@ from operator import attrgetter
from typing import override
from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower
from pyenphase.models.acb import EnvoyACB
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -19,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyACBBatteryEntity, EnvoyBaseEntity
PARALLEL_UPDATES = 0
@@ -107,6 +108,35 @@ C6CC_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyACBBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Envoy per-device ACB Battery binary sensor entity."""
value_fn: Callable[[EnvoyACB], bool]
ACB_INVENTORY_SENSORS = (
EnvoyACBBinarySensorEntityDescription(
key="communicating",
translation_key="communicating",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("communicating"),
),
EnvoyACBBinarySensorEntityDescription(
key="operating",
translation_key="operating",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("operating"),
),
EnvoyACBBinarySensorEntityDescription(
key="producing",
translation_key="producing",
value_fn=attrgetter("producing"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
@@ -142,6 +172,13 @@ async def async_setup_entry(
for description in C6CC_SENSORS
)
if envoy_data.acb_inventory:
entities.extend(
EnvoyACBBinarySensorEntity(coordinator, description, serial_number)
for description in ACB_INVENTORY_SENSORS
for serial_number in envoy_data.acb_inventory
)
async_add_entities(entities)
@@ -285,3 +322,18 @@ class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity):
c6cc_data = self.data.c6cc
assert c6cc_data is not None
return self.entity_description.value_fn(c6cc_data)
class EnvoyACBBinarySensorEntity(EnvoyACBBatteryEntity, BinarySensorEntity):
"""Defines a per-device ACB Battery binary_sensor entity."""
entity_description: EnvoyACBBinarySensorEntityDescription
@property
@override
def is_on(self) -> bool | None:
"""Return the state of the ACB battery binary_sensor."""
acb_inventory = self.data.acb_inventory
if not acb_inventory or self._serial_number not in acb_inventory:
return None
return self.entity_description.value_fn(acb_inventory[self._serial_number])
@@ -0,0 +1,96 @@
"""Button platform for Enphase Envoy solar energy monitor."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, override
from pyenphase import Envoy
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry
from .entity import EnvoyACBAggregateControlEntity, exception_handler
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class EnvoyACBButtonEntityDescription(ButtonEntityDescription):
"""Describes an Envoy ACB battery button entity."""
press_fn: Callable[
[Envoy, list[str], int, int], Coroutine[Any, Any, dict[str, Any]]
]
ACB_BUTTONS = (
EnvoyACBButtonEntityDescription(
key="acb_sleep",
translation_key="acb_sleep",
press_fn=lambda envoy, serials, low, high: envoy.set_acb_sleep(
[
{"serial_num": serial, "sleep_min_soc": low, "sleep_max_soc": high}
for serial in serials
]
),
),
EnvoyACBButtonEntityDescription(
key="acb_wake",
translation_key="acb_wake",
press_fn=lambda envoy, serials, low, high: envoy.clear_acb_sleep(serials),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up envoy button platform."""
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
assert envoy_data is not None
if not envoy_data.acb_inventory:
return
entities: list[ButtonEntity] = [
EnvoyACBButtonEntity(coordinator, description) for description in ACB_BUTTONS
]
async_add_entities(entities)
class EnvoyACBButtonEntity(EnvoyACBAggregateControlEntity, ButtonEntity):
"""Envoy button that puts all ACB batteries to sleep or wakes them."""
entity_description: EnvoyACBButtonEntityDescription
@exception_handler
@override
async def async_press(self) -> None:
"""Send the sleep or wake request for all ACB batteries."""
acb_inventory = self.data.acb_inventory
assert acb_inventory is not None
low, high = self.coordinator.acb_sleep_soc()
try:
await self.entity_description.press_fn(
self.coordinator.envoy, list(acb_inventory), low, high
)
except ValueError as err:
# pyenphase raises ValueError for validation failures and Envoy
# HTTP 400 responses, which exception_handler does not cover.
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_error",
translation_placeholders={
"host": self.coordinator.envoy.host,
"args": err.args[0] if err.args else str(err),
"action": "async_press",
"entity": self.entity_id,
},
) from err
await self.coordinator.async_request_refresh()
@@ -8,6 +8,7 @@ DOMAIN = "enphase_envoy"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
@@ -16,6 +17,15 @@ PLATFORMS = [
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
# ACB battery sleep is configured with a state of charge (SOC) band. The Enphase
# installer UI only offers 5% increments, so we constrain the choice to those
# tested values rather than allowing an arbitrary SOC.
ACB_SLEEP_SOC_STEP = 5
ACB_SLEEP_SOC_BANDS = [
f"{low}-{low + ACB_SLEEP_SOC_STEP}" for low in range(0, 100, ACB_SLEEP_SOC_STEP)
]
DEFAULT_ACB_SLEEP_SOC_BAND = "95-100"
ACCESS_TOKEN_LOGIN_URL = "https://entrez.enphaseenergy.com"
CONF_MANUAL_TOKEN = "use_manual_token"
@@ -20,7 +20,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from homeassistant.util import dt as dt_util
from .const import (
ACB_SLEEP_SOC_BANDS,
CONF_MANUAL_TOKEN,
DEFAULT_ACB_SLEEP_SOC_BAND,
DOMAIN,
INVALID_AUTH_ERRORS,
OPERATIONAL_RETRY_TIMEOUT,
@@ -66,6 +68,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._cancel_firmware_refresh: CALLBACK_TYPE | None = None
self._cancel_mac_verification: CALLBACK_TYPE | None = None
self.token_lifetime = 0
self._acb_sleep_soc_band: str | None = None
super().__init__(
hass,
_LOGGER,
@@ -87,6 +90,36 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
return self.token_lifetime > STALE_TOKEN_THRESHOLD
@property
def acb_sleep_soc_band(self) -> str:
"""Return the selected ACB sleep SOC band (e.g. ``95-100``).
This is the SOC band that the sleep buttons apply. It is kept as
in-memory state, since persisting it in the config entry options would
trigger a reload on every change. When not set by the user it defaults
to the first battery reporting a valid band (batteries are not
guaranteed to agree), else a default.
"""
if self._acb_sleep_soc_band is not None:
return self._acb_sleep_soc_band
if (data := self.envoy.data) and data.acb_inventory:
for acb in data.acb_inventory.values():
if acb.sleep_min_soc is not None and acb.sleep_max_soc is not None:
band = f"{acb.sleep_min_soc}-{acb.sleep_max_soc}"
if band in ACB_SLEEP_SOC_BANDS:
return band
return DEFAULT_ACB_SLEEP_SOC_BAND
@acb_sleep_soc_band.setter
def acb_sleep_soc_band(self, value: str) -> None:
"""Store the selected ACB sleep SOC band."""
self._acb_sleep_soc_band = value
def acb_sleep_soc(self) -> tuple[int, int]:
"""Return the selected ACB sleep SOC band as (min, max) integers."""
low, high = self.acb_sleep_soc_band.split("-")
return int(low), int(high)
@callback
def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None:
"""Proactively refresh token if its stale in case cloud services goes down."""
@@ -1,13 +1,14 @@
"""Support for Enphase Envoy solar energy monitor."""
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from typing import Any, Concatenate, override
from aiohttp import ClientError
from pyenphase import EnvoyData
from pyenphase.exceptions import EnvoyError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -66,3 +67,56 @@ def exception_handler[_EntityT: EnvoyBaseEntity, **_P](
) from error
return handler
class EnvoyACBAggregateEntity(EnvoyBaseEntity):
"""Defines a base entity on the aggregate ACB device."""
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Init the ACB aggregate device entity."""
super().__init__(coordinator, description)
self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self.envoy_serial_num}_acb")},
manufacturer="Enphase",
model="ACB",
name=f"ACB {self.envoy_serial_num}",
via_device=(DOMAIN, self.envoy_serial_num),
)
class EnvoyACBAggregateControlEntity(EnvoyACBAggregateEntity):
"""Defines a control on the aggregate ACB device backed by battery inventory."""
@property
@override
def available(self) -> bool:
"""Return if the ACB controls are available."""
return super().available and bool(self.data.acb_inventory)
class EnvoyACBBatteryEntity(EnvoyBaseEntity):
"""Defines a base entity on an individual AC Battery device."""
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EntityDescription,
serial_number: str,
) -> None:
"""Init the AC Battery device entity."""
super().__init__(coordinator, description)
self._serial_number = serial_number
self._attr_unique_id = f"{serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
manufacturer="Enphase",
model="AC Battery",
name=f"AC Battery {serial_number}",
via_device=(DOMAIN, f"{self.envoy_serial_num}_acb"),
serial_number=serial_number,
)
@@ -6,9 +6,67 @@
"state": {
"off": "mdi:transmission-tower-off"
}
},
"operating": {
"default": "mdi:cog",
"state": {
"off": "mdi:cog-off"
}
},
"producing": {
"default": "mdi:flash",
"state": {
"off": "mdi:flash-off"
}
}
},
"button": {
"acb_sleep": {
"default": "mdi:sleep"
},
"acb_wake": {
"default": "mdi:sleep-off"
}
},
"select": {
"acb_sleep_soc": {
"default": "mdi:battery-clock"
}
},
"sensor": {
"acb_aggregate_sleep_state": {
"default": "mdi:sleep",
"state": {
"awake": "mdi:sleep-off",
"mixed": "mdi:dots-horizontal",
"waking": "mdi:sleep-off"
}
},
"acb_battery_charge_status": {
"default": "mdi:battery",
"state": {
"charging": "mdi:battery-charging",
"discharging": "mdi:battery-arrow-down",
"idle": "mdi:battery"
}
},
"acb_battery_sleep_soc_target": {
"default": "mdi:battery-clock-outline"
},
"acb_battery_sleep_state": {
"default": "mdi:sleep",
"state": {
"awake": "mdi:sleep-off",
"waking": "mdi:sleep-off"
}
},
"acb_battery_state": {
"default": "mdi:battery",
"state": {
"charging": "mdi:battery-charging",
"discharging": "mdi:battery-arrow-down"
}
},
"admin_state": {
"default": "mdi:transmission-tower",
"state": {
@@ -10,13 +10,14 @@ from pyenphase.models.dry_contacts import DryContactAction, DryContactMode
from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .const import ACB_SLEEP_SOC_BANDS, DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity, exception_handler
from .entity import EnvoyACBAggregateControlEntity, EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -124,6 +125,13 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription(
),
)
ACB_SLEEP_SOC_ENTITY = SelectEntityDescription(
key="acb_sleep_soc",
translation_key="acb_sleep_soc",
options=ACB_SLEEP_SOC_BANDS,
entity_category=EntityCategory.CONFIG,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -149,6 +157,8 @@ async def async_setup_entry(
entities.append(
EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY)
)
if envoy_data.acb_inventory:
entities.append(EnvoyACBSleepSocSelectEntity(coordinator, ACB_SLEEP_SOC_ENTITY))
async_add_entities(entities)
@@ -252,3 +262,19 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, option)
await self.coordinator.async_request_refresh()
class EnvoyACBSleepSocSelectEntity(EnvoyACBAggregateControlEntity, SelectEntity):
"""Select for the SOC band applied when putting ACB batteries to sleep."""
@property
@override
def current_option(self) -> str:
"""Return the selected ACB sleep SOC band."""
return self.coordinator.acb_sleep_soc_band
@override
async def async_select_option(self, option: str) -> None:
"""Store the selected ACB sleep SOC band."""
self.coordinator.acb_sleep_soc_band = option
self.async_write_ha_state()
+153 -21
View File
@@ -1,6 +1,6 @@
"""Support for Enphase Envoy solar energy monitor."""
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass, replace
import datetime
import logging
@@ -8,6 +8,7 @@ from operator import attrgetter
from typing import TYPE_CHECKING, override
from pyenphase import (
EnvoyACB,
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyC6CC,
@@ -21,6 +22,7 @@ from pyenphase import (
EnvoySystemProduction,
)
from pyenphase.const import PHASENAMES
from pyenphase.models.acb import ACBChargeStatus, ACBSleepState
from pyenphase.models.meters import (
CtMeterStatus,
CtState,
@@ -55,7 +57,7 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyACBAggregateEntity, EnvoyACBBatteryEntity, EnvoyBaseEntity
_LOGGER = logging.getLogger(__name__)
@@ -867,6 +869,115 @@ ACB_BATTERY_ENERGY_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyACBInventorySensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy per-device ACB Battery sensor entity."""
value_fn: Callable[[EnvoyACB], int | str | datetime.datetime | None]
ACB_INVENTORY_SENSORS = (
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_soc",
translation_key="acb_battery_soc",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("percent_full"),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
value_fn=attrgetter("last_report_watts"),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("max_cell_temp"),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_charge_status",
translation_key="acb_battery_charge_status",
device_class=SensorDeviceClass.ENUM,
options=[
status.value
for status in ACBChargeStatus
if status is not ACBChargeStatus.UNKNOWN
],
value_fn=lambda acb: (
None if acb.charge_status is ACBChargeStatus.UNKNOWN else acb.charge_status
),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_last_reported",
translation_key=LAST_REPORTED_KEY,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda acb: (
dt_util.utc_from_timestamp(acb.last_report_date)
if acb.last_report_date is not None
else None
),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_sleep_soc_target",
translation_key="acb_battery_sleep_soc_target",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda acb: (
f"{acb.sleep_min_soc}-{acb.sleep_max_soc}"
if acb.sleep_min_soc is not None and acb.sleep_max_soc is not None
else None
),
),
EnvoyACBInventorySensorEntityDescription(
key="acb_battery_sleep_state",
translation_key="acb_battery_sleep_state",
device_class=SensorDeviceClass.ENUM,
options=[state.value for state in ACBSleepState],
value_fn=attrgetter("sleep_state"),
),
)
ACB_AGGREGATE_SLEEP_STATE_MIXED = "mixed"
ACB_AGGREGATE_SLEEP_STATES = [
*(state.value for state in ACBSleepState),
ACB_AGGREGATE_SLEEP_STATE_MIXED,
]
def aggregate_acb_sleep_state(acbs: Iterable[EnvoyACB]) -> str:
"""Summarize the sleep state of all ACB batteries into a single value.
An in-progress transition takes priority, so the aggregate keeps reporting
going_to_sleep/waking until every battery has settled. Batteries check in at
different times, so a steady split (e.g. one asleep, one awake mid-changeover)
is reported as ``mixed`` rather than picking one battery's state.
"""
states = {acb.sleep_state for acb in acbs}
if ACBSleepState.GOING_TO_SLEEP in states:
return ACBSleepState.GOING_TO_SLEEP.value
if ACBSleepState.WAKING in states:
return ACBSleepState.WAKING.value
if states == {ACBSleepState.ASLEEP}:
return ACBSleepState.ASLEEP.value
if states == {ACBSleepState.AWAKE}:
return ACBSleepState.AWAKE.value
return ACB_AGGREGATE_SLEEP_STATE_MIXED
ACB_AGGREGATE_SLEEP_STATE_SENSOR = SensorEntityDescription(
key="acb_aggregate_sleep_state",
translation_key="acb_aggregate_sleep_state",
device_class=SensorDeviceClass.ENUM,
options=ACB_AGGREGATE_SLEEP_STATES,
)
@dataclass(frozen=True, kw_only=True)
class EnvoyAggregateBatterySensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy aggregate Ensemble and ACB Battery sensor entity."""
@@ -1005,6 +1116,17 @@ async def async_setup_entry(
EnvoyAcbBatteryEnergyEntity(coordinator, description)
for description in ACB_BATTERY_ENERGY_SENSORS
)
if envoy_data.acb_inventory:
entities.extend(
EnvoyACBInventoryEntity(coordinator, description, serial_number)
for description in ACB_INVENTORY_SENSORS
for serial_number in envoy_data.acb_inventory
)
entities.append(
EnvoyACBAggregateSleepStateEntity(
coordinator, ACB_AGGREGATE_SLEEP_STATE_SENSOR
)
)
if envoy_data.battery_aggregate:
entities.extend(
AggregateBatteryEntity(coordinator, description)
@@ -1351,29 +1473,11 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
return self.entity_description.value_fn(enpower)
class EnvoyAcbBatteryPowerEntity(EnvoySensorBaseEntity):
class EnvoyAcbBatteryPowerEntity(EnvoyACBAggregateEntity, SensorEntity):
"""Envoy ACB Battery power sensor entity."""
entity_description: EnvoyAcbBatterySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyAcbBatterySensorEntityDescription,
) -> None:
"""Initialize ACB Battery entity."""
super().__init__(coordinator, description)
acb_data = self.data.acb_power
assert acb_data is not None
self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self.envoy_serial_num}_acb")},
manufacturer="Enphase",
model="ACB",
name=f"ACB {self.envoy_serial_num}",
via_device=(DOMAIN, self.envoy_serial_num),
)
@property
@override
def native_value(self) -> int | str | None:
@@ -1411,6 +1515,34 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity):
return self.entity_description.value_fn(battery_aggregate)
class EnvoyACBInventoryEntity(EnvoyACBBatteryEntity, SensorEntity):
"""Envoy per-device ACB Battery sensor entity."""
entity_description: EnvoyACBInventorySensorEntityDescription
@property
@override
def native_value(self) -> int | str | datetime.datetime | None:
"""Return the state of the per-device ACB battery sensors."""
acb_inventory = self.data.acb_inventory
if not acb_inventory or self._serial_number not in acb_inventory:
return None
return self.entity_description.value_fn(acb_inventory[self._serial_number])
class EnvoyACBAggregateSleepStateEntity(EnvoyACBAggregateEntity, SensorEntity):
"""Envoy aggregate sleep state across all ACB batteries."""
@property
@override
def native_value(self) -> str | None:
"""Return the aggregate sleep state across all ACB batteries."""
acb_inventory = self.data.acb_inventory
if not acb_inventory:
return None
return aggregate_acb_sleep_state(acb_inventory.values())
class EnvoyCollarEntity(EnvoySensorBaseEntity):
"""Envoy Collar sensor entity."""
@@ -74,6 +74,20 @@
},
"grid_status": {
"name": "Grid status"
},
"operating": {
"name": "Operating"
},
"producing": {
"name": "Producing"
}
},
"button": {
"acb_sleep": {
"name": "Sleep"
},
"acb_wake": {
"name": "Wake"
}
},
"number": {
@@ -88,6 +102,31 @@
}
},
"select": {
"acb_sleep_soc": {
"name": "Battery sleep SOC target",
"state": {
"0-5": "0 - 5%",
"5-10": "5 - 10%",
"10-15": "10 - 15%",
"15-20": "15 - 20%",
"20-25": "20 - 25%",
"25-30": "25 - 30%",
"30-35": "30 - 35%",
"35-40": "35 - 40%",
"40-45": "40 - 45%",
"45-50": "45 - 50%",
"50-55": "50 - 55%",
"55-60": "55 - 60%",
"60-65": "60 - 65%",
"65-70": "65 - 70%",
"70-75": "70 - 75%",
"75-80": "75 - 80%",
"80-85": "80 - 85%",
"85-90": "85 - 90%",
"90-95": "90 - 95%",
"95-100": "95 - 100%"
}
},
"relay_generator_action": {
"name": "Generator action",
"state": {
@@ -138,9 +177,42 @@
"ac_voltage": {
"name": "AC voltage"
},
"acb_aggregate_sleep_state": {
"name": "Sleep state",
"state": {
"asleep": "Asleep",
"awake": "Awake",
"going_to_sleep": "Going to sleep",
"mixed": "Mixed",
"waking": "Waking"
}
},
"acb_available_energy": {
"name": "Available ACB battery energy"
},
"acb_battery_charge_status": {
"name": "Charge status",
"state": {
"charging": "[%key:common::state::charging%]",
"discharging": "[%key:common::state::discharging%]",
"idle": "[%key:common::state::idle%]"
}
},
"acb_battery_sleep_soc_target": {
"name": "Sleep SOC target"
},
"acb_battery_sleep_state": {
"name": "Sleep state",
"state": {
"asleep": "Asleep",
"awake": "Awake",
"going_to_sleep": "Going to sleep",
"waking": "Waking"
}
},
"acb_battery_soc": {
"name": "State of charge"
},
"acb_battery_state": {
"name": "Battery state",
"state": {
@@ -12,6 +12,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_RADAR_COLORS,
CONF_RADAR_DURATION,
CONF_RADAR_FPS,
CONF_RADAR_LAYER,
@@ -20,6 +21,7 @@ from .const import (
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_COLORS,
DEFAULT_RADAR_DURATION,
DEFAULT_RADAR_FPS,
DEFAULT_RADAR_LAYER,
@@ -81,6 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
loop_minutes=int(options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION)),
fps=int(options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS)),
colors=int(options.get(CONF_RADAR_COLORS, DEFAULT_RADAR_COLORS)),
)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
@@ -30,6 +30,7 @@ from homeassistant.helpers.selector import (
)
from .const import (
CONF_RADAR_COLORS,
CONF_RADAR_DURATION,
CONF_RADAR_FPS,
CONF_RADAR_LAYER,
@@ -39,6 +40,7 @@ from .const import (
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
DEFAULT_RADAR_COLORS,
DEFAULT_RADAR_DURATION,
DEFAULT_RADAR_FPS,
DEFAULT_RADAR_LAYER,
@@ -47,6 +49,7 @@ from .const import (
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
RADAR_COLOR_OPTIONS,
RADAR_LAYERS,
)
@@ -230,6 +233,15 @@ class OptionsFlowHandler(OptionsFlowWithReload):
min=1, max=30, step=1, unit_of_measurement="fps"
)
),
vol.Required(
CONF_RADAR_COLORS,
default=options.get(CONF_RADAR_COLORS, DEFAULT_RADAR_COLORS),
): SelectSelector(
SelectSelectorConfig(
options=RADAR_COLOR_OPTIONS,
translation_key="radar_colors",
)
),
}
)
@@ -14,8 +14,10 @@ CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
CONF_RADAR_DURATION = "radar_duration"
CONF_RADAR_FPS = "radar_fps"
CONF_RADAR_COLORS = "radar_colors"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
RADAR_COLOR_OPTIONS = ["8", "14"]
# Defaults preserve the radar behaviour from before the options flow existed:
# the precipitation-type layer with the legend hidden.
@@ -27,3 +29,6 @@ DEFAULT_RADAR_RADIUS = 200
# 0 means use the full range of images Environment Canada reports as available.
DEFAULT_RADAR_DURATION = 0
DEFAULT_RADAR_FPS = 5
# Colour count only affects the rain/snow layers; precip_type always uses the
# WMS server's single default style.
DEFAULT_RADAR_COLORS = "14"
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.16.1"]
"requirements": ["env-canada==0.17.0"]
}
@@ -121,6 +121,7 @@
"step": {
"init": {
"data": {
"radar_colors": "Colour scale",
"radar_duration": "Loop duration",
"radar_fps": "Loop frame rate",
"radar_layer": "Radar type",
@@ -130,6 +131,7 @@
"radar_timestamp": "Show timestamp"
},
"data_description": {
"radar_colors": "Number of colours in the rain/snow radar scale (has no effect on the precipitation type layer)",
"radar_duration": "How far back the radar animation goes, in minutes (0 for the full available history)",
"radar_fps": "Frame rate of the radar animation",
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
@@ -140,6 +142,12 @@
}
},
"selector": {
"radar_colors": {
"options": {
"8": "8 colours",
"14": "14 colours"
}
},
"radar_layer": {
"options": {
"precip_type": "Precipitation type",
@@ -8,11 +8,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
@@ -77,6 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) ->
return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Eurotronic Comet Blue integration."""
async_setup_services(hass)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -16,5 +16,16 @@
"default": "mdi:thermometer-check"
}
}
},
"services": {
"get_schedule": {
"service": "mdi:calendar-search"
},
"set_holiday": {
"service": "mdi:beach"
},
"set_schedule": {
"service": "mdi:calendar-edit"
}
}
}
@@ -1,17 +1,13 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide actions.
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide actions.
docs-actions: done
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
@@ -32,9 +28,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide actions.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -45,7 +39,7 @@ rules:
reauthentication-flow:
status: exempt
comment: This integration does not login to any device or service.
test-coverage: todo
test-coverage: done
# Gold
devices: done
@@ -0,0 +1,270 @@
"""Comet Blue services."""
from datetime import time, timedelta
import logging
from typing import Final, TypedDict, cast
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .climate import MAX_TEMP, MIN_TEMP
from .const import DOMAIN
from .entity import CometBlueBluetoothEntity
LOGGER = logging.getLogger(__name__)
ATTR_SCHEDULE: Final = "schedule"
ATTR_MONDAY: Final = "monday"
ATTR_TUESDAY: Final = "tuesday"
ATTR_WEDNESDAY: Final = "wednesday"
ATTR_THURSDAY: Final = "thursday"
ATTR_FRIDAY: Final = "friday"
ATTR_SATURDAY: Final = "saturday"
ATTR_SUNDAY: Final = "sunday"
ATTR_DATA: Final = "data"
ATTR_START: Final = "start"
ATTR_END: Final = "end"
ATTR_FROM: Final = "from"
ATTR_TO: Final = "to"
ATTR_ALL_DAYS: Final = [
ATTR_MONDAY,
ATTR_TUESDAY,
ATTR_WEDNESDAY,
ATTR_THURSDAY,
ATTR_FRIDAY,
ATTR_SATURDAY,
ATTR_SUNDAY,
]
ScheduleEntry = TypedDict(
"ScheduleEntry",
{
"from": time,
"to": time,
},
total=False,
)
def _validate_half_precision(value: float) -> float:
"""Return True if the value is a half precision float."""
try:
r = value % 0.5
if r != 0:
raise ServiceValidationError(
f"value {value} is not a half precision float, remainder is {r}"
)
except TypeError as err:
raise ServiceValidationError(f"value {value} is not a float") from err
return value
def _validate_cometblue_schedule(
schedule: list[ScheduleEntry],
) -> dict[str, time] | None:
"""Validate day schedule time ranges.
Ensure they have no overlap and the end time is greater than the start time.
"""
if not schedule:
return {}
schedule = sorted(
schedule,
key=lambda entry: entry.get(ATTR_FROM, time.min),
)
normalized_schedule: dict[str, time] = {}
previous_to: time | None = None
for i, entry in enumerate(schedule, start=1):
start = entry.get(ATTR_FROM)
end = entry.get(ATTR_TO)
if start is None or end is None:
raise ServiceValidationError("Missing from/to in entry")
# Check if the start time of the current event is before the end time of the current event
if start >= end:
raise ServiceValidationError(
f"Invalid time range {i}, {start} is after {end}"
)
# Check if the from time of the event is after the to time of the previous event
if previous_to is not None and previous_to > start:
raise ServiceValidationError(
f"Overlapping times found in schedule, {start} is earlier than previous entry {previous_to} ends"
)
normalized_schedule[f"{ATTR_START}{i}"] = start
normalized_schedule[f"{ATTR_END}{i}"] = end
previous_to = end
return normalized_schedule
SCHEDULE_ENTRY_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_FROM): cv.time,
vol.Optional(ATTR_TO): cv.time,
},
extra=vol.REMOVE_EXTRA,
)
SCHEDULE_DAY_SCHEMA = vol.All(
[SCHEDULE_ENTRY_SCHEMA],
vol.Length(max=4),
_validate_cometblue_schedule,
)
SERVICE_SCHEDULE_SCHEMA = {
vol.Optional(day): SCHEDULE_DAY_SCHEMA for day in ATTR_ALL_DAYS
}
SERVICE_HOLIDAY_SCHEMA = {
vol.Required(ATTR_FROM): cv.datetime,
vol.Required(ATTR_TO): cv.datetime,
vol.Required(ATTR_TEMPERATURE): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMP, max=MAX_TEMP),
_validate_half_precision,
),
}
async def get_schedule(
entity: CometBlueBluetoothEntity, service_call: ServiceCall
) -> ServiceResponse:
"""Service call to retrieve the schedule from the device."""
device_schedule = cast(
dict[str, dict[str, str] | None],
await entity.coordinator.send_command(
entity.coordinator.device.get_multiple_async,
{"values": ["weekdays"]},
),
)
week_schedule: JsonObjectType = {}
for day in ATTR_ALL_DAYS:
day_schedule = device_schedule.get(day, {})
if day_schedule:
curr_day_schedule: JsonArrayType = [
{
ATTR_FROM: start,
ATTR_TO: end,
}
for i in range(1, 5)
if (start := day_schedule.get(f"{ATTR_START}{i}")) is not None
and (end := day_schedule.get(f"{ATTR_END}{i}")) is not None
]
week_schedule[day] = curr_day_schedule
return week_schedule
async def set_schedule(
entity: CometBlueBluetoothEntity, service_call: ServiceCall
) -> None:
"""Service call to update the schedule on the device."""
LOGGER.info(
"Setting schedule for %s (%s) on days: %s",
entity.entity_id,
entity.coordinator.device.device.address,
", ".join(
day for day in ATTR_ALL_DAYS if service_call.data.get(day) is not None
),
)
for day in ATTR_ALL_DAYS:
LOGGER.debug(
"Settings schedule for %s: %s",
day,
service_call.data.get(day),
)
values = {
day: {k: v.strftime("%H:%M") for k, v in sched.items()}
for day, sched in service_call.data.items()
if sched is not None and day in ATTR_ALL_DAYS
}
await entity.coordinator.send_command(
entity.coordinator.device.set_weekdays_async,
{"values": values},
)
async def set_holiday(
entity: CometBlueBluetoothEntity, service_call: ServiceCall
) -> None:
"""Service call to update the holiday time on the device."""
# ceil the start time to the next full hour
away_start = service_call.data[ATTR_FROM].replace(
minute=0, second=0, microsecond=0
) + timedelta(hours=1)
if away_start < dt_util.naive_now():
raise ServiceValidationError(
"Start date (ceiled to next hour) must be in the future"
)
LOGGER.info(
"Setting holiday for %s (%s) until %s with temperature %s",
entity.entity_id,
entity.coordinator.device.device.address,
service_call.data[ATTR_TO],
service_call.data[ATTR_TEMPERATURE],
)
await entity.coordinator.send_command(
entity.coordinator.device.set_holiday_async,
{
"number": 1,
"values": {
ATTR_START: service_call.data[ATTR_FROM],
ATTR_END: service_call.data[ATTR_TO],
ATTR_TEMPERATURE: service_call.data[ATTR_TEMPERATURE],
},
},
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Eurotronic Comet Blue services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
"get_schedule",
entity_domain=CLIMATE_DOMAIN,
schema=None,
supports_response=SupportsResponse.ONLY,
func=get_schedule,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_schedule",
entity_domain=CLIMATE_DOMAIN,
schema=cv.make_entity_service_schema(SERVICE_SCHEDULE_SCHEMA),
supports_response=SupportsResponse.NONE,
func=set_schedule,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_holiday",
entity_domain=CLIMATE_DOMAIN,
schema=cv.make_entity_service_schema(SERVICE_HOLIDAY_SCHEMA),
supports_response=SupportsResponse.NONE,
func=set_holiday,
)

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