mirror of
https://github.com/home-assistant/core.git
synced 2026-03-03 14:26:59 +01:00
Compare commits
164 Commits
epenet/202
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e95c483d | ||
|
|
9cb6e02c5f | ||
|
|
2c75e3289a | ||
|
|
348012a6b8 | ||
|
|
e0db00e089 | ||
|
|
b2280198d9 | ||
|
|
9cc4a3e427 | ||
|
|
f94a075641 | ||
|
|
f1856e6ef6 | ||
|
|
ed35bafa6c | ||
|
|
66e16d728b | ||
|
|
a806efa7e2 | ||
|
|
ad4b4bd221 | ||
|
|
c9c9a149b6 | ||
|
|
0f9fdfe2de | ||
|
|
a76b63912d | ||
|
|
bc03e13d38 | ||
|
|
450aa9757d | ||
|
|
158389a4f2 | ||
|
|
95e89d5ef1 | ||
|
|
e107b8e5cd | ||
|
|
f875b43ede | ||
|
|
6242ef78c4 | ||
|
|
3c342c0768 | ||
|
|
5dba5fc79d | ||
|
|
713b7cf36d | ||
|
|
cb016b014b | ||
|
|
afb4523f63 | ||
|
|
05ad4986ac | ||
|
|
42dbd5f98f | ||
|
|
f58a514ce7 | ||
|
|
8fb384a5e1 | ||
|
|
c24302b5ce | ||
|
|
999ad9b642 | ||
|
|
36d6b4dafe | ||
|
|
06870a2e25 | ||
|
|
85eba2bb15 | ||
|
|
5dd6dcc215 | ||
|
|
8bf894a514 | ||
|
|
d3c67f2ae1 | ||
|
|
b60a282b60 | ||
|
|
0da1d40a19 | ||
|
|
aa3be915a0 | ||
|
|
0d97bfbc59 | ||
|
|
fe830337c9 | ||
|
|
5210b7d847 | ||
|
|
2f7ed4040b | ||
|
|
6376ba93a7 | ||
|
|
fd3a1cc9f4 | ||
|
|
208013ab76 | ||
|
|
770b3f910e | ||
|
|
5dce4a8eda | ||
|
|
6fcc9da948 | ||
|
|
bf93580ff9 | ||
|
|
0c2fe045d5 | ||
|
|
e14a3a6b0e | ||
|
|
e032740e90 | ||
|
|
78ad1e102d | ||
|
|
4f97cc7b68 | ||
|
|
df8f135532 | ||
|
|
0066801b0f | ||
|
|
0aa66ed6cb | ||
|
|
6903463f14 | ||
|
|
a473010fee | ||
|
|
ddf7a783a8 | ||
|
|
513e4d52fe | ||
|
|
17bb14e260 | ||
|
|
cd1258464b | ||
|
|
d3f5e0e6d7 | ||
|
|
e124829364 | ||
|
|
87b83dcc1b | ||
|
|
be9b47539d | ||
|
|
be6ddc314c | ||
|
|
c6f8a7b7e4 | ||
|
|
53da5612e9 | ||
|
|
6cc56b76f9 | ||
|
|
03cb65d555 | ||
|
|
73dd024933 | ||
|
|
1c8c92bf8f | ||
|
|
7e041a6759 | ||
|
|
ee05f14530 | ||
|
|
f0ba5178b7 | ||
|
|
df51ac932b | ||
|
|
e96b5f2eb1 | ||
|
|
4e59c89327 | ||
|
|
15676021a9 | ||
|
|
d3197a0d1e | ||
|
|
35692b335c | ||
|
|
cc5c810501 | ||
|
|
f2681f2dc8 | ||
|
|
fe0a22c790 | ||
|
|
186ab50458 | ||
|
|
b524c40176 | ||
|
|
642864959a | ||
|
|
7ef6c34149 | ||
|
|
5b32e42b8c | ||
|
|
1be8b8e525 | ||
|
|
3fae15c430 | ||
|
|
c7e78568d0 | ||
|
|
492b542136 | ||
|
|
0f4852d8c2 | ||
|
|
737c0c1823 | ||
|
|
5fadcb01e9 | ||
|
|
2b4f46a739 | ||
|
|
44fe37da1f | ||
|
|
abd4e89577 | ||
|
|
033798835a | ||
|
|
83c77957c1 | ||
|
|
b1bc1dc102 | ||
|
|
40b8a2c380 | ||
|
|
fb23a6fbf8 | ||
|
|
faad3de02c | ||
|
|
5f30f532e5 | ||
|
|
667e8c4d38 | ||
|
|
74240ecd26 | ||
|
|
c81ee53265 | ||
|
|
8835f1d5e6 | ||
|
|
2ca84182d8 | ||
|
|
3f0d1bc071 | ||
|
|
350f462bdf | ||
|
|
2f98e68ed8 | ||
|
|
5b7fac94e5 | ||
|
|
c32ce3da5c | ||
|
|
0e1d1fbaed | ||
|
|
57d7f364f4 | ||
|
|
7cc5777b47 | ||
|
|
5e3f23b6a2 | ||
|
|
6873a40407 | ||
|
|
ddaa2fb293 | ||
|
|
53b6223459 | ||
|
|
7329cfb927 | ||
|
|
44b80dde0c | ||
|
|
8c125e4e4f | ||
|
|
227a258382 | ||
|
|
addc2a6766 | ||
|
|
97bcea9727 | ||
|
|
4f05c807b0 | ||
|
|
177a918c26 | ||
|
|
9705770c6c | ||
|
|
7309351165 | ||
|
|
d0401de70d | ||
|
|
6b89359a73 | ||
|
|
b31bafab99 | ||
|
|
84c556bb63 | ||
|
|
225ea02d9a | ||
|
|
ebd1cc994c | ||
|
|
9ec22ba158 | ||
|
|
2ff85d2134 | ||
|
|
3eb7f04510 | ||
|
|
54613ac8d9 | ||
|
|
044522a8ab | ||
|
|
19bf41496a | ||
|
|
a7efba098d | ||
|
|
042ad3b759 | ||
|
|
4270e4c793 | ||
|
|
cb11c22e76 | ||
|
|
c6e23fec93 | ||
|
|
553cecb397 | ||
|
|
bb7d5897d1 | ||
|
|
3e050ebe59 | ||
|
|
856a9e695a | ||
|
|
1944a8bd3a | ||
|
|
3f11af8084 | ||
|
|
46a87cd9dd |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -182,7 +182,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
||||
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
@@ -605,7 +605,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -978,7 +978,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- name: Compile English translations
|
||||
@@ -1387,7 +1387,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1558,7 +1558,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1587,7 +1587,7 @@ jobs:
|
||||
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
10
.github/workflows/wheels.yml
vendored
10
.github/workflows/wheels.yml
vendored
@@ -124,12 +124,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -175,17 +175,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
|
||||
@@ -545,6 +545,7 @@ homeassistant.components.tcp.*
|
||||
homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.teslemetry.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
homeassistant.components.threshold.*
|
||||
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -401,8 +401,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/homeassistant/components/duckdns/ @tr4nt0r
|
||||
/tests/components/duckdns/ @tr4nt0r
|
||||
/homeassistant/components/duke_energy/ @hunterjm
|
||||
/tests/components/duke_energy/ @hunterjm
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
/tests/components/duotecno/ @cereal2nd
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
|
||||
@@ -1901,8 +1899,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/withings/ @joostlek
|
||||
/homeassistant/components/wiz/ @sbidy @arturpragacz
|
||||
/tests/components/wiz/ @sbidy @arturpragacz
|
||||
/homeassistant/components/wled/ @frenck
|
||||
/tests/components/wled/ @frenck
|
||||
/homeassistant/components/wled/ @frenck @mik-laj
|
||||
/tests/components/wled/ @frenck @mik-laj
|
||||
/homeassistant/components/wmspro/ @mback2k
|
||||
/tests/components/wmspro/ @mback2k
|
||||
/homeassistant/components/wolflink/ @adamkrol93 @mtielen
|
||||
|
||||
@@ -70,7 +70,7 @@ from .const import (
|
||||
SIGNAL_BOOTSTRAP_INTEGRATIONS,
|
||||
)
|
||||
from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .exceptions import HomeAssistantError, UnsupportedStorageVersionError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
category_registry,
|
||||
@@ -433,32 +433,56 @@ def _init_blocking_io_modules_in_executor() -> None:
|
||||
is_docker_env()
|
||||
|
||||
|
||||
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
"""Load the registries and modules that will do blocking I/O."""
|
||||
async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
"""Load the registries and modules that will do blocking I/O.
|
||||
|
||||
Return whether loading succeeded.
|
||||
"""
|
||||
if DATA_REGISTRIES_LOADED in hass.data:
|
||||
return
|
||||
return True
|
||||
|
||||
hass.data[DATA_REGISTRIES_LOADED] = None
|
||||
entity.async_setup(hass)
|
||||
frame.async_setup(hass)
|
||||
template.async_setup(hass)
|
||||
translation.async_setup(hass)
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
create_eager_task(area_registry.async_load(hass)),
|
||||
create_eager_task(category_registry.async_load(hass)),
|
||||
create_eager_task(device_registry.async_load(hass)),
|
||||
create_eager_task(entity_registry.async_load(hass)),
|
||||
create_eager_task(floor_registry.async_load(hass)),
|
||||
create_eager_task(issue_registry.async_load(hass)),
|
||||
create_eager_task(label_registry.async_load(hass)),
|
||||
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
|
||||
create_eager_task(template.async_load_custom_templates(hass)),
|
||||
create_eager_task(restore_state.async_load(hass)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(condition.async_setup(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
create_eager_task(area_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(category_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(device_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(entity_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(floor_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(issue_registry.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(label_registry.async_load(hass, load_empty=recovery)),
|
||||
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
|
||||
create_eager_task(template.async_load_custom_templates(hass)),
|
||||
create_eager_task(restore_state.async_load(hass, load_empty=recovery)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(condition.async_setup(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
except UnsupportedStorageVersionError as err:
|
||||
# If we're already in recovery mode, we don't want to handle the exception
|
||||
# and activate recovery mode again, as that would lead to an infinite loop.
|
||||
if recovery:
|
||||
raise
|
||||
|
||||
_LOGGER.error(
|
||||
"Storage file %s was created by a newer version of Home Assistant"
|
||||
" (storage version %s > %s); activating recovery mode; on-disk data"
|
||||
" is preserved; upgrade Home Assistant or restore from a backup",
|
||||
err.storage_key,
|
||||
err.found_version,
|
||||
err.max_supported_version,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_from_config_dict(
|
||||
@@ -475,7 +499,9 @@ async def async_from_config_dict(
|
||||
# Prime custom component cache early so we know if registry entries are tied
|
||||
# to a custom integration
|
||||
await loader.async_get_custom_components(hass)
|
||||
await async_load_base_functionality(hass)
|
||||
|
||||
if not await async_load_base_functionality(hass):
|
||||
return None
|
||||
|
||||
# Set up core.
|
||||
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
|
||||
|
||||
5
homeassistant/brands/ubisys.json
Normal file
5
homeassistant/brands/ubisys.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "ubisys",
|
||||
"name": "Ubisys",
|
||||
"iot_standards": ["zigbee"]
|
||||
}
|
||||
@@ -191,7 +191,7 @@ class AccuWeatherEntity(
|
||||
{
|
||||
ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(),
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"],
|
||||
ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"]["Average"],
|
||||
ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"].get("Average"),
|
||||
ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE],
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE],
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][
|
||||
|
||||
@@ -93,7 +93,6 @@ class AirobotNumber(AirobotEntity, NumberEntity):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_value_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
"message": "Failed to set temperature to {temperature}."
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set value: {error}"
|
||||
"message": "Failed to set value."
|
||||
},
|
||||
"switch_turn_off_failed": {
|
||||
"message": "Failed to turn off {switch}."
|
||||
|
||||
@@ -44,7 +44,7 @@ def make_entity_state_trigger_required_features(
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_domains = {domain}
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==12.0.0"]
|
||||
"requirements": ["aioamazondevices==12.0.2"]
|
||||
}
|
||||
|
||||
@@ -400,8 +400,8 @@ def _convert_content(
|
||||
# If there is only one text block, simplify the content to a string
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
raise HomeAssistantError("Unexpected content type in chat log")
|
||||
|
||||
return messages, container_id
|
||||
|
||||
@@ -442,8 +442,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if stream is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
if stream is None or not hasattr(stream, "__aiter__"):
|
||||
raise HomeAssistantError("Expected a stream of messages")
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
@@ -456,8 +456,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
input_usage = response.message.usage
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
@@ -666,7 +664,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
raise HomeAssistantError("First message must be a system message")
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
|
||||
@@ -31,10 +31,7 @@ rules:
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: todo
|
||||
comment: |
|
||||
Reevaluate exceptions for entity services.
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
|
||||
@@ -117,6 +117,7 @@ class SharpAquosTVDevice(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
)
|
||||
_attr_volume_step = 2 / 60
|
||||
|
||||
def __init__(
|
||||
self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False
|
||||
@@ -161,22 +162,6 @@ class SharpAquosTVDevice(MediaPlayerEntity):
|
||||
"""Turn off tvplayer."""
|
||||
self._remote.power(0)
|
||||
|
||||
@_retry
|
||||
def volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
_LOGGER.debug("Unknown volume in volume_up")
|
||||
return
|
||||
self._remote.volume(int(self.volume_level * 60) + 2)
|
||||
|
||||
@_retry
|
||||
def volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
if self.volume_level is None:
|
||||
_LOGGER.debug("Unknown volume in volume_down")
|
||||
return
|
||||
self._remote.volume(int(self.volume_level * 60) - 2)
|
||||
|
||||
@_retry
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set Volume media player."""
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
|
||||
}
|
||||
|
||||
@@ -29,12 +29,17 @@ class StoredBackupData(TypedDict):
|
||||
class _BackupStore(Store[StoredBackupData]):
|
||||
"""Class to help storing backup data."""
|
||||
|
||||
# Maximum version we support reading for forward compatibility.
|
||||
# This allows reading data written by a newer HA version after downgrade.
|
||||
_MAX_READABLE_VERSION = 2
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize storage class."""
|
||||
super().__init__(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_KEY,
|
||||
max_readable_version=self._MAX_READABLE_VERSION,
|
||||
minor_version=STORAGE_VERSION_MINOR,
|
||||
)
|
||||
|
||||
@@ -86,8 +91,8 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
|
||||
# planned to happen after a 6 month quiet period with no minor version
|
||||
# changes.
|
||||
# Reject if major version is higher than 2.
|
||||
if old_major_version > 2:
|
||||
# Reject if major version is higher than _MAX_READABLE_VERSION.
|
||||
if old_major_version > self._MAX_READABLE_VERSION:
|
||||
raise NotImplementedError
|
||||
return data
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domain: str = DOMAIN
|
||||
_domains = {DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
|
||||
@@ -190,7 +190,7 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "miners_revenue_usd":
|
||||
self._attr_native_value = f"{stats.miners_revenue_usd:.0f}"
|
||||
elif sensor_type == "btc_mined":
|
||||
self._attr_native_value = str(stats.btc_mined * 0.00000001)
|
||||
self._attr_native_value = str(stats.btc_mined * 1e-8)
|
||||
elif sensor_type == "trade_volume_usd":
|
||||
self._attr_native_value = f"{stats.trade_volume_usd:.1f}"
|
||||
elif sensor_type == "difficulty":
|
||||
@@ -208,13 +208,13 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "blocks_size":
|
||||
self._attr_native_value = f"{stats.blocks_size:.1f}"
|
||||
elif sensor_type == "total_fees_btc":
|
||||
self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}"
|
||||
self._attr_native_value = f"{stats.total_fees_btc * 1e-8:.2f}"
|
||||
elif sensor_type == "total_btc_sent":
|
||||
self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}"
|
||||
self._attr_native_value = f"{stats.total_btc_sent * 1e-8:.2f}"
|
||||
elif sensor_type == "estimated_btc_sent":
|
||||
self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}"
|
||||
self._attr_native_value = f"{stats.estimated_btc_sent * 1e-8:.2f}"
|
||||
elif sensor_type == "total_btc":
|
||||
self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}"
|
||||
self._attr_native_value = f"{stats.total_btc * 1e-8:.2f}"
|
||||
elif sensor_type == "total_blocks":
|
||||
self._attr_native_value = f"{stats.total_blocks:.0f}"
|
||||
elif sensor_type == "next_retarget":
|
||||
@@ -222,7 +222,7 @@ class BitcoinSensor(SensorEntity):
|
||||
elif sensor_type == "estimated_transaction_volume_usd":
|
||||
self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}"
|
||||
elif sensor_type == "miners_revenue_btc":
|
||||
self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}"
|
||||
self._attr_native_value = f"{stats.miners_revenue_btc * 1e-8:.1f}"
|
||||
elif sensor_type == "market_price_usd":
|
||||
self._attr_native_value = f"{stats.market_price_usd:.2f}"
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
_attr_media_content_type = MediaType.MUSIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_volume_step = 0.01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -688,24 +689,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
|
||||
await self._player.play_url(url)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
|
||||
new_volume = self.volume_level + 0.01
|
||||
new_volume = min(1, new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
|
||||
new_volume = self.volume_level - 0.01
|
||||
new_volume = max(0, new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
volume = int(round(volume * 100))
|
||||
|
||||
@@ -64,6 +64,8 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: (
|
||||
data.sensor.total_energy.value
|
||||
if data.sensor.total_energy is not None
|
||||
|
||||
@@ -31,10 +31,6 @@ ATTR_FRIDAY_SLOTS = "friday_slots"
|
||||
ATTR_SATURDAY_SLOTS = "saturday_slots"
|
||||
ATTR_SUNDAY_SLOTS = "sunday_slots"
|
||||
|
||||
# Service names
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
|
||||
SERVICE_SYNC_TIME = "sync_time"
|
||||
|
||||
|
||||
# Schema for a single time slot
|
||||
_SLOT_SCHEMA = vol.Schema(
|
||||
@@ -260,14 +256,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the BSB-LAN services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
"set_hot_water_schedule",
|
||||
set_hot_water_schedule,
|
||||
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SYNC_TIME,
|
||||
"sync_time",
|
||||
async_sync_time,
|
||||
schema=SYNC_TIME_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from . import DOMAIN
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_domains = {DOMAIN}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
"early_update": {
|
||||
"default": "mdi:update"
|
||||
},
|
||||
"equalizer": {
|
||||
"default": "mdi:equalizer",
|
||||
"state": {
|
||||
"off": "mdi:equalizer-outline"
|
||||
}
|
||||
},
|
||||
"pre_amp": {
|
||||
"default": "mdi:volume-high",
|
||||
"state": {
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"early_update": {
|
||||
"name": "Early update"
|
||||
},
|
||||
"equalizer": {
|
||||
"name": "Equalizer"
|
||||
},
|
||||
"pre_amp": {
|
||||
"name": "Pre-Amp"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,13 @@ def room_correction_enabled(client: StreamMagicClient) -> bool:
|
||||
return client.audio.tilt_eq.enabled
|
||||
|
||||
|
||||
def equalizer_enabled(client: StreamMagicClient) -> bool:
|
||||
"""Check if equalizer is enabled."""
|
||||
if TYPE_CHECKING:
|
||||
assert client.audio.user_eq is not None
|
||||
return client.audio.user_eq.enabled
|
||||
|
||||
|
||||
CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
|
||||
CambridgeAudioSwitchEntityDescription(
|
||||
key="pre_amp",
|
||||
@@ -56,6 +63,14 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
|
||||
value_fn=room_correction_enabled,
|
||||
set_value_fn=lambda client, value: client.set_room_correction_mode(value),
|
||||
),
|
||||
CambridgeAudioSwitchEntityDescription(
|
||||
key="equalizer",
|
||||
translation_key="equalizer",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
load_fn=lambda client: client.audio.user_eq is not None,
|
||||
value_fn=equalizer_enabled,
|
||||
set_value_fn=lambda client, value: client.set_equalizer_mode(value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -807,6 +807,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
# The lovelace app loops media to prevent timing out, don't show that
|
||||
if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if (media_status := self._media_status()[0]) is not None:
|
||||
if media_status.player_state == MEDIA_PLAYER_STATE_PLAYING:
|
||||
return MediaPlayerState.PLAYING
|
||||
@@ -817,19 +818,19 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
if media_status.player_is_idle:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
# If library consider us idle, that is our off state
|
||||
# it takes HDMI status into account for cast devices.
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if self.app_id is not None:
|
||||
if self.app_id is not None and self.app_id != pychromecast.config.APP_BACKDROP:
|
||||
# We have an active app
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
# If library consider us idle, that is our off state
|
||||
# it takes HDMI status into account for cast devices.
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -43,7 +43,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_domains = {DOMAIN}
|
||||
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
|
||||
@@ -48,6 +48,8 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
vol.Optional("conversation_id"): vol.Any(str, None),
|
||||
vol.Optional("language"): str,
|
||||
vol.Optional("agent_id"): agent_id_validator,
|
||||
vol.Optional("device_id"): vol.Any(str, None),
|
||||
vol.Optional("satellite_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -64,6 +66,8 @@ async def websocket_process(
|
||||
context=connection.context(msg),
|
||||
language=msg.get("language"),
|
||||
agent_id=msg.get("agent_id"),
|
||||
device_id=msg.get("device_id"),
|
||||
satellite_id=msg.get("satellite_id"),
|
||||
)
|
||||
connection.send_result(msg["id"], result.as_dict())
|
||||
|
||||
@@ -248,6 +252,8 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
vol.Optional("conversation_id"): str,
|
||||
vol.Optional("language"): str,
|
||||
vol.Optional("agent_id"): agent_id_validator,
|
||||
vol.Optional("device_id"): vol.Any(str, None),
|
||||
vol.Optional("satellite_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -262,6 +268,8 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
context=self.context(request),
|
||||
language=data.get("language"),
|
||||
agent_id=data.get("agent_id"),
|
||||
device_id=data.get("device_id"),
|
||||
satellite_id=data.get("satellite_id"),
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
@@ -112,11 +112,12 @@ def _zone_is_configured(zone: DaikinZone) -> bool:
|
||||
|
||||
def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]:
|
||||
"""Return the decoded zone temperature lists."""
|
||||
try:
|
||||
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
|
||||
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
|
||||
except AttributeError, KeyError:
|
||||
values = device.values
|
||||
if DAIKIN_ZONE_TEMP_HEAT not in values or DAIKIN_ZONE_TEMP_COOL not in values:
|
||||
return ([], [])
|
||||
|
||||
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
|
||||
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
|
||||
return (list(heating or []), list(cooling or []))
|
||||
|
||||
|
||||
|
||||
@@ -139,18 +139,6 @@ class AbstractDemoPlayer(MediaPlayerEntity):
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def volume_up(self) -> None:
|
||||
"""Increase volume."""
|
||||
assert self.volume_level is not None
|
||||
self._attr_volume_level = min(1.0, self.volume_level + 0.1)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def volume_down(self) -> None:
|
||||
"""Decrease volume."""
|
||||
assert self.volume_level is not None
|
||||
self._attr_volume_level = max(0.0, self.volume_level - 0.1)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level, range 0..1."""
|
||||
self._attr_volume_level = volume
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.4.3"]
|
||||
"requirements": ["dsmr-parser==1.5.0"]
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""The Duke Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
|
||||
"""Set up Duke Energy from a config entry."""
|
||||
|
||||
coordinator = DukeEnergyCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Config flow for Duke Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiodukeenergy import DukeEnergy
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Duke Energy."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = DukeEnergy(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session
|
||||
)
|
||||
try:
|
||||
auth = await api.authenticate()
|
||||
except ClientResponseError as e:
|
||||
errors["base"] = "invalid_auth" if e.status == 404 else "cannot_connect"
|
||||
except ClientError, TimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
username = auth["internalUserID"].lower()
|
||||
await self.async_set_unique_id(username)
|
||||
self._abort_if_unique_id_configured()
|
||||
email = auth["loginEmailAddress"].lower()
|
||||
data = {
|
||||
CONF_EMAIL: email,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
self._async_abort_entries_match(data)
|
||||
return self.async_create_entry(title=email, data=data)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the Duke Energy integration."""
|
||||
|
||||
DOMAIN = "duke_energy"
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Coordinator to handle Duke Energy connections."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiodukeenergy import DukeEnergy
|
||||
from aiohttp import ClientError
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SUPPORTED_METER_TYPES = ("ELECTRIC",)
|
||||
|
||||
type DukeEnergyConfigEntry = ConfigEntry[DukeEnergyCoordinator]
|
||||
|
||||
|
||||
class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Handle inserting statistics."""
|
||||
|
||||
config_entry: DukeEnergyConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: DukeEnergyConfigEntry
|
||||
) -> None:
|
||||
"""Initialize the data handler."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="Duke Energy",
|
||||
# Data is updated daily on Duke Energy.
|
||||
# Refresh every 12h to be at most 12h behind.
|
||||
update_interval=timedelta(hours=12),
|
||||
)
|
||||
self.api = DukeEnergy(
|
||||
config_entry.data[CONF_USERNAME],
|
||||
config_entry.data[CONF_PASSWORD],
|
||||
async_get_clientsession(hass),
|
||||
)
|
||||
self._statistic_ids: set = set()
|
||||
|
||||
@callback
|
||||
def _dummy_listener() -> None:
|
||||
pass
|
||||
|
||||
# Force the coordinator to periodically update by registering at least one listener.
|
||||
# Duke Energy does not provide forecast data, so all information is historical.
|
||||
# This makes _async_update_data get periodically called so we can insert statistics.
|
||||
self.async_add_listener(_dummy_listener)
|
||||
|
||||
self.config_entry.async_on_unload(self._clear_statistics)
|
||||
|
||||
def _clear_statistics(self) -> None:
|
||||
"""Clear statistics."""
|
||||
get_instance(self.hass).async_clear_statistics(list(self._statistic_ids))
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Insert Duke Energy statistics."""
|
||||
meters: dict[str, dict[str, Any]] = await self.api.get_meters()
|
||||
for serial_number, meter in meters.items():
|
||||
if (
|
||||
not isinstance(meter["serviceType"], str)
|
||||
or meter["serviceType"] not in _SUPPORTED_METER_TYPES
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Skipping unsupported meter type %s", meter["serviceType"]
|
||||
)
|
||||
continue
|
||||
|
||||
id_prefix = f"{meter['serviceType'].lower()}_{serial_number}"
|
||||
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
|
||||
self._statistic_ids.add(consumption_statistic_id)
|
||||
_LOGGER.debug(
|
||||
"Updating Statistics for %s",
|
||||
consumption_statistic_id,
|
||||
)
|
||||
|
||||
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||
get_last_statistics, self.hass, 1, consumption_statistic_id, True, set()
|
||||
)
|
||||
if not last_stat:
|
||||
_LOGGER.debug("Updating statistic for the first time")
|
||||
usage = await self._async_get_energy_usage(meter)
|
||||
consumption_sum = 0.0
|
||||
last_stats_time = None
|
||||
else:
|
||||
usage = await self._async_get_energy_usage(
|
||||
meter,
|
||||
last_stat[consumption_statistic_id][0]["start"],
|
||||
)
|
||||
if not usage:
|
||||
_LOGGER.debug("No recent usage data. Skipping update")
|
||||
continue
|
||||
stats = await get_instance(self.hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
self.hass,
|
||||
min(usage.keys()),
|
||||
None,
|
||||
{consumption_statistic_id},
|
||||
"hour",
|
||||
None,
|
||||
{"sum"},
|
||||
)
|
||||
consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"])
|
||||
last_stats_time = stats[consumption_statistic_id][0]["start"]
|
||||
|
||||
consumption_statistics = []
|
||||
|
||||
for start, data in usage.items():
|
||||
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||
continue
|
||||
consumption_sum += data["energy"]
|
||||
|
||||
consumption_statistics.append(
|
||||
StatisticData(
|
||||
start=start, state=data["energy"], sum=consumption_sum
|
||||
)
|
||||
)
|
||||
|
||||
name_prefix = (
|
||||
f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
|
||||
)
|
||||
consumption_metadata = StatisticMetaData(
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"{name_prefix} Consumption",
|
||||
source=DOMAIN,
|
||||
statistic_id=consumption_statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
|
||||
if meter["serviceType"] == "ELECTRIC"
|
||||
else UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Adding %s statistics for %s",
|
||||
len(consumption_statistics),
|
||||
consumption_statistic_id,
|
||||
)
|
||||
async_add_external_statistics(
|
||||
self.hass, consumption_metadata, consumption_statistics
|
||||
)
|
||||
|
||||
async def _async_get_energy_usage(
|
||||
self, meter: dict[str, Any], start_time: float | None = None
|
||||
) -> dict[datetime, dict[str, float | int]]:
|
||||
"""Get energy usage.
|
||||
|
||||
If start_time is None, get usage since account activation (or as far back as possible),
|
||||
otherwise since start_time - 30 days to allow corrections in data.
|
||||
|
||||
Duke Energy provides hourly data all the way back to ~3 years.
|
||||
"""
|
||||
|
||||
# All of Duke Energy Service Areas are currently in America/New_York timezone
|
||||
# May need to re-think this if that ever changes and determine timezone based
|
||||
# on the service address somehow.
|
||||
tz = await dt_util.async_get_time_zone("America/New_York")
|
||||
lookback = timedelta(days=30)
|
||||
one = timedelta(days=1)
|
||||
if start_time is None:
|
||||
# Max 3 years of data
|
||||
start = dt_util.now(tz) - timedelta(days=3 * 365)
|
||||
else:
|
||||
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
|
||||
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
|
||||
if agreement_date is not None:
|
||||
start = max(agreement_date.replace(tzinfo=tz), start)
|
||||
|
||||
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
|
||||
_LOGGER.debug("Data lookup range: %s - %s", start, end)
|
||||
|
||||
start_step = max(end - lookback, start)
|
||||
end_step = end
|
||||
usage: dict[datetime, dict[str, float | int]] = {}
|
||||
while True:
|
||||
_LOGGER.debug("Getting hourly usage: %s - %s", start_step, end_step)
|
||||
try:
|
||||
# Get data
|
||||
results = await self.api.get_energy_usage(
|
||||
meter["serialNum"], "HOURLY", "DAY", start_step, end_step
|
||||
)
|
||||
usage = {**results["data"], **usage}
|
||||
|
||||
for missing in results["missing"]:
|
||||
_LOGGER.debug("Missing data: %s", missing)
|
||||
|
||||
# Set next range
|
||||
end_step = start_step - one
|
||||
start_step = max(start_step - lookback, start)
|
||||
|
||||
# Make sure we don't go back too far
|
||||
if end_step < start:
|
||||
break
|
||||
except TimeoutError, ClientError:
|
||||
# ClientError is raised when there is no more data for the range
|
||||
break
|
||||
|
||||
_LOGGER.debug("Got %s meter usage reads", len(usage))
|
||||
return usage
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "duke_energy",
|
||||
"name": "Duke Energy",
|
||||
"codeowners": ["@hunterjm"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["recorder"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodukeenergy==0.3.0"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = (
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,39 @@
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EafmConfigEntry, EafmCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
def _fix_device_registry_identifiers(
|
||||
hass: HomeAssistant, entry: EafmConfigEntry
|
||||
) -> None:
|
||||
"""Fix invalid identifiers in device registry.
|
||||
|
||||
Added in 2026.4, can be removed in 2026.10 or later.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
old_identifier = (DOMAIN, "measure-id", entry.data["station"])
|
||||
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
|
||||
continue
|
||||
new_identifiers = device_entry.identifiers.copy()
|
||||
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
|
||||
new_identifiers.add((DOMAIN, entry.data["station"]))
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, new_identifiers=new_identifiers
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool:
|
||||
"""Set up flood monitoring sensors for this config entry."""
|
||||
_fix_device_registry_identifiers(hass, entry)
|
||||
coordinator = EafmCoordinator(hass, entry=entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -94,11 +94,11 @@ class Measurement(CoordinatorEntity, SensorEntity):
|
||||
return self.coordinator.data["measures"][self.key]["parameterName"]
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device info."""
|
||||
return DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, "measure-id", self.station_id)},
|
||||
identifiers={(DOMAIN, self.station_id)},
|
||||
manufacturer="https://environment.data.gov.uk/",
|
||||
model=self.parameter_name,
|
||||
name=f"{self.station_name} {self.parameter_name} {self.qualifier}",
|
||||
|
||||
@@ -405,8 +405,13 @@ CT_SENSORS = (
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
|
||||
# Production CT energy_delivered is not used
|
||||
(CtType.PRODUCTION, "production_ct_energy_delivered"),
|
||||
(CtType.STORAGE, "lifetime_battery_discharged"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_delivered"),
|
||||
(CtType.BACKFEED, "backfeed_ct_energy_delivered"),
|
||||
(CtType.LOAD, "load_ct_energy_delivered"),
|
||||
(CtType.EVSE, "evse_ct_energy_delivered"),
|
||||
(CtType.PV3P, "pv3p_ct_energy_delivered"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -423,8 +428,13 @@ CT_SENSORS = (
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
|
||||
# Production CT energy_received is not used
|
||||
(CtType.PRODUCTION, "production_ct_energy_received"),
|
||||
(CtType.STORAGE, "lifetime_battery_charged"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_received"),
|
||||
(CtType.BACKFEED, "backfeed_ct_energy_received"),
|
||||
(CtType.LOAD, "load_ct_energy_received"),
|
||||
(CtType.EVSE, "evse_ct_energy_received"),
|
||||
(CtType.PV3P, "pv3p_ct_energy_received"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -441,8 +451,13 @@ CT_SENSORS = (
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_consumption"),
|
||||
# Production CT active_power is not used
|
||||
(CtType.PRODUCTION, "production_ct_power"),
|
||||
(CtType.STORAGE, "battery_discharge"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_power"),
|
||||
(CtType.BACKFEED, "backfeed_ct_power"),
|
||||
(CtType.LOAD, "load_ct_power"),
|
||||
(CtType.EVSE, "evse_ct_power"),
|
||||
(CtType.PV3P, "pv3p_ct_power"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -461,6 +476,11 @@ CT_SENSORS = (
|
||||
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
|
||||
(CtType.PRODUCTION, "production_ct_frequency", ""),
|
||||
(CtType.STORAGE, "storage_ct_frequency", ""),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_frequency", ""),
|
||||
(CtType.BACKFEED, "backfeed_ct_frequency", ""),
|
||||
(CtType.LOAD, "load_ct_frequency", ""),
|
||||
(CtType.EVSE, "evse_ct_frequency", ""),
|
||||
(CtType.PV3P, "pv3p_ct_frequency", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -480,6 +500,11 @@ CT_SENSORS = (
|
||||
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
|
||||
(CtType.PRODUCTION, "production_ct_voltage", ""),
|
||||
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_voltage", ""),
|
||||
(CtType.BACKFEED, "backfeed_ct_voltage", ""),
|
||||
(CtType.LOAD, "load_ct_voltage", ""),
|
||||
(CtType.EVSE, "evse_ct_voltage", ""),
|
||||
(CtType.PV3P, "pv3p_ct_voltage", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -499,6 +524,11 @@ CT_SENSORS = (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_current"),
|
||||
(CtType.PRODUCTION, "production_ct_current"),
|
||||
(CtType.STORAGE, "storage_ct_current"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_current"),
|
||||
(CtType.BACKFEED, "backfeed_ct_current"),
|
||||
(CtType.LOAD, "load_ct_current"),
|
||||
(CtType.EVSE, "evse_ct_current"),
|
||||
(CtType.PV3P, "pv3p_ct_current"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -516,6 +546,11 @@ CT_SENSORS = (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
|
||||
(CtType.PRODUCTION, "production_ct_powerfactor"),
|
||||
(CtType.STORAGE, "storage_ct_powerfactor"),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_powerfactor"),
|
||||
(CtType.BACKFEED, "backfeed_ct_powerfactor"),
|
||||
(CtType.LOAD, "load_ct_powerfactor"),
|
||||
(CtType.EVSE, "evse_ct_powerfactor"),
|
||||
(CtType.PV3P, "pv3p_ct_powerfactor"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -537,6 +572,11 @@ CT_SENSORS = (
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_metering_status", ""),
|
||||
(CtType.STORAGE, "storage_ct_metering_status", ""),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_metering_status", ""),
|
||||
(CtType.BACKFEED, "backfeed_ct_metering_status", ""),
|
||||
(CtType.LOAD, "load_ct_metering_status", ""),
|
||||
(CtType.EVSE, "evse_ct_metering_status", ""),
|
||||
(CtType.PV3P, "pv3p_ct_metering_status", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
@@ -557,6 +597,11 @@ CT_SENSORS = (
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_status_flags", ""),
|
||||
(CtType.STORAGE, "storage_ct_status_flags", ""),
|
||||
(CtType.TOTAL_CONSUMPTION, "total_consumption_ct_status_flags", ""),
|
||||
(CtType.BACKFEED, "backfeed_ct_status_flags", ""),
|
||||
(CtType.LOAD, "load_ct_status_flags", ""),
|
||||
(CtType.EVSE, "evse_ct_status_flags", ""),
|
||||
(CtType.PV3P, "pv3p_ct_status_flags", ""),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -160,6 +160,60 @@
|
||||
"available_energy": {
|
||||
"name": "Available battery energy"
|
||||
},
|
||||
"backfeed_ct_current": {
|
||||
"name": "Backfeed CT current"
|
||||
},
|
||||
"backfeed_ct_current_phase": {
|
||||
"name": "Backfeed CT current {phase_name}"
|
||||
},
|
||||
"backfeed_ct_energy_delivered": {
|
||||
"name": "Backfeed CT energy delivered"
|
||||
},
|
||||
"backfeed_ct_energy_delivered_phase": {
|
||||
"name": "Backfeed CT energy delivered {phase_name}"
|
||||
},
|
||||
"backfeed_ct_energy_received": {
|
||||
"name": "Backfeed CT energy received"
|
||||
},
|
||||
"backfeed_ct_energy_received_phase": {
|
||||
"name": "Backfeed CT energy received {phase_name}"
|
||||
},
|
||||
"backfeed_ct_frequency": {
|
||||
"name": "Frequency backfeed CT"
|
||||
},
|
||||
"backfeed_ct_frequency_phase": {
|
||||
"name": "Frequency backfeed CT {phase_name}"
|
||||
},
|
||||
"backfeed_ct_metering_status": {
|
||||
"name": "Metering status backfeed CT"
|
||||
},
|
||||
"backfeed_ct_metering_status_phase": {
|
||||
"name": "Metering status backfeed CT {phase_name}"
|
||||
},
|
||||
"backfeed_ct_power": {
|
||||
"name": "Backfeed CT power"
|
||||
},
|
||||
"backfeed_ct_power_phase": {
|
||||
"name": "Backfeed CT power {phase_name}"
|
||||
},
|
||||
"backfeed_ct_powerfactor": {
|
||||
"name": "Power factor backfeed CT"
|
||||
},
|
||||
"backfeed_ct_powerfactor_phase": {
|
||||
"name": "Power factor backfeed CT {phase_name}"
|
||||
},
|
||||
"backfeed_ct_status_flags": {
|
||||
"name": "Meter status flags active backfeed CT"
|
||||
},
|
||||
"backfeed_ct_status_flags_phase": {
|
||||
"name": "Meter status flags active backfeed CT {phase_name}"
|
||||
},
|
||||
"backfeed_ct_voltage": {
|
||||
"name": "Voltage backfeed CT"
|
||||
},
|
||||
"backfeed_ct_voltage_phase": {
|
||||
"name": "Voltage backfeed CT {phase_name}"
|
||||
},
|
||||
"balanced_net_consumption": {
|
||||
"name": "Balanced net power consumption"
|
||||
},
|
||||
@@ -211,6 +265,60 @@
|
||||
"energy_today": {
|
||||
"name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]"
|
||||
},
|
||||
"evse_ct_current": {
|
||||
"name": "EVSE CT current"
|
||||
},
|
||||
"evse_ct_current_phase": {
|
||||
"name": "EVSE CT current {phase_name}"
|
||||
},
|
||||
"evse_ct_energy_delivered": {
|
||||
"name": "EVSE CT energy delivered"
|
||||
},
|
||||
"evse_ct_energy_delivered_phase": {
|
||||
"name": "EVSE CT energy delivered {phase_name}"
|
||||
},
|
||||
"evse_ct_energy_received": {
|
||||
"name": "EVSE CT energy received"
|
||||
},
|
||||
"evse_ct_energy_received_phase": {
|
||||
"name": "EVSE CT energy received {phase_name}"
|
||||
},
|
||||
"evse_ct_frequency": {
|
||||
"name": "Frequency EVSE CT"
|
||||
},
|
||||
"evse_ct_frequency_phase": {
|
||||
"name": "Frequency EVSE CT {phase_name}"
|
||||
},
|
||||
"evse_ct_metering_status": {
|
||||
"name": "Metering status EVSE CT"
|
||||
},
|
||||
"evse_ct_metering_status_phase": {
|
||||
"name": "Metering status EVSE CT {phase_name}"
|
||||
},
|
||||
"evse_ct_power": {
|
||||
"name": "EVSE CT power"
|
||||
},
|
||||
"evse_ct_power_phase": {
|
||||
"name": "EVSE CT power {phase_name}"
|
||||
},
|
||||
"evse_ct_powerfactor": {
|
||||
"name": "Power factor EVSE CT"
|
||||
},
|
||||
"evse_ct_powerfactor_phase": {
|
||||
"name": "Power factor EVSE CT {phase_name}"
|
||||
},
|
||||
"evse_ct_status_flags": {
|
||||
"name": "Meter status flags active EVSE CT"
|
||||
},
|
||||
"evse_ct_status_flags_phase": {
|
||||
"name": "Meter status flags active EVSE CT {phase_name}"
|
||||
},
|
||||
"evse_ct_voltage": {
|
||||
"name": "Voltage EVSE CT"
|
||||
},
|
||||
"evse_ct_voltage_phase": {
|
||||
"name": "Voltage EVSE CT {phase_name}"
|
||||
},
|
||||
"grid_status": {
|
||||
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]",
|
||||
"state": {
|
||||
@@ -270,6 +378,60 @@
|
||||
"lifetime_production_phase": {
|
||||
"name": "Lifetime energy production {phase_name}"
|
||||
},
|
||||
"load_ct_current": {
|
||||
"name": "Load CT current"
|
||||
},
|
||||
"load_ct_current_phase": {
|
||||
"name": "Load CT current {phase_name}"
|
||||
},
|
||||
"load_ct_energy_delivered": {
|
||||
"name": "Load CT energy delivered"
|
||||
},
|
||||
"load_ct_energy_delivered_phase": {
|
||||
"name": "Load CT energy delivered {phase_name}"
|
||||
},
|
||||
"load_ct_energy_received": {
|
||||
"name": "Load CT energy received"
|
||||
},
|
||||
"load_ct_energy_received_phase": {
|
||||
"name": "Load CT energy received {phase_name}"
|
||||
},
|
||||
"load_ct_frequency": {
|
||||
"name": "Frequency load CT"
|
||||
},
|
||||
"load_ct_frequency_phase": {
|
||||
"name": "Frequency load CT {phase_name}"
|
||||
},
|
||||
"load_ct_metering_status": {
|
||||
"name": "Metering status load CT"
|
||||
},
|
||||
"load_ct_metering_status_phase": {
|
||||
"name": "Metering status load CT {phase_name}"
|
||||
},
|
||||
"load_ct_power": {
|
||||
"name": "Load CT power"
|
||||
},
|
||||
"load_ct_power_phase": {
|
||||
"name": "Load CT power {phase_name}"
|
||||
},
|
||||
"load_ct_powerfactor": {
|
||||
"name": "Power factor load CT"
|
||||
},
|
||||
"load_ct_powerfactor_phase": {
|
||||
"name": "Power factor load CT {phase_name}"
|
||||
},
|
||||
"load_ct_status_flags": {
|
||||
"name": "Meter status flags active load CT"
|
||||
},
|
||||
"load_ct_status_flags_phase": {
|
||||
"name": "Meter status flags active load CT {phase_name}"
|
||||
},
|
||||
"load_ct_voltage": {
|
||||
"name": "Voltage load CT"
|
||||
},
|
||||
"load_ct_voltage_phase": {
|
||||
"name": "Voltage load CT {phase_name}"
|
||||
},
|
||||
"max_capacity": {
|
||||
"name": "Battery capacity"
|
||||
},
|
||||
@@ -331,6 +493,18 @@
|
||||
"production_ct_current_phase": {
|
||||
"name": "Production CT current {phase_name}"
|
||||
},
|
||||
"production_ct_energy_delivered": {
|
||||
"name": "Production CT energy delivered"
|
||||
},
|
||||
"production_ct_energy_delivered_phase": {
|
||||
"name": "Production CT energy delivered {phase_name}"
|
||||
},
|
||||
"production_ct_energy_received": {
|
||||
"name": "Production CT energy received"
|
||||
},
|
||||
"production_ct_energy_received_phase": {
|
||||
"name": "Production CT energy received {phase_name}"
|
||||
},
|
||||
"production_ct_frequency": {
|
||||
"name": "Frequency production CT"
|
||||
},
|
||||
@@ -343,6 +517,12 @@
|
||||
"production_ct_metering_status_phase": {
|
||||
"name": "Metering status production CT {phase_name}"
|
||||
},
|
||||
"production_ct_power": {
|
||||
"name": "Production CT power"
|
||||
},
|
||||
"production_ct_power_phase": {
|
||||
"name": "Production CT power {phase_name}"
|
||||
},
|
||||
"production_ct_powerfactor": {
|
||||
"name": "Power factor production CT"
|
||||
},
|
||||
@@ -361,6 +541,60 @@
|
||||
"production_ct_voltage_phase": {
|
||||
"name": "Voltage production CT {phase_name}"
|
||||
},
|
||||
"pv3p_ct_current": {
|
||||
"name": "PV3P CT current"
|
||||
},
|
||||
"pv3p_ct_current_phase": {
|
||||
"name": "PV3P CT current {phase_name}"
|
||||
},
|
||||
"pv3p_ct_energy_delivered": {
|
||||
"name": "PV3P CT energy delivered"
|
||||
},
|
||||
"pv3p_ct_energy_delivered_phase": {
|
||||
"name": "PV3P CT energy delivered {phase_name}"
|
||||
},
|
||||
"pv3p_ct_energy_received": {
|
||||
"name": "PV3P CT energy received"
|
||||
},
|
||||
"pv3p_ct_energy_received_phase": {
|
||||
"name": "PV3P CT energy received {phase_name}"
|
||||
},
|
||||
"pv3p_ct_frequency": {
|
||||
"name": "Frequency PV3P CT"
|
||||
},
|
||||
"pv3p_ct_frequency_phase": {
|
||||
"name": "Frequency PV3P CT {phase_name}"
|
||||
},
|
||||
"pv3p_ct_metering_status": {
|
||||
"name": "Metering status PV3P CT"
|
||||
},
|
||||
"pv3p_ct_metering_status_phase": {
|
||||
"name": "Metering status PV3P CT {phase_name}"
|
||||
},
|
||||
"pv3p_ct_power": {
|
||||
"name": "PV3P CT power"
|
||||
},
|
||||
"pv3p_ct_power_phase": {
|
||||
"name": "PV3P CT power {phase_name}"
|
||||
},
|
||||
"pv3p_ct_powerfactor": {
|
||||
"name": "Power factor PV3P CT"
|
||||
},
|
||||
"pv3p_ct_powerfactor_phase": {
|
||||
"name": "Power factor PV3P CT {phase_name}"
|
||||
},
|
||||
"pv3p_ct_status_flags": {
|
||||
"name": "Meter status flags active PV3P CT"
|
||||
},
|
||||
"pv3p_ct_status_flags_phase": {
|
||||
"name": "Meter status flags active PV3P CT {phase_name}"
|
||||
},
|
||||
"pv3p_ct_voltage": {
|
||||
"name": "Voltage PV3P CT"
|
||||
},
|
||||
"pv3p_ct_voltage_phase": {
|
||||
"name": "Voltage PV3P CT {phase_name}"
|
||||
},
|
||||
"reserve_energy": {
|
||||
"name": "Reserve battery energy"
|
||||
},
|
||||
@@ -414,6 +648,60 @@
|
||||
},
|
||||
"storage_ct_voltage_phase": {
|
||||
"name": "Voltage storage CT {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_current": {
|
||||
"name": "Total consumption CT current"
|
||||
},
|
||||
"total_consumption_ct_current_phase": {
|
||||
"name": "Total consumption CT current {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_energy_delivered": {
|
||||
"name": "Total consumption CT energy delivered"
|
||||
},
|
||||
"total_consumption_ct_energy_delivered_phase": {
|
||||
"name": "Total consumption CT energy delivered {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_energy_received": {
|
||||
"name": "Total consumption CT energy received"
|
||||
},
|
||||
"total_consumption_ct_energy_received_phase": {
|
||||
"name": "Total consumption CT energy received {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_frequency": {
|
||||
"name": "Frequency total consumption CT"
|
||||
},
|
||||
"total_consumption_ct_frequency_phase": {
|
||||
"name": "Frequency total consumption CT {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_metering_status": {
|
||||
"name": "Metering status total consumption CT"
|
||||
},
|
||||
"total_consumption_ct_metering_status_phase": {
|
||||
"name": "Metering status total consumption CT {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_power": {
|
||||
"name": "Total consumption CT power"
|
||||
},
|
||||
"total_consumption_ct_power_phase": {
|
||||
"name": "Total consumption CT power {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_powerfactor": {
|
||||
"name": "Power factor total consumption CT"
|
||||
},
|
||||
"total_consumption_ct_powerfactor_phase": {
|
||||
"name": "Power factor total consumption CT {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_status_flags": {
|
||||
"name": "Meter status flags active total consumption CT"
|
||||
},
|
||||
"total_consumption_ct_status_flags_phase": {
|
||||
"name": "Meter status flags active total consumption CT {phase_name}"
|
||||
},
|
||||
"total_consumption_ct_voltage": {
|
||||
"name": "Voltage total consumption CT"
|
||||
},
|
||||
"total_consumption_ct_voltage_phase": {
|
||||
"name": "Voltage total consumption CT {phase_name}"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env-canada==0.12.4"]
|
||||
"requirements": ["env-canada==0.13.2"]
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ async def platform_async_setup_entry(
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
state_type: type[_StateT],
|
||||
info_filter: Callable[[_InfoT], bool] | None = None,
|
||||
) -> None:
|
||||
"""Set up an esphome platform.
|
||||
|
||||
@@ -208,10 +209,22 @@ async def platform_async_setup_entry(
|
||||
entity_type,
|
||||
state_type,
|
||||
)
|
||||
|
||||
if info_filter is not None:
|
||||
|
||||
def on_filtered_update(infos: list[EntityInfo]) -> None:
|
||||
on_static_info_update(
|
||||
[info for info in infos if info_filter(cast(_InfoT, info))]
|
||||
)
|
||||
|
||||
info_callback = on_filtered_update
|
||||
else:
|
||||
info_callback = on_static_info_update
|
||||
|
||||
entry_data.cleanup_callbacks.append(
|
||||
entry_data.async_register_static_info_callback(
|
||||
info_type,
|
||||
on_static_info_update,
|
||||
info_callback,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from aioesphomeapi import (
|
||||
Event,
|
||||
EventInfo,
|
||||
FanInfo,
|
||||
InfraredInfo,
|
||||
LightInfo,
|
||||
LockInfo,
|
||||
MediaPlayerInfo,
|
||||
@@ -85,6 +86,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
DateTimeInfo: Platform.DATETIME,
|
||||
EventInfo: Platform.EVENT,
|
||||
FanInfo: Platform.FAN,
|
||||
InfraredInfo: Platform.INFRARED,
|
||||
LightInfo: Platform.LIGHT,
|
||||
LockInfo: Platform.LOCK,
|
||||
MediaPlayerInfo: Platform.MEDIA_PLAYER,
|
||||
|
||||
59
homeassistant/components/esphome/infrared.py
Normal file
59
homeassistant/components/esphome/infrared.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
|
||||
"""ESPHome infrared entity using native API."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
_LOGGER.debug("Sending command: %s", timings)
|
||||
|
||||
self._client.infrared_rf_transmit_raw_timings(
|
||||
self._static_info.key,
|
||||
carrier_frequency=command.modulation,
|
||||
timings=timings,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
)
|
||||
@@ -241,7 +241,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
|
||||
# Do not use kelvin_to_mired here to prevent precision loss
|
||||
data["color_temperature"] = 1000000.0 / color_temp_k
|
||||
data["color_temperature"] = 1_000_000.0 / color_temp_k
|
||||
if color_temp_modes := _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLOR_TEMPERATURE
|
||||
):
|
||||
|
||||
@@ -36,12 +36,12 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, EVOHOME_DATA, EvoService
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
from .entity import EvoChild, EvoEntity
|
||||
|
||||
@@ -132,6 +132,24 @@ class EvoClimateEntity(EvoEntity, ClimateEntity):
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
async def async_clear_zone_override(self) -> None:
|
||||
"""Clear the zone override; only supported by zones."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="zone_only_service",
|
||||
translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE},
|
||||
)
|
||||
|
||||
async def async_set_zone_override(
|
||||
self, setpoint: float, duration: timedelta | None = None
|
||||
) -> None:
|
||||
"""Set the zone override; only supported by zones."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="zone_only_service",
|
||||
translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE},
|
||||
)
|
||||
|
||||
|
||||
class EvoZone(EvoChild, EvoClimateEntity):
|
||||
"""Base for any evohome-compatible heating zone."""
|
||||
@@ -170,22 +188,22 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (setpoint override) for a zone."""
|
||||
if service == EvoService.CLEAR_ZONE_OVERRIDE:
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
return
|
||||
async def async_clear_zone_override(self) -> None:
|
||||
"""Clear the zone's override, if any."""
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
|
||||
# otherwise it is EvoService.SET_ZONE_OVERRIDE
|
||||
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
|
||||
async def async_set_zone_override(
|
||||
self, setpoint: float, duration: timedelta | None = None
|
||||
) -> None:
|
||||
"""Set the zone's override (mode/setpoint)."""
|
||||
temperature = max(min(setpoint, self.max_temp), self.min_temp)
|
||||
|
||||
if ATTR_DURATION in data:
|
||||
duration: timedelta = data[ATTR_DURATION]
|
||||
if duration is not None:
|
||||
if duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = self.setpoints.get("next_sp_from")
|
||||
else:
|
||||
until = dt_util.now() + data[ATTR_DURATION]
|
||||
until = dt_util.now() + duration
|
||||
else:
|
||||
until = None # indefinitely
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, EvoService
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -47,22 +47,12 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
|
||||
raise NotImplementedError
|
||||
if payload["unique_id"] != self._attr_unique_id:
|
||||
return
|
||||
if payload["service"] in (
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
):
|
||||
await self.async_zone_svc_request(payload["service"], payload["data"])
|
||||
return
|
||||
await self.async_tcs_svc_request(payload["service"], payload["data"])
|
||||
|
||||
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (system mode) for a controller."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (setpoint override) for a zone."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any]:
|
||||
"""Return the evohome-specific state attributes."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
|
||||
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
|
||||
from evohomeasync2.schemas.const import (
|
||||
@@ -13,9 +13,10 @@ from evohomeasync2.schemas.const import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service import verify_domain_control
|
||||
|
||||
@@ -25,21 +26,38 @@ from .coordinator import EvoDataUpdateCoordinator
|
||||
# system mode schemas are built dynamically when the services are registered
|
||||
# because supported modes can vary for edge-case systems
|
||||
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
|
||||
)
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
|
||||
),
|
||||
}
|
||||
)
|
||||
# Zone service schemas (registered as entity services)
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _register_zone_entity_services(hass: HomeAssistant) -> None:
|
||||
"""Register entity-level services for zones."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
func="async_clear_zone_override",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema=SET_ZONE_OVERRIDE_SCHEMA,
|
||||
func="async_set_zone_override",
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -51,8 +69,6 @@ def setup_service_functions(
|
||||
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
|
||||
each mode will require any of four distinct service schemas. This has to be
|
||||
enumerated before registering the appropriate handlers.
|
||||
|
||||
It appears that all TCC-compatible systems support the same three zones modes.
|
||||
"""
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@@ -72,28 +88,6 @@ def setup_service_functions(
|
||||
}
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def set_zone_override(call: ServiceCall) -> None:
|
||||
"""Set the zone override (setpoint)."""
|
||||
entity_id = call.data[ATTR_ENTITY_ID]
|
||||
|
||||
registry = er.async_get(hass)
|
||||
registry_entry = registry.async_get(entity_id)
|
||||
|
||||
if registry_entry is None or registry_entry.platform != DOMAIN:
|
||||
raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
|
||||
|
||||
if registry_entry.domain != "climate":
|
||||
raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
|
||||
|
||||
payload = {
|
||||
"unique_id": registry_entry.unique_id,
|
||||
"service": call.service,
|
||||
"data": call.data,
|
||||
}
|
||||
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
|
||||
@@ -156,16 +150,4 @@ def setup_service_functions(
|
||||
schema=vol.Schema(vol.Any(*system_mode_schemas)),
|
||||
)
|
||||
|
||||
# The zone modes are consistent across all systems and use the same schema
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
set_zone_override,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
set_zone_override,
|
||||
schema=SET_ZONE_OVERRIDE_SCHEMA,
|
||||
)
|
||||
_register_zone_entity_services(hass)
|
||||
|
||||
@@ -28,14 +28,11 @@ reset_system:
|
||||
refresh_system:
|
||||
|
||||
set_zone_override:
|
||||
target:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
example: climate.bathroom
|
||||
selector:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
setpoint:
|
||||
required: true
|
||||
selector:
|
||||
@@ -49,10 +46,7 @@ set_zone_override:
|
||||
object:
|
||||
|
||||
clear_zone_override:
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
target:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"zone_only_service": {
|
||||
"message": "Only zones support the `{service}` action"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_zone_override": {
|
||||
"description": "Sets a zone to follow its schedule.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]",
|
||||
"name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Clear zone override"
|
||||
},
|
||||
"refresh_system": {
|
||||
@@ -43,10 +42,6 @@
|
||||
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"entity_id": {
|
||||
"description": "The entity ID of the Evohome zone.",
|
||||
"name": "Entity"
|
||||
},
|
||||
"setpoint": {
|
||||
"description": "The temperature to be used instead of the scheduled setpoint.",
|
||||
"name": "Setpoint"
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260226.0"]
|
||||
"requirements": ["home-assistant-frontend==20260302.0"]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
||||
except BaseException as ex:
|
||||
del stores[user_id]
|
||||
future.set_exception(ex)
|
||||
# Ensure the future is marked as retrieved
|
||||
# since if there is no concurrent call it
|
||||
# will otherwise never be retrieved.
|
||||
future.exception()
|
||||
raise
|
||||
future.set_result(store)
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"mqtt": ["fully/deviceInfo/+"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-fullykiosk==0.0.14"]
|
||||
"requirements": ["python-fullykiosk==0.0.15"]
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ query ($owner: String!, $repository: String!) {
|
||||
number
|
||||
}
|
||||
}
|
||||
merged_pull_request: pullRequests(
|
||||
first:1
|
||||
states: MERGED
|
||||
) {
|
||||
total: totalCount
|
||||
}
|
||||
release: latestRelease {
|
||||
name
|
||||
url
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"latest_tag": {
|
||||
"default": "mdi:tag"
|
||||
},
|
||||
"merged_pulls_count": {
|
||||
"default": "mdi:source-merge"
|
||||
},
|
||||
"pulls_count": {
|
||||
"default": "mdi:source-pull"
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiogithubapi"],
|
||||
"requirements": ["aiogithubapi==24.6.0"]
|
||||
"requirements": ["aiogithubapi==26.0.0"]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,13 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data["pull_request"]["total"],
|
||||
),
|
||||
GitHubSensorEntityDescription(
|
||||
key="merged_pulls_count",
|
||||
translation_key="merged_pulls_count",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value_fn=lambda data: data["merged_pull_request"]["total"],
|
||||
),
|
||||
GitHubSensorEntityDescription(
|
||||
key="latest_commit",
|
||||
translation_key="latest_commit",
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
"latest_tag": {
|
||||
"name": "Latest tag"
|
||||
},
|
||||
"merged_pulls_count": {
|
||||
"name": "Merged pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
},
|
||||
"pulls_count": {
|
||||
"name": "Pull requests",
|
||||
"unit_of_measurement": "pull requests"
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"connectable": false,
|
||||
"local_name": "GVH5110*"
|
||||
},
|
||||
{
|
||||
"connectable": false,
|
||||
"local_name": "GV5140*"
|
||||
},
|
||||
{
|
||||
"connectable": false,
|
||||
"manufacturer_id": 1,
|
||||
@@ -140,5 +144,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-ble==0.44.0"]
|
||||
"requirements": ["govee-ble==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfTemperature,
|
||||
@@ -72,6 +73,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
(DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
|
||||
key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"audio_unmute": {
|
||||
"default": "mdi:volume-high"
|
||||
},
|
||||
"earc_unmute": {
|
||||
"default": "mdi:volume-high"
|
||||
},
|
||||
"oled_fade": {
|
||||
"default": "mdi:cellphone-information"
|
||||
},
|
||||
|
||||
@@ -31,6 +31,32 @@ class HDFuryNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
|
||||
NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = (
|
||||
HDFuryNumberEntityDescription(
|
||||
key="unmutecnt",
|
||||
translation_key="audio_unmute",
|
||||
entity_registry_enabled_default=False,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=50,
|
||||
native_max_value=1000,
|
||||
native_step=1,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_audio_unmute(value),
|
||||
),
|
||||
HDFuryNumberEntityDescription(
|
||||
key="earcunmutecnt",
|
||||
translation_key="earc_unmute",
|
||||
entity_registry_enabled_default=False,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=1000,
|
||||
native_step=1,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_earc_unmute(value),
|
||||
),
|
||||
HDFuryNumberEntityDescription(
|
||||
key="oledfade",
|
||||
translation_key="oled_fade",
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"audio_unmute": {
|
||||
"name": "Unmute delay"
|
||||
},
|
||||
"earc_unmute": {
|
||||
"name": "eARC unmute delay"
|
||||
},
|
||||
"oled_fade": {
|
||||
"name": "OLED fade timer"
|
||||
},
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
"requirements": ["pyhive-integration==1.0.8"]
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"battery_charge_discharge_state": {
|
||||
"name": "Battery charge/discharge state",
|
||||
"state": {
|
||||
"charging": "Charging",
|
||||
"discharging": "Discharging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"static": "Static"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2026.2.25.165736"
|
||||
"knx-frontend==2026.3.2.183756"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ def _convert_uint8_to_percentage(value: Any) -> float:
|
||||
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for brightness changed."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_domains = {DOMAIN}
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
@@ -34,7 +34,7 @@ class BrightnessCrossedThresholdTrigger(
|
||||
):
|
||||
"""Trigger for brightness crossed threshold."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_domains = {DOMAIN}
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pylutron_caseta"],
|
||||
"requirements": ["pylutron-caseta==0.26.0"],
|
||||
"requirements": ["pylutron-caseta==0.27.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
|
||||
ADDON_SLUG = "core_matter_server"
|
||||
|
||||
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
|
||||
@@ -15,3 +17,100 @@ ID_TYPE_DEVICE_ID = "deviceid"
|
||||
ID_TYPE_SERIAL = "serial"
|
||||
|
||||
FEATUREMAP_ATTRIBUTE_ID = 65532
|
||||
|
||||
# --- Lock domain constants ---
|
||||
|
||||
# Shared field keys
|
||||
ATTR_CREDENTIAL_RULE = "credential_rule"
|
||||
ATTR_MAX_CREDENTIALS_PER_USER = "max_credentials_per_user"
|
||||
ATTR_MAX_PIN_USERS = "max_pin_users"
|
||||
ATTR_MAX_RFID_USERS = "max_rfid_users"
|
||||
ATTR_MAX_USERS = "max_users"
|
||||
ATTR_SUPPORTS_USER_MGMT = "supports_user_management"
|
||||
ATTR_USER_INDEX = "user_index"
|
||||
ATTR_USER_NAME = "user_name"
|
||||
ATTR_USER_STATUS = "user_status"
|
||||
ATTR_USER_TYPE = "user_type"
|
||||
|
||||
# Magic values
|
||||
CLEAR_ALL_INDEX = 0xFFFE # Matter spec: pass to ClearUser/ClearCredential to clear all
|
||||
|
||||
# Timed request timeout for lock commands that modify state.
|
||||
# 10 seconds accounts for Thread network latency and retransmissions.
|
||||
LOCK_TIMED_REQUEST_TIMEOUT_MS = 10000
|
||||
|
||||
# Credential field keys
|
||||
ATTR_CREDENTIAL_DATA = "credential_data"
|
||||
ATTR_CREDENTIAL_INDEX = "credential_index"
|
||||
ATTR_CREDENTIAL_TYPE = "credential_type"
|
||||
|
||||
# Credential type strings
|
||||
CRED_TYPE_FACE = "face"
|
||||
CRED_TYPE_FINGERPRINT = "fingerprint"
|
||||
CRED_TYPE_FINGER_VEIN = "finger_vein"
|
||||
CRED_TYPE_PIN = "pin"
|
||||
CRED_TYPE_RFID = "rfid"
|
||||
|
||||
# User status mapping (Matter DoorLock UserStatusEnum)
|
||||
_UserStatus = clusters.DoorLock.Enums.UserStatusEnum
|
||||
USER_STATUS_MAP: dict[int, str] = {
|
||||
_UserStatus.kAvailable: "available",
|
||||
_UserStatus.kOccupiedEnabled: "occupied_enabled",
|
||||
_UserStatus.kOccupiedDisabled: "occupied_disabled",
|
||||
}
|
||||
USER_STATUS_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_STATUS_MAP.items()}
|
||||
|
||||
# User type mapping (Matter DoorLock UserTypeEnum)
|
||||
_UserType = clusters.DoorLock.Enums.UserTypeEnum
|
||||
USER_TYPE_MAP: dict[int, str] = {
|
||||
_UserType.kUnrestrictedUser: "unrestricted_user",
|
||||
_UserType.kYearDayScheduleUser: "year_day_schedule_user",
|
||||
_UserType.kWeekDayScheduleUser: "week_day_schedule_user",
|
||||
_UserType.kProgrammingUser: "programming_user",
|
||||
_UserType.kNonAccessUser: "non_access_user",
|
||||
_UserType.kForcedUser: "forced_user",
|
||||
_UserType.kDisposableUser: "disposable_user",
|
||||
_UserType.kExpiringUser: "expiring_user",
|
||||
_UserType.kScheduleRestrictedUser: "schedule_restricted_user",
|
||||
_UserType.kRemoteOnlyUser: "remote_only_user",
|
||||
}
|
||||
USER_TYPE_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_TYPE_MAP.items()}
|
||||
|
||||
# Credential type mapping (Matter DoorLock CredentialTypeEnum)
|
||||
_CredentialType = clusters.DoorLock.Enums.CredentialTypeEnum
|
||||
CREDENTIAL_TYPE_MAP: dict[int, str] = {
|
||||
_CredentialType.kProgrammingPIN: "programming_pin",
|
||||
_CredentialType.kPin: CRED_TYPE_PIN,
|
||||
_CredentialType.kRfid: CRED_TYPE_RFID,
|
||||
_CredentialType.kFingerprint: CRED_TYPE_FINGERPRINT,
|
||||
_CredentialType.kFingerVein: CRED_TYPE_FINGER_VEIN,
|
||||
_CredentialType.kFace: CRED_TYPE_FACE,
|
||||
_CredentialType.kAliroCredentialIssuerKey: "aliro_credential_issuer_key",
|
||||
_CredentialType.kAliroEvictableEndpointKey: "aliro_evictable_endpoint_key",
|
||||
_CredentialType.kAliroNonEvictableEndpointKey: "aliro_non_evictable_endpoint_key",
|
||||
}
|
||||
|
||||
# Credential rule mapping (Matter DoorLock CredentialRuleEnum)
|
||||
_CredentialRule = clusters.DoorLock.Enums.CredentialRuleEnum
|
||||
CREDENTIAL_RULE_MAP: dict[int, str] = {
|
||||
_CredentialRule.kSingle: "single",
|
||||
_CredentialRule.kDual: "dual",
|
||||
_CredentialRule.kTri: "tri",
|
||||
}
|
||||
CREDENTIAL_RULE_REVERSE_MAP: dict[str, int] = {
|
||||
v: k for k, v in CREDENTIAL_RULE_MAP.items()
|
||||
}
|
||||
|
||||
# Reverse mapping for credential types (str -> int)
|
||||
CREDENTIAL_TYPE_REVERSE_MAP: dict[str, int] = {
|
||||
v: k for k, v in CREDENTIAL_TYPE_MAP.items()
|
||||
}
|
||||
|
||||
# Credential types allowed in set/clear services (excludes programming_pin, aliro_*)
|
||||
SERVICE_CREDENTIAL_TYPES = [
|
||||
CRED_TYPE_PIN,
|
||||
CRED_TYPE_RFID,
|
||||
CRED_TYPE_FINGERPRINT,
|
||||
CRED_TYPE_FINGER_VEIN,
|
||||
CRED_TYPE_FACE,
|
||||
]
|
||||
|
||||
@@ -174,6 +174,27 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_lock_credential": {
|
||||
"service": "mdi:key-remove"
|
||||
},
|
||||
"clear_lock_user": {
|
||||
"service": "mdi:account-remove"
|
||||
},
|
||||
"get_lock_credential_status": {
|
||||
"service": "mdi:key-chain"
|
||||
},
|
||||
"get_lock_info": {
|
||||
"service": "mdi:lock-question"
|
||||
},
|
||||
"get_lock_users": {
|
||||
"service": "mdi:account-multiple"
|
||||
},
|
||||
"set_lock_credential": {
|
||||
"service": "mdi:key-plus"
|
||||
},
|
||||
"set_lock_user": {
|
||||
"service": "mdi:account-lock"
|
||||
},
|
||||
"water_heater_boost": {
|
||||
"service": "mdi:water-boiler"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from matter_server.common.errors import MatterError
|
||||
from matter_server.common.models import EventType, MatterNodeEvent
|
||||
|
||||
from homeassistant.components.lock import (
|
||||
@@ -17,32 +18,56 @@ from homeassistant.components.lock import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_CODE, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import (
|
||||
ATTR_CREDENTIAL_DATA,
|
||||
ATTR_CREDENTIAL_INDEX,
|
||||
ATTR_CREDENTIAL_RULE,
|
||||
ATTR_CREDENTIAL_TYPE,
|
||||
ATTR_USER_INDEX,
|
||||
ATTR_USER_NAME,
|
||||
ATTR_USER_STATUS,
|
||||
ATTR_USER_TYPE,
|
||||
LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
LOGGER,
|
||||
)
|
||||
from .entity import MatterEntity, MatterEntityDescription
|
||||
from .helpers import get_matter
|
||||
from .lock_helpers import (
|
||||
DoorLockFeature,
|
||||
GetLockCredentialStatusResult,
|
||||
GetLockInfoResult,
|
||||
GetLockUsersResult,
|
||||
SetLockCredentialResult,
|
||||
clear_lock_credential,
|
||||
clear_lock_user,
|
||||
get_lock_credential_status,
|
||||
get_lock_info,
|
||||
get_lock_users,
|
||||
set_lock_credential,
|
||||
set_lock_user,
|
||||
)
|
||||
from .models import MatterDiscoverySchema
|
||||
|
||||
DOOR_LOCK_OPERATION_SOURCE = {
|
||||
# mapping from operation source id's to textual representation
|
||||
0: "Unspecified",
|
||||
1: "Manual", # [Optional]
|
||||
2: "Proprietary Remote", # [Optional]
|
||||
3: "Keypad", # [Optional]
|
||||
4: "Auto", # [Optional]
|
||||
5: "Button", # [Optional]
|
||||
6: "Schedule", # [HDSCH]
|
||||
7: "Remote", # [M]
|
||||
8: "RFID", # [RID]
|
||||
9: "Biometric", # [USR]
|
||||
10: "Aliro", # [Aliro]
|
||||
# Door lock operation source mapping (Matter DoorLock OperationSourceEnum)
|
||||
_OperationSource = clusters.DoorLock.Enums.OperationSourceEnum
|
||||
DOOR_LOCK_OPERATION_SOURCE: dict[int, str] = {
|
||||
_OperationSource.kUnspecified: "Unspecified",
|
||||
_OperationSource.kManual: "Manual",
|
||||
_OperationSource.kProprietaryRemote: "Proprietary Remote",
|
||||
_OperationSource.kKeypad: "Keypad",
|
||||
_OperationSource.kAuto: "Auto",
|
||||
_OperationSource.kButton: "Button",
|
||||
_OperationSource.kSchedule: "Schedule",
|
||||
_OperationSource.kRemote: "Remote",
|
||||
_OperationSource.kRfid: "RFID",
|
||||
_OperationSource.kBiometric: "Biometric",
|
||||
_OperationSource.kAliro: "Aliro",
|
||||
}
|
||||
|
||||
|
||||
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -98,17 +123,15 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
node_event.data,
|
||||
)
|
||||
|
||||
# handle the DoorLock events
|
||||
# Handle the DoorLock events
|
||||
node_event_data: dict[str, int] = node_event.data or {}
|
||||
match node_event.event_id:
|
||||
case (
|
||||
clusters.DoorLock.Events.LockOperation.event_id
|
||||
): # Lock cluster event 2
|
||||
# update the changed_by attribute to indicate lock operation source
|
||||
case clusters.DoorLock.Events.LockOperation.event_id:
|
||||
operation_source: int = node_event_data.get("operationSource", -1)
|
||||
self._attr_changed_by = DOOR_LOCK_OPERATION_SOURCE.get(
|
||||
source_name = DOOR_LOCK_OPERATION_SOURCE.get(
|
||||
operation_source, "Unknown"
|
||||
)
|
||||
self._attr_changed_by = source_name
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
@@ -146,7 +169,7 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
code_bytes = code.encode() if code else None
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.LockDoor(code_bytes),
|
||||
timed_request_timeout_ms=1000,
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
@@ -168,12 +191,12 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
# and unlatch on the HA 'open' command.
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes),
|
||||
timed_request_timeout_ms=1000,
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
else:
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
|
||||
timed_request_timeout_ms=1000,
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
@@ -190,7 +213,7 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
code_bytes = code.encode() if code else None
|
||||
await self.send_device_command(
|
||||
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
|
||||
timed_request_timeout_ms=1000,
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -256,6 +279,109 @@ class MatterLock(MatterEntity, LockEntity):
|
||||
supported_features |= LockEntityFeature.OPEN
|
||||
self._attr_supported_features = supported_features
|
||||
|
||||
# --- Entity service methods ---
|
||||
|
||||
async def async_set_lock_user(self, **kwargs: Any) -> None:
|
||||
"""Set a lock user (full CRUD)."""
|
||||
try:
|
||||
await set_lock_user(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
user_index=kwargs.get(ATTR_USER_INDEX),
|
||||
user_name=kwargs.get(ATTR_USER_NAME),
|
||||
user_type=kwargs.get(ATTR_USER_TYPE),
|
||||
credential_rule=kwargs.get(ATTR_CREDENTIAL_RULE),
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to set lock user on {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_clear_lock_user(self, **kwargs: Any) -> None:
|
||||
"""Clear a lock user."""
|
||||
try:
|
||||
await clear_lock_user(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
kwargs[ATTR_USER_INDEX],
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to clear lock user on {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_get_lock_info(self) -> GetLockInfoResult:
|
||||
"""Get lock capabilities and configuration info."""
|
||||
try:
|
||||
return await get_lock_info(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to get lock info for {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_get_lock_users(self) -> GetLockUsersResult:
|
||||
"""Get all users from the lock."""
|
||||
try:
|
||||
return await get_lock_users(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to get lock users for {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_set_lock_credential(self, **kwargs: Any) -> SetLockCredentialResult:
|
||||
"""Set a credential on the lock."""
|
||||
try:
|
||||
return await set_lock_credential(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
|
||||
credential_data=kwargs[ATTR_CREDENTIAL_DATA],
|
||||
credential_index=kwargs.get(ATTR_CREDENTIAL_INDEX),
|
||||
user_index=kwargs.get(ATTR_USER_INDEX),
|
||||
user_status=kwargs.get(ATTR_USER_STATUS),
|
||||
user_type=kwargs.get(ATTR_USER_TYPE),
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to set lock credential on {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_clear_lock_credential(self, **kwargs: Any) -> None:
|
||||
"""Clear a credential from the lock."""
|
||||
try:
|
||||
await clear_lock_credential(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
|
||||
credential_index=kwargs[ATTR_CREDENTIAL_INDEX],
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to clear lock credential on {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_get_lock_credential_status(
|
||||
self, **kwargs: Any
|
||||
) -> GetLockCredentialStatusResult:
|
||||
"""Get the status of a credential slot on the lock."""
|
||||
try:
|
||||
return await get_lock_credential_status(
|
||||
self.matter_client,
|
||||
self._endpoint.node,
|
||||
credential_type=kwargs[ATTR_CREDENTIAL_TYPE],
|
||||
credential_index=kwargs[ATTR_CREDENTIAL_INDEX],
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to get credential status for {self.entity_id}: {err}"
|
||||
) from err
|
||||
|
||||
|
||||
DISCOVERY_SCHEMAS = [
|
||||
MatterDiscoverySchema(
|
||||
|
||||
843
homeassistant/components/matter/lock_helpers.py
Normal file
843
homeassistant/components/matter/lock_helpers.py
Normal file
@@ -0,0 +1,843 @@
|
||||
"""Lock-specific helpers for the Matter integration.
|
||||
|
||||
Provides DoorLock cluster endpoint resolution, feature detection, and
|
||||
business logic for lock user/credential management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from chip.clusters.Types import NullValue
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
|
||||
from .const import (
|
||||
CRED_TYPE_FACE,
|
||||
CRED_TYPE_FINGER_VEIN,
|
||||
CRED_TYPE_FINGERPRINT,
|
||||
CRED_TYPE_PIN,
|
||||
CRED_TYPE_RFID,
|
||||
CREDENTIAL_RULE_MAP,
|
||||
CREDENTIAL_RULE_REVERSE_MAP,
|
||||
CREDENTIAL_TYPE_MAP,
|
||||
CREDENTIAL_TYPE_REVERSE_MAP,
|
||||
LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
USER_STATUS_MAP,
|
||||
USER_STATUS_REVERSE_MAP,
|
||||
USER_TYPE_MAP,
|
||||
USER_TYPE_REVERSE_MAP,
|
||||
)
|
||||
|
||||
# Error translation keys (used in ServiceValidationError/HomeAssistantError)
|
||||
ERR_CREDENTIAL_TYPE_NOT_SUPPORTED = "credential_type_not_supported"
|
||||
ERR_INVALID_CREDENTIAL_DATA = "invalid_credential_data"
|
||||
|
||||
# SetCredential response status mapping (Matter DlStatus)
|
||||
_DlStatus = clusters.DoorLock.Enums.DlStatus
|
||||
SET_CREDENTIAL_STATUS_MAP: dict[int, str] = {
|
||||
_DlStatus.kSuccess: "success",
|
||||
_DlStatus.kFailure: "failure",
|
||||
_DlStatus.kDuplicate: "duplicate",
|
||||
_DlStatus.kOccupied: "occupied",
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matter_server.client import MatterClient
|
||||
from matter_server.client.models.node import MatterEndpoint, MatterNode
|
||||
|
||||
# DoorLock Feature bitmap from Matter SDK
|
||||
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
|
||||
|
||||
|
||||
# --- TypedDicts for service action responses ---
|
||||
|
||||
|
||||
class LockUserCredentialData(TypedDict):
|
||||
"""Credential data within a user response."""
|
||||
|
||||
type: str
|
||||
index: int | None
|
||||
|
||||
|
||||
class LockUserData(TypedDict):
|
||||
"""User data returned from lock queries."""
|
||||
|
||||
user_index: int | None
|
||||
user_name: str | None
|
||||
user_unique_id: int | None
|
||||
user_status: str
|
||||
user_type: str
|
||||
credential_rule: str
|
||||
credentials: list[LockUserCredentialData]
|
||||
next_user_index: int | None
|
||||
|
||||
|
||||
class SetLockUserResult(TypedDict):
|
||||
"""Result of set_lock_user service action."""
|
||||
|
||||
user_index: int
|
||||
|
||||
|
||||
class GetLockUsersResult(TypedDict):
|
||||
"""Result of get_lock_users service action."""
|
||||
|
||||
max_users: int
|
||||
users: list[LockUserData]
|
||||
|
||||
|
||||
class GetLockInfoResult(TypedDict):
|
||||
"""Result of get_lock_info service action."""
|
||||
|
||||
supports_user_management: bool
|
||||
supported_credential_types: list[str]
|
||||
max_users: int | None
|
||||
max_pin_users: int | None
|
||||
max_rfid_users: int | None
|
||||
max_credentials_per_user: int | None
|
||||
min_pin_length: int | None
|
||||
max_pin_length: int | None
|
||||
min_rfid_length: int | None
|
||||
max_rfid_length: int | None
|
||||
|
||||
|
||||
class SetLockCredentialResult(TypedDict):
|
||||
"""Result of set_lock_credential service action."""
|
||||
|
||||
credential_index: int
|
||||
user_index: int | None
|
||||
next_credential_index: int | None
|
||||
|
||||
|
||||
class GetLockCredentialStatusResult(TypedDict):
|
||||
"""Result of get_lock_credential_status service action."""
|
||||
|
||||
credential_exists: bool
|
||||
user_index: int | None
|
||||
next_credential_index: int | None
|
||||
|
||||
|
||||
def _get_lock_endpoint_from_node(node: MatterNode) -> MatterEndpoint | None:
|
||||
"""Get the DoorLock endpoint from a node.
|
||||
|
||||
Returns the first endpoint that has the DoorLock cluster, or None if not found.
|
||||
"""
|
||||
for endpoint in node.endpoints.values():
|
||||
if endpoint.has_cluster(clusters.DoorLock):
|
||||
return endpoint
|
||||
return None
|
||||
|
||||
|
||||
def _get_feature_map(endpoint: MatterEndpoint) -> int | None:
|
||||
"""Read the DoorLock FeatureMap attribute from an endpoint."""
|
||||
value: int | None = endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.FeatureMap
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _lock_supports_usr_feature(endpoint: MatterEndpoint) -> bool:
|
||||
"""Check if lock endpoint supports USR (User) feature.
|
||||
|
||||
The USR feature indicates the lock supports user and credential management
|
||||
commands like SetUser, GetUser, SetCredential, etc.
|
||||
"""
|
||||
feature_map = _get_feature_map(endpoint)
|
||||
if feature_map is None:
|
||||
return False
|
||||
return bool(feature_map & DoorLockFeature.kUser)
|
||||
|
||||
|
||||
# --- Pure utility functions ---
|
||||
|
||||
|
||||
def _get_attr(obj: Any, attr: str) -> Any:
|
||||
"""Get attribute from object or dict.
|
||||
|
||||
Matter SDK responses can be either dataclass objects or dicts depending on
|
||||
the SDK version and serialization context. NullValue (a truthy,
|
||||
non-iterable singleton) is normalized to None.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
value = obj.get(attr)
|
||||
else:
|
||||
value = getattr(obj, attr, None)
|
||||
# The Matter SDK uses NullValue for nullable fields instead of None.
|
||||
if value is NullValue:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _get_supported_credential_types(feature_map: int) -> list[str]:
|
||||
"""Get list of supported credential types from feature map."""
|
||||
types = []
|
||||
if feature_map & DoorLockFeature.kPinCredential:
|
||||
types.append(CRED_TYPE_PIN)
|
||||
if feature_map & DoorLockFeature.kRfidCredential:
|
||||
types.append(CRED_TYPE_RFID)
|
||||
if feature_map & DoorLockFeature.kFingerCredentials:
|
||||
types.append(CRED_TYPE_FINGERPRINT)
|
||||
if feature_map & DoorLockFeature.kFaceCredentials:
|
||||
types.append(CRED_TYPE_FACE)
|
||||
return types
|
||||
|
||||
|
||||
def _format_user_response(user_data: Any) -> LockUserData | None:
|
||||
"""Format GetUser response to API response format.
|
||||
|
||||
Returns None if the user slot is empty (no userStatus).
|
||||
"""
|
||||
if user_data is None:
|
||||
return None
|
||||
|
||||
user_status = _get_attr(user_data, "userStatus")
|
||||
if user_status is None:
|
||||
return None
|
||||
|
||||
creds = _get_attr(user_data, "credentials")
|
||||
credentials: list[LockUserCredentialData] = [
|
||||
LockUserCredentialData(
|
||||
type=CREDENTIAL_TYPE_MAP.get(_get_attr(cred, "credentialType"), "unknown"),
|
||||
index=_get_attr(cred, "credentialIndex"),
|
||||
)
|
||||
for cred in (creds or [])
|
||||
]
|
||||
|
||||
return LockUserData(
|
||||
user_index=_get_attr(user_data, "userIndex"),
|
||||
user_name=_get_attr(user_data, "userName"),
|
||||
user_unique_id=_get_attr(user_data, "userUniqueID"),
|
||||
user_status=USER_STATUS_MAP.get(user_status, "unknown"),
|
||||
user_type=USER_TYPE_MAP.get(_get_attr(user_data, "userType"), "unknown"),
|
||||
credential_rule=CREDENTIAL_RULE_MAP.get(
|
||||
_get_attr(user_data, "credentialRule"), "unknown"
|
||||
),
|
||||
credentials=credentials,
|
||||
next_user_index=_get_attr(user_data, "nextUserIndex"),
|
||||
)
|
||||
|
||||
|
||||
# --- Credential management helpers ---
|
||||
|
||||
|
||||
class LockEndpointNotFoundError(HomeAssistantError):
|
||||
"""Lock endpoint not found on node."""
|
||||
|
||||
|
||||
class UsrFeatureNotSupportedError(ServiceValidationError):
|
||||
"""Lock does not support USR (user management) feature."""
|
||||
|
||||
|
||||
class UserSlotEmptyError(ServiceValidationError):
|
||||
"""User slot is empty."""
|
||||
|
||||
|
||||
class NoAvailableUserSlotsError(ServiceValidationError):
|
||||
"""No available user slots on the lock."""
|
||||
|
||||
|
||||
class CredentialTypeNotSupportedError(ServiceValidationError):
|
||||
"""Lock does not support the requested credential type."""
|
||||
|
||||
|
||||
class CredentialDataInvalidError(ServiceValidationError):
|
||||
"""Credential data fails validation."""
|
||||
|
||||
|
||||
class SetCredentialFailedError(HomeAssistantError):
|
||||
"""SetCredential command returned a non-success status."""
|
||||
|
||||
|
||||
def _get_lock_endpoint_or_raise(node: MatterNode) -> MatterEndpoint:
|
||||
"""Get the DoorLock endpoint from a node or raise an error."""
|
||||
lock_endpoint = _get_lock_endpoint_from_node(node)
|
||||
if lock_endpoint is None:
|
||||
raise LockEndpointNotFoundError("No lock endpoint found on this device")
|
||||
return lock_endpoint
|
||||
|
||||
|
||||
def _ensure_usr_support(lock_endpoint: MatterEndpoint) -> None:
|
||||
"""Ensure the lock endpoint supports USR (user management) feature.
|
||||
|
||||
Raises UsrFeatureNotSupportedError if the lock doesn't support user management.
|
||||
"""
|
||||
if not _lock_supports_usr_feature(lock_endpoint):
|
||||
raise UsrFeatureNotSupportedError(
|
||||
"Lock does not support user/credential management"
|
||||
)
|
||||
|
||||
|
||||
# --- High-level business logic functions ---
|
||||
|
||||
|
||||
async def get_lock_info(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
) -> GetLockInfoResult:
|
||||
"""Get lock capabilities and configuration info.
|
||||
|
||||
Returns a typed dict with lock capability information.
|
||||
Raises HomeAssistantError if lock endpoint not found.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
supports_usr = _lock_supports_usr_feature(lock_endpoint)
|
||||
|
||||
# Get feature map for credential type detection
|
||||
feature_map = (
|
||||
lock_endpoint.get_attribute_value(None, clusters.DoorLock.Attributes.FeatureMap)
|
||||
or 0
|
||||
)
|
||||
|
||||
result = GetLockInfoResult(
|
||||
supports_user_management=supports_usr,
|
||||
supported_credential_types=_get_supported_credential_types(feature_map),
|
||||
max_users=None,
|
||||
max_pin_users=None,
|
||||
max_rfid_users=None,
|
||||
max_credentials_per_user=None,
|
||||
min_pin_length=None,
|
||||
max_pin_length=None,
|
||||
min_rfid_length=None,
|
||||
max_rfid_length=None,
|
||||
)
|
||||
|
||||
# Populate capacity info if USR feature is supported
|
||||
if supports_usr:
|
||||
result["max_users"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
|
||||
)
|
||||
result["max_pin_users"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfPINUsersSupported
|
||||
)
|
||||
result["max_rfid_users"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported
|
||||
)
|
||||
result["max_credentials_per_user"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfCredentialsSupportedPerUser
|
||||
)
|
||||
result["min_pin_length"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MinPINCodeLength
|
||||
)
|
||||
result["max_pin_length"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MaxPINCodeLength
|
||||
)
|
||||
result["min_rfid_length"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MinRFIDCodeLength
|
||||
)
|
||||
result["max_rfid_length"] = lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MaxRFIDCodeLength
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def set_lock_user(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
*,
|
||||
user_index: int | None = None,
|
||||
user_name: str | None = None,
|
||||
user_unique_id: int | None = None,
|
||||
user_status: str | None = None,
|
||||
user_type: str | None = None,
|
||||
credential_rule: str | None = None,
|
||||
) -> SetLockUserResult:
|
||||
"""Add or update a user on the lock.
|
||||
|
||||
When user_status, user_type, or credential_rule is None, defaults are used
|
||||
for new users and existing values are preserved for modifications.
|
||||
|
||||
Returns typed dict with user_index on success.
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
|
||||
if user_index is None:
|
||||
# Adding new user - find first available slot
|
||||
max_users = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
for idx in range(1, max_users + 1):
|
||||
get_user_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetUser(userIndex=idx),
|
||||
)
|
||||
if _get_attr(get_user_response, "userStatus") is None:
|
||||
user_index = idx
|
||||
break
|
||||
|
||||
if user_index is None:
|
||||
raise NoAvailableUserSlotsError("No available user slots on the lock")
|
||||
|
||||
user_status_enum = (
|
||||
USER_STATUS_REVERSE_MAP.get(
|
||||
user_status,
|
||||
clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled,
|
||||
)
|
||||
if user_status is not None
|
||||
else clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled
|
||||
)
|
||||
|
||||
await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.SetUser(
|
||||
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd,
|
||||
userIndex=user_index,
|
||||
userName=user_name,
|
||||
userUniqueID=user_unique_id,
|
||||
userStatus=user_status_enum,
|
||||
userType=USER_TYPE_REVERSE_MAP.get(
|
||||
user_type,
|
||||
clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser,
|
||||
)
|
||||
if user_type is not None
|
||||
else clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser,
|
||||
credentialRule=CREDENTIAL_RULE_REVERSE_MAP.get(
|
||||
credential_rule,
|
||||
clusters.DoorLock.Enums.CredentialRuleEnum.kSingle,
|
||||
)
|
||||
if credential_rule is not None
|
||||
else clusters.DoorLock.Enums.CredentialRuleEnum.kSingle,
|
||||
),
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
else:
|
||||
# Updating existing user - preserve existing values when not specified
|
||||
get_user_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetUser(userIndex=user_index),
|
||||
)
|
||||
|
||||
if _get_attr(get_user_response, "userStatus") is None:
|
||||
raise UserSlotEmptyError(f"User slot {user_index} is empty")
|
||||
|
||||
resolved_user_name = (
|
||||
user_name
|
||||
if user_name is not None
|
||||
else _get_attr(get_user_response, "userName")
|
||||
)
|
||||
resolved_unique_id = (
|
||||
user_unique_id
|
||||
if user_unique_id is not None
|
||||
else _get_attr(get_user_response, "userUniqueID")
|
||||
)
|
||||
|
||||
resolved_status = (
|
||||
USER_STATUS_REVERSE_MAP[user_status]
|
||||
if user_status is not None
|
||||
else _get_attr(get_user_response, "userStatus")
|
||||
)
|
||||
|
||||
resolved_type = (
|
||||
USER_TYPE_REVERSE_MAP[user_type]
|
||||
if user_type is not None
|
||||
else _get_attr(get_user_response, "userType")
|
||||
)
|
||||
|
||||
resolved_rule = (
|
||||
CREDENTIAL_RULE_REVERSE_MAP[credential_rule]
|
||||
if credential_rule is not None
|
||||
else _get_attr(get_user_response, "credentialRule")
|
||||
)
|
||||
|
||||
await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.SetUser(
|
||||
operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify,
|
||||
userIndex=user_index,
|
||||
userName=resolved_user_name,
|
||||
userUniqueID=resolved_unique_id,
|
||||
userStatus=resolved_status,
|
||||
userType=resolved_type,
|
||||
credentialRule=resolved_rule,
|
||||
),
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
return SetLockUserResult(user_index=user_index)
|
||||
|
||||
|
||||
async def get_lock_users(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
) -> GetLockUsersResult:
|
||||
"""Get all users from the lock.
|
||||
|
||||
Returns typed dict with users list and max_users capacity.
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
|
||||
max_users = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
users: list[LockUserData] = []
|
||||
current_index = 1
|
||||
|
||||
# Iterate through users using next_user_index for efficiency
|
||||
while current_index is not None and current_index <= max_users:
|
||||
get_user_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetUser(
|
||||
userIndex=current_index,
|
||||
),
|
||||
)
|
||||
|
||||
user_data = _format_user_response(get_user_response)
|
||||
if user_data is not None:
|
||||
users.append(user_data)
|
||||
|
||||
# Move to next user index
|
||||
next_index = _get_attr(get_user_response, "nextUserIndex")
|
||||
if next_index is None or next_index <= current_index:
|
||||
break
|
||||
current_index = next_index
|
||||
|
||||
return GetLockUsersResult(
|
||||
max_users=max_users,
|
||||
users=users,
|
||||
)
|
||||
|
||||
|
||||
async def clear_lock_user(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
user_index: int,
|
||||
) -> None:
|
||||
"""Clear a user from the lock.
|
||||
|
||||
Per the Matter spec, ClearUser also clears all associated credentials
|
||||
and schedules for the user.
|
||||
Use index 0xFFFE (CLEAR_ALL_INDEX) to clear all users.
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
|
||||
await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.ClearUser(
|
||||
userIndex=user_index,
|
||||
),
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
# --- Credential validation helpers ---
|
||||
|
||||
# Map credential type strings to the feature bit that must be set
|
||||
_CREDENTIAL_TYPE_FEATURE_MAP: dict[str, int] = {
|
||||
CRED_TYPE_PIN: DoorLockFeature.kPinCredential,
|
||||
CRED_TYPE_RFID: DoorLockFeature.kRfidCredential,
|
||||
CRED_TYPE_FINGERPRINT: DoorLockFeature.kFingerCredentials,
|
||||
CRED_TYPE_FINGER_VEIN: DoorLockFeature.kFingerCredentials,
|
||||
CRED_TYPE_FACE: DoorLockFeature.kFaceCredentials,
|
||||
}
|
||||
|
||||
# Map credential type strings to the capacity attribute for slot iteration.
|
||||
# Biometric types have no dedicated capacity attribute; fall back to total users.
|
||||
_CREDENTIAL_TYPE_CAPACITY_ATTR = {
|
||||
CRED_TYPE_PIN: clusters.DoorLock.Attributes.NumberOfPINUsersSupported,
|
||||
CRED_TYPE_RFID: clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported,
|
||||
}
|
||||
|
||||
|
||||
def _validate_credential_type_support(
|
||||
lock_endpoint: MatterEndpoint, credential_type: str
|
||||
) -> None:
|
||||
"""Validate the lock supports the requested credential type.
|
||||
|
||||
Raises CredentialTypeNotSupportedError if not supported.
|
||||
"""
|
||||
required_bit = _CREDENTIAL_TYPE_FEATURE_MAP.get(credential_type)
|
||||
if required_bit is None:
|
||||
raise CredentialTypeNotSupportedError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED,
|
||||
translation_placeholders={"credential_type": credential_type},
|
||||
)
|
||||
|
||||
feature_map = _get_feature_map(lock_endpoint) or 0
|
||||
if not (feature_map & required_bit):
|
||||
raise CredentialTypeNotSupportedError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED,
|
||||
translation_placeholders={"credential_type": credential_type},
|
||||
)
|
||||
|
||||
|
||||
def _validate_credential_data(
|
||||
lock_endpoint: MatterEndpoint, credential_type: str, credential_data: str
|
||||
) -> None:
|
||||
"""Validate credential data against lock constraints.
|
||||
|
||||
For PIN: checks digits-only and length against Min/MaxPINCodeLength.
|
||||
For RFID: checks valid hex and byte length against Min/MaxRFIDCodeLength.
|
||||
Raises CredentialDataInvalidError on failure.
|
||||
"""
|
||||
if credential_type == CRED_TYPE_PIN:
|
||||
if not credential_data.isdigit():
|
||||
raise CredentialDataInvalidError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_INVALID_CREDENTIAL_DATA,
|
||||
translation_placeholders={"reason": "PIN must contain only digits"},
|
||||
)
|
||||
min_len = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MinPINCodeLength
|
||||
)
|
||||
or 0
|
||||
)
|
||||
max_len = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MaxPINCodeLength
|
||||
)
|
||||
or 255
|
||||
)
|
||||
if not min_len <= len(credential_data) <= max_len:
|
||||
raise CredentialDataInvalidError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_INVALID_CREDENTIAL_DATA,
|
||||
translation_placeholders={
|
||||
"reason": (f"PIN length must be between {min_len} and {max_len}")
|
||||
},
|
||||
)
|
||||
|
||||
elif credential_type == CRED_TYPE_RFID:
|
||||
try:
|
||||
rfid_bytes = bytes.fromhex(credential_data)
|
||||
except ValueError as err:
|
||||
raise CredentialDataInvalidError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_INVALID_CREDENTIAL_DATA,
|
||||
translation_placeholders={
|
||||
"reason": "RFID data must be valid hexadecimal"
|
||||
},
|
||||
) from err
|
||||
min_len = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MinRFIDCodeLength
|
||||
)
|
||||
or 0
|
||||
)
|
||||
max_len = (
|
||||
lock_endpoint.get_attribute_value(
|
||||
None, clusters.DoorLock.Attributes.MaxRFIDCodeLength
|
||||
)
|
||||
or 255
|
||||
)
|
||||
if not min_len <= len(rfid_bytes) <= max_len:
|
||||
raise CredentialDataInvalidError(
|
||||
translation_domain="matter",
|
||||
translation_key=ERR_INVALID_CREDENTIAL_DATA,
|
||||
translation_placeholders={
|
||||
"reason": (
|
||||
f"RFID data length must be between"
|
||||
f" {min_len} and {max_len} bytes"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _credential_data_to_bytes(credential_type: str, credential_data: str) -> bytes:
|
||||
"""Convert credential data string to bytes for the Matter command."""
|
||||
if credential_type == CRED_TYPE_RFID:
|
||||
return bytes.fromhex(credential_data)
|
||||
# PIN and other types: encode as UTF-8
|
||||
return credential_data.encode()
|
||||
|
||||
|
||||
# --- Credential business logic functions ---
|
||||
|
||||
|
||||
async def set_lock_credential(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
*,
|
||||
credential_type: str,
|
||||
credential_data: str,
|
||||
credential_index: int | None = None,
|
||||
user_index: int | None = None,
|
||||
user_status: str | None = None,
|
||||
user_type: str | None = None,
|
||||
) -> SetLockCredentialResult:
|
||||
"""Add or modify a credential on the lock.
|
||||
|
||||
Returns typed dict with credential_index, user_index, and next_credential_index.
|
||||
Raises ServiceValidationError for validation failures.
|
||||
Raises HomeAssistantError for device communication failures.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
_validate_credential_type_support(lock_endpoint, credential_type)
|
||||
_validate_credential_data(lock_endpoint, credential_type, credential_data)
|
||||
|
||||
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
|
||||
cred_data_bytes = _credential_data_to_bytes(credential_type, credential_data)
|
||||
|
||||
# Determine operation type and credential index
|
||||
operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
|
||||
|
||||
if credential_index is None:
|
||||
# Auto-find first available credential slot.
|
||||
# Use the credential-type-specific capacity as the upper bound.
|
||||
max_creds_attr = _CREDENTIAL_TYPE_CAPACITY_ATTR.get(
|
||||
credential_type,
|
||||
clusters.DoorLock.Attributes.NumberOfTotalUsersSupported,
|
||||
)
|
||||
max_creds_raw = lock_endpoint.get_attribute_value(None, max_creds_attr)
|
||||
max_creds = (
|
||||
max_creds_raw if isinstance(max_creds_raw, int) and max_creds_raw > 0 else 5
|
||||
)
|
||||
for idx in range(1, max_creds + 1):
|
||||
status_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetCredentialStatus(
|
||||
credential=clusters.DoorLock.Structs.CredentialStruct(
|
||||
credentialType=cred_type_int,
|
||||
credentialIndex=idx,
|
||||
),
|
||||
),
|
||||
)
|
||||
if not _get_attr(status_response, "credentialExists"):
|
||||
credential_index = idx
|
||||
break
|
||||
|
||||
if credential_index is None:
|
||||
raise NoAvailableUserSlotsError("No available credential slots on the lock")
|
||||
else:
|
||||
# Check if slot is occupied to determine Add vs Modify
|
||||
status_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetCredentialStatus(
|
||||
credential=clusters.DoorLock.Structs.CredentialStruct(
|
||||
credentialType=cred_type_int,
|
||||
credentialIndex=credential_index,
|
||||
),
|
||||
),
|
||||
)
|
||||
if _get_attr(status_response, "credentialExists"):
|
||||
operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kModify
|
||||
|
||||
# Resolve optional user_status and user_type enums
|
||||
resolved_user_status = (
|
||||
USER_STATUS_REVERSE_MAP.get(user_status) if user_status is not None else None
|
||||
)
|
||||
resolved_user_type = (
|
||||
USER_TYPE_REVERSE_MAP.get(user_type) if user_type is not None else None
|
||||
)
|
||||
|
||||
set_cred_response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.SetCredential(
|
||||
operationType=operation_type,
|
||||
credential=clusters.DoorLock.Structs.CredentialStruct(
|
||||
credentialType=cred_type_int,
|
||||
credentialIndex=credential_index,
|
||||
),
|
||||
credentialData=cred_data_bytes,
|
||||
userIndex=user_index,
|
||||
userStatus=resolved_user_status,
|
||||
userType=resolved_user_type,
|
||||
),
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
status_code = _get_attr(set_cred_response, "status")
|
||||
status_str = SET_CREDENTIAL_STATUS_MAP.get(status_code, f"unknown({status_code})")
|
||||
if status_str != "success":
|
||||
raise SetCredentialFailedError(
|
||||
translation_domain="matter",
|
||||
translation_key="set_credential_failed",
|
||||
translation_placeholders={"status": status_str},
|
||||
)
|
||||
|
||||
return SetLockCredentialResult(
|
||||
credential_index=credential_index,
|
||||
user_index=_get_attr(set_cred_response, "userIndex"),
|
||||
next_credential_index=_get_attr(set_cred_response, "nextCredentialIndex"),
|
||||
)
|
||||
|
||||
|
||||
async def clear_lock_credential(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
*,
|
||||
credential_type: str,
|
||||
credential_index: int,
|
||||
) -> None:
|
||||
"""Clear a credential from the lock.
|
||||
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
|
||||
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
|
||||
|
||||
await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.ClearCredential(
|
||||
credential=clusters.DoorLock.Structs.CredentialStruct(
|
||||
credentialType=cred_type_int,
|
||||
credentialIndex=credential_index,
|
||||
),
|
||||
),
|
||||
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
async def get_lock_credential_status(
|
||||
matter_client: MatterClient,
|
||||
node: MatterNode,
|
||||
*,
|
||||
credential_type: str,
|
||||
credential_index: int,
|
||||
) -> GetLockCredentialStatusResult:
|
||||
"""Get the status of a credential slot on the lock.
|
||||
|
||||
Returns typed dict with credential_exists, user_index, next_credential_index.
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
lock_endpoint = _get_lock_endpoint_or_raise(node)
|
||||
_ensure_usr_support(lock_endpoint)
|
||||
|
||||
cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type]
|
||||
|
||||
response = await matter_client.send_device_command(
|
||||
node_id=node.node_id,
|
||||
endpoint_id=lock_endpoint.endpoint_id,
|
||||
command=clusters.DoorLock.Commands.GetCredentialStatus(
|
||||
credential=clusters.DoorLock.Structs.CredentialStruct(
|
||||
credentialType=cred_type_int,
|
||||
credentialIndex=credential_index,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return GetLockCredentialStatusResult(
|
||||
credential_exists=bool(_get_attr(response, "credentialExists")),
|
||||
user_index=_get_attr(response, "userIndex"),
|
||||
next_credential_index=_get_attr(response, "nextCredentialIndex"),
|
||||
)
|
||||
@@ -4,11 +4,27 @@ from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
||||
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
ATTR_CREDENTIAL_DATA,
|
||||
ATTR_CREDENTIAL_INDEX,
|
||||
ATTR_CREDENTIAL_RULE,
|
||||
ATTR_CREDENTIAL_TYPE,
|
||||
ATTR_USER_INDEX,
|
||||
ATTR_USER_NAME,
|
||||
ATTR_USER_STATUS,
|
||||
ATTR_USER_TYPE,
|
||||
CLEAR_ALL_INDEX,
|
||||
CREDENTIAL_RULE_REVERSE_MAP,
|
||||
CREDENTIAL_TYPE_REVERSE_MAP,
|
||||
DOMAIN,
|
||||
SERVICE_CREDENTIAL_TYPES,
|
||||
USER_TYPE_REVERSE_MAP,
|
||||
)
|
||||
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_EMERGENCY_BOOST = "emergency_boost"
|
||||
@@ -36,3 +52,108 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
},
|
||||
func="async_set_boost",
|
||||
)
|
||||
|
||||
# Lock services - Full user CRUD
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"set_lock_user",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(ATTR_USER_NAME): vol.Any(str, None),
|
||||
vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()),
|
||||
vol.Optional(ATTR_CREDENTIAL_RULE): vol.In(
|
||||
CREDENTIAL_RULE_REVERSE_MAP.keys()
|
||||
),
|
||||
},
|
||||
func="async_set_lock_user",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"clear_lock_user",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_USER_INDEX): vol.All(
|
||||
vol.Coerce(int),
|
||||
vol.Any(vol.Range(min=1), CLEAR_ALL_INDEX),
|
||||
),
|
||||
},
|
||||
func="async_clear_lock_user",
|
||||
)
|
||||
|
||||
# Lock services - Query operations
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"get_lock_info",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={},
|
||||
func="async_get_lock_info",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"get_lock_users",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={},
|
||||
func="async_get_lock_users",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Lock services - Credential management
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"set_lock_credential",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES),
|
||||
vol.Required(ATTR_CREDENTIAL_DATA): str,
|
||||
vol.Optional(ATTR_CREDENTIAL_INDEX): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0)
|
||||
),
|
||||
vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(ATTR_USER_STATUS): vol.In(
|
||||
["occupied_enabled", "occupied_disabled"]
|
||||
),
|
||||
vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()),
|
||||
},
|
||||
func="async_set_lock_credential",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"clear_lock_credential",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES),
|
||||
vol.Required(ATTR_CREDENTIAL_INDEX): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0)
|
||||
),
|
||||
},
|
||||
func="async_clear_lock_credential",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"get_lock_credential_status",
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(
|
||||
CREDENTIAL_TYPE_REVERSE_MAP.keys()
|
||||
),
|
||||
vol.Required(ATTR_CREDENTIAL_INDEX): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0)
|
||||
),
|
||||
},
|
||||
func="async_get_lock_credential_status",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,177 @@
|
||||
clear_lock_credential:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
fields:
|
||||
credential_type:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- pin
|
||||
- rfid
|
||||
- fingerprint
|
||||
- finger_vein
|
||||
- face
|
||||
required: true
|
||||
credential_index:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 65534
|
||||
step: 1
|
||||
mode: box
|
||||
required: true
|
||||
|
||||
clear_lock_user:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
fields:
|
||||
user_index:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 65534
|
||||
step: 1
|
||||
mode: box
|
||||
required: true
|
||||
|
||||
get_lock_credential_status:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
fields:
|
||||
credential_type:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- programming_pin
|
||||
- pin
|
||||
- rfid
|
||||
- fingerprint
|
||||
- finger_vein
|
||||
- face
|
||||
- aliro_credential_issuer_key
|
||||
- aliro_evictable_endpoint_key
|
||||
- aliro_non_evictable_endpoint_key
|
||||
required: true
|
||||
credential_index:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 65534
|
||||
step: 1
|
||||
mode: box
|
||||
required: true
|
||||
|
||||
get_lock_info:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
|
||||
get_lock_users:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
|
||||
set_lock_credential:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
fields:
|
||||
credential_type:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- pin
|
||||
- rfid
|
||||
- fingerprint
|
||||
- finger_vein
|
||||
- face
|
||||
required: true
|
||||
credential_data:
|
||||
selector:
|
||||
text:
|
||||
required: true
|
||||
credential_index:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 65534
|
||||
step: 1
|
||||
mode: box
|
||||
user_index:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 65534
|
||||
step: 1
|
||||
mode: box
|
||||
user_status:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- occupied_enabled
|
||||
- occupied_disabled
|
||||
user_type:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- unrestricted_user
|
||||
- year_day_schedule_user
|
||||
- week_day_schedule_user
|
||||
- programming_user
|
||||
- non_access_user
|
||||
- forced_user
|
||||
- disposable_user
|
||||
- expiring_user
|
||||
- schedule_restricted_user
|
||||
- remote_only_user
|
||||
|
||||
set_lock_user:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: matter
|
||||
fields:
|
||||
user_index:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 255
|
||||
step: 1
|
||||
mode: box
|
||||
user_name:
|
||||
selector:
|
||||
text:
|
||||
user_type:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- unrestricted_user
|
||||
- year_day_schedule_user
|
||||
- week_day_schedule_user
|
||||
- programming_user
|
||||
- non_access_user
|
||||
- forced_user
|
||||
- disposable_user
|
||||
- expiring_user
|
||||
- schedule_restricted_user
|
||||
- remote_only_user
|
||||
credential_rule:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- single
|
||||
- dual
|
||||
- tri
|
||||
|
||||
water_heater_boost:
|
||||
target:
|
||||
entity:
|
||||
|
||||
@@ -619,6 +619,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"credential_type_not_supported": {
|
||||
"message": "The lock does not support credential type `{credential_type}`."
|
||||
},
|
||||
"invalid_credential_data": {
|
||||
"message": "Invalid credential data: {reason}."
|
||||
},
|
||||
"set_credential_failed": {
|
||||
"message": "Failed to set credential: lock returned status `{status}`."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_version_version_too_new": {
|
||||
"description": "The version of the Matter Server you are currently running is too new for this version of Home Assistant. Please update Home Assistant or downgrade the Matter Server to an older version to fix this issue.",
|
||||
@@ -630,6 +641,52 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_lock_credential": {
|
||||
"description": "Removes a credential from a lock.",
|
||||
"fields": {
|
||||
"credential_index": {
|
||||
"description": "The credential slot index to clear.",
|
||||
"name": "Credential index"
|
||||
},
|
||||
"credential_type": {
|
||||
"description": "The type of credential to clear.",
|
||||
"name": "Credential type"
|
||||
}
|
||||
},
|
||||
"name": "Clear lock credential"
|
||||
},
|
||||
"clear_lock_user": {
|
||||
"description": "Deletes a lock user and all associated credentials. Use index 65534 to clear all users.",
|
||||
"fields": {
|
||||
"user_index": {
|
||||
"description": "The user slot index (1-based) to clear, or 65534 to clear all.",
|
||||
"name": "User index"
|
||||
}
|
||||
},
|
||||
"name": "Clear lock user"
|
||||
},
|
||||
"get_lock_credential_status": {
|
||||
"description": "Returns the status of a credential slot on a lock.",
|
||||
"fields": {
|
||||
"credential_index": {
|
||||
"description": "The credential slot index to query.",
|
||||
"name": "Credential index"
|
||||
},
|
||||
"credential_type": {
|
||||
"description": "The type of credential to query.",
|
||||
"name": "Credential type"
|
||||
}
|
||||
},
|
||||
"name": "Get lock credential status"
|
||||
},
|
||||
"get_lock_info": {
|
||||
"description": "Returns lock capabilities including supported credential types, user capacity, and PIN length constraints.",
|
||||
"name": "Get lock info"
|
||||
},
|
||||
"get_lock_users": {
|
||||
"description": "Returns all users configured on a lock with their credentials.",
|
||||
"name": "Get lock users"
|
||||
},
|
||||
"open_commissioning_window": {
|
||||
"description": "Allows adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds.",
|
||||
"fields": {
|
||||
@@ -640,6 +697,58 @@
|
||||
},
|
||||
"name": "Open commissioning window"
|
||||
},
|
||||
"set_lock_credential": {
|
||||
"description": "Adds or updates a credential on a lock.",
|
||||
"fields": {
|
||||
"credential_data": {
|
||||
"description": "The credential data. For PIN: digits only. For RFID: hexadecimal string.",
|
||||
"name": "Credential data"
|
||||
},
|
||||
"credential_index": {
|
||||
"description": "The credential slot index. Leave empty to auto-find an available slot.",
|
||||
"name": "Credential index"
|
||||
},
|
||||
"credential_type": {
|
||||
"description": "The type of credential (e.g., pin, rfid, fingerprint).",
|
||||
"name": "Credential type"
|
||||
},
|
||||
"user_index": {
|
||||
"description": "The user index to associate the credential with. Leave empty for automatic assignment.",
|
||||
"name": "User index"
|
||||
},
|
||||
"user_status": {
|
||||
"description": "The user status to set when creating a new user for this credential.",
|
||||
"name": "User status"
|
||||
},
|
||||
"user_type": {
|
||||
"description": "The user type to set when creating a new user for this credential.",
|
||||
"name": "User type"
|
||||
}
|
||||
},
|
||||
"name": "Set lock credential"
|
||||
},
|
||||
"set_lock_user": {
|
||||
"description": "Creates or updates a lock user.",
|
||||
"fields": {
|
||||
"credential_rule": {
|
||||
"description": "The credential rule for the user.",
|
||||
"name": "Credential rule"
|
||||
},
|
||||
"user_index": {
|
||||
"description": "The user slot index (1-based). Leave empty to auto-find an available slot.",
|
||||
"name": "User index"
|
||||
},
|
||||
"user_name": {
|
||||
"description": "The name for the user.",
|
||||
"name": "User name"
|
||||
},
|
||||
"user_type": {
|
||||
"description": "The type of user to create.",
|
||||
"name": "User type"
|
||||
}
|
||||
},
|
||||
"name": "Set lock user"
|
||||
},
|
||||
"water_heater_boost": {
|
||||
"description": "Enables water heater boost for a specific duration.",
|
||||
"fields": {
|
||||
|
||||
@@ -168,8 +168,9 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
segments: dict[str, Segment] = {}
|
||||
for area in supported_areas:
|
||||
area_name = None
|
||||
if area.areaInfo and area.areaInfo.locationInfo:
|
||||
area_name = area.areaInfo.locationInfo.locationName
|
||||
location_info = area.areaInfo.locationInfo
|
||||
if location_info not in (None, clusters.NullValue):
|
||||
area_name = location_info.locationName
|
||||
|
||||
if area_name:
|
||||
segment_id = str(area.areaID)
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MedcomBleUpdateCoordinator
|
||||
from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator
|
||||
|
||||
# Supported platforms
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MedcomBleConfigEntry) -> bool:
|
||||
"""Set up Medcom BLE radiation monitor from a config entry."""
|
||||
|
||||
address = entry.unique_id
|
||||
@@ -31,16 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MedcomBleConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -18,13 +18,17 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type MedcomBleConfigEntry = ConfigEntry[MedcomBleUpdateCoordinator]
|
||||
|
||||
|
||||
class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]):
|
||||
"""Coordinator for Medcom BLE radiation monitor data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MedcomBleConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: MedcomBleConfigEntry, address: str
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -15,8 +14,8 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, UNIT_CPM
|
||||
from .coordinator import MedcomBleUpdateCoordinator
|
||||
from .const import UNIT_CPM
|
||||
from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,12 +31,12 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
entry: MedcomBleConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Medcom BLE radiation monitor sensors."""
|
||||
|
||||
coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities = []
|
||||
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""Support for Meteoclimatic weather data."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import MeteoclimaticUpdateCoordinator
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MeteoclimaticConfigEntry
|
||||
) -> bool:
|
||||
"""Set up a Meteoclimatic entry."""
|
||||
coordinator = MeteoclimaticUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: MeteoclimaticConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -14,13 +14,15 @@ from .const import CONF_STATION_CODE, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type MeteoclimaticConfigEntry = ConfigEntry[MeteoclimaticUpdateCoordinator]
|
||||
|
||||
|
||||
class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for Meteoclimatic weather data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MeteoclimaticConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: MeteoclimaticConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self._station_code = entry.data[CONF_STATION_CODE]
|
||||
super().__init__(
|
||||
|
||||
@@ -6,7 +6,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
@@ -21,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import MeteoclimaticUpdateCoordinator
|
||||
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
|
||||
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
@@ -113,11 +112,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoclimaticConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Meteoclimatic sensor platform."""
|
||||
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES],
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING
|
||||
from meteoclimatic import Condition
|
||||
|
||||
from homeassistant.components.weather import WeatherEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
@@ -13,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import MeteoclimaticUpdateCoordinator
|
||||
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
|
||||
|
||||
|
||||
def format_condition(condition):
|
||||
@@ -27,11 +26,11 @@ def format_condition(condition):
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoclimaticConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Meteoclimatic weather platform."""
|
||||
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([MeteoclimaticWeather(coordinator)], False)
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from datapoint.Forecast import Forecast
|
||||
from datapoint.Manager import Manager
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -19,93 +17,71 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
METOFFICE_COORDINATES,
|
||||
METOFFICE_DAILY_COORDINATOR,
|
||||
METOFFICE_HOURLY_COORDINATOR,
|
||||
METOFFICE_NAME,
|
||||
METOFFICE_TWICE_DAILY_COORDINATOR,
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
MetOfficeConfigEntry,
|
||||
MetOfficeRuntimeData,
|
||||
MetOfficeUpdateCoordinator,
|
||||
)
|
||||
from .helpers import fetch_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MetOfficeConfigEntry) -> bool:
|
||||
"""Set up a Met Office entry."""
|
||||
|
||||
latitude = entry.data[CONF_LATITUDE]
|
||||
longitude = entry.data[CONF_LONGITUDE]
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
site_name = entry.data[CONF_NAME]
|
||||
|
||||
coordinates = f"{latitude}_{longitude}"
|
||||
latitude: float = entry.data[CONF_LATITUDE]
|
||||
longitude: float = entry.data[CONF_LONGITUDE]
|
||||
api_key: str = entry.data[CONF_API_KEY]
|
||||
site_name: str = entry.data[CONF_NAME]
|
||||
|
||||
connection = Manager(api_key=api_key)
|
||||
|
||||
async def async_update_hourly() -> Forecast:
|
||||
return await hass.async_add_executor_job(
|
||||
fetch_data, connection, latitude, longitude, "hourly"
|
||||
)
|
||||
|
||||
async def async_update_daily() -> Forecast:
|
||||
return await hass.async_add_executor_job(
|
||||
fetch_data, connection, latitude, longitude, "daily"
|
||||
)
|
||||
|
||||
async def async_update_twice_daily() -> Forecast:
|
||||
return await hass.async_add_executor_job(
|
||||
fetch_data, connection, latitude, longitude, "twice-daily"
|
||||
)
|
||||
|
||||
metoffice_hourly_coordinator = TimestampDataUpdateCoordinator(
|
||||
metoffice_hourly_coordinator = MetOfficeUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
entry,
|
||||
name=f"MetOffice Hourly Coordinator for {site_name}",
|
||||
update_method=async_update_hourly,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
connection=connection,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
frequency="hourly",
|
||||
)
|
||||
|
||||
metoffice_daily_coordinator = TimestampDataUpdateCoordinator(
|
||||
metoffice_daily_coordinator = MetOfficeUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
entry,
|
||||
name=f"MetOffice Daily Coordinator for {site_name}",
|
||||
update_method=async_update_daily,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
connection=connection,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
frequency="daily",
|
||||
)
|
||||
|
||||
metoffice_twice_daily_coordinator = TimestampDataUpdateCoordinator(
|
||||
metoffice_twice_daily_coordinator = MetOfficeUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
entry,
|
||||
name=f"MetOffice Twice Daily Coordinator for {site_name}",
|
||||
update_method=async_update_twice_daily,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
connection=connection,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
frequency="twice-daily",
|
||||
)
|
||||
|
||||
metoffice_hass_data = hass.data.setdefault(DOMAIN, {})
|
||||
metoffice_hass_data[entry.entry_id] = {
|
||||
METOFFICE_HOURLY_COORDINATOR: metoffice_hourly_coordinator,
|
||||
METOFFICE_DAILY_COORDINATOR: metoffice_daily_coordinator,
|
||||
METOFFICE_TWICE_DAILY_COORDINATOR: metoffice_twice_daily_coordinator,
|
||||
METOFFICE_NAME: site_name,
|
||||
METOFFICE_COORDINATES: coordinates,
|
||||
}
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await asyncio.gather(
|
||||
metoffice_hourly_coordinator.async_config_entry_first_refresh(),
|
||||
metoffice_daily_coordinator.async_config_entry_first_refresh(),
|
||||
)
|
||||
|
||||
entry.runtime_data = MetOfficeRuntimeData(
|
||||
coordinates=f"{latitude}_{longitude}",
|
||||
hourly_coordinator=metoffice_hourly_coordinator,
|
||||
daily_coordinator=metoffice_daily_coordinator,
|
||||
twice_daily_coordinator=metoffice_twice_daily_coordinator,
|
||||
name=site_name,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -113,12 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.data.pop(DOMAIN)
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def get_device_info(coordinates: str, name: str) -> DeviceInfo:
|
||||
|
||||
@@ -38,13 +38,6 @@ ATTRIBUTION = "Data provided by the Met Office"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
METOFFICE_COORDINATES = "metoffice_coordinates"
|
||||
METOFFICE_HOURLY_COORDINATOR = "metoffice_hourly_coordinator"
|
||||
METOFFICE_DAILY_COORDINATOR = "metoffice_daily_coordinator"
|
||||
METOFFICE_TWICE_DAILY_COORDINATOR = "metoffice_twice_daily_coordinator"
|
||||
METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions"
|
||||
METOFFICE_NAME = "metoffice_name"
|
||||
|
||||
CONDITION_CLASSES: dict[str, list[int]] = {
|
||||
ATTR_CONDITION_CLEAR_NIGHT: [0],
|
||||
ATTR_CONDITION_CLOUDY: [7, 8],
|
||||
|
||||
96
homeassistant/components/metoffice/coordinator.py
Normal file
96
homeassistant/components/metoffice/coordinator.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Data update coordinator for the Met Office integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from datapoint.exceptions import APIException
|
||||
from datapoint.Forecast import Forecast
|
||||
from datapoint.Manager import Manager
|
||||
from requests import HTTPError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
TimestampDataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type MetOfficeConfigEntry = ConfigEntry[MetOfficeRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetOfficeRuntimeData:
|
||||
"""Met Office config entry."""
|
||||
|
||||
coordinates: str
|
||||
hourly_coordinator: MetOfficeUpdateCoordinator
|
||||
daily_coordinator: MetOfficeUpdateCoordinator
|
||||
twice_daily_coordinator: MetOfficeUpdateCoordinator
|
||||
name: str
|
||||
|
||||
|
||||
class MetOfficeUpdateCoordinator(TimestampDataUpdateCoordinator[Forecast]):
|
||||
"""Coordinator for Met Office forecast data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
name: str,
|
||||
connection: Manager,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
frequency: Literal["daily", "twice-daily", "hourly"],
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=name,
|
||||
config_entry=entry,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
self._connection = connection
|
||||
self._latitude = latitude
|
||||
self._longitude = longitude
|
||||
self._frequency = frequency
|
||||
|
||||
async def _async_update_data(self) -> Forecast:
|
||||
"""Get data from Met Office."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
fetch_data,
|
||||
self._connection,
|
||||
self._latitude,
|
||||
self._longitude,
|
||||
self._frequency,
|
||||
)
|
||||
|
||||
|
||||
def fetch_data(
|
||||
connection: Manager,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
frequency: Literal["daily", "twice-daily", "hourly"],
|
||||
) -> Forecast:
|
||||
"""Fetch weather and forecast from Datapoint API."""
|
||||
try:
|
||||
return connection.get_forecast(
|
||||
latitude, longitude, frequency, convert_weather_code=False
|
||||
)
|
||||
except (ValueError, APIException) as err:
|
||||
_LOGGER.error("Check Met Office connection: %s", err.args)
|
||||
raise UpdateFailed from err
|
||||
except HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise
|
||||
@@ -2,38 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from datapoint.exceptions import APIException
|
||||
from datapoint.Forecast import Forecast
|
||||
from datapoint.Manager import Manager
|
||||
from requests import HTTPError
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_data(
|
||||
connection: Manager,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
frequency: Literal["daily", "twice-daily", "hourly"],
|
||||
) -> Forecast:
|
||||
"""Fetch weather and forecast from Datapoint API."""
|
||||
try:
|
||||
return connection.get_forecast(
|
||||
latitude, longitude, frequency, convert_weather_code=False
|
||||
)
|
||||
except (ValueError, APIException) as err:
|
||||
_LOGGER.error("Check Met Office connection: %s", err.args)
|
||||
raise UpdateFailed from err
|
||||
except HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_attribute(data: dict[str, Any] | None, attr_name: str) -> Any | None:
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from datapoint.Forecast import Forecast
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
EntityCategory,
|
||||
@@ -15,7 +13,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
@@ -29,19 +26,14 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import get_device_info
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
CONDITION_MAP,
|
||||
DOMAIN,
|
||||
METOFFICE_COORDINATES,
|
||||
METOFFICE_HOURLY_COORDINATOR,
|
||||
METOFFICE_NAME,
|
||||
from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN
|
||||
from .coordinator import (
|
||||
MetOfficeConfigEntry,
|
||||
MetOfficeRuntimeData,
|
||||
MetOfficeUpdateCoordinator,
|
||||
)
|
||||
from .helpers import get_attribute
|
||||
|
||||
@@ -176,19 +168,19 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MetOfficeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Met Office weather sensor platform."""
|
||||
entity_registry = er.async_get(hass)
|
||||
hass_data = hass.data[DOMAIN][entry.entry_id]
|
||||
hass_data = entry.runtime_data
|
||||
|
||||
# Remove daily entities from legacy config entries
|
||||
for description in SENSOR_TYPES:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{description.key}_{hass_data[METOFFICE_COORDINATES]}_daily",
|
||||
f"{description.key}_{hass_data.coordinates}_daily",
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
@@ -196,20 +188,20 @@ async def async_setup_entry(
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}_daily",
|
||||
f"visibility_distance_{hass_data.coordinates}_daily",
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}",
|
||||
f"visibility_distance_{hass_data.coordinates}",
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
MetOfficeCurrentSensor(
|
||||
hass_data[METOFFICE_HOURLY_COORDINATOR],
|
||||
hass_data.hourly_coordinator,
|
||||
hass_data,
|
||||
description,
|
||||
)
|
||||
@@ -220,7 +212,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class MetOfficeCurrentSensor(
|
||||
CoordinatorEntity[DataUpdateCoordinator[Forecast]], SensorEntity
|
||||
CoordinatorEntity[MetOfficeUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Implementation of a Met Office current weather condition sensor."""
|
||||
|
||||
@@ -231,8 +223,8 @@ class MetOfficeCurrentSensor(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator[Forecast],
|
||||
hass_data: dict[str, Any],
|
||||
coordinator: MetOfficeUpdateCoordinator,
|
||||
hass_data: MetOfficeRuntimeData,
|
||||
description: MetOfficeSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -241,9 +233,9 @@ class MetOfficeCurrentSensor(
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_device_info = get_device_info(
|
||||
coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME]
|
||||
coordinates=hass_data.coordinates, name=hass_data.name
|
||||
)
|
||||
self._attr_unique_id = f"{description.key}_{hass_data[METOFFICE_COORDINATES]}"
|
||||
self._attr_unique_id = f"{description.key}_{hass_data.coordinates}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
|
||||
from datapoint.Forecast import Forecast
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_IS_DAYTIME,
|
||||
@@ -25,7 +23,6 @@ from homeassistant.components.weather import (
|
||||
Forecast as WeatherForecast,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
UnitOfPressure,
|
||||
@@ -35,7 +32,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
|
||||
|
||||
from . import get_device_info
|
||||
from .const import (
|
||||
@@ -45,39 +41,39 @@ from .const import (
|
||||
DAY_FORECAST_ATTRIBUTE_MAP,
|
||||
DOMAIN,
|
||||
HOURLY_FORECAST_ATTRIBUTE_MAP,
|
||||
METOFFICE_COORDINATES,
|
||||
METOFFICE_DAILY_COORDINATOR,
|
||||
METOFFICE_HOURLY_COORDINATOR,
|
||||
METOFFICE_NAME,
|
||||
METOFFICE_TWICE_DAILY_COORDINATOR,
|
||||
NIGHT_FORECAST_ATTRIBUTE_MAP,
|
||||
)
|
||||
from .coordinator import (
|
||||
MetOfficeConfigEntry,
|
||||
MetOfficeRuntimeData,
|
||||
MetOfficeUpdateCoordinator,
|
||||
)
|
||||
from .helpers import get_attribute
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MetOfficeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Met Office weather sensor platform."""
|
||||
entity_registry = er.async_get(hass)
|
||||
hass_data = hass.data[DOMAIN][entry.entry_id]
|
||||
hass_data = entry.runtime_data
|
||||
|
||||
# Remove daily entity from legacy config entries
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
WEATHER_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{hass_data[METOFFICE_COORDINATES]}_daily",
|
||||
f"{hass_data.coordinates}_daily",
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
MetOfficeWeather(
|
||||
hass_data[METOFFICE_DAILY_COORDINATOR],
|
||||
hass_data[METOFFICE_HOURLY_COORDINATOR],
|
||||
hass_data[METOFFICE_TWICE_DAILY_COORDINATOR],
|
||||
hass_data.daily_coordinator,
|
||||
hass_data.hourly_coordinator,
|
||||
hass_data.twice_daily_coordinator,
|
||||
hass_data,
|
||||
)
|
||||
],
|
||||
@@ -153,9 +149,9 @@ def _populate_forecast_data(
|
||||
|
||||
class MetOfficeWeather(
|
||||
CoordinatorWeatherEntity[
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
MetOfficeUpdateCoordinator,
|
||||
MetOfficeUpdateCoordinator,
|
||||
MetOfficeUpdateCoordinator,
|
||||
]
|
||||
):
|
||||
"""Implementation of a Met Office weather condition."""
|
||||
@@ -177,10 +173,10 @@ class MetOfficeWeather(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator_daily: TimestampDataUpdateCoordinator[Forecast],
|
||||
coordinator_hourly: TimestampDataUpdateCoordinator[Forecast],
|
||||
coordinator_twice_daily: TimestampDataUpdateCoordinator[Forecast],
|
||||
hass_data: dict[str, Any],
|
||||
coordinator_daily: MetOfficeUpdateCoordinator,
|
||||
coordinator_hourly: MetOfficeUpdateCoordinator,
|
||||
coordinator_twice_daily: MetOfficeUpdateCoordinator,
|
||||
hass_data: MetOfficeRuntimeData,
|
||||
) -> None:
|
||||
"""Initialise the platform with a data instance."""
|
||||
observation_coordinator = coordinator_hourly
|
||||
@@ -192,9 +188,9 @@ class MetOfficeWeather(
|
||||
)
|
||||
|
||||
self._attr_device_info = get_device_info(
|
||||
coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME]
|
||||
coordinates=hass_data.coordinates, name=hass_data.name
|
||||
)
|
||||
self._attr_unique_id = hass_data[METOFFICE_COORDINATES]
|
||||
self._attr_unique_id = hass_data.coordinates
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
@@ -266,7 +262,7 @@ class MetOfficeWeather(
|
||||
def _async_forecast_daily(self) -> list[WeatherForecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
coordinator = cast(
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
MetOfficeUpdateCoordinator,
|
||||
self.forecast_coordinators["daily"],
|
||||
)
|
||||
timesteps = coordinator.data.timesteps
|
||||
@@ -283,7 +279,7 @@ class MetOfficeWeather(
|
||||
def _async_forecast_hourly(self) -> list[WeatherForecast] | None:
|
||||
"""Return the hourly forecast in native units."""
|
||||
coordinator = cast(
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
MetOfficeUpdateCoordinator,
|
||||
self.forecast_coordinators["hourly"],
|
||||
)
|
||||
|
||||
@@ -301,7 +297,7 @@ class MetOfficeWeather(
|
||||
def _async_forecast_twice_daily(self) -> list[WeatherForecast] | None:
|
||||
"""Return the twice daily forecast in native units."""
|
||||
coordinator = cast(
|
||||
TimestampDataUpdateCoordinator[Forecast],
|
||||
MetOfficeUpdateCoordinator,
|
||||
self.forecast_coordinators["twice_daily"],
|
||||
)
|
||||
timesteps = coordinator.data.timesteps
|
||||
|
||||
@@ -13,22 +13,25 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type MicroBeesConfigEntry = ConfigEntry[HomeAssistantMicroBeesData]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeAssistantMicroBeesData:
|
||||
"""Microbees data stored in the Home Assistant data object."""
|
||||
"""Microbees data stored in the config entry runtime_data."""
|
||||
|
||||
connector: MicroBees
|
||||
coordinator: MicroBeesUpdateCoordinator
|
||||
session: config_entry_oauth2_flow.OAuth2Session
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
@@ -45,7 +48,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
|
||||
"""Set up microBees from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
@@ -67,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN])
|
||||
coordinator = MicroBeesUpdateCoordinator(hass, entry, microbees)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData(
|
||||
entry.runtime_data = HomeAssistantMicroBeesData(
|
||||
connector=microbees,
|
||||
coordinator=coordinator,
|
||||
session=session,
|
||||
@@ -76,9 +79,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: MicroBeesConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -7,11 +7,10 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import MicroBeesConfigEntry
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from .entity import MicroBeesEntity
|
||||
|
||||
@@ -37,13 +36,11 @@ BINARYSENSOR_TYPES = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MicroBeesConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the microBees binary sensor platform."""
|
||||
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
async_add_entities(
|
||||
MBBinarySensor(coordinator, entity_description, bee_id, binary_sensor.id)
|
||||
for bee_id, bee in coordinator.data.bees.items()
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
from typing import Any
|
||||
|
||||
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 . import MicroBeesConfigEntry
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from .entity import MicroBeesActuatorEntity
|
||||
|
||||
@@ -16,13 +15,11 @@ BUTTON_TRANSLATIONS = {51: "button_gate", 91: "button_panic"}
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MicroBeesConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the microBees button platform."""
|
||||
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
async_add_entities(
|
||||
MBButton(coordinator, bee_id, button.id)
|
||||
for bee_id, bee in coordinator.data.bees.items()
|
||||
|
||||
@@ -7,13 +7,12 @@ from homeassistant.components.climate import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import MicroBeesConfigEntry
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from .entity import MicroBeesActuatorEntity
|
||||
|
||||
@@ -27,13 +26,11 @@ THERMOVALVE_SENSOR_ID = 782
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MicroBeesConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the microBees climate platform."""
|
||||
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
async_add_entities(
|
||||
MBClimate(
|
||||
coordinator,
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""The microBees Coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from microBeesPy import Actuator, Bee, MicroBees, MicroBeesException, Sensor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MicroBeesConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -29,10 +34,13 @@ class MicroBeesCoordinatorData:
|
||||
class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]):
|
||||
"""MicroBees coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MicroBeesConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, microbees: MicroBees
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MicroBeesConfigEntry,
|
||||
microbees: MicroBees,
|
||||
) -> None:
|
||||
"""Initialize microBees coordinator."""
|
||||
super().__init__(
|
||||
|
||||
@@ -9,14 +9,12 @@ from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from . import MicroBeesConfigEntry
|
||||
from .entity import MicroBeesEntity
|
||||
|
||||
COVER_IDS = {47: "roller_shutter"}
|
||||
@@ -24,13 +22,11 @@ COVER_IDS = {47: "roller_shutter"}
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MicroBeesConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the microBees cover platform."""
|
||||
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
async_add_entities(
|
||||
MBCover(
|
||||
|
||||
@@ -3,25 +3,22 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import MicroBeesConfigEntry
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from .entity import MicroBeesActuatorEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MicroBeesConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Config entry."""
|
||||
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
async_add_entities(
|
||||
MBLight(coordinator, bee_id, light.id)
|
||||
for bee_id, bee in coordinator.data.bees.items()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user