mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Merge branch 'dev' into gj-20260705-04
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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%]"
|
||||
},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user