forked from home-assistant/core
Compare commits
236 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 573644459a | |||
| e64f76bebe | |||
| 7c5090d627 | |||
| f6a0d630c3 | |||
| 880f5faeec | |||
| 0cf503d871 | |||
| 9d050360c8 | |||
| 0c0c61f9e0 | |||
| e868b3e8ff | |||
| 555215a848 | |||
| 484a547758 | |||
| 7d25f68fa5 | |||
| 8b22ab93c1 | |||
| 78e3a2d0c6 | |||
| 241c89e885 | |||
| 7d96a2a620 | |||
| 08104eec56 | |||
| 0fc81d6b33 | |||
| cb84e55c34 | |||
| 68c3d5a159 | |||
| 77bab39ed0 | |||
| 92e570ffc1 | |||
| 919684e20a | |||
| a1d6df6ce9 | |||
| 07c3c3bba8 | |||
| f11e040662 | |||
| 8d83341308 | |||
| f27b2c4df1 | |||
| 717b84bab9 | |||
| a34bce6202 | |||
| bd190b9b4c | |||
| da6c6c5201 | |||
| f50afae1c3 | |||
| 177afea5ad | |||
| a3aae68229 | |||
| 9ff9d9230e | |||
| 2bb0843c30 | |||
| 5f2425f421 | |||
| e46ca41697 | |||
| fa5a7aea7e | |||
| 030681a443 | |||
| aa3cbf2473 | |||
| ce71f6444c | |||
| eb4d561b96 | |||
| 075a41c69a | |||
| 2ba2248f67 | |||
| ff5ed82de8 | |||
| 541b969d3b | |||
| 3d83c6299b | |||
| 3ecde49dca | |||
| c1fcd8ea7f | |||
| 78ac8ba841 | |||
| d9cfab4c8e | |||
| 4c10502b0e | |||
| a576f7baf3 | |||
| 520c964656 | |||
| 3f59b1c376 | |||
| 3ff095cc51 | |||
| aa4c41abe8 | |||
| 906b3901fb | |||
| 2aba4f261f | |||
| 3eb0c8ddff | |||
| 705a987547 | |||
| 888f17c504 | |||
| 2f4d0ede0f | |||
| 6fd9857666 | |||
| f07265ece4 | |||
| a169d6ca97 | |||
| ebed38c1dc | |||
| 5619042fe7 | |||
| 67b3428b07 | |||
| 2302a3bb33 | |||
| a83eafd949 | |||
| 2956f4fea1 | |||
| 180e1f462c | |||
| 2dc63eb8c5 | |||
| 4c40ec4948 | |||
| 56b3dc02a7 | |||
| db5bcd9fc4 | |||
| c845f4e9b2 | |||
| 5aff3499a0 | |||
| a501451038 | |||
| 0deed82bea | |||
| f9231de824 | |||
| 757c66613d | |||
| 9d2302f2f5 | |||
| 0bbbd2cd54 | |||
| dbc15a2dda | |||
| 7fefd58b84 | |||
| 87b60967a6 | |||
| e80069545f | |||
| be5685695e | |||
| 6b769ac263 | |||
| 9114816384 | |||
| db3e596e48 | |||
| bdc21da076 | |||
| a500eeb831 | |||
| 119d0c576a | |||
| 38cee53999 | |||
| 2ca9d4689e | |||
| 8a32ffc7b9 | |||
| 6475b1a446 | |||
| 07db244f91 | |||
| ff4aed1f6e | |||
| 3208815e10 | |||
| b4a1bdcb83 | |||
| 97869636f8 | |||
| cbb092f792 | |||
| 0c5ee37721 | |||
| e74aeeab1a | |||
| b8df9c7e97 | |||
| 82a9e67b7e | |||
| 7410b8778a | |||
| 3e92f23680 | |||
| 3942e6a841 | |||
| e76b483067 | |||
| 3de740ed1e | |||
| bbe975baef | |||
| 6dff975711 | |||
| 71108d9ca0 | |||
| 053e5417a7 | |||
| 9bbc49e842 | |||
| 270780ef5f | |||
| e15963b422 | |||
| 52e8196d0a | |||
| cc62943835 | |||
| d195726ed2 | |||
| 50e6c83dd8 | |||
| 3a58d97496 | |||
| ace12958d1 | |||
| d33a0f75fd | |||
| d24a60777b | |||
| f2a3a5cbbd | |||
| 3bf9908789 | |||
| 912798ee34 | |||
| 28990e1db5 | |||
| e8281bb009 | |||
| 334f9deaec | |||
| 1d47dc41c9 | |||
| 66ecc4d69d | |||
| fa3edb5c01 | |||
| ea046f32be | |||
| fd09476b28 | |||
| 7c306acd5d | |||
| 9c4733595a | |||
| c7cf9585ae | |||
| 301ca88f41 | |||
| 9a0fed89bd | |||
| 2050b0b375 | |||
| 34c7c3f384 | |||
| 3b9d8e00bc | |||
| 6b35b069b2 | |||
| 9428127021 | |||
| 1e8843947c | |||
| dbdffbba23 | |||
| 460f02ede5 | |||
| 0eb6c88bc5 | |||
| 4b7650f2d2 | |||
| 8004c6605b | |||
| 9d451b6358 | |||
| 7963665c40 | |||
| d44a34ce1e | |||
| 49b7559b1f | |||
| 43b1dd64a7 | |||
| 2d0c1fac24 | |||
| a0f35a84ae | |||
| 4bc5987f36 | |||
| 11644d48ee | |||
| d273a92a19 | |||
| b0ff4b5841 | |||
| ef99658919 | |||
| a9238c7577 | |||
| 993e98a43f | |||
| 10dd11f257 | |||
| fb9be3da79 | |||
| 3b1a33d606 | |||
| 710e18f399 | |||
| 67b9904740 | |||
| e413e9b93b | |||
| 5c86042b31 | |||
| e89333811e | |||
| 4f723232e3 | |||
| 48520d90ef | |||
| 2fdda91cb8 | |||
| c023f610dd | |||
| 161b62d8fa | |||
| 8ccedd4064 | |||
| 9a06584a1d | |||
| a21e586140 | |||
| 91f01d660f | |||
| 1748dbd60f | |||
| 5acae7f86d | |||
| 30ecba9944 | |||
| 4287df5f3d | |||
| 063deab3cb | |||
| 27798a6004 | |||
| 577ddd9021 | |||
| 34663e160d | |||
| ac54b81289 | |||
| 67174fb07e | |||
| d2a692393f | |||
| 9aa2664188 | |||
| ab5d60e33d | |||
| 31847d8cfb | |||
| 9729f1f38b | |||
| 6bc6733c40 | |||
| b1ffcb4245 | |||
| f0c5fbfb8a | |||
| c76239806d | |||
| 6d809b0b5a | |||
| de2cbb7f5c | |||
| cd61f37df7 | |||
| 26796f87cd | |||
| e2dd897ac7 | |||
| 3bbe4baaf7 | |||
| d409b86217 | |||
| 7928c15849 | |||
| d197debbc0 | |||
| 55b9dee448 | |||
| 5c6984d326 | |||
| a7787d6080 | |||
| 2db60340c2 | |||
| c121631fef | |||
| b0fb16d48d | |||
| 3e07f6543e | |||
| d4c2356c70 | |||
| eec617b391 | |||
| c8527fc309 | |||
| 16e69bda9e | |||
| 0713328d7d | |||
| f7fac75693 | |||
| 7bd9508c9e | |||
| 3d3771732b | |||
| 877b796a98 | |||
| a8f8681844 | |||
| 33bb3de1b1 |
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.7.0
|
||||
uses: actions/dependency-review-action@v4.7.1
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -944,7 +944,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1020,6 +1021,12 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1070,7 +1077,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1154,6 +1162,12 @@ jobs:
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1202,7 +1216,8 @@ jobs:
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
@@ -1290,6 +1305,12 @@ jobs:
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1320,7 +1341,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1357,7 +1378,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1436,6 +1458,12 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1463,7 +1491,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.17
|
||||
uses: github/codeql-action/init@v3.28.18
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.17
|
||||
uses: github/codeql-action/analyze@v3.28.18
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -270,6 +270,7 @@ homeassistant.components.image_processing.*
|
||||
homeassistant.components.image_upload.*
|
||||
homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
|
||||
Generated
+6
-4
@@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||
/homeassistant/components/imgw_pib/ @bieniu
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
@@ -1484,8 +1486,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/subaru/ @G-Two
|
||||
/homeassistant/components/suez_water/ @ooii @jb101010-2
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @Swamp-Ig
|
||||
/tests/components/sun/ @Swamp-Ig
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
@@ -1498,8 +1500,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switch_as_x/ @home-assistant/core
|
||||
/homeassistant/components/switchbee/ @jafar-atili
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Concatenate
|
||||
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
|
||||
model_id=measures.model,
|
||||
serial_number=coordinator.serial_number,
|
||||
sw_version=measures.firmware_version,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ from .const import (
|
||||
CONF_VIDEO_SOURCE,
|
||||
DEFAULT_STREAM_PROFILE,
|
||||
DEFAULT_VIDEO_SOURCE,
|
||||
DOMAIN as AXIS_DOMAIN,
|
||||
DOMAIN,
|
||||
)
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
@@ -58,7 +58,7 @@ DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
|
||||
class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Axis config flow."""
|
||||
|
||||
VERSION = 3
|
||||
@@ -146,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN)
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
|
||||
@@ -39,11 +39,20 @@ async def async_setup_entry(
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
"""Create a ContainerClient."""
|
||||
|
||||
return ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
# has a blocking call to open in cpython
|
||||
container_client: ContainerClient = await hass.async_add_executor_job(
|
||||
create_container_client
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -27,9 +27,25 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
def get_account_url(self, account_name: str) -> str:
|
||||
"""Get the account URL."""
|
||||
return f"https://{account_name}.blob.core.windows.net/"
|
||||
async def get_container_client(
|
||||
self, account_name: str, container_name: str, storage_account_key: str
|
||||
) -> ContainerClient:
|
||||
"""Get the container client.
|
||||
|
||||
ContainerClient has a blocking call to open in cpython
|
||||
"""
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
return ContainerClient(
|
||||
account_url=f"https://{account_name}.blob.core.windows.net/",
|
||||
container_name=container_name,
|
||||
credential=storage_account_key,
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
return await self.hass.async_add_executor_job(create_container_client)
|
||||
|
||||
async def validate_config(
|
||||
self, container_client: ContainerClient
|
||||
@@ -58,11 +74,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=user_input[CONF_ACCOUNT_NAME],
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
|
||||
@@ -99,12 +114,12 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reauth_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_name=reauth_entry.data[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -129,13 +144,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(
|
||||
reconfigure_entry.data[CONF_ACCOUNT_NAME]
|
||||
),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reconfigure_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
|
||||
@@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE
|
||||
|
||||
type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Support for Blue Current buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from bluecurrent_api.client import Client
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BlueCurrentConfigEntry, Connector
|
||||
from .entity import ChargepointEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ChargePointButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes a Blue Current button entity."""
|
||||
|
||||
function: Callable[[Client, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
CHARGE_POINT_BUTTONS = (
|
||||
ChargePointButtonEntityDescription(
|
||||
key="reset",
|
||||
translation_key="reset",
|
||||
function=lambda client, evse_id: client.reset(evse_id),
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ChargePointButtonEntityDescription(
|
||||
key="reboot",
|
||||
translation_key="reboot",
|
||||
function=lambda client, evse_id: client.reboot(evse_id),
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ChargePointButtonEntityDescription(
|
||||
key="stop_charge_session",
|
||||
translation_key="stop_charge_session",
|
||||
function=lambda client, evse_id: client.stop_session(evse_id),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BlueCurrentConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Blue Current buttons."""
|
||||
connector: Connector = entry.runtime_data
|
||||
async_add_entities(
|
||||
ChargePointButton(
|
||||
connector,
|
||||
button,
|
||||
evse_id,
|
||||
)
|
||||
for evse_id in connector.charge_points
|
||||
for button in CHARGE_POINT_BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class ChargePointButton(ChargepointEntity, ButtonEntity):
|
||||
"""Define a charge point button."""
|
||||
|
||||
has_value = True
|
||||
entity_description: ChargePointButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector: Connector,
|
||||
description: ChargePointButtonEntityDescription,
|
||||
evse_id: str,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(connector, evse_id)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{description.key}_{evse_id}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.entity_description.function(self.connector.client, self.evse_id)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Entity representing a Blue Current charge point."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -17,12 +15,12 @@ class BlueCurrentEntity(Entity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
has_value = False
|
||||
|
||||
def __init__(self, connector: Connector, signal: str) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.connector = connector
|
||||
self.signal = signal
|
||||
self.has_value = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -43,7 +41,6 @@ class BlueCurrentEntity(Entity):
|
||||
return self.connector.connected and self.has_value
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@
|
||||
"current_left": {
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"reset": {
|
||||
"default": "mdi:restart"
|
||||
},
|
||||
"reboot": {
|
||||
"default": "mdi:restart-alert"
|
||||
},
|
||||
"stop_charge_session": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,17 @@
|
||||
"grid_max_current": {
|
||||
"name": "Max grid current"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"stop_charge_session": {
|
||||
"name": "Stop charge session"
|
||||
},
|
||||
"reboot": {
|
||||
"name": "Reboot"
|
||||
},
|
||||
"reset": {
|
||||
"name": "Reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import (
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
DOMAIN as BMW_DOMAIN,
|
||||
SCAN_INTERVALS,
|
||||
)
|
||||
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,7 +57,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
update_interval=timedelta(
|
||||
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
|
||||
),
|
||||
@@ -81,26 +75,26 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
raise UpdateFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
# Clear refresh token and trigger reauth if previous update failed as well
|
||||
self._update_config_entry_refresh_token(None)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (MyBMWAPIError, RequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -7,15 +7,17 @@ from ssl import SSLError
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
@@ -52,8 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
mac = entry.data.get(CONF_MAC)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
|
||||
@@ -34,6 +34,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||
"""An alarm control panel entity for a bosch alarm panel."""
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Support for Bosch Alarm Panel binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Bosch Alarm sensor entity."""
|
||||
|
||||
fault: int
|
||||
|
||||
|
||||
FAULT_TYPES = [
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_battery_low",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
fault=ALARM_PANEL_FAULTS.BATTERY_LOW,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_battery_mising",
|
||||
translation_key="panel_fault_battery_mising",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.BATTERY_MISING,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_ac_fail",
|
||||
translation_key="panel_fault_ac_fail",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.AC_FAIL,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_phone_line_failure",
|
||||
translation_key="panel_fault_phone_line_failure",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_parameter_crc_fail_in_pif",
|
||||
translation_key="panel_fault_parameter_crc_fail_in_pif",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_communication_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_communication_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_sdi_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_sdi_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_user_code_tamper_since_rps_hang_up",
|
||||
translation_key="panel_fault_user_code_tamper_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_fail_to_call_rps_since_rps_hang_up",
|
||||
translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_point_bus_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_point_bus_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_log_overflow",
|
||||
translation_key="panel_fault_log_overflow",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_log_threshold",
|
||||
translation_key="panel_fault_log_threshold",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BoschAlarmConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors for alarm points and the connection status."""
|
||||
panel = config_entry.runtime_data
|
||||
|
||||
entities: list[BinarySensorEntity] = [
|
||||
PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id)
|
||||
for point_id in panel.points
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
PanelFaultsSensor(
|
||||
panel,
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
fault_type,
|
||||
)
|
||||
for fault_type in FAULT_TYPES
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
AreaReadyToArmSensor(
|
||||
panel, area_id, config_entry.unique_id or config_entry.entry_id, "away"
|
||||
)
|
||||
for area_id in panel.areas
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
AreaReadyToArmSensor(
|
||||
panel, area_id, config_entry.unique_id or config_entry.entry_id, "home"
|
||||
)
|
||||
for area_id in panel.areas
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity for each fault type in a bosch alarm panel."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
entity_description: BoschAlarmFaultEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
panel: Panel,
|
||||
unique_id: str,
|
||||
entity_description: BoschAlarmFaultEntityDescription,
|
||||
) -> None:
|
||||
"""Set up a binary sensor entity for each fault type in a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id, True)
|
||||
self.entity_description = entity_description
|
||||
self._fault_type = entity_description.fault
|
||||
self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this fault has occurred."""
|
||||
return self._fault_type in self.panel.panel_faults_ids
|
||||
|
||||
|
||||
class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity showing if a panel is ready to arm."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(
|
||||
self, panel: Panel, area_id: int, unique_id: str, arm_type: str
|
||||
) -> None:
|
||||
"""Set up a binary sensor entity for the arming status in a bosch alarm panel."""
|
||||
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||
self.panel = panel
|
||||
self._arm_type = arm_type
|
||||
self._attr_translation_key = f"area_ready_to_arm_{arm_type}"
|
||||
self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this panel is ready to arm."""
|
||||
if self._arm_type == "away":
|
||||
return self._area.all_ready
|
||||
if self._arm_type == "home":
|
||||
return self._area.all_ready or self._area.part_ready
|
||||
return False
|
||||
|
||||
|
||||
class PointSensor(BoschAlarmPointEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity for a point in a bosch alarm panel."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
|
||||
"""Set up a binary sensor entity for a point in a bosch alarm panel."""
|
||||
super().__init__(panel, point_id, unique_id)
|
||||
self._attr_unique_id = self._point_unique_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this point sensor is on."""
|
||||
return self._point.is_open()
|
||||
@@ -6,12 +6,13 @@ import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_DHCP,
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigFlow,
|
||||
@@ -20,11 +21,14 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MODEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
@@ -88,6 +92,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Init config flow."""
|
||||
|
||||
self._data: dict[str, Any] = {}
|
||||
self.mac: str | None = None
|
||||
self.host: str | None = None
|
||||
|
||||
def is_matching(self, other_flow: Self) -> bool:
|
||||
"""Return True if other_flow is matching this flow."""
|
||||
return self.mac == other_flow.mac or self.host == other_flow.host
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -96,9 +106,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self.host = user_input[CONF_HOST]
|
||||
if self.source == SOURCE_USER:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
try:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, serial) = await try_connect(user_input, 0)
|
||||
(model, _) = await try_connect(user_input, 0)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
@@ -129,6 +142,55 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
self.mac = format_mac(discovery_info.macaddress)
|
||||
self.host = discovery_info.ip
|
||||
if self.hass.config_entries.flow.async_has_matching_flow(self):
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data[CONF_MAC] == self.mac:
|
||||
result = self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_HOST: discovery_info.ip,
|
||||
},
|
||||
)
|
||||
if result:
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
try:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, _) = await try_connect(
|
||||
{CONF_HOST: discovery_info.ip, CONF_PORT: 7700}, 0
|
||||
)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
ssl.SSLError,
|
||||
asyncio.exceptions.TimeoutError,
|
||||
):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
self.context["title_placeholders"] = {
|
||||
"model": model,
|
||||
"host": discovery_info.ip,
|
||||
}
|
||||
self._data = {
|
||||
CONF_HOST: discovery_info.ip,
|
||||
CONF_MAC: self.mac,
|
||||
CONF_MODEL: model,
|
||||
CONF_PORT: 7700,
|
||||
}
|
||||
|
||||
return await self.async_step_auth()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -172,7 +234,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
if self.source == SOURCE_USER:
|
||||
if self.source in (SOURCE_USER, SOURCE_DHCP):
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
@@ -184,6 +246,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_mismatch(reason="device_mismatch")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=self._data,
|
||||
|
||||
@@ -17,9 +17,13 @@ class BoschAlarmEntity(Entity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, panel: Panel, unique_id: str) -> None:
|
||||
def __init__(
|
||||
self, panel: Panel, unique_id: str, observe_faults: bool = False
|
||||
) -> None:
|
||||
"""Set up a entity for a bosch alarm panel."""
|
||||
self.panel = panel
|
||||
self._observe_faults = observe_faults
|
||||
self._attr_should_poll = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
@@ -34,10 +38,14 @@ class BoschAlarmEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
||||
if self._observe_faults:
|
||||
self.panel.faults_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
||||
if self._observe_faults:
|
||||
self.panel.faults_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
@@ -88,6 +96,33 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmPointEntity(BoschAlarmEntity):
|
||||
"""A base entity for point related entities within a bosch alarm panel."""
|
||||
|
||||
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
|
||||
"""Set up a area related entity for a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id)
|
||||
self._point_id = point_id
|
||||
self._point_unique_id = f"{unique_id}_point_{point_id}"
|
||||
self._point = panel.points[point_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._point_unique_id)},
|
||||
name=self._point.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(DOMAIN, unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._point.status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._point.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmDoorEntity(BoschAlarmEntity):
|
||||
"""A base entity for area related entities within a bosch alarm panel."""
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alarms_gas": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"alarms_fire": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"alarms_burglary": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"faulting_points": {
|
||||
"default": "mdi:alert-circle"
|
||||
}
|
||||
@@ -24,6 +33,44 @@
|
||||
"on": "mdi:lock-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"panel_fault_parameter_crc_fail_in_pif": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_phone_line_failure": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_sdi_fail_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_user_code_tamper_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_point_bus_fail_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_log_overflow": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_log_threshold": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"area_ready_to_arm_away": {
|
||||
"default": "mdi:shield",
|
||||
"state": {
|
||||
"on": "mdi:shield-lock"
|
||||
}
|
||||
},
|
||||
"area_ready_to_arm_home": {
|
||||
"default": "mdi:shield",
|
||||
"state": {
|
||||
"on": "mdi:shield-home"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"name": "Bosch Alarm",
|
||||
"codeowners": ["@mag1024", "@sanjay900"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"macaddress": "000463*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -39,15 +39,15 @@ rules:
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES
|
||||
from bosch_alarm_mode2.panel import Area
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -15,18 +16,53 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
|
||||
ALARM_TYPES = {
|
||||
"burglary": {
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_ALARM: "alarm",
|
||||
},
|
||||
"gas": {
|
||||
ALARM_MEMORY_PRIORITIES.GAS_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.GAS_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.GAS_ALARM: "alarm",
|
||||
},
|
||||
"fire": {
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_ALARM: "alarm",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Bosch Alarm sensor entity."""
|
||||
|
||||
value_fn: Callable[[Area], int]
|
||||
value_fn: Callable[[Area], str | int]
|
||||
observe_alarms: bool = False
|
||||
observe_ready: bool = False
|
||||
observe_status: bool = False
|
||||
|
||||
|
||||
def priority_value_fn(priority_info: dict[int, str]) -> Callable[[Area], str]:
|
||||
"""Build a value_fn for a given priority type."""
|
||||
return lambda area: next(
|
||||
(key for priority, key in priority_info.items() if priority in area.alarms_ids),
|
||||
"no_issues",
|
||||
)
|
||||
|
||||
|
||||
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
|
||||
*[
|
||||
BoschAlarmSensorEntityDescription(
|
||||
key=f"alarms_{key}",
|
||||
translation_key=f"alarms_{key}",
|
||||
value_fn=priority_value_fn(priority_type),
|
||||
observe_alarms=True,
|
||||
)
|
||||
for key, priority_type in ALARM_TYPES.items()
|
||||
],
|
||||
BoschAlarmSensorEntityDescription(
|
||||
key="faulting_points",
|
||||
translation_key="faulting_points",
|
||||
@@ -81,6 +117,6 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
def native_value(self) -> str | int:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._area)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{model} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -42,6 +43,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
@@ -56,22 +58,95 @@
|
||||
"message": "Incorrect credentials for panel."
|
||||
},
|
||||
"incorrect_door_state": {
|
||||
"message": "Door cannot be manipulated while it is being cycled."
|
||||
"message": "Door cannot be manipulated while it is momentarily unlocked."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"panel_fault_battery_mising": {
|
||||
"name": "Battery missing"
|
||||
},
|
||||
"panel_fault_ac_fail": {
|
||||
"name": "AC Failure"
|
||||
},
|
||||
"panel_fault_parameter_crc_fail_in_pif": {
|
||||
"name": "CRC failure in panel configuration"
|
||||
},
|
||||
"panel_fault_phone_line_failure": {
|
||||
"name": "Phone line failure"
|
||||
},
|
||||
"panel_fault_sdi_fail_since_rps_hang_up": {
|
||||
"name": "SDI failure since RPS hang up"
|
||||
},
|
||||
"panel_fault_user_code_tamper_since_rps_hang_up": {
|
||||
"name": "User code tamper since RPS hang up"
|
||||
},
|
||||
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
|
||||
"name": "Failure to call RPS since RPS hang up"
|
||||
},
|
||||
"panel_fault_point_bus_fail_since_rps_hang_up": {
|
||||
"name": "Point bus failure since RPS hang up"
|
||||
},
|
||||
"panel_fault_log_overflow": {
|
||||
"name": "Log overflow"
|
||||
},
|
||||
"panel_fault_log_threshold": {
|
||||
"name": "Log threshold reached"
|
||||
},
|
||||
"area_ready_to_arm_away": {
|
||||
"name": "Area ready to arm away",
|
||||
"state": {
|
||||
"on": "Ready",
|
||||
"off": "Not ready"
|
||||
}
|
||||
},
|
||||
"area_ready_to_arm_home": {
|
||||
"name": "Area ready to arm home",
|
||||
"state": {
|
||||
"on": "Ready",
|
||||
"off": "Not ready"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"secured": {
|
||||
"name": "Secured"
|
||||
},
|
||||
"cycling": {
|
||||
"name": "Cycling"
|
||||
"name": "Momentarily unlocked"
|
||||
},
|
||||
"locked": {
|
||||
"name": "Locked"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"alarms_gas": {
|
||||
"name": "Gas alarm issues",
|
||||
"state": {
|
||||
"supervisory": "Supervisory",
|
||||
"trouble": "Trouble",
|
||||
"alarm": "Alarm",
|
||||
"no_issues": "No issues"
|
||||
}
|
||||
},
|
||||
"alarms_fire": {
|
||||
"name": "Fire alarm issues",
|
||||
"state": {
|
||||
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
|
||||
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
|
||||
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
|
||||
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
|
||||
}
|
||||
},
|
||||
"alarms_burglary": {
|
||||
"name": "Burglary alarm issues",
|
||||
"state": {
|
||||
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
|
||||
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
|
||||
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
|
||||
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
|
||||
}
|
||||
},
|
||||
"faulting_points": {
|
||||
"name": "Faulting points",
|
||||
"unit_of_measurement": "points"
|
||||
|
||||
@@ -60,7 +60,7 @@ from .const import (
|
||||
ADDED_CAST_DEVICES_KEY,
|
||||
CAST_MULTIZONE_MANAGER_KEY,
|
||||
CONF_IGNORE_CEC,
|
||||
DOMAIN as CAST_DOMAIN,
|
||||
DOMAIN,
|
||||
SIGNAL_CAST_DISCOVERED,
|
||||
SIGNAL_CAST_REMOVED,
|
||||
SIGNAL_HASS_CAST_SHOW_VIEW,
|
||||
@@ -315,7 +315,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
self._cast_view_remove_handler: CALLBACK_TYPE | None = None
|
||||
self._attr_unique_id = str(cast_info.uuid)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))},
|
||||
identifiers={(DOMAIN, str(cast_info.uuid).replace("-", ""))},
|
||||
manufacturer=str(cast_info.cast_info.manufacturer),
|
||||
model=cast_info.cast_info.model_name,
|
||||
name=str(cast_info.friendly_name),
|
||||
@@ -591,7 +591,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
"""Generate root node."""
|
||||
children = []
|
||||
# Add media browsers
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
children.extend(
|
||||
await platform.async_get_media_browser_root_object(
|
||||
self.hass, self._chromecast.cast_type
|
||||
@@ -650,7 +650,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
platform: CastProtocol
|
||||
assert media_content_type is not None
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
browse_media = await platform.async_browse_media(
|
||||
self.hass,
|
||||
media_content_type,
|
||||
@@ -680,7 +680,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||
|
||||
# Handle media supported by a known cast app
|
||||
if media_type == CAST_DOMAIN:
|
||||
if media_type == DOMAIN:
|
||||
try:
|
||||
app_data = json.loads(media_id)
|
||||
if metadata := extra.get("metadata"):
|
||||
@@ -712,7 +712,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return
|
||||
|
||||
# Try the cast platforms
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
result = await platform.async_play_media(
|
||||
self.hass, self.entity_id, chromecast, media_type, media_id
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
DOMAIN as CLOUD_DOMAIN,
|
||||
DOMAIN,
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
PREF_ENABLE_ALEXA,
|
||||
PREF_SHOULD_EXPOSE,
|
||||
@@ -55,7 +55,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
|
||||
CLOUD_ALEXA = f"{DOMAIN}.{ALEXA_DOMAIN}"
|
||||
|
||||
# Time to wait when entity preferences have changed before syncing it to
|
||||
# the cloud.
|
||||
|
||||
@@ -41,7 +41,7 @@ from .const import (
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
DEFAULT_DISABLE_2FA,
|
||||
DOMAIN as CLOUD_DOMAIN,
|
||||
DOMAIN,
|
||||
PREF_DISABLE_2FA,
|
||||
PREF_SHOULD_EXPOSE,
|
||||
)
|
||||
@@ -52,7 +52,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
|
||||
CLOUD_GOOGLE = f"{DOMAIN}.{GOOGLE_DOMAIN}"
|
||||
|
||||
|
||||
SUPPORTED_DOMAINS = {
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.100.0"],
|
||||
"requirements": ["hass-nabucasa==0.101.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -134,11 +134,9 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
self._attr_current_temperature = values[0] / 10
|
||||
|
||||
self._attr_hvac_action = None
|
||||
if _mode == ClimaComelitMode.OFF:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
if not _active:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
if _mode in API_STATUS:
|
||||
elif _mode in API_STATUS:
|
||||
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
|
||||
|
||||
self._attr_hvac_mode = None
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, cast
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -68,16 +68,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
|
||||
if self._last_state in [None, "unknown"]:
|
||||
return None
|
||||
|
||||
if self.device_status != STATE_COVER.index("stopped"):
|
||||
return False
|
||||
|
||||
if self._last_action:
|
||||
return self._last_action == STATE_COVER.index("closing")
|
||||
|
||||
return self._last_state == CoverState.CLOSED
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocomelit==0.12.1"]
|
||||
"requirements": ["aiocomelit==0.12.3"]
|
||||
}
|
||||
|
||||
@@ -55,10 +55,8 @@ rules:
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: review and complete missing ones
|
||||
docs-supported-functions: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["debugpy==1.8.13"]
|
||||
"requirements": ["debugpy==1.8.14"]
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from ..const import (
|
||||
CONF_MASTER_GATEWAY,
|
||||
DOMAIN as DECONZ_DOMAIN,
|
||||
HASSIO_CONFIGURATION_URL,
|
||||
PLATFORMS,
|
||||
)
|
||||
from ..const import CONF_MASTER_GATEWAY, DOMAIN, HASSIO_CONFIGURATION_URL, PLATFORMS
|
||||
from .config import DeconzConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -193,7 +188,7 @@ class DeconzHub:
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
configuration_url=configuration_url,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)},
|
||||
identifiers={(DOMAIN, self.api.config.bridge_id)},
|
||||
manufacturer="Dresden Elektronik",
|
||||
model=self.api.config.model_id,
|
||||
name=self.api.config.name,
|
||||
|
||||
@@ -6,12 +6,16 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
SearchMedia,
|
||||
SearchMediaQuery,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -407,3 +411,18 @@ class DemoSearchPlayer(AbstractDemoPlayer):
|
||||
"""A Demo media player that supports searching."""
|
||||
|
||||
_attr_supported_features = SEARCH_PLAYER_SUPPORT
|
||||
|
||||
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
|
||||
"""Demo implementation of search media."""
|
||||
return SearchMedia(
|
||||
result=[
|
||||
BrowseMedia(
|
||||
title="Search result",
|
||||
media_class=MediaClass.MOVIE,
|
||||
media_content_type=MediaType.MOVIE,
|
||||
media_content_id="search_result_id",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/denonavr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.1.0"],
|
||||
"requirements": ["denonavr==1.1.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Denon",
|
||||
|
||||
@@ -8,11 +8,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
TriggerProtocol,
|
||||
)
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import (
|
||||
@@ -25,13 +21,28 @@ from .helpers import async_validate_device_automation_config
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol):
|
||||
class DeviceAutomationTriggerProtocol(Protocol):
|
||||
"""Define the format of device_trigger modules.
|
||||
|
||||
Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config
|
||||
from TriggerProtocol.
|
||||
Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config.
|
||||
"""
|
||||
|
||||
TRIGGER_SCHEMA: vol.Schema
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
|
||||
async def async_attach_trigger(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: TriggerActionType,
|
||||
trigger_info: TriggerInfo,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
async def async_get_trigger_capabilities(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> dict[str, vol.Schema]:
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache, partial
|
||||
from ipaddress import IPv4Address
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
@@ -22,6 +23,7 @@ from aiodiscover.discovery import (
|
||||
from cached_ipaddress import cached_ip_addresses
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import network
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IP,
|
||||
@@ -421,9 +423,33 @@ class DHCPWatcher(WatcherBase):
|
||||
response.ip_address, response.hostname, response.mac_address
|
||||
)
|
||||
|
||||
async def async_get_adapter_indexes(self) -> list[int] | None:
|
||||
"""Get the adapter indexes."""
|
||||
adapters = await network.async_get_adapters(self.hass)
|
||||
if network.async_only_default_interface_enabled(adapters):
|
||||
return None
|
||||
return [
|
||||
adapter["index"]
|
||||
for adapter in adapters
|
||||
if (
|
||||
adapter["enabled"]
|
||||
and adapter["index"] is not None
|
||||
and adapter["ipv4"]
|
||||
and (
|
||||
addresses := [IPv4Address(ip["address"]) for ip in adapter["ipv4"]]
|
||||
)
|
||||
and any(
|
||||
ip for ip in addresses if not ip.is_loopback and not ip.is_global
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start watching for dhcp packets."""
|
||||
self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request)
|
||||
self._unsub = await aiodhcpwatcher.async_start(
|
||||
self._async_process_dhcp_request,
|
||||
await self.async_get_adapter_indexes(),
|
||||
)
|
||||
|
||||
|
||||
class RediscoveryWatcher(WatcherBase):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "dhcp",
|
||||
"name": "DHCP Discovery",
|
||||
"codeowners": ["@bdraco"],
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dhcp",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -2,32 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
ATTR_FILENAME,
|
||||
ATTR_OVERWRITE,
|
||||
ATTR_SUBDIR,
|
||||
ATTR_URL,
|
||||
CONF_DOWNLOAD_DIR,
|
||||
DOMAIN,
|
||||
DOWNLOAD_COMPLETED_EVENT,
|
||||
DOWNLOAD_FAILED_EVENT,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
)
|
||||
from .const import _LOGGER, CONF_DOWNLOAD_DIR
|
||||
from .services import register_services
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -44,127 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
def download_file(service: ServiceCall) -> None:
|
||||
"""Start thread to download file specified in the URL."""
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
_LOGGER.warning(
|
||||
"Downloading '%s' failed, status_code=%d", url, req.status_code
|
||||
)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
else:
|
||||
if filename is None and "content-disposition" in req.headers:
|
||||
match = re.findall(
|
||||
r"filename=(\S+)", req.headers["content-disposition"]
|
||||
)
|
||||
|
||||
if match:
|
||||
filename = match[0].strip("'\" ")
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(url).strip()
|
||||
|
||||
if not filename:
|
||||
filename = "ha_download"
|
||||
|
||||
# Check the filename
|
||||
raise_if_invalid_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
subdir_path = os.path.join(download_path, subdir)
|
||||
|
||||
# Ensure subdir exist
|
||||
os.makedirs(subdir_path, exist_ok=True)
|
||||
|
||||
final_path = os.path.join(subdir_path, filename)
|
||||
|
||||
else:
|
||||
final_path = os.path.join(download_path, filename)
|
||||
|
||||
path, ext = os.path.splitext(final_path)
|
||||
|
||||
# If file exist append a number.
|
||||
# We test filename, filename_2..
|
||||
if not overwrite:
|
||||
tries = 1
|
||||
final_path = path + ext
|
||||
while os.path.isfile(final_path):
|
||||
tries += 1
|
||||
|
||||
final_path = f"{path}_{tries}.{ext}"
|
||||
|
||||
_LOGGER.debug("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, "wb") as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
_LOGGER.debug("Downloading of %s done", url)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.exception("ConnectionError occurred for %s", url)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
except ValueError:
|
||||
_LOGGER.exception("Invalid value")
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
download_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Support for functionality to download files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
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_OVERWRITE,
|
||||
ATTR_SUBDIR,
|
||||
ATTR_URL,
|
||||
CONF_DOWNLOAD_DIR,
|
||||
DOMAIN,
|
||||
DOWNLOAD_COMPLETED_EVENT,
|
||||
DOWNLOAD_FAILED_EVENT,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
)
|
||||
|
||||
|
||||
def download_file(service: ServiceCall) -> None:
|
||||
"""Start thread to download file specified in the URL."""
|
||||
|
||||
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
download_path = entry.data[CONF_DOWNLOAD_DIR]
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
_LOGGER.warning(
|
||||
"Downloading '%s' failed, status_code=%d", url, req.status_code
|
||||
)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
else:
|
||||
if filename is None and "content-disposition" in req.headers:
|
||||
match = re.findall(
|
||||
r"filename=(\S+)", req.headers["content-disposition"]
|
||||
)
|
||||
|
||||
if match:
|
||||
filename = match[0].strip("'\" ")
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(url).strip()
|
||||
|
||||
if not filename:
|
||||
filename = "ha_download"
|
||||
|
||||
# Check the filename
|
||||
raise_if_invalid_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
subdir_path = os.path.join(download_path, subdir)
|
||||
|
||||
# Ensure subdir exist
|
||||
os.makedirs(subdir_path, exist_ok=True)
|
||||
|
||||
final_path = os.path.join(subdir_path, filename)
|
||||
|
||||
else:
|
||||
final_path = os.path.join(download_path, filename)
|
||||
|
||||
path, ext = os.path.splitext(final_path)
|
||||
|
||||
# If file exist append a number.
|
||||
# We test filename, filename_2..
|
||||
if not overwrite:
|
||||
tries = 1
|
||||
final_path = path + ext
|
||||
while os.path.isfile(final_path):
|
||||
tries += 1
|
||||
|
||||
final_path = f"{path}_{tries}.{ext}"
|
||||
|
||||
_LOGGER.debug("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, "wb") as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
_LOGGER.debug("Downloading of %s done", url)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.exception("ConnectionError occurred for %s", url)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
except ValueError:
|
||||
_LOGGER.exception("Invalid value")
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
"""Register the services for the downloader component."""
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
download_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -148,11 +148,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
if target_temp_low or target_temp_high:
|
||||
self._econet.set_set_point(None, target_temp_high, target_temp_low)
|
||||
|
||||
@property
|
||||
def is_aux_heat(self) -> bool:
|
||||
"""Return true if aux heater."""
|
||||
return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool, mode.
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
async_add_entities(
|
||||
get_supported_entitites(
|
||||
get_supported_entities(
|
||||
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,13 +16,13 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
|
||||
from .const import SUPPORTED_LIFESPANS
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
|
||||
key=f"station_action_{action.name.lower()}",
|
||||
translation_key=f"station_action_{action.name.lower()}",
|
||||
)
|
||||
for action in SUPPORTED_STATION_ACTIONS
|
||||
for action in StationAction
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
|
||||
@@ -172,7 +172,13 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input:
|
||||
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_OVERRIDE_REST_URL: user_input.get(CONF_OVERRIDE_REST_URL),
|
||||
CONF_OVERRIDE_MQTT_URL: user_input.get(CONF_OVERRIDE_MQTT_URL),
|
||||
}
|
||||
)
|
||||
|
||||
errors = await _validate_input(self.hass, user_input)
|
||||
|
||||
@@ -214,6 +220,9 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=vol.Schema(schema), suggested_values=user_input
|
||||
),
|
||||
description_placeholders={
|
||||
"account_name": "Ecovacs",
|
||||
},
|
||||
errors=errors,
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from .entity import (
|
||||
EcovacsEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -87,7 +87,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
rules:
|
||||
# Bronze
|
||||
config-flow: done
|
||||
test-before-configure: done
|
||||
unique-config-entry: done
|
||||
config-flow-test-coverage: done
|
||||
runtime-data: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: Legacy code will not raise on setup currently
|
||||
appropriate-polling:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Please check legacy code.
|
||||
deebot-client pulls only once at beginning and afterwards is pushed based
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
entity-event-setup: done
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: Currently unsure if all dependencies need to validated or only direct ones.
|
||||
action-setup:
|
||||
status: done
|
||||
comment: "`raw_get_positions` is a entity service"
|
||||
common-modules: done
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: todo
|
||||
docs-removal-instructions: todo
|
||||
docs-actions: todo
|
||||
brands: done
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: todo
|
||||
entity-unavailable: done
|
||||
action-exceptions: todo
|
||||
reauthentication-flow: todo
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Please check legacy code.
|
||||
deebot-client uses internally semaphores to prevent to many parallel requests
|
||||
test-coverage: todo
|
||||
integration-owner: done
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: todo
|
||||
|
||||
# Gold
|
||||
entity-translations:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Legacy entities are not translated
|
||||
entity-device-class: done
|
||||
devices: done
|
||||
entity-category: done
|
||||
entity-disabled-by-default: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Not supported as we don't talk directly to the devices
|
||||
stale-devices: todo
|
||||
diagnostics: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: New devices are discovered only on boot currently
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Not supported as we don't talk directly to the devices
|
||||
repair-issues: todo
|
||||
docs-use-cases: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-data-update: todo
|
||||
docs-known-limitations: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-examples: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Please check legacy code.
|
||||
deebot-client is async
|
||||
inject-websession:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Please check legacy code.
|
||||
deebot-client uses the passed websession
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: |
|
||||
@mib1185 Please check legacy code.
|
||||
deebot-client is typed
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_name_key, get_supported_entitites
|
||||
from .util import get_name_key, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -59,7 +59,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities = get_supported_entitites(
|
||||
entities = get_supported_entities(
|
||||
controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -6,7 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan
|
||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import (
|
||||
BatteryEvent,
|
||||
ErrorEvent,
|
||||
@@ -34,7 +35,7 @@ from homeassistant.const import (
|
||||
UnitOfArea,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
@@ -47,7 +48,7 @@ from .entity import (
|
||||
EcovacsLegacyEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_name_key, get_options, get_supported_entitites
|
||||
from .util import get_name_key, get_options, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -59,6 +60,15 @@ class EcovacsSensorEntityDescription(
|
||||
"""Ecovacs sensor entity description."""
|
||||
|
||||
value_fn: Callable[[EventT], StateType]
|
||||
native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None
|
||||
|
||||
|
||||
@callback
|
||||
def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None:
|
||||
"""Get the area native unit of measurement based on device type."""
|
||||
if device_type is DeviceType.MOWER:
|
||||
return UnitOfArea.SQUARE_CENTIMETERS
|
||||
return UnitOfArea.SQUARE_METERS
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
@@ -68,7 +78,9 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
capability_fn=lambda caps: caps.stats.clean,
|
||||
value_fn=lambda e: e.area,
|
||||
translation_key="stats_area",
|
||||
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
device_class=SensorDeviceClass.AREA,
|
||||
native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
|
||||
suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
),
|
||||
EcovacsSensorEntityDescription[StatsEvent](
|
||||
key="stats_time",
|
||||
@@ -85,8 +97,10 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
value_fn=lambda e: e.area,
|
||||
key="total_stats_area",
|
||||
translation_key="total_stats_area",
|
||||
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
device_class=SensorDeviceClass.AREA,
|
||||
native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
),
|
||||
EcovacsSensorEntityDescription[TotalStatsEvent](
|
||||
capability_fn=lambda caps: caps.stats.total,
|
||||
@@ -197,7 +211,7 @@ async def async_setup_entry(
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsSensor, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
@@ -249,6 +263,27 @@ class EcovacsSensor(
|
||||
|
||||
entity_description: EcovacsSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilityEvent,
|
||||
entity_description: EcovacsSensorEntityDescription,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, entity_description, **kwargs)
|
||||
if (
|
||||
entity_description.native_unit_of_measurement_fn
|
||||
and (
|
||||
native_unit_of_measurement
|
||||
:= entity_description.native_unit_of_measurement_fn(
|
||||
device.capabilities.device_type
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self._attr_native_unit_of_measurement = native_unit_of_measurement
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
"verify_mqtt_certificate": "Verify MQTT SSL certificate"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "The country of your {account_name} account.",
|
||||
"override_rest_url": "Enter the REST URL of your self-hosted instance including the scheme (http/https).",
|
||||
"override_mqtt_url": "Enter the MQTT URL of your self-hosted instance including the scheme (mqtt/mqtts)."
|
||||
"override_mqtt_url": "Enter the MQTT URL of your self-hosted instance including the scheme (mqtt/mqtts).",
|
||||
"password": "[%key:common::config_flow::data_description::password%]",
|
||||
"username": "[%key:common::config_flow::data_description::username%]",
|
||||
"verify_mqtt_certificate": "Should SSL certificates be verified? Uncheck this checkbox only if you are using a self-signed certificate."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -17,7 +17,7 @@ from .entity import (
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -109,7 +109,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -32,7 +32,7 @@ def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str:
|
||||
)
|
||||
|
||||
|
||||
def get_supported_entitites(
|
||||
def get_supported_entities(
|
||||
controller: EcovacsController,
|
||||
entity_class: type[EcovacsDescriptionEntity],
|
||||
descriptions: tuple[EcovacsCapabilityEntityDescription, ...],
|
||||
|
||||
@@ -13,6 +13,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
},
|
||||
"night_temperature_offset": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"system_led": {
|
||||
"default": "mdi:led-on",
|
||||
"state": {
|
||||
"0": "mdi:led-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -109,6 +109,20 @@ HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ..
|
||||
),
|
||||
)
|
||||
|
||||
GENERAL_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalDevice], ...] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalDevice](
|
||||
key="system_led",
|
||||
translation_key="system_led",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device: device.sys_led,
|
||||
set_value_fn=lambda device, value: device.set_sys_led(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -138,6 +152,10 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in HEATER_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description)
|
||||
for description in GENERAL_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""EHEIM Digital select entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import FilterMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital select entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT_co], str | None]
|
||||
set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]]
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSelectDescription[EheimDigitalClassicVario], ...
|
||||
] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalClassicVario](
|
||||
key="filter_mode",
|
||||
translation_key="filter_mode",
|
||||
value_fn=(
|
||||
lambda device: device.filter_mode.name.lower()
|
||||
if device.filter_mode is not None
|
||||
else None
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
|
||||
),
|
||||
options=[name.lower() for name in FilterMode.__members__],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so select entities can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the number entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalSelect[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
EheimDigitalSelect[EheimDigitalClassicVario](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in CLASSICVARIO_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSelect(
|
||||
EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent an EHEIM Digital select entity."""
|
||||
|
||||
entity_description: EheimDigitalSelectDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalSelectDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital select entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@override
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
return await self.entity_description.set_value_fn(self._device, option)
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_current_option = self.entity_description.value_fn(self._device)
|
||||
@@ -62,6 +62,19 @@
|
||||
},
|
||||
"night_temperature_offset": {
|
||||
"name": "Night temperature offset"
|
||||
},
|
||||
"system_led": {
|
||||
"name": "System LED brightness"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"filter_mode": {
|
||||
"name": "Filter mode",
|
||||
"state": {
|
||||
"manual": "Manual",
|
||||
"pulse": "Pulse",
|
||||
"bio": "Bio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -20,10 +20,8 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import PRECISION_WHOLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import ElkEntity, create_elk_entities
|
||||
|
||||
SUPPORT_HVAC = [
|
||||
@@ -78,7 +76,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
_attr_precision = PRECISION_WHOLE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.AUX_HEAT
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
@@ -128,11 +125,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
"""Return the current humidity."""
|
||||
return self._element.humidity
|
||||
|
||||
@property
|
||||
def is_aux_heat(self) -> bool:
|
||||
"""Return if aux heater is on."""
|
||||
return self._element.mode == ThermostatMode.EMERGENCY_HEAT
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
@@ -151,34 +143,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
thermostat_mode, fan_mode = HASS_TO_ELK_HVAC_MODES[hvac_mode]
|
||||
self._elk_set(thermostat_mode, fan_mode)
|
||||
|
||||
async def async_turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
translation_key="migrate_aux_heat",
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
self._elk_set(ThermostatMode.EMERGENCY_HEAT, None)
|
||||
|
||||
async def async_turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
translation_key="migrate_aux_heat",
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
self._elk_set(ThermostatMode.HEAT, None)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
thermostat_mode, elk_fan_mode = HASS_TO_ELK_FAN_MODES[fan_mode]
|
||||
|
||||
@@ -189,18 +189,5 @@
|
||||
"name": "Sensor zone trigger",
|
||||
"description": "Triggers zone."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"migrate_aux_heat": {
|
||||
"title": "Migration of Elk-M1 set_aux_heat action",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select **Submit** to fix this issue.",
|
||||
"title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.7"]
|
||||
"requirements": ["sense-energy==0.13.8"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ from pyephember2.pyephember2 import (
|
||||
ZoneMode,
|
||||
zone_current_temperature,
|
||||
zone_is_active,
|
||||
zone_is_boost_active,
|
||||
zone_is_hotwater,
|
||||
zone_mode,
|
||||
zone_name,
|
||||
@@ -102,7 +101,6 @@ class EphEmberThermostat(ClimateEntity):
|
||||
self._attr_name = self._zone_name
|
||||
|
||||
if self._hot_water:
|
||||
self._attr_supported_features = ClimateEntityFeature.AUX_HEAT
|
||||
self._attr_target_temperature_step = None
|
||||
else:
|
||||
self._attr_target_temperature_step = 0.5
|
||||
@@ -144,22 +142,6 @@ class EphEmberThermostat(ClimateEntity):
|
||||
else:
|
||||
_LOGGER.error("Invalid operation mode provided %s", hvac_mode)
|
||||
|
||||
@property
|
||||
def is_aux_heat(self) -> bool:
|
||||
"""Return true if aux heater."""
|
||||
|
||||
return zone_is_boost_active(self._zone)
|
||||
|
||||
def turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
self._ember.activate_boost_by_name(
|
||||
self._zone_name, zone_target_temperature(self._zone)
|
||||
)
|
||||
|
||||
def turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
self._ember.deactivate_boost_by_name(self._zone_name)
|
||||
|
||||
def set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
|
||||
@@ -239,7 +239,6 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
self._states = cast(dict[int, _StateT], entry_data.state[state_type])
|
||||
assert entry_data.device_info is not None
|
||||
device_info = entry_data.device_info
|
||||
self._device_info = device_info
|
||||
self._on_entry_data_changed()
|
||||
self._key = entity_info.key
|
||||
self._state_type = state_type
|
||||
@@ -327,6 +326,11 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
@callback
|
||||
def _on_entry_data_changed(self) -> None:
|
||||
entry_data = self._entry_data
|
||||
# Update the device info since it can change
|
||||
# when the device is reconnected
|
||||
if TYPE_CHECKING:
|
||||
assert entry_data.device_info is not None
|
||||
self._device_info = entry_data.device_info
|
||||
self._api_version = entry_data.api_version
|
||||
self._client = entry_data.client
|
||||
if self._device_info.has_deep_sleep:
|
||||
|
||||
@@ -63,7 +63,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
if self._supports_speed_levels:
|
||||
data["speed_level"] = math.ceil(
|
||||
percentage_to_ranged_value(
|
||||
(1, self._static_info.supported_speed_levels), percentage
|
||||
(1, self._static_info.supported_speed_count), percentage
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -121,7 +121,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
)
|
||||
|
||||
return ranged_value_to_percentage(
|
||||
(1, self._static_info.supported_speed_levels), self._state.speed_level
|
||||
(1, self._static_info.supported_speed_count), self._state.speed_level
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -164,7 +164,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
if not supports_speed_levels:
|
||||
self._attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
else:
|
||||
self._attr_speed_count = static_info.supported_speed_levels
|
||||
self._attr_speed_count = static_info.supported_speed_count
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIVersion,
|
||||
ColorMode as ESPHomeColorMode,
|
||||
EntityInfo,
|
||||
LightColorCapability,
|
||||
LightInfo,
|
||||
@@ -106,15 +107,15 @@ def _mired_to_kelvin(mired_temperature: float) -> int:
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _color_mode_to_ha(mode: int) -> str:
|
||||
def _color_mode_to_ha(mode: ESPHomeColorMode) -> ColorMode:
|
||||
"""Convert an esphome color mode to a HA color mode constant.
|
||||
|
||||
Choose the color mode that best matches the feature-set.
|
||||
"""
|
||||
candidates = []
|
||||
candidates: list[tuple[ColorMode, LightColorCapability]] = []
|
||||
for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items():
|
||||
for caps in cap_lists:
|
||||
if caps == mode:
|
||||
if caps.value == mode:
|
||||
# exact match
|
||||
return ha_mode
|
||||
if (mode & caps) == caps:
|
||||
@@ -131,8 +132,8 @@ def _color_mode_to_ha(mode: int) -> str:
|
||||
|
||||
@lru_cache
|
||||
def _filter_color_modes(
|
||||
supported: list[int], features: LightColorCapability
|
||||
) -> tuple[int, ...]:
|
||||
supported: list[ESPHomeColorMode], features: LightColorCapability
|
||||
) -> tuple[ESPHomeColorMode, ...]:
|
||||
"""Filter the given supported color modes.
|
||||
|
||||
Excluding all values that don't have the requested features.
|
||||
@@ -156,7 +157,7 @@ def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int:
|
||||
class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
"""A light implementation for ESPHome."""
|
||||
|
||||
_native_supported_color_modes: tuple[int, ...]
|
||||
_native_supported_color_modes: tuple[ESPHomeColorMode, ...]
|
||||
_supports_color_mode = False
|
||||
|
||||
@property
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==30.2.0",
|
||||
"aioesphomeapi==31.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==2.15.1"
|
||||
],
|
||||
|
||||
@@ -88,9 +88,9 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
return
|
||||
if (
|
||||
state_class == EsphomeSensorStateClass.MEASUREMENT
|
||||
and static_info.last_reset_type == LastResetType.AUTO
|
||||
and static_info.legacy_last_reset_type == LastResetType.AUTO
|
||||
):
|
||||
# Legacy, last_reset_type auto was the equivalent to the
|
||||
# Legacy, legacy_last_reset_type auto was the equivalent to the
|
||||
# TOTAL_INCREASING state class
|
||||
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
else:
|
||||
|
||||
@@ -35,7 +35,7 @@ async def validate_host(
|
||||
hass: HomeAssistant, host: str
|
||||
) -> tuple[str, FroniusConfigEntryData]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
fronius = Fronius(async_get_clientsession(hass), host)
|
||||
fronius = Fronius(async_get_clientsession(hass, verify_ssl=False), host)
|
||||
|
||||
try:
|
||||
datalogger_info: dict[str, Any]
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"current_dc": {
|
||||
"default": "mdi:current-dc"
|
||||
},
|
||||
"current_dc_2": {
|
||||
"current_dc_mppt_no": {
|
||||
"default": "mdi:current-dc"
|
||||
},
|
||||
"voltage_dc": {
|
||||
"default": "mdi:current-dc"
|
||||
},
|
||||
"voltage_dc_2": {
|
||||
"voltage_dc_mppt_no": {
|
||||
"default": "mdi:current-dc"
|
||||
},
|
||||
"co2_factor": {
|
||||
|
||||
@@ -168,6 +168,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "2"},
|
||||
),
|
||||
FroniusSensorEntityDescription(
|
||||
key="current_dc_3",
|
||||
default_value=0,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "3"},
|
||||
),
|
||||
FroniusSensorEntityDescription(
|
||||
key="current_dc_4",
|
||||
default_value=0,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="current_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "4"},
|
||||
),
|
||||
FroniusSensorEntityDescription(
|
||||
key="power_ac",
|
||||
@@ -197,6 +217,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "2"},
|
||||
),
|
||||
FroniusSensorEntityDescription(
|
||||
key="voltage_dc_3",
|
||||
default_value=0,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "3"},
|
||||
),
|
||||
FroniusSensorEntityDescription(
|
||||
key="voltage_dc_4",
|
||||
default_value=0,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_dc_mppt_no",
|
||||
translation_placeholders={"mppt_no": "4"},
|
||||
),
|
||||
# device status entities
|
||||
FroniusSensorEntityDescription(
|
||||
@@ -727,7 +767,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
|
||||
self.response_key = description.response_key or description.key
|
||||
self.solar_net_id = solar_net_id
|
||||
self._attr_native_value = self._get_entity_value()
|
||||
self._attr_translation_key = description.key
|
||||
self._attr_translation_key = description.translation_key or description.key
|
||||
|
||||
def _device_data(self) -> dict[str, Any]:
|
||||
"""Extract information for SolarNet device from coordinator data."""
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
"current_dc": {
|
||||
"name": "DC current"
|
||||
},
|
||||
"current_dc_2": {
|
||||
"name": "DC current 2"
|
||||
"current_dc_mppt_no": {
|
||||
"name": "DC current {mppt_no}"
|
||||
},
|
||||
"power_ac": {
|
||||
"name": "AC power"
|
||||
@@ -64,8 +64,8 @@
|
||||
"voltage_dc": {
|
||||
"name": "DC voltage"
|
||||
},
|
||||
"voltage_dc_2": {
|
||||
"name": "DC voltage 2"
|
||||
"voltage_dc_mppt_no": {
|
||||
"name": "DC voltage {mppt_no}"
|
||||
},
|
||||
"inverter_state": {
|
||||
"name": "Inverter state"
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250509.0"]
|
||||
"requirements": ["home-assistant-frontend==20250516.0"]
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ from homeassistant.helpers import config_entry_flow, config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type GeofencyConfigEntry = ConfigEntry[set[str]]
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
CONF_MOBILE_BEACONS = "mobile_beacons"
|
||||
@@ -75,16 +78,13 @@ WEBHOOK_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DATA_GEOFENCY: HassKey[list[str]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
|
||||
"""Set up the Geofency component."""
|
||||
config = hass_config.get(DOMAIN, {})
|
||||
mobile_beacons = config.get(CONF_MOBILE_BEACONS, [])
|
||||
hass.data[DOMAIN] = {
|
||||
"beacons": [slugify(beacon) for beacon in mobile_beacons],
|
||||
"devices": set(),
|
||||
"unsub_device_tracker": {},
|
||||
}
|
||||
mobile_beacons = hass_config.get(DOMAIN, {}).get(CONF_MOBILE_BEACONS, [])
|
||||
hass.data[_DATA_GEOFENCY] = [slugify(beacon) for beacon in mobile_beacons]
|
||||
return True
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ async def handle_webhook(
|
||||
text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
)
|
||||
|
||||
if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]):
|
||||
if _is_mobile_beacon(data, hass.data[_DATA_GEOFENCY]):
|
||||
return _set_location(hass, data, None)
|
||||
if data["entry"] == LOCATION_ENTRY:
|
||||
location_name = data["name"]
|
||||
@@ -140,8 +140,9 @@ def _set_location(hass, data, location_name):
|
||||
return web.Response(text=f"Setting location for {device}")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GeofencyConfigEntry) -> bool:
|
||||
"""Configure based on config entry."""
|
||||
entry.runtime_data = set()
|
||||
webhook.async_register(
|
||||
hass, DOMAIN, "Geofency", entry.data[CONF_WEBHOOK_ID], handle_webhook
|
||||
)
|
||||
@@ -150,10 +151,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GeofencyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for the Geofency device tracker platform."""
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -10,12 +9,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import DOMAIN, TRACKER_UPDATE
|
||||
from . import TRACKER_UPDATE, GeofencyConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: GeofencyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Geofency config entry."""
|
||||
@@ -23,14 +23,16 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def _receive_data(device, gps, location_name, attributes):
|
||||
"""Fire HA event to set location."""
|
||||
if device in hass.data[DOMAIN]["devices"]:
|
||||
if device in config_entry.runtime_data:
|
||||
return
|
||||
|
||||
hass.data[DOMAIN]["devices"].add(device)
|
||||
config_entry.runtime_data.add(device)
|
||||
|
||||
async_add_entities([GeofencyEntity(device, gps, location_name, attributes)])
|
||||
async_add_entities(
|
||||
[GeofencyEntity(config_entry, device, gps, location_name, attributes)]
|
||||
)
|
||||
|
||||
hass.data[DOMAIN]["unsub_device_tracker"][config_entry.entry_id] = (
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)
|
||||
)
|
||||
|
||||
@@ -45,8 +47,8 @@ async def async_setup_entry(
|
||||
}
|
||||
|
||||
if dev_ids:
|
||||
hass.data[DOMAIN]["devices"].update(dev_ids)
|
||||
async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids)
|
||||
config_entry.runtime_data.update(dev_ids)
|
||||
async_add_entities(GeofencyEntity(config_entry, dev_id) for dev_id in dev_ids)
|
||||
|
||||
|
||||
class GeofencyEntity(TrackerEntity, RestoreEntity):
|
||||
@@ -55,8 +57,9 @@ class GeofencyEntity(TrackerEntity, RestoreEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, device, gps=None, location_name=None, attributes=None):
|
||||
def __init__(self, entry, device, gps=None, location_name=None, attributes=None):
|
||||
"""Set up Geofency entity."""
|
||||
self._entry = entry
|
||||
self._attr_extra_state_attributes = attributes or {}
|
||||
self._name = device
|
||||
self._attr_location_name = location_name
|
||||
@@ -93,7 +96,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity):
|
||||
"""Clean up after entity before removal."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._unsub_dispatcher()
|
||||
self.hass.data[DOMAIN]["devices"].remove(self.unique_id)
|
||||
self._entry.runtime_data.remove(self.unique_id)
|
||||
|
||||
@callback
|
||||
def _async_receive_data(self, device, gps, location_name, attributes):
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["go2rtc-client==0.1.2"],
|
||||
"requirements": ["go2rtc-client==0.1.3b0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.2"]
|
||||
"requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.4"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_travel_time",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google", "homeassistant.helpers.location"],
|
||||
"requirements": ["google-maps-routing==0.6.14"]
|
||||
"requirements": ["google-maps-routing==0.6.15"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type GPSLoggerConfigEntry = ConfigEntry[set[str]]
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
TRACKER_UPDATE = f"{DOMAIN}_tracker_update"
|
||||
@@ -88,9 +90,9 @@ async def handle_webhook(
|
||||
return web.Response(text=f"Setting location for {device}")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GPSLoggerConfigEntry) -> bool:
|
||||
"""Configure based on config entry."""
|
||||
hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}})
|
||||
entry.runtime_data = set()
|
||||
webhook.async_register(
|
||||
hass, DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook
|
||||
)
|
||||
@@ -103,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for the GPSLogger device tracking."""
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
ATTR_GPS_ACCURACY,
|
||||
@@ -15,19 +14,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import DOMAIN, TRACKER_UPDATE
|
||||
from . import TRACKER_UPDATE, GPSLoggerConfigEntry
|
||||
from .const import (
|
||||
ATTR_ACTIVITY,
|
||||
ATTR_ALTITUDE,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_PROVIDER,
|
||||
ATTR_SPEED,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: GPSLoggerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Configure a dispatcher connection based on a config entry."""
|
||||
@@ -35,16 +35,14 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def _receive_data(device, gps, battery, accuracy, attrs):
|
||||
"""Receive set location."""
|
||||
if device in hass.data[DOMAIN]["devices"]:
|
||||
if device in entry.runtime_data:
|
||||
return
|
||||
|
||||
hass.data[DOMAIN]["devices"].add(device)
|
||||
entry.runtime_data.add(device)
|
||||
|
||||
async_add_entities([GPSLoggerEntity(device, gps, battery, accuracy, attrs)])
|
||||
|
||||
hass.data[DOMAIN]["unsub_device_tracker"][entry.entry_id] = (
|
||||
async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)
|
||||
)
|
||||
entry.async_on_unload(async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data))
|
||||
|
||||
# Restore previously loaded devices
|
||||
dev_reg = dr.async_get(hass)
|
||||
@@ -58,7 +56,7 @@ async def async_setup_entry(
|
||||
|
||||
entities = []
|
||||
for dev_id in dev_ids:
|
||||
hass.data[DOMAIN]["devices"].add(dev_id)
|
||||
entry.runtime_data.add(dev_id)
|
||||
entity = GPSLoggerEntity(dev_id, None, None, None, None)
|
||||
entities.append(entity)
|
||||
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
"""The Gree Climate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import (
|
||||
COORDINATORS,
|
||||
DATA_DISCOVERY_SERVICE,
|
||||
DISCOVERY_SCAN_INTERVAL,
|
||||
DISPATCHERS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import DiscoveryService
|
||||
from .const import DISCOVERY_SCAN_INTERVAL
|
||||
from .coordinator import DiscoveryService, GreeConfigEntry, GreeRuntimeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool:
|
||||
"""Set up Gree Climate from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
gree_discovery = DiscoveryService(hass, entry)
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery
|
||||
entry.runtime_data = GreeRuntimeData(
|
||||
discovery_service=gree_discovery, coordinators=[]
|
||||
)
|
||||
|
||||
async def _async_scan_update(_=None):
|
||||
bcast_addr = list(await async_get_ipv4_broadcast_addresses(hass))
|
||||
@@ -47,15 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if hass.data.get(DATA_DISCOVERY_SERVICE) is not None:
|
||||
hass.data.pop(DATA_DISCOVERY_SERVICE)
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(COORDINATORS, None)
|
||||
hass.data[DOMAIN].pop(DISPATCHERS, None)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -36,21 +36,18 @@ from homeassistant.components.climate import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
COORDINATORS,
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
FAN_MEDIUM_HIGH,
|
||||
FAN_MEDIUM_LOW,
|
||||
TARGET_TEMPERATURE_STEP,
|
||||
)
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
from .coordinator import DeviceDataUpdateCoordinator, GreeConfigEntry
|
||||
from .entity import GreeEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -87,17 +84,17 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH]
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: GreeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Gree HVAC device from a config entry."""
|
||||
|
||||
@callback
|
||||
def init_device(coordinator):
|
||||
def init_device(coordinator: DeviceDataUpdateCoordinator) -> None:
|
||||
"""Register the device."""
|
||||
async_add_entities([GreeClimateEntity(coordinator)])
|
||||
|
||||
for coordinator in hass.data[DOMAIN][COORDINATORS]:
|
||||
for coordinator in entry.runtime_data.coordinators:
|
||||
init_device(coordinator)
|
||||
|
||||
entry.async_on_unload(
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"""Constants for the Gree Climate integration."""
|
||||
|
||||
COORDINATORS = "coordinators"
|
||||
|
||||
DATA_DISCOVERY_SERVICE = "gree_discovery"
|
||||
|
||||
DISCOVERY_SCAN_INTERVAL = 300
|
||||
DISCOVERY_TIMEOUT = 8
|
||||
DISPATCH_DEVICE_DISCOVERED = "gree_device_discovered"
|
||||
DISPATCHERS = "dispatchers"
|
||||
|
||||
DOMAIN = "gree"
|
||||
COORDINATOR = "coordinator"
|
||||
|
||||
FAN_MEDIUM_LOW = "medium low"
|
||||
FAN_MEDIUM_HIGH = "medium high"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -20,7 +21,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
COORDINATORS,
|
||||
DISCOVERY_TIMEOUT,
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
@@ -31,14 +31,24 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type GreeConfigEntry = ConfigEntry[GreeRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GreeRuntimeData:
|
||||
"""RUntime data for Gree Climate integration."""
|
||||
|
||||
discovery_service: DiscoveryService
|
||||
coordinators: list[DeviceDataUpdateCoordinator]
|
||||
|
||||
|
||||
class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages polling for state changes from the device."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: GreeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device
|
||||
self, hass: HomeAssistant, config_entry: GreeConfigEntry, device: Device
|
||||
) -> None:
|
||||
"""Initialize the data update coordinator."""
|
||||
super().__init__(
|
||||
@@ -128,7 +138,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
class DiscoveryService(Listener):
|
||||
"""Discovery event handler for gree devices."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: GreeConfigEntry) -> None:
|
||||
"""Initialize discovery service."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
@@ -137,8 +147,6 @@ class DiscoveryService(Listener):
|
||||
self.discovery = Discovery(DISCOVERY_TIMEOUT)
|
||||
self.discovery.add_listener(self)
|
||||
|
||||
hass.data[DOMAIN].setdefault(COORDINATORS, [])
|
||||
|
||||
async def device_found(self, device_info: DeviceInfo) -> None:
|
||||
"""Handle new device found on the network."""
|
||||
|
||||
@@ -157,14 +165,14 @@ class DiscoveryService(Listener):
|
||||
device.device_info.port,
|
||||
)
|
||||
coordo = DeviceDataUpdateCoordinator(self.hass, self.entry, device)
|
||||
self.hass.data[DOMAIN][COORDINATORS].append(coordo)
|
||||
self.entry.runtime_data.coordinators.append(coordo)
|
||||
await coordo.async_refresh()
|
||||
|
||||
async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo)
|
||||
|
||||
async def device_update(self, device_info: DeviceInfo) -> None:
|
||||
"""Handle updates in device information, update if ip has changed."""
|
||||
for coordinator in self.hass.data[DOMAIN][COORDINATORS]:
|
||||
for coordinator in self.entry.runtime_data.coordinators:
|
||||
if coordinator.device.device_info.mac == device_info.mac:
|
||||
coordinator.device.device_info.ip = device_info.ip
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -13,13 +13,13 @@ from homeassistant.components.switch import (
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
|
||||
from .entity import GreeEntity
|
||||
from .const import DISPATCH_DEVICE_DISCOVERED
|
||||
from .coordinator import GreeConfigEntry
|
||||
from .entity import DeviceDataUpdateCoordinator, GreeEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -92,13 +92,13 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: GreeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Gree HVAC device from a config entry."""
|
||||
|
||||
@callback
|
||||
def init_device(coordinator):
|
||||
def init_device(coordinator: DeviceDataUpdateCoordinator) -> None:
|
||||
"""Register the device."""
|
||||
|
||||
async_add_entities(
|
||||
@@ -106,7 +106,7 @@ async def async_setup_entry(
|
||||
for description in GREE_SWITCHES
|
||||
)
|
||||
|
||||
for coordinator in hass.data[DOMAIN][COORDINATORS]:
|
||||
for coordinator in entry.runtime_data.coordinators:
|
||||
init_device(coordinator)
|
||||
|
||||
entry.async_on_unload(
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Shared constants for the greeneye_monitor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from greeneye import Monitors
|
||||
|
||||
CONF_CHANNELS = "channels"
|
||||
CONF_COUNTED_QUANTITY = "counted_quantity"
|
||||
CONF_COUNTED_QUANTITY_PER_PULSE = "counted_quantity_per_pulse"
|
||||
@@ -13,8 +22,8 @@ CONF_TEMPERATURE_SENSORS = "temperature_sensors"
|
||||
CONF_TIME_UNIT = "time_unit"
|
||||
CONF_VOLTAGE_SENSORS = "voltage"
|
||||
|
||||
DATA_GREENEYE_MONITOR = "greeneye_monitor"
|
||||
DOMAIN = "greeneye_monitor"
|
||||
DATA_GREENEYE_MONITOR: HassKey[Monitors] = HassKey(DOMAIN)
|
||||
|
||||
SENSOR_TYPE_CURRENT = "current_sensor"
|
||||
SENSOR_TYPE_PULSE_COUNTER = "pulse_counter"
|
||||
|
||||
@@ -109,7 +109,7 @@ async def async_setup_platform(
|
||||
if len(monitor_configs) == 0:
|
||||
monitors.remove_listener(on_new_monitor)
|
||||
|
||||
monitors: greeneye.Monitors = hass.data[DATA_GREENEYE_MONITOR]
|
||||
monitors = hass.data[DATA_GREENEYE_MONITOR]
|
||||
monitors.add_listener(on_new_monitor)
|
||||
for monitor in monitors.monitors.values():
|
||||
on_new_monitor(monitor)
|
||||
|
||||
@@ -52,10 +52,10 @@ type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
|
||||
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
"""Habitica Data Update Coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: HabiticaConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, habitica: Habitica
|
||||
self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica
|
||||
) -> None:
|
||||
"""Initialize the Habitica data coordinator."""
|
||||
super().__init__(
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
"consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
|
||||
"consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
|
||||
"consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
|
||||
"dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
|
||||
"dishcare_dishwasher_program_pre_rinse": "Pre-rinse",
|
||||
"dishcare_dishwasher_program_auto_1": "Auto 1",
|
||||
"dishcare_dishwasher_program_auto_2": "Auto 2",
|
||||
"dishcare_dishwasher_program_auto_3": "Auto 3",
|
||||
@@ -252,7 +252,7 @@
|
||||
"dishcare_dishwasher_program_intensiv_power": "Intensive power",
|
||||
"dishcare_dishwasher_program_magic_daily": "Magic daily",
|
||||
"dishcare_dishwasher_program_super_60": "Super 60ºC",
|
||||
"dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
|
||||
"dishcare_dishwasher_program_kurz_60": "Speed 60ºC",
|
||||
"dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
|
||||
"dishcare_dishwasher_program_machine_care": "Machine care",
|
||||
"dishcare_dishwasher_program_steam_fresh": "Steam fresh",
|
||||
|
||||
@@ -90,16 +90,17 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
if config_entry.minor_version == 2:
|
||||
# Add a `firmware_version` key
|
||||
if config_entry.minor_version <= 3:
|
||||
# Add a `firmware_version` key if it doesn't exist to handle entries created
|
||||
# with minor version 1.3 where the firmware version was not set.
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
**config_entry.data,
|
||||
FIRMWARE_VERSION: None,
|
||||
FIRMWARE_VERSION: config_entry.data.get(FIRMWARE_VERSION),
|
||||
},
|
||||
version=1,
|
||||
minor_version=3,
|
||||
minor_version=4,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -62,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Home Assistant Yellow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 4
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Instantiate config flow."""
|
||||
@@ -116,6 +116,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
|
||||
if self._probed_firmware_info is not None
|
||||
else ApplicationType.EZSP
|
||||
).value,
|
||||
FIRMWARE_VERSION: (
|
||||
self._probed_firmware_info.firmware_version
|
||||
if self._probed_firmware_info is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""The Homee alarm control panel platform."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyHomee.const import AttributeChangedBy, AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
AlarmControlPanelEntityDescription,
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, HomeeConfigEntry
|
||||
from .entity import HomeeEntity
|
||||
from .helpers import get_name_for_enum
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeeAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription):
|
||||
"""A class that describes Homee alarm control panel entities."""
|
||||
|
||||
code_arm_required: bool = False
|
||||
state_list: list[AlarmControlPanelState]
|
||||
|
||||
|
||||
ALARM_DESCRIPTIONS = {
|
||||
AttributeType.HOMEE_MODE: HomeeAlarmControlPanelEntityDescription(
|
||||
key="homee_mode",
|
||||
code_arm_required=False,
|
||||
state_list=[
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def get_supported_features(
|
||||
state_list: list[AlarmControlPanelState],
|
||||
) -> AlarmControlPanelEntityFeature:
|
||||
"""Return supported features based on the state list."""
|
||||
supported_features = AlarmControlPanelEntityFeature(0)
|
||||
if AlarmControlPanelState.ARMED_HOME in state_list:
|
||||
supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if AlarmControlPanelState.ARMED_AWAY in state_list:
|
||||
supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if AlarmControlPanelState.ARMED_NIGHT in state_list:
|
||||
supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
if AlarmControlPanelState.ARMED_VACATION in state_list:
|
||||
supported_features |= AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
return supported_features
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the alarm control panel component."""
|
||||
|
||||
async_add_entities(
|
||||
HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
|
||||
)
|
||||
|
||||
|
||||
class HomeeAlarmPanel(HomeeEntity, AlarmControlPanelEntity):
|
||||
"""Representation of a Homee alarm control panel."""
|
||||
|
||||
entity_description: HomeeAlarmControlPanelEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: HomeeAlarmControlPanelEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee alarm control panel entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_code_arm_required = description.code_arm_required
|
||||
self._attr_supported_features = get_supported_features(description.state_list)
|
||||
self._attr_translation_key = description.key
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState:
|
||||
"""Return current state."""
|
||||
return self.entity_description.state_list[int(self._attribute.current_value)]
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str:
|
||||
"""Return by whom or what the entity was last changed."""
|
||||
changed_by_name = get_name_for_enum(
|
||||
AttributeChangedBy, self._attribute.changed_by
|
||||
)
|
||||
return f"{changed_by_name} - {self._attribute.changed_by_id}"
|
||||
|
||||
async def _async_set_alarm_state(self, state: AlarmControlPanelState) -> None:
|
||||
"""Set the alarm state."""
|
||||
if state in self.entity_description.state_list:
|
||||
await self.async_set_homee_value(
|
||||
self.entity_description.state_list.index(state)
|
||||
)
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
# Since disarm is always present in the UI, we raise an error.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="disarm_not_supported",
|
||||
)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self._async_set_alarm_state(AlarmControlPanelState.ARMED_HOME)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self._async_set_alarm_state(AlarmControlPanelState.ARMED_NIGHT)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self._async_set_alarm_state(AlarmControlPanelState.ARMED_AWAY)
|
||||
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Send arm vacation command."""
|
||||
await self._async_set_alarm_state(AlarmControlPanelState.ARMED_VACATION)
|
||||
@@ -27,14 +27,20 @@ class HomeeEntity(Entity):
|
||||
)
|
||||
self._entry = entry
|
||||
node = entry.runtime_data.get_node_by_id(attribute.node_id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}")
|
||||
},
|
||||
name=node.name,
|
||||
model=get_name_for_enum(NodeProfile, node.profile),
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
# Homee hub itself has node-id -1
|
||||
if node.id == -1:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.runtime_data.settings.uid)},
|
||||
)
|
||||
else:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}")
|
||||
},
|
||||
name=node.name,
|
||||
model=get_name_for_enum(NodeProfile, node.profile),
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
|
||||
self._host_connected = entry.runtime_data.connected
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"alarm_control_panel": {
|
||||
"homee_mode": {
|
||||
"name": "Status"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"blackout_alarm": {
|
||||
"name": "Blackout"
|
||||
@@ -370,6 +375,9 @@
|
||||
"connection_closed": {
|
||||
"message": "Could not connect to homee while setting attribute."
|
||||
},
|
||||
"disarm_not_supported": {
|
||||
"message": "Disarm is not supported by homee."
|
||||
},
|
||||
"invalid_preset_mode": {
|
||||
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
@@ -21,7 +20,7 @@ from .const import (
|
||||
HMIPC_HAPID,
|
||||
HMIPC_NAME,
|
||||
)
|
||||
from .hap import HomematicipHAP
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
from .services import async_setup_services, async_unload_services
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
@@ -45,8 +44,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HomematicIP Cloud component."""
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
accesspoints = config.get(DOMAIN, [])
|
||||
|
||||
for conf in accesspoints:
|
||||
@@ -69,7 +66,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) -> bool:
|
||||
"""Set up an access point from a config entry."""
|
||||
|
||||
# 0.104 introduced config entry unique id, this makes upgrading possible
|
||||
@@ -81,8 +78,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
hap = HomematicipHAP(hass, entry)
|
||||
hass.data[DOMAIN][entry.unique_id] = hap
|
||||
|
||||
entry.runtime_data = hap
|
||||
if not await hap.async_setup():
|
||||
return False
|
||||
|
||||
@@ -110,9 +107,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: HomematicIPConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hap = hass.data[DOMAIN].pop(entry.unique_id)
|
||||
hap = entry.runtime_data
|
||||
assert hap.reset_connection_listener is not None
|
||||
hap.reset_connection_listener()
|
||||
|
||||
await async_unload_services(hass)
|
||||
@@ -122,7 +122,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@callback
|
||||
def _async_remove_obsolete_entities(
|
||||
hass: HomeAssistant, entry: ConfigEntry, hap: HomematicipHAP
|
||||
hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP
|
||||
):
|
||||
"""Remove obsolete entities from entity registry."""
|
||||
|
||||
|
||||
@@ -11,13 +11,12 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .hap import AsyncHome, HomematicipHAP
|
||||
from .hap import AsyncHome, HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,11 +25,11 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP alrm control panel from a config entry."""
|
||||
hap = hass.data[DOMAIN][config_entry.unique_id]
|
||||
hap = config_entry.runtime_data
|
||||
async_add_entities([HomematicipAlarmControlPanelEntity(hap)])
|
||||
|
||||
|
||||
|
||||
@@ -34,14 +34,13 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
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 DOMAIN
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicipHAP
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode"
|
||||
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
|
||||
@@ -75,11 +74,11 @@ SAM_DEVICE_ATTRIBUTES = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP Cloud binary sensor from a config entry."""
|
||||
hap = hass.data[DOMAIN][config_entry.unique_id]
|
||||
hap = config_entry.runtime_data
|
||||
entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)]
|
||||
for device in hap.home.devices:
|
||||
if isinstance(device, AccelerationSensor):
|
||||
|
||||
@@ -5,22 +5,20 @@ from __future__ import annotations
|
||||
from homematicip.device import WallMountedGarageDoorController
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicipHAP
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP button from a config entry."""
|
||||
hap = hass.data[DOMAIN][config_entry.unique_id]
|
||||
hap = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HomematicipGarageDoorControllerButton(hap, device)
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -32,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicipHAP
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2}
|
||||
COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5}
|
||||
@@ -55,11 +54,11 @@ HMIP_ECO_CM = "ECO"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP climate from a config entry."""
|
||||
hap = hass.data[DOMAIN][config_entry.unique_id]
|
||||
hap = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HomematicipHeatingGroup(hap, device)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user