mirror of
https://github.com/home-assistant/core.git
synced 2025-12-04 23:18:01 +00:00
Compare commits
44 Commits
tibber_dat
...
knx-data-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3c9e2520 | ||
|
|
c0863ca585 | ||
|
|
9d53d37cbf | ||
|
|
823f320425 | ||
|
|
b5a8516bd6 | ||
|
|
f05cb6b2c7 | ||
|
|
1a60c46d67 | ||
|
|
62fba5ca20 | ||
|
|
b54cde795c | ||
|
|
0f456373bf | ||
|
|
a5042027b8 | ||
|
|
b15b5ba95c | ||
|
|
cd6e72798e | ||
|
|
739157e59f | ||
|
|
267aa1af42 | ||
|
|
7328b61a69 | ||
|
|
203f2fb364 | ||
|
|
b956c17ce4 | ||
|
|
5163dc0567 | ||
|
|
31a0478717 | ||
|
|
24da3f0db8 | ||
|
|
786922fc5d | ||
|
|
c2f8b6986b | ||
|
|
0a0832671f | ||
|
|
7b353d7ad4 | ||
|
|
99de73a729 | ||
|
|
1995fbd252 | ||
|
|
315ea9dc76 | ||
|
|
639a96f8cb | ||
|
|
b6786c5a42 | ||
|
|
6f6e9b8057 | ||
|
|
e0c687e415 | ||
|
|
982362110c | ||
|
|
90dc3a8fdf | ||
|
|
5112742b71 | ||
|
|
8899bc01bd | ||
|
|
ed8f9105ff | ||
|
|
185de98f5e | ||
|
|
e857abb43f | ||
|
|
5b1829f3a1 | ||
|
|
520156a33a | ||
|
|
e3b5342b76 | ||
|
|
951b19e80c | ||
|
|
e2351ecec2 |
12
.github/workflows/builder.yml
vendored
12
.github/workflows/builder.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -509,7 +509,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -41,8 +41,8 @@ env:
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
DEFAULT_PYTHON: "3.13.9"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.9', '3.14.0']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0"]
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2025/12/03/release-202512/#purpose-specific-triggers-and-conditions",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_presence": "The behavior of the targeted presence sensors to trigger on.",
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
@@ -336,17 +336,17 @@
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one ore more occupancy sensors start detecting occupancy.",
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,11 +15,11 @@ occupancy_cleared:
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: presence
|
||||
device_class: occupancy
|
||||
|
||||
occupancy_detected:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: presence
|
||||
device_class: occupancy
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.7.0"
|
||||
"habluetooth==5.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -407,8 +407,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return [
|
||||
RTCIceServer(
|
||||
urls=[
|
||||
"stun:stun.home-assistant.io:80",
|
||||
"stun:stun.home-assistant.io:3478",
|
||||
"stun:stun.home-assistant.io:80",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
@@ -71,6 +71,7 @@ class CloudClient(Interface):
|
||||
self._google_config_init_lock = asyncio.Lock()
|
||||
self._relayer_region: str | None = None
|
||||
self._cloud_ice_servers_listener: Callable[[], None] | None = None
|
||||
self._ice_servers: list[RTCIceServer] = []
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
@@ -117,6 +118,11 @@ class CloudClient(Interface):
|
||||
"""Return the connected relayer region."""
|
||||
return self._relayer_region
|
||||
|
||||
@property
|
||||
def ice_servers(self) -> list[RTCIceServer]:
|
||||
"""Return the current ICE servers."""
|
||||
return self._ice_servers
|
||||
|
||||
async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig:
|
||||
"""Return Alexa config."""
|
||||
if self._alexa_config is None:
|
||||
@@ -203,11 +209,8 @@ class CloudClient(Interface):
|
||||
ice_servers: list[RTCIceServer],
|
||||
) -> Callable[[], None]:
|
||||
"""Register cloud ice server."""
|
||||
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
return ice_servers
|
||||
|
||||
return async_register_ice_servers(self._hass, get_ice_servers)
|
||||
self._ice_servers = ice_servers
|
||||
return async_register_ice_servers(self._hass, lambda: self._ice_servers)
|
||||
|
||||
async def async_register_cloud_ice_servers_listener(
|
||||
prefs: CloudPreferences,
|
||||
@@ -268,6 +271,7 @@ class CloudClient(Interface):
|
||||
|
||||
async def logout_cleanups(self) -> None:
|
||||
"""Cleanup some stuff after logout."""
|
||||
self._ice_servers = []
|
||||
await self.prefs.async_set_username(None)
|
||||
|
||||
if self._alexa_config:
|
||||
|
||||
@@ -561,7 +561,7 @@ class BaseCloudLLMEntity(Entity):
|
||||
"schema": _format_structured_output(
|
||||
structure, chat_log.llm_api
|
||||
),
|
||||
"strict": True,
|
||||
"strict": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_hook_delete)
|
||||
websocket_api.async_register_command(hass, websocket_remote_connect)
|
||||
websocket_api.async_register_command(hass, websocket_remote_disconnect)
|
||||
websocket_api.async_register_command(hass, websocket_webrtc_ice_servers)
|
||||
|
||||
websocket_api.async_register_command(hass, google_assistant_get)
|
||||
websocket_api.async_register_command(hass, google_assistant_list)
|
||||
@@ -1107,6 +1108,7 @@ async def alexa_sync(
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "cloud/tts/info"})
|
||||
@callback
|
||||
def tts_info(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
@@ -1134,3 +1136,22 @@ def tts_info(
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"languages": result})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "cloud/webrtc/ice_servers",
|
||||
}
|
||||
)
|
||||
@_require_cloud_login
|
||||
@callback
|
||||
def websocket_webrtc_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle get WebRTC ICE servers websocket command."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
[server.to_dict() for server in hass.data[DATA_CLOUD].client.ice_servers],
|
||||
)
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.6.2"],
|
||||
"requirements": ["hass-nabucasa==1.7.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.12.2"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_STABILITY = 0.5
|
||||
DEFAULT_SIMILARITY = 0.75
|
||||
DEFAULT_STT_AUTO_LANGUAGE = False
|
||||
DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STT_MODEL = "scribe_v2"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
@@ -129,4 +129,5 @@ STT_LANGUAGES = [
|
||||
STT_MODELS = {
|
||||
"scribe_v1": "Scribe v1",
|
||||
"scribe_v1_experimental": "Scribe v1 Experimental",
|
||||
"scribe_v2": "Scribe v2 Realtime",
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"deep_sleep": {
|
||||
"name": "Deep Sleep"
|
||||
"name": "Deep sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ from homeassistant.components.notify import (
|
||||
SERVICE_SEND_MESSAGE,
|
||||
BaseNotificationService,
|
||||
NotifyEntity,
|
||||
NotifyEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ACTION,
|
||||
CONF_ENTITIES,
|
||||
CONF_SERVICE,
|
||||
@@ -173,14 +175,23 @@ class NotifyGroup(GroupEntity, NotifyEntity):
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to all members of the group."""
|
||||
|
||||
data = {
|
||||
ATTR_MESSAGE: message,
|
||||
ATTR_ENTITY_ID: self._entity_ids,
|
||||
}
|
||||
|
||||
# add title only if supported and provided
|
||||
if (
|
||||
title is not None
|
||||
and self._attr_supported_features & NotifyEntityFeature.TITLE
|
||||
):
|
||||
data[ATTR_TITLE] = title
|
||||
|
||||
await self.hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_MESSAGE: message,
|
||||
ATTR_TITLE: title,
|
||||
ATTR_ENTITY_ID: self._entity_ids,
|
||||
},
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
@@ -194,3 +205,15 @@ class NotifyGroup(GroupEntity, NotifyEntity):
|
||||
for entity_id in self._entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
)
|
||||
|
||||
# Support title if all members support it
|
||||
self._attr_supported_features |= NotifyEntityFeature.TITLE
|
||||
for entity_id in self._entity_ids:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if (
|
||||
state is None
|
||||
or not state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
& NotifyEntityFeature.TITLE
|
||||
):
|
||||
self._attr_supported_features &= ~NotifyEntityFeature.TITLE
|
||||
break
|
||||
|
||||
@@ -37,7 +37,6 @@ def get_device_list_classic(
|
||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
||||
# DEBUG: Log the actual response structure
|
||||
except Exception as ex:
|
||||
_LOGGER.error("DEBUG - Login response: %s", login_response)
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
) from ex
|
||||
|
||||
@@ -113,9 +113,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching min device data for %s: %s", self.device_id, err
|
||||
)
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
@@ -180,7 +177,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._sync_update_data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
_LOGGER.error("Unable to fetch data from Growatt server: %s", err)
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
def get_currency(self):
|
||||
|
||||
74
homeassistant/components/growatt_server/quality_scale.yaml
Normal file
74
homeassistant/components/growatt_server/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: data-descriptions missing
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: todo
|
||||
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: todo
|
||||
comment: Replace bare Exception catches in __init__.py with specific growattServer exceptions.
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True.
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not raise repairable issues.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -161,6 +161,7 @@ EXTRA_PLACEHOLDERS = {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE: {
|
||||
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
|
||||
"storage_url": "/config/storage",
|
||||
},
|
||||
ISSUE_KEY_ADDON_PWNED: {
|
||||
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"title": "Restart(s) required"
|
||||
},
|
||||
"issue_system_free_space": {
|
||||
"description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space.",
|
||||
"description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. Go to [storage]({storage_url}) to see what is taking up space or see [clear up storage]({more_info_free_space}) for tips on how to free up space.",
|
||||
"title": "Data disk is running low on free space"
|
||||
},
|
||||
"issue_system_multiple_data_disks": {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["iometer==0.2.0"],
|
||||
"requirements": ["iometer==0.3.0"],
|
||||
"zeroconf": ["_iometer._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ SERVICE_KNX_EVENT_REGISTER: Final = "event_register"
|
||||
SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
|
||||
SERVICE_KNX_READ: Final = "read"
|
||||
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
|
||||
|
||||
|
||||
class KNXConfigEntryData(TypedDict, total=False):
|
||||
"""Config entry for the KNX integration."""
|
||||
@@ -162,8 +164,11 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
}
|
||||
|
||||
# Map KNX controller modes to HA modes. This list might not be complete.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as dt_date
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateDevice as XknxDateDevice
|
||||
from xknx.dpt.dpt_11 import KNXDate as XKNXDate
|
||||
|
||||
@@ -18,7 +18,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -26,11 +29,14 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxYamlEntity
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_DATE
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -40,40 +46,36 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.DATE]
|
||||
|
||||
async_add_entities(
|
||||
KNXDateEntity(knx_module, entity_config) for entity_config in config
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.DATE,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiDate,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxDateDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.DATE):
|
||||
entities.extend(
|
||||
KnxYamlDate(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATE):
|
||||
entities.extend(
|
||||
KnxUiDate(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
|
||||
class _KNXDate(DateEntity, RestoreEntity):
|
||||
"""Representation of a KNX date."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -94,3 +96,52 @@ class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: dt_date) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value)
|
||||
|
||||
|
||||
class KnxYamlDate(_KNXDate, KnxYamlEntity):
|
||||
"""Representation of a KNX date configured from YAML."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX date."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxDateDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiDate(_KNXDate, KnxUiEntity):
|
||||
"""Representation of a KNX date configured from the UI."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX date."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxDateDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_DATE),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATE),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateTimeDevice as XknxDateTimeDevice
|
||||
from xknx.dpt.dpt_19 import KNXDateTime as XKNXDateTime
|
||||
|
||||
@@ -18,7 +18,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -27,11 +30,14 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxYamlEntity
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_DATETIME
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -41,40 +47,36 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.DATETIME]
|
||||
|
||||
async_add_entities(
|
||||
KNXDateTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.DATETIME,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiDateTime,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxDateTimeDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.DATETIME):
|
||||
entities.extend(
|
||||
KnxYamlDateTime(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATETIME):
|
||||
entities.extend(
|
||||
KnxUiDateTime(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
|
||||
class _KNXDateTime(DateTimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX datetime."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -99,3 +101,52 @@ class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: datetime) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value.astimezone(dt_util.get_default_time_zone()))
|
||||
|
||||
|
||||
class KnxYamlDateTime(_KNXDateTime, KnxYamlEntity):
|
||||
"""Representation of a KNX datetime configured from YAML."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX datetime."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxDateTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiDateTime(_KNXDateTime, KnxUiEntity):
|
||||
"""Representation of a KNX datetime configured from the UI."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX datetime."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxDateTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_DATETIME),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATETIME),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -77,6 +77,11 @@ class _KnxEntityBase(Entity):
|
||||
"""Store register state change callback and start device object."""
|
||||
self._device.register_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_add(self._device)
|
||||
if uid := self.unique_id:
|
||||
self._knx_module.add_to_group_address_entities(
|
||||
group_addresses=self._device.group_addresses(),
|
||||
identifier=(self.platform_data.domain, uid),
|
||||
)
|
||||
# super call needed to have methods of multi-inherited classes called
|
||||
# eg. for restoring state (like _KNXSwitch)
|
||||
await super().async_added_to_hass()
|
||||
@@ -85,6 +90,11 @@ class _KnxEntityBase(Entity):
|
||||
"""Disconnect device object when removed."""
|
||||
self._device.unregister_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_remove(self._device)
|
||||
if uid := self.unique_id:
|
||||
self._knx_module.remove_from_group_address_entities(
|
||||
group_addresses=self._device.group_addresses(),
|
||||
identifier=(self.platform_data.domain, uid),
|
||||
)
|
||||
|
||||
|
||||
class KnxYamlEntity(_KnxEntityBase):
|
||||
|
||||
@@ -56,6 +56,7 @@ from .const import (
|
||||
from .device import KNXInterfaceDevice
|
||||
from .expose import KNXExposeSensor, KNXExposeTime
|
||||
from .project import KNXProject
|
||||
from .repairs import data_secure_group_key_issue_dispatcher
|
||||
from .storage.config_store import KNXConfigStore
|
||||
from .telegrams import Telegrams
|
||||
|
||||
@@ -107,8 +108,12 @@ class KNXModule:
|
||||
|
||||
self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {}
|
||||
self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {}
|
||||
self.group_address_entities: dict[
|
||||
DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),}
|
||||
] = {}
|
||||
self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback()
|
||||
|
||||
self.entry.async_on_unload(data_secure_group_key_issue_dispatcher(self))
|
||||
self.entry.async_on_unload(
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
|
||||
)
|
||||
@@ -225,6 +230,29 @@ class KNXModule:
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def add_to_group_address_entities(
|
||||
self,
|
||||
group_addresses: set[DeviceGroupAddress],
|
||||
identifier: tuple[str, str], # (platform, unique_id)
|
||||
) -> None:
|
||||
"""Register entity in group_address_entities map."""
|
||||
for ga in group_addresses:
|
||||
if ga not in self.group_address_entities:
|
||||
self.group_address_entities[ga] = set()
|
||||
self.group_address_entities[ga].add(identifier)
|
||||
|
||||
def remove_from_group_address_entities(
|
||||
self,
|
||||
group_addresses: set[DeviceGroupAddress],
|
||||
identifier: tuple[str, str],
|
||||
) -> None:
|
||||
"""Unregister entity from group_address_entities map."""
|
||||
for ga in group_addresses:
|
||||
if ga in self.group_address_entities:
|
||||
self.group_address_entities[ga].discard(identifier)
|
||||
if not self.group_address_entities[ga]:
|
||||
del self.group_address_entities[ga]
|
||||
|
||||
def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
|
||||
"""Call invoked after a KNX connection state change was received."""
|
||||
self.connected = state == XknxConnectionState.CONNECTED
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"xknx==3.11.0",
|
||||
"xknx==3.12.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
],
|
||||
|
||||
175
homeassistant/components/knx/repairs.py
Normal file
175
homeassistant/components/knx/repairs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Repairs for KNX integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import voluptuous as vol
|
||||
from xknx.exceptions.exception import InvalidSecureConfiguration
|
||||
from xknx.telegram import GroupAddress, IndividualAddress, Telegram
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir, selector
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .knx_module import KNXModule
|
||||
|
||||
from .const import (
|
||||
CONF_KNX_KNXKEY_PASSWORD,
|
||||
DOMAIN,
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
KNXConfigEntryData,
|
||||
)
|
||||
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
|
||||
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
|
||||
|
||||
CONF_KEYRING_FILE: Final = "knxkeys_file"
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == REPAIR_ISSUE_DATA_SECURE_GROUP_KEY:
|
||||
return DataSecureGroupIssueRepairFlow()
|
||||
# If KNX adds confirm-only repairs in the future, this should be changed
|
||||
# to return a ConfirmRepairFlow instead of raising a ValueError
|
||||
raise ValueError(f"unknown repair {issue_id}")
|
||||
|
||||
|
||||
######################
|
||||
# DataSecure key issue
|
||||
######################
|
||||
|
||||
|
||||
@callback
|
||||
def data_secure_group_key_issue_dispatcher(knx_module: KNXModule) -> Callable[[], None]:
|
||||
"""Watcher for DataSecure group key issues."""
|
||||
return async_dispatcher_connect(
|
||||
knx_module.hass,
|
||||
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
target=partial(_data_secure_group_key_issue_handler, knx_module),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _data_secure_group_key_issue_handler(
|
||||
knx_module: KNXModule, telegram: Telegram, telegram_dict: TelegramDict
|
||||
) -> None:
|
||||
"""Handle DataSecure group key issue telegrams."""
|
||||
if telegram.destination_address not in knx_module.group_address_entities:
|
||||
# Only report issues for configured group addresses
|
||||
return
|
||||
|
||||
issue_registry = ir.async_get(knx_module.hass)
|
||||
new_ga = str(telegram.destination_address)
|
||||
new_ia = str(telegram.source_address)
|
||||
new_data = {new_ga: new_ia}
|
||||
|
||||
if existing_issue := issue_registry.async_get_issue(
|
||||
DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
|
||||
):
|
||||
assert isinstance(existing_issue.data, dict)
|
||||
existing_data: dict[str, str] = existing_issue.data # type: ignore[assignment]
|
||||
if new_ga in existing_data:
|
||||
current_ias = existing_data[new_ga].split(", ")
|
||||
if new_ia in current_ias:
|
||||
return
|
||||
current_ias = sorted([*current_ias, new_ia], key=IndividualAddress)
|
||||
new_data[new_ga] = ", ".join(current_ias)
|
||||
new_data_unsorted = existing_data | new_data
|
||||
new_data = {
|
||||
key: new_data_unsorted[key]
|
||||
for key in sorted(new_data_unsorted, key=GroupAddress)
|
||||
}
|
||||
|
||||
issue_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
data=new_data, # type: ignore[arg-type]
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
translation_placeholders={
|
||||
"addresses": "\n".join(
|
||||
f"`{ga}` from {ias}" for ga, ias in new_data.items()
|
||||
),
|
||||
"interface": str(knx_module.xknx.current_address),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DataSecureGroupIssueRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow for outdated DataSecure keys."""
|
||||
|
||||
@callback
|
||||
def _async_get_placeholders(self) -> dict[str, str]:
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
|
||||
assert issue is not None
|
||||
return issue.translation_placeholders or {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_secure_knxkeys()
|
||||
|
||||
async def async_step_secure_knxkeys(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Manage upload of new KNX Keyring file."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
password = user_input[CONF_KNX_KNXKEY_PASSWORD]
|
||||
keyring = None
|
||||
try:
|
||||
keyring = await save_uploaded_knxkeys_file(
|
||||
self.hass,
|
||||
uploaded_file_id=user_input[CONF_KEYRING_FILE],
|
||||
password=password,
|
||||
)
|
||||
except InvalidSecureConfiguration:
|
||||
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
|
||||
|
||||
if not errors and keyring:
|
||||
new_entry_data = KNXConfigEntryData(
|
||||
knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}",
|
||||
knxkeys_password=password,
|
||||
)
|
||||
return self.finish_flow(new_entry_data)
|
||||
|
||||
fields = {
|
||||
vol.Required(CONF_KEYRING_FILE): selector.FileSelector(
|
||||
config=selector.FileSelectorConfig(accept=".knxkeys")
|
||||
),
|
||||
vol.Required(CONF_KNX_KNXKEY_PASSWORD): selector.TextSelector(),
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="secure_knxkeys",
|
||||
data_schema=vol.Schema(fields),
|
||||
description_placeholders=self._async_get_placeholders(),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@callback
|
||||
def finish_flow(
|
||||
self, new_entry_data: KNXConfigEntryData
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Finish the repair flow. Reload the config entry."""
|
||||
knx_config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
if knx_config_entries:
|
||||
config_entry = knx_config_entries[0] # single_config_entry
|
||||
new_data = {**config_entry.data, **new_entry_data}
|
||||
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
@@ -13,6 +13,9 @@ CONF_DPT: Final = "dpt"
|
||||
|
||||
CONF_GA_SENSOR: Final = "ga_sensor"
|
||||
CONF_GA_SWITCH: Final = "ga_switch"
|
||||
CONF_GA_DATE: Final = "ga_date"
|
||||
CONF_GA_DATETIME: Final = "ga_datetime"
|
||||
CONF_GA_TIME: Final = "ga_time"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
|
||||
@@ -46,6 +46,8 @@ from .const import (
|
||||
CONF_GA_COLOR_TEMP,
|
||||
CONF_GA_CONTROLLER_MODE,
|
||||
CONF_GA_CONTROLLER_STATUS,
|
||||
CONF_GA_DATE,
|
||||
CONF_GA_DATETIME,
|
||||
CONF_GA_FAN_SPEED,
|
||||
CONF_GA_FAN_SWING,
|
||||
CONF_GA_FAN_SWING_HORIZONTAL,
|
||||
@@ -72,6 +74,7 @@ from .const import (
|
||||
CONF_GA_SWITCH,
|
||||
CONF_GA_TEMPERATURE_CURRENT,
|
||||
CONF_GA_TEMPERATURE_TARGET,
|
||||
CONF_GA_TIME,
|
||||
CONF_GA_UP_DOWN,
|
||||
CONF_GA_VALVE,
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
@@ -199,6 +202,24 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
|
||||
),
|
||||
)
|
||||
|
||||
DATE_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_DATE): GASelector(write_required=True, valid_dpt="11.001"),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_DATETIME): GASelector(
|
||||
write_required=True, valid_dpt="19.001"
|
||||
),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class LightColorMode(StrEnum):
|
||||
@@ -336,6 +357,14 @@ SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
TIME_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_TIME): GASelector(write_required=True, valid_dpt="10.001"),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class ConfSetpointShiftMode(StrEnum):
|
||||
@@ -482,8 +511,11 @@ KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
|
||||
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.DATE: DATE_KNX_SCHEMA,
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
}
|
||||
|
||||
ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All(
|
||||
|
||||
@@ -10,9 +10,10 @@ from xknx.secure.keyring import Keyring, sync_load_keyring
|
||||
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..const import DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,4 +46,11 @@ async def save_uploaded_knxkeys_file(
|
||||
shutil.move(file_path, dest_file)
|
||||
return keyring
|
||||
|
||||
return await hass.async_add_executor_job(_process_upload)
|
||||
keyring = await hass.async_add_executor_job(_process_upload)
|
||||
|
||||
# If there is an existing DataSecure group key issue, remove it.
|
||||
# GAs might not be DataSecure anymore after uploading a valid keyring,
|
||||
# if they are, we raise the issue again when receiving a telegram.
|
||||
ir.async_delete_issue(hass, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
|
||||
|
||||
return keyring
|
||||
|
||||
@@ -176,6 +176,10 @@
|
||||
"state_address": "State address",
|
||||
"valid_dpts": "Valid DPTs"
|
||||
},
|
||||
"respond_to_read": {
|
||||
"description": "Respond to GroupValueRead telegrams received to the configured send address.",
|
||||
"label": "Respond to read"
|
||||
},
|
||||
"sync_state": {
|
||||
"description": "Actively request state updates from KNX bus for state addresses.",
|
||||
"options": {
|
||||
@@ -438,6 +442,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"description": "The KNX date platform is used as an interface to date objects.",
|
||||
"knx": {
|
||||
"ga_date": {
|
||||
"description": "The group address of the date object.",
|
||||
"label": "Date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"datetime": {
|
||||
"description": "The KNX datetime platform is used as an interface to date and time objects.",
|
||||
"knx": {
|
||||
"ga_datetime": {
|
||||
"description": "The group address of the date and time object.",
|
||||
"label": "Date and time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": "Create new entity",
|
||||
"light": {
|
||||
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
|
||||
@@ -546,10 +568,15 @@
|
||||
"invert": {
|
||||
"description": "Invert payloads before processing or sending.",
|
||||
"label": "Invert"
|
||||
},
|
||||
"respond_to_read": {
|
||||
"description": "Respond to GroupValueRead telegrams received to the configured send address.",
|
||||
"label": "Respond to read"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"description": "The KNX time platform is used as an interface to time objects.",
|
||||
"knx": {
|
||||
"ga_time": {
|
||||
"description": "The group address of the time object.",
|
||||
"label": "Time"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -644,6 +671,30 @@
|
||||
"message": "Invalid type for `knx.send` service: {type}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"data_secure_group_key_issue": {
|
||||
"fix_flow": {
|
||||
"error": {
|
||||
"keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]"
|
||||
},
|
||||
"step": {
|
||||
"secure_knxkeys": {
|
||||
"data": {
|
||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
|
||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
|
||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
|
||||
},
|
||||
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
|
||||
"title": "Update KNX Keyring"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "KNX Data Secure telegrams can't be decrypted"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"communication_settings": {
|
||||
|
||||
@@ -26,6 +26,9 @@ STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
|
||||
|
||||
# dispatcher signal for KNX interface device triggers
|
||||
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
|
||||
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
|
||||
"knx_data_secure_issue_telegram"
|
||||
)
|
||||
|
||||
|
||||
class DecodedTelegramPayload(TypedDict):
|
||||
@@ -74,6 +77,11 @@ class Telegrams:
|
||||
match_for_outgoing=True,
|
||||
)
|
||||
)
|
||||
self._xknx_data_secure_group_key_issue_cb_handle = (
|
||||
xknx.telegram_queue.register_data_secure_group_key_issue_cb(
|
||||
self._xknx_data_secure_group_key_issue_cb,
|
||||
)
|
||||
)
|
||||
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
|
||||
self.last_ga_telegrams: dict[str, TelegramDict] = {}
|
||||
|
||||
@@ -107,6 +115,14 @@ class Telegrams:
|
||||
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
|
||||
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
|
||||
|
||||
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
|
||||
"""Handle telegrams with undecodable data secure payload from xknx."""
|
||||
telegram_dict = self.telegram_to_dict(telegram)
|
||||
self.recent_telegrams.append(telegram_dict)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
|
||||
)
|
||||
|
||||
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
|
||||
"""Convert a Telegram to a dict."""
|
||||
dst_name = ""
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time as dt_time
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import TimeDevice as XknxTimeDevice
|
||||
from xknx.dpt.dpt_10 import KNXTime as XknxTime
|
||||
|
||||
@@ -18,7 +18,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -26,11 +29,14 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxYamlEntity
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_TIME
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -40,40 +46,36 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.TIME]
|
||||
|
||||
async_add_entities(
|
||||
KNXTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.TIME,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiTime,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxTimeDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.TIME):
|
||||
entities.extend(
|
||||
KnxYamlTime(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.TIME):
|
||||
entities.extend(
|
||||
KnxUiTime(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
|
||||
class _KNXTime(TimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX time."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -94,3 +96,52 @@ class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: dt_time) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value)
|
||||
|
||||
|
||||
class KnxYamlTime(_KNXTime, KnxYamlEntity):
|
||||
"""Representation of a KNX time configured from YAML."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiTime(_KNXTime, KnxUiEntity):
|
||||
"""Representation of a KNX time configured from the UI."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_TIME),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_TIME),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import ExitStack
|
||||
from functools import wraps
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Any, Final, overload
|
||||
@@ -34,7 +35,11 @@ from .storage.entity_store_validation import (
|
||||
validate_entity_data,
|
||||
)
|
||||
from .storage.serialize import get_serialized_schema
|
||||
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict
|
||||
from .telegrams import (
|
||||
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
SIGNAL_KNX_TELEGRAM,
|
||||
TelegramDict,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .knx_module import KNXModule
|
||||
@@ -334,11 +339,23 @@ def ws_subscribe_telegram(
|
||||
telegram_dict,
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
stack = ExitStack()
|
||||
stack.callback(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
)
|
||||
)
|
||||
stack.callback(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
)
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = stack.close
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoBluetoothUpdateCoordinator,
|
||||
LaMarzoccoConfigEntry,
|
||||
LaMarzoccoConfigUpdateCoordinator,
|
||||
LaMarzoccoRuntimeData,
|
||||
@@ -72,38 +73,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
settings = await cloud_client.get_thing_settings(serial)
|
||||
except AuthFail as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except (RequestNotSuccessful, TimeoutError) as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
|
||||
gateway_version = version.parse(
|
||||
settings.firmwares[FirmwareType.GATEWAY].build_version
|
||||
)
|
||||
|
||||
if gateway_version < version.parse("v5.0.9"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": str(gateway_version)},
|
||||
)
|
||||
|
||||
# initialize Bluetooth
|
||||
bluetooth_client: LaMarzoccoBluetoothClient | None = None
|
||||
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
|
||||
token := (entry.data.get(CONF_TOKEN) or settings.ble_auth_token)
|
||||
token := entry.data.get(CONF_TOKEN)
|
||||
):
|
||||
if CONF_MAC not in entry.data:
|
||||
for discovery_info in async_discovered_service_info(hass):
|
||||
@@ -145,6 +118,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
_LOGGER.info(
|
||||
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
|
||||
)
|
||||
try:
|
||||
settings = await cloud_client.get_thing_settings(serial)
|
||||
except AuthFail as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except (RequestNotSuccessful, TimeoutError) as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
if not bluetooth_client:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
|
||||
else:
|
||||
gateway_version = version.parse(
|
||||
settings.firmwares[FirmwareType.GATEWAY].build_version
|
||||
)
|
||||
|
||||
if gateway_version < version.parse("v5.0.9"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": str(gateway_version)},
|
||||
)
|
||||
# Update BLE Token if exists
|
||||
if settings.ble_auth_token:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_TOKEN: settings.ble_auth_token,
|
||||
},
|
||||
)
|
||||
|
||||
device = LaMarzoccoMachine(
|
||||
serial_number=entry.unique_id,
|
||||
@@ -153,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
)
|
||||
|
||||
coordinators = LaMarzoccoRuntimeData(
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client),
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||
@@ -166,6 +177,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
|
||||
)
|
||||
|
||||
# bt coordinator only if bluetooth client is available
|
||||
# and after the initial refresh of the config coordinator
|
||||
# to fetch only if the others failed
|
||||
if bluetooth_client:
|
||||
bluetooth_coordinator = LaMarzoccoBluetoothUpdateCoordinator(
|
||||
hass, entry, device
|
||||
)
|
||||
await bluetooth_coordinator.async_config_entry_first_refresh()
|
||||
coordinators.bluetooth_coordinator = bluetooth_coordinator
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import cast
|
||||
|
||||
from pylamarzocco import LaMarzoccoMachine
|
||||
from pylamarzocco.const import BackFlushStatus, MachineState, ModelName, WidgetType
|
||||
from pylamarzocco.models import BackFlush, MachineStatus
|
||||
from pylamarzocco.models import BackFlush, MachineStatus, NoWater
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -39,8 +39,15 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
|
||||
key="water_tank",
|
||||
translation_key="water_tank",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config,
|
||||
is_on_fn=(
|
||||
lambda machine: cast(
|
||||
NoWater, machine.dashboard.config[WidgetType.CM_NO_WATER]
|
||||
).allarm
|
||||
if WidgetType.CM_NO_WATER in machine.dashboard.config
|
||||
else False
|
||||
),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoBinarySensorEntityDescription(
|
||||
key="brew_active",
|
||||
@@ -93,7 +100,9 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoBinarySensorEntity(coordinator, description)
|
||||
LaMarzoccoBinarySensorEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -10,8 +10,12 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco import LaMarzoccoMachine
|
||||
from pylamarzocco.exceptions import (
|
||||
AuthFail,
|
||||
BluetoothConnectionFailed,
|
||||
RequestNotSuccessful,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
@@ -36,6 +40,7 @@ class LaMarzoccoRuntimeData:
|
||||
settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
|
||||
schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
|
||||
statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
|
||||
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None
|
||||
|
||||
|
||||
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
|
||||
@@ -46,14 +51,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
_default_update_interval = SCAN_INTERVAL
|
||||
config_entry: LaMarzoccoConfigEntry
|
||||
_websocket_task: Task | None = None
|
||||
update_success = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: LaMarzoccoConfigEntry,
|
||||
device: LaMarzoccoMachine,
|
||||
cloud_client: LaMarzoccoCloudClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
@@ -64,7 +68,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
update_interval=self._default_update_interval,
|
||||
)
|
||||
self.device = device
|
||||
self.cloud_client = cloud_client
|
||||
self._websocket_task: Task | None = None
|
||||
|
||||
@property
|
||||
def websocket_terminated(self) -> bool:
|
||||
@@ -81,14 +85,28 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
await func()
|
||||
except AuthFail as ex:
|
||||
_LOGGER.debug("Authentication failed", exc_info=True)
|
||||
self.update_success = False
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except RequestNotSuccessful as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
self.update_success = False
|
||||
# if no bluetooth coordinator, this is a fatal error
|
||||
# otherwise, bluetooth may still work
|
||||
if not self.device.bluetooth_client_available:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
except BluetoothConnectionFailed as err:
|
||||
self.update_success = False
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bluetooth_connection_failed",
|
||||
) from err
|
||||
else:
|
||||
self.update_success = True
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up coordinator."""
|
||||
@@ -109,11 +127,9 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco API centrally."""
|
||||
|
||||
cloud_client: LaMarzoccoCloudClient
|
||||
|
||||
async def _internal_async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
await self.cloud_client.async_get_access_token()
|
||||
await self.device.ensure_token_valid()
|
||||
await self.device.get_dashboard()
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
|
||||
@@ -121,7 +137,7 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Fetch data from API endpoint."""
|
||||
|
||||
# ensure token stays valid; does nothing if token is still valid
|
||||
await self.cloud_client.async_get_access_token()
|
||||
await self.device.ensure_token_valid()
|
||||
|
||||
# Only skip websocket reconnection if it's currently connected and the task is still running
|
||||
if self.device.websocket.connected and not self.websocket_terminated:
|
||||
@@ -193,3 +209,19 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Fetch data from API endpoint."""
|
||||
await self.device.get_coffee_and_flush_counter()
|
||||
_LOGGER.debug("Current statistics: %s", self.device.statistics.to_dict())
|
||||
|
||||
|
||||
class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco Bluetooth API centrally."""
|
||||
|
||||
async def _internal_async_setup(self) -> None:
|
||||
"""Initial setup for Bluetooth coordinator."""
|
||||
await self.device.get_model_info_from_bluetooth()
|
||||
|
||||
async def _internal_async_update_data(self) -> None:
|
||||
"""Fetch data from Bluetooth endpoint."""
|
||||
# if the websocket is connected and the machine is connected to the cloud
|
||||
# skip bluetooth update, because we get push updates
|
||||
if self.device.websocket.connected and self.device.dashboard.connected:
|
||||
return
|
||||
await self.device.get_dashboard_from_bluetooth()
|
||||
|
||||
@@ -17,7 +17,10 @@ from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaMarzoccoUpdateCoordinator
|
||||
from .coordinator import (
|
||||
LaMarzoccoBluetoothUpdateCoordinator,
|
||||
LaMarzoccoUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -26,6 +29,7 @@ class LaMarzoccoEntityDescription(EntityDescription):
|
||||
|
||||
available_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
|
||||
supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
|
||||
bt_offline_mode: bool = False
|
||||
|
||||
|
||||
class LaMarzoccoBaseEntity(
|
||||
@@ -45,14 +49,19 @@ class LaMarzoccoBaseEntity(
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.device
|
||||
self._attr_unique_id = f"{device.serial_number}_{key}"
|
||||
sw_version = (
|
||||
device.settings.firmwares[FirmwareType.MACHINE].build_version
|
||||
if FirmwareType.MACHINE in device.settings.firmwares
|
||||
else None
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.serial_number)},
|
||||
name=device.dashboard.name,
|
||||
name=device.dashboard.name or self.coordinator.config_entry.title,
|
||||
manufacturer="La Marzocco",
|
||||
model=device.dashboard.model_name.value,
|
||||
model_id=device.dashboard.model_code.value,
|
||||
serial_number=device.serial_number,
|
||||
sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
|
||||
sw_version=sw_version,
|
||||
)
|
||||
connections: set[tuple[str, str]] = set()
|
||||
if coordinator.config_entry.data.get(CONF_ADDRESS):
|
||||
@@ -77,8 +86,12 @@ class LaMarzoccoBaseEntity(
|
||||
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
|
||||
else MachineState.OFF
|
||||
)
|
||||
return super().available and not (
|
||||
self._unavailable_when_machine_off and machine_state is MachineState.OFF
|
||||
return (
|
||||
super().available
|
||||
and not (
|
||||
self._unavailable_when_machine_off and machine_state is MachineState.OFF
|
||||
)
|
||||
and self.coordinator.update_success
|
||||
)
|
||||
|
||||
|
||||
@@ -90,6 +103,11 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
if (
|
||||
self.entity_description.bt_offline_mode
|
||||
and self.bluetooth_coordinator is not None
|
||||
):
|
||||
return self.bluetooth_coordinator.last_update_success
|
||||
if super().available:
|
||||
return self.entity_description.available_fn(self.coordinator)
|
||||
return False
|
||||
@@ -98,7 +116,17 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
|
||||
self,
|
||||
coordinator: LaMarzoccoUpdateCoordinator,
|
||||
entity_description: LaMarzoccoEntityDescription,
|
||||
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, entity_description.key)
|
||||
self.entity_description = entity_description
|
||||
self.bluetooth_coordinator = bluetooth_coordinator
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if self.bluetooth_coordinator is not None:
|
||||
self.async_on_remove(
|
||||
self.bluetooth_coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
|
||||
).target_temperature
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="steam_temp",
|
||||
@@ -78,6 +79,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.GS3_AV, ModelName.GS3_MP)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="smart_standby_time",
|
||||
@@ -96,6 +98,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
)
|
||||
),
|
||||
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="preinfusion_off",
|
||||
@@ -226,13 +229,14 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up number entities."""
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
entities: list[NumberEntity] = [
|
||||
LaMarzoccoNumberEntity(coordinator, description)
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoNumberEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
)
|
||||
|
||||
|
||||
class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
|
||||
|
||||
@@ -80,6 +80,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSelectEntityDescription(
|
||||
key="prebrew_infusion_select",
|
||||
@@ -128,7 +129,9 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoSelectEntity(coordinator, description)
|
||||
LaMarzoccoSelectEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"auto_on_off_error": {
|
||||
"message": "Error while setting auto on/off to {state} for {id}"
|
||||
},
|
||||
"bluetooth_connection_failed": {
|
||||
"message": "Error while connecting to machine via Bluetooth"
|
||||
},
|
||||
"button_error": {
|
||||
"message": "Error while executing button {key}"
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
).mode
|
||||
is MachineMode.BREWING_MODE
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="steam_boiler_enable",
|
||||
@@ -65,6 +66,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="steam_boiler_enable",
|
||||
@@ -80,6 +82,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="smart_standby_enabled",
|
||||
@@ -91,6 +94,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
||||
),
|
||||
is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -106,7 +110,9 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[SwitchEntity] = []
|
||||
entities.extend(
|
||||
LaMarzoccoSwitchEntity(coordinator, description)
|
||||
LaMarzoccoSwitchEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["letpot"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["letpot==0.6.3"]
|
||||
"requirements": ["letpot==0.6.4"]
|
||||
}
|
||||
|
||||
1
homeassistant/components/levoit/__init__.py
Normal file
1
homeassistant/components/levoit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Levoit."""
|
||||
6
homeassistant/components/levoit/manifest.json
Normal file
6
homeassistant/components/levoit/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "levoit",
|
||||
"name": "Levoit",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
}
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
|
||||
from librehardwaremonitor_api import (
|
||||
LibreHardwareMonitorClient,
|
||||
@@ -55,15 +54,11 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
|
||||
registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
|
||||
)
|
||||
self._previous_devices: MappingProxyType[DeviceId, DeviceName] = (
|
||||
MappingProxyType(
|
||||
{
|
||||
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
|
||||
for device in device_entries
|
||||
if device.identifiers and device.name
|
||||
}
|
||||
)
|
||||
)
|
||||
self._previous_devices: dict[DeviceId, DeviceName] = {
|
||||
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
|
||||
for device in device_entries
|
||||
if device.identifiers and device.name
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> LibreHardwareMonitorData:
|
||||
try:
|
||||
@@ -75,7 +70,9 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
except LibreHardwareMonitorNoDevicesError as err:
|
||||
raise UpdateFailed("No sensor data available, will retry") from err
|
||||
|
||||
await self._async_handle_changes_in_devices(lhm_data.main_device_ids_and_names)
|
||||
await self._async_handle_changes_in_devices(
|
||||
dict(lhm_data.main_device_ids_and_names)
|
||||
)
|
||||
|
||||
return lhm_data
|
||||
|
||||
@@ -92,18 +89,21 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
)
|
||||
|
||||
async def _async_handle_changes_in_devices(
|
||||
self, detected_devices: MappingProxyType[DeviceId, DeviceName]
|
||||
self, detected_devices: dict[DeviceId, DeviceName]
|
||||
) -> None:
|
||||
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
|
||||
detected_devices = {
|
||||
DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name
|
||||
for detected_id, device_name in detected_devices.items()
|
||||
}
|
||||
|
||||
previous_device_ids = set(self._previous_devices.keys())
|
||||
detected_device_ids = set(detected_devices.keys())
|
||||
|
||||
if previous_device_ids == detected_device_ids:
|
||||
return
|
||||
_LOGGER.debug("Previous device_ids: %s", previous_device_ids)
|
||||
_LOGGER.debug("Detected device_ids: %s", detected_device_ids)
|
||||
|
||||
if self.data is None:
|
||||
# initial update during integration startup
|
||||
self._previous_devices = detected_devices # type: ignore[unreachable]
|
||||
if previous_device_ids == detected_device_ids:
|
||||
return
|
||||
|
||||
if orphaned_devices := previous_device_ids - detected_device_ids:
|
||||
@@ -114,13 +114,21 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in orphaned_devices:
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{self.config_entry.entry_id}_{device_id}")}
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Removing device: %s", self._previous_devices[device_id]
|
||||
)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
if self.data is None:
|
||||
# initial update during integration startup
|
||||
self._previous_devices = detected_devices # type: ignore[unreachable]
|
||||
return
|
||||
|
||||
if new_devices := detected_device_ids - previous_device_ids:
|
||||
_LOGGER.warning(
|
||||
"New Device(s) detected, reload integration to add them to Home Assistant: %s",
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==9.1.1"]
|
||||
"requirements": ["google-nest-sdm==9.1.2"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@gjohansson-ST"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/nordpool",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynordpool"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioonkyo"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioonkyo==0.3.0"],
|
||||
"requirements": ["aioonkyo==0.4.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/oralb",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["oralb_ble"],
|
||||
"requirements": ["oralb-ble==0.17.6"]
|
||||
|
||||
@@ -162,7 +162,7 @@ class PingDataSubProcess(PingData):
|
||||
|
||||
if pinger:
|
||||
with suppress(TypeError, ProcessLookupError):
|
||||
await pinger.kill() # type: ignore[func-returns-value]
|
||||
pinger.kill()
|
||||
del pinger
|
||||
|
||||
return None
|
||||
|
||||
@@ -17,7 +17,12 @@ from .coordinator import PooldoseConfigEntry, PooldoseCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
|
||||
|
||||
@@ -68,6 +68,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cl_target": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"ofa_cl_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_cl_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"ofa_orp_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_orp_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"ofa_ph_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_ph_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"orp_target": {
|
||||
"default": "mdi:water-check"
|
||||
},
|
||||
"ph_target": {
|
||||
"default": "mdi:ph"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"default": "mdi:pool"
|
||||
|
||||
142
homeassistant/components/pooldose/number.py
Normal file
142
homeassistant/components/pooldose/number.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Number entities for the Seko PoolDose integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PooldoseConfigEntry
|
||||
from .entity import PooldoseEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import PooldoseCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
key="ph_target",
|
||||
translation_key="ph_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="orp_target",
|
||||
translation_key="orp_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="cl_target",
|
||||
translation_key="cl_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_ph_lower",
|
||||
translation_key="ofa_ph_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_ph_upper",
|
||||
translation_key="ofa_ph_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_orp_lower",
|
||||
translation_key="ofa_orp_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_orp_upper",
|
||||
translation_key="ofa_orp_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_cl_lower",
|
||||
translation_key="ofa_cl_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_cl_upper",
|
||||
translation_key="ofa_cl_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: PooldoseConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up PoolDose number entities from a config entry."""
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id is not None
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
number_data = coordinator.data.get("number", {})
|
||||
serial_number = config_entry.unique_id
|
||||
|
||||
async_add_entities(
|
||||
PooldoseNumber(coordinator, serial_number, coordinator.device_info, description)
|
||||
for description in NUMBER_DESCRIPTIONS
|
||||
if description.key in number_data
|
||||
)
|
||||
|
||||
|
||||
class PooldoseNumber(PooldoseEntity, NumberEntity):
|
||||
"""Number entity for the Seko PoolDose Python API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PooldoseCoordinator,
|
||||
serial_number: str,
|
||||
device_info: Any,
|
||||
description: NumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number."""
|
||||
super().__init__(coordinator, serial_number, device_info, description, "number")
|
||||
self._async_update_attrs()
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._async_update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update number attributes."""
|
||||
data = cast(dict, self.get_data())
|
||||
self._attr_native_value = data["value"]
|
||||
self._attr_native_min_value = data["min"]
|
||||
self._attr_native_max_value = data["max"]
|
||||
self._attr_native_step = data["step"]
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.coordinator.client.set_number(self.entity_description.key, value)
|
||||
self._attr_native_value = value
|
||||
self.async_write_ha_state()
|
||||
@@ -68,6 +68,35 @@
|
||||
"name": "Auxiliary relay 3 status"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cl_target": {
|
||||
"name": "Chlorine target"
|
||||
},
|
||||
"ofa_cl_lower": {
|
||||
"name": "Chlorine overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_cl_upper": {
|
||||
"name": "Chlorine overfeed alarm upper limit"
|
||||
},
|
||||
"ofa_orp_lower": {
|
||||
"name": "ORP overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_orp_upper": {
|
||||
"name": "ORP overfeed alarm upper limit"
|
||||
},
|
||||
"ofa_ph_lower": {
|
||||
"name": "pH overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_ph_upper": {
|
||||
"name": "pH overfeed alarm upper limit"
|
||||
},
|
||||
"orp_target": {
|
||||
"name": "ORP target"
|
||||
},
|
||||
"ph_target": {
|
||||
"name": "pH target"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"name": "Chlorine"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==3.8.4",
|
||||
"python-roborock==3.9.2",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Roborock storage."""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -17,7 +16,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_PATH = f".storage/{DOMAIN}"
|
||||
MAPS_PATH = "maps"
|
||||
CACHE_VERSION = 1
|
||||
CACHE_VERSION = 2
|
||||
|
||||
|
||||
def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path:
|
||||
@@ -44,6 +43,31 @@ async def async_cleanup_map_storage(hass: HomeAssistant, entry_id: str) -> None:
|
||||
await hass.async_add_executor_job(remove, path_prefix)
|
||||
|
||||
|
||||
class StoreImpl(Store[dict[str, Any]]):
|
||||
"""Store implementation for Roborock cache."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Initialize StoreImpl."""
|
||||
super().__init__(
|
||||
hass,
|
||||
version=CACHE_VERSION,
|
||||
key=f"{DOMAIN}/{entry_id}",
|
||||
private=True,
|
||||
)
|
||||
|
||||
async def _async_migrate_func(
|
||||
self,
|
||||
old_major_version: int,
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Wipe out old caches with the old format."""
|
||||
if old_major_version == 1:
|
||||
# No need for migration as version 1 was never in any stable releases
|
||||
return {}
|
||||
return old_data
|
||||
|
||||
|
||||
class CacheStore(Cache):
|
||||
"""Store and retrieve cache for a Roborock device.
|
||||
|
||||
@@ -55,19 +79,14 @@ class CacheStore(Cache):
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Initialize CacheStore."""
|
||||
self._cache_store = Store[dict[str, Any]](
|
||||
hass,
|
||||
version=CACHE_VERSION,
|
||||
key=f"{DOMAIN}/{entry_id}",
|
||||
private=True,
|
||||
)
|
||||
self._cache_store = StoreImpl(hass, entry_id)
|
||||
self._cache_data: CacheData | None = None
|
||||
|
||||
async def get(self) -> CacheData:
|
||||
"""Retrieve cached metadata."""
|
||||
if self._cache_data is None:
|
||||
if data := await self._cache_store.async_load():
|
||||
self._cache_data = CacheData(**data)
|
||||
self._cache_data = CacheData.from_dict(data)
|
||||
else:
|
||||
self._cache_data = CacheData()
|
||||
|
||||
@@ -80,7 +99,7 @@ class CacheStore(Cache):
|
||||
async def flush(self) -> None:
|
||||
"""Flush cached metadata to disk."""
|
||||
if self._cache_data is not None:
|
||||
await self._cache_store.async_save(dataclasses.asdict(self._cache_data))
|
||||
await self._cache_store.async_save(self._cache_data.as_dict())
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove cached metadata from disk."""
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"dsl_connect_count": {
|
||||
"name": "DSL connect count",
|
||||
"unit_of_measurement": "connects"
|
||||
"unit_of_measurement": "attempts"
|
||||
},
|
||||
"dsl_crc_error_count": {
|
||||
"name": "DSL CRC error count",
|
||||
|
||||
@@ -1157,6 +1157,17 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
)
|
||||
]
|
||||
},
|
||||
Capability.SAMSUNG_CE_HOOD_FILTER: {
|
||||
Attribute.HOOD_FILTER_USAGE: [
|
||||
SmartThingsSensorEntityDescription(
|
||||
key=Attribute.HOOD_FILTER_USAGE,
|
||||
translation_key="hood_filter_usage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -411,6 +411,9 @@
|
||||
"simmer": "Simmer"
|
||||
}
|
||||
},
|
||||
"hood_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"infrared_level": {
|
||||
"name": "Infrared level"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/tedee",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiotedee"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from wled import WLED, Device, WLEDConnectionError
|
||||
from wled import WLED, Device, WLEDConnectionError, WLEDUnsupportedVersionError
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import (
|
||||
@@ -48,6 +48,8 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
try:
|
||||
device = await self._async_get_device(user_input[CONF_HOST])
|
||||
except WLEDUnsupportedVersionError:
|
||||
errors["base"] = "unsupported_version"
|
||||
except WLEDConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
@@ -110,6 +112,8 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.discovered_host = discovery_info.host
|
||||
try:
|
||||
self.discovered_device = await self._async_get_device(discovery_info.host)
|
||||
except WLEDUnsupportedVersionError:
|
||||
return self.async_abort(reason="unsupported_version")
|
||||
except WLEDConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from wled import (
|
||||
WLEDConnectionClosedError,
|
||||
WLEDError,
|
||||
WLEDReleases,
|
||||
WLEDUnsupportedVersionError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -115,6 +116,14 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
|
||||
"""Fetch data from WLED."""
|
||||
try:
|
||||
device = await self.wled.update()
|
||||
except WLEDUnsupportedVersionError as error:
|
||||
# Error message from WLED library contains version info
|
||||
# better to show that to user, but it is not translatable.
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_version",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except WLEDError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -150,12 +150,9 @@ class WLEDSegmentLight(WLEDEntity, LightEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
try:
|
||||
self.coordinator.data.state.segments[self._segment]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
return (
|
||||
super().available and self._segment in self.coordinator.data.state.segments
|
||||
)
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
|
||||
@@ -97,12 +97,9 @@ class WLEDNumber(WLEDEntity, NumberEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
try:
|
||||
self.coordinator.data.state.segments[self._segment]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
return (
|
||||
super().available and self._segment in self.coordinator.data.state.segments
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
|
||||
88
homeassistant/components/wled/quality_scale.yaml
Normal file
88
homeassistant/components/wled/quality_scale.yaml
Normal file
@@ -0,0 +1,88 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not have custom service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
test_connection_error and test_unsupported_version_error should end in CREATE_ENTRY
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not have custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require authentication.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
The test_setting_unique_id test is redundant.
|
||||
The test_websocket_already_connected test can use the freezer.
|
||||
The snapshot tests should be used more widely.
|
||||
We should use pytest.mark.freeze_time instead of mock.
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: todo
|
||||
comment: |
|
||||
Analog RGBCCT Strip are poor supported by HA.
|
||||
See: https://github.com/home-assistant/core/issues/123614
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Led count could receive unit of measurement
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration does not have any known issues that require repair.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -173,12 +173,9 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
try:
|
||||
self.coordinator.data.state.segments[self._segment]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
return (
|
||||
super().available and self._segment in self.coordinator.data.state.segments
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"common": {
|
||||
"unsupported_version": "The WLED device's firmware version is not supported."
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device."
|
||||
"unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device.",
|
||||
"unsupported_version": "[%key:component::wled::common::unsupported_version%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unsupported_version": "[%key:component::wled::common::unsupported_version%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
@@ -138,6 +143,9 @@
|
||||
},
|
||||
"mac_address_mismatch": {
|
||||
"message": "MAC address does not match the configured device. Expected to connect to device with MAC: {expected_mac}, but connected to device with MAC: {actual_mac}."
|
||||
},
|
||||
"unsupported_version": {
|
||||
"message": "The WLED device's firmware version is not supported: {error}"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -167,12 +167,9 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
try:
|
||||
self.coordinator.data.state.segments[self._segment]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
return super().available
|
||||
return (
|
||||
super().available and self._segment in self.coordinator.data.state.segments
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -102,7 +102,7 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity):
|
||||
"""URL to the full release notes of the latest version available."""
|
||||
if (version := self.latest_version) is None:
|
||||
return None
|
||||
return f"https://github.com/Aircoookie/WLED/releases/tag/v{version}"
|
||||
return f"https://github.com/wled/WLED/releases/tag/v{version}"
|
||||
|
||||
@wled_exception_handler
|
||||
async def async_install(
|
||||
|
||||
@@ -3503,6 +3503,11 @@
|
||||
"zwave"
|
||||
]
|
||||
},
|
||||
"levoit": {
|
||||
"name": "Levoit",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
},
|
||||
"lg": {
|
||||
"name": "LG",
|
||||
"integrations": {
|
||||
@@ -4506,7 +4511,7 @@
|
||||
},
|
||||
"nordpool": {
|
||||
"name": "Nord Pool",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"single_config_entry": true
|
||||
@@ -4827,7 +4832,7 @@
|
||||
},
|
||||
"oralb": {
|
||||
"name": "Oral-B",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
|
||||
4
homeassistant/generated/labs.py
generated
4
homeassistant/generated/labs.py
generated
@@ -6,8 +6,8 @@ To update, run python3 -m script.hassfest
|
||||
LABS_PREVIEW_FEATURES = {
|
||||
"automation": {
|
||||
"new_triggers_conditions": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
"feedback_url": "https://forms.gle/fWFZqf5MzuwWTsCH8",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2025/12/03/release-202512/#purpose-specific-triggers-and-conditions",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/automation&integration_name=Automation",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,9 +35,9 @@ file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.3.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.6.2
|
||||
hassil==3.4.0
|
||||
habluetooth==5.8.0
|
||||
hass-nabucasa==1.7.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251202.0
|
||||
home-assistant-intents==2025.12.2
|
||||
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.6.2",
|
||||
"hass-nabucasa==1.7.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
fnv-hash-fast==1.6.0
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.7.0
|
||||
httpx==0.28.1
|
||||
home-assistant-bluetooth==1.13.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
18
requirements_all.txt
generated
18
requirements_all.txt
generated
@@ -340,7 +340,7 @@ aiontfy==0.6.1
|
||||
aionut==4.3.4
|
||||
|
||||
# homeassistant.components.onkyo
|
||||
aioonkyo==0.3.0
|
||||
aioonkyo==0.4.0
|
||||
|
||||
# homeassistant.components.openexchangerates
|
||||
aioopenexchangerates==0.6.8
|
||||
@@ -1090,7 +1090,7 @@ google-genai==1.38.0
|
||||
google-maps-routing==0.6.15
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==9.1.1
|
||||
google-nest-sdm==9.1.2
|
||||
|
||||
# homeassistant.components.google_photos
|
||||
google-photos-library-api==0.12.1
|
||||
@@ -1157,20 +1157,20 @@ ha-silabs-firmware-client==0.3.0
|
||||
habiticalib==0.4.6
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.7.0
|
||||
habluetooth==5.8.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.7.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
hassil==3.4.0
|
||||
hassil==3.5.0
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate[astral]==1.1.2
|
||||
@@ -1288,7 +1288,7 @@ insteon-frontend-home-assistant==0.5.0
|
||||
intellifire4py==4.2.1
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.2.0
|
||||
iometer==0.3.0
|
||||
|
||||
# homeassistant.components.iotty
|
||||
iottycloud==0.3.0
|
||||
@@ -1370,7 +1370,7 @@ led-ble==1.1.7
|
||||
lektricowifi==0.1
|
||||
|
||||
# homeassistant.components.letpot
|
||||
letpot==0.6.3
|
||||
letpot==0.6.4
|
||||
|
||||
# homeassistant.components.foscam
|
||||
libpyfoscamcgi==0.0.9
|
||||
@@ -2563,7 +2563,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==3.8.4
|
||||
python-roborock==3.9.2
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
@@ -3191,7 +3191,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.2.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.11.0
|
||||
xknx==3.12.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
18
requirements_test_all.txt
generated
18
requirements_test_all.txt
generated
@@ -325,7 +325,7 @@ aiontfy==0.6.1
|
||||
aionut==4.3.4
|
||||
|
||||
# homeassistant.components.onkyo
|
||||
aioonkyo==0.3.0
|
||||
aioonkyo==0.4.0
|
||||
|
||||
# homeassistant.components.openexchangerates
|
||||
aioopenexchangerates==0.6.8
|
||||
@@ -966,7 +966,7 @@ google-genai==1.38.0
|
||||
google-maps-routing==0.6.15
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==9.1.1
|
||||
google-nest-sdm==9.1.2
|
||||
|
||||
# homeassistant.components.google_photos
|
||||
google-photos-library-api==0.12.1
|
||||
@@ -1027,17 +1027,17 @@ ha-silabs-firmware-client==0.3.0
|
||||
habiticalib==0.4.6
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.7.0
|
||||
habluetooth==5.8.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.7.0
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
hassil==3.4.0
|
||||
hassil==3.5.0
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate[astral]==1.1.2
|
||||
@@ -1134,7 +1134,7 @@ insteon-frontend-home-assistant==0.5.0
|
||||
intellifire4py==4.2.1
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.2.0
|
||||
iometer==0.3.0
|
||||
|
||||
# homeassistant.components.iotty
|
||||
iottycloud==0.3.0
|
||||
@@ -1201,7 +1201,7 @@ led-ble==1.1.7
|
||||
lektricowifi==0.1
|
||||
|
||||
# homeassistant.components.letpot
|
||||
letpot==0.6.3
|
||||
letpot==0.6.4
|
||||
|
||||
# homeassistant.components.foscam
|
||||
libpyfoscamcgi==0.0.9
|
||||
@@ -2144,7 +2144,7 @@ python-pooldose==0.8.1
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==3.8.4
|
||||
python-roborock==3.9.2
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
@@ -2658,7 +2658,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.2.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.11.0
|
||||
xknx==3.12.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -30,7 +30,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
ruff==0.13.0 \
|
||||
PyTurboJPEG==1.8.0 \
|
||||
ha-ffmpeg==3.2.2 \
|
||||
hassil==3.4.0 \
|
||||
hassil==3.5.0 \
|
||||
home-assistant-intents==2025.12.2 \
|
||||
mutagen==1.47.0 \
|
||||
pymicro-vad==1.0.1 \
|
||||
|
||||
@@ -444,7 +444,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"greeneye_monitor",
|
||||
"greenwave",
|
||||
"group",
|
||||
"growatt_server",
|
||||
"gtfs",
|
||||
"guardian",
|
||||
"harman_kardon_avr",
|
||||
@@ -1061,7 +1060,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"wirelesstag",
|
||||
"withings",
|
||||
"wiz",
|
||||
"wled",
|
||||
"wmspro",
|
||||
"wolflink",
|
||||
"workday",
|
||||
@@ -1454,8 +1452,8 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"greeneye_monitor",
|
||||
"greenwave",
|
||||
"group",
|
||||
"growatt_server",
|
||||
"gtfs",
|
||||
"growatt_server",
|
||||
"guardian",
|
||||
"harman_kardon_avr",
|
||||
"harmony",
|
||||
|
||||
@@ -205,8 +205,8 @@ async def test_ws_get_client_config(
|
||||
"iceServers": [
|
||||
{
|
||||
"urls": [
|
||||
"stun:stun.home-assistant.io:80",
|
||||
"stun:stun.home-assistant.io:3478",
|
||||
"stun:stun.home-assistant.io:80",
|
||||
]
|
||||
},
|
||||
],
|
||||
@@ -238,8 +238,8 @@ async def test_ws_get_client_config(
|
||||
"iceServers": [
|
||||
{
|
||||
"urls": [
|
||||
"stun:stun.home-assistant.io:80",
|
||||
"stun:stun.home-assistant.io:3478",
|
||||
"stun:stun.home-assistant.io:80",
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.cloud.const import AI_TASK_ENTITY_UNIQUE_ID, DOMAIN
|
||||
from homeassistant.components.cloud.entity import (
|
||||
BaseCloudLLMEntity,
|
||||
_convert_content_to_param,
|
||||
@@ -18,7 +19,8 @@ from homeassistant.components.cloud.entity import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm, selector
|
||||
from homeassistant.helpers import entity_registry as er, llm, selector
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -219,3 +221,66 @@ async def test_prepare_chat_for_generation_passes_messages_through(
|
||||
|
||||
assert response["messages"] == messages
|
||||
assert response["conversation_id"] == "conversation-id"
|
||||
|
||||
|
||||
async def test_async_handle_chat_log_service_sets_structured_output_non_strict(
|
||||
hass: HomeAssistant,
|
||||
cloud: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_cloud_login: None,
|
||||
) -> None:
|
||||
"""Ensure structured output requests always disable strict validation via service."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
on_start_callback = cloud.register_on_start.call_args[0][0]
|
||||
await on_start_callback()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
"ai_task", DOMAIN, AI_TASK_ENTITY_UNIQUE_ID
|
||||
)
|
||||
assert entity_id is not None
|
||||
|
||||
async def _empty_stream():
|
||||
return
|
||||
|
||||
async def _fake_delta_stream(
|
||||
self: conversation.ChatLog,
|
||||
agent_id: str,
|
||||
stream,
|
||||
):
|
||||
content = conversation.AssistantContent(
|
||||
agent_id=agent_id, content='{"value": "ok"}'
|
||||
)
|
||||
self.async_add_assistant_content_without_tools(content)
|
||||
yield content
|
||||
|
||||
cloud.llm.async_generate_data = AsyncMock(return_value=_empty_stream())
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.conversation.chat_log.ChatLog.async_add_delta_content_stream",
|
||||
_fake_delta_stream,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"ai_task",
|
||||
"generate_data",
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"task_name": "Device Report",
|
||||
"instructions": "Provide value.",
|
||||
"structure": {
|
||||
"value": {
|
||||
"selector": {"text": None},
|
||||
"required": True,
|
||||
}
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
cloud.llm.async_generate_data.assert_awaited_once()
|
||||
_, kwargs = cloud.llm.async_generate_data.call_args
|
||||
|
||||
assert kwargs["response_format"]["json_schema"]["strict"] is False
|
||||
|
||||
@@ -22,6 +22,7 @@ from hass_nabucasa.payments_api import PaymentsApiError
|
||||
from hass_nabucasa.remote import CertificateStatus
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from webrtc_models import RTCIceServer
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.components.alexa import errors as alexa_errors
|
||||
@@ -2186,3 +2187,39 @@ async def test_download_support_package_integration_load_error(
|
||||
req = await cloud_client.get("/api/cloud/support_package")
|
||||
assert req.status == HTTPStatus.OK
|
||||
assert await req.text() == snapshot
|
||||
|
||||
|
||||
async def test_websocket_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
cloud: MagicMock,
|
||||
setup_cloud: None,
|
||||
) -> None:
|
||||
"""Test getting ICE servers."""
|
||||
cloud.client._ice_servers = [
|
||||
RTCIceServer(urls="stun:stun.l.bla.com:19302"),
|
||||
RTCIceServer(
|
||||
urls="turn:turn.example.com:3478", username="user", credential="pass"
|
||||
),
|
||||
]
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "cloud/webrtc/ice_servers"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == [
|
||||
{"urls": "stun:stun.l.bla.com:19302"},
|
||||
{
|
||||
"urls": "turn:turn.example.com:3478",
|
||||
"username": "user",
|
||||
"credential": "pass",
|
||||
},
|
||||
]
|
||||
|
||||
cloud.id_token = None
|
||||
|
||||
await client.send_json_auto_id({"type": "cloud/webrtc/ice_servers"})
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "not_logged_in"
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.POWER: 'power'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Deep Sleep',
|
||||
'original_name': 'Deep sleep',
|
||||
'platform': 'fressnapf_tracker',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -87,7 +87,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'Fluffy Deep Sleep',
|
||||
'friendly_name': 'Fluffy Deep sleep',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.fluffy_deep_sleep',
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.notify import (
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
NotifyEntity,
|
||||
NotifyEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import (
|
||||
@@ -298,9 +299,11 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
|
||||
class MockNotifyEntity(MockEntity, NotifyEntity):
|
||||
"""Mock Email notifier entity to use in tests."""
|
||||
|
||||
def __init__(self, **values: Any) -> None:
|
||||
def __init__(self, *, is_title_supported: bool, **values: Any) -> None:
|
||||
"""Initialize the mock entity."""
|
||||
super().__init__(**values)
|
||||
if is_title_supported:
|
||||
self._attr_supported_features = NotifyEntityFeature.TITLE
|
||||
self.send_message_mock_calls = MagicMock()
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
@@ -330,11 +333,21 @@ async def help_async_unload_entry(
|
||||
@pytest.fixture
|
||||
async def mock_notifiers(
|
||||
hass: HomeAssistant, config_flow_fixture: None
|
||||
) -> list[NotifyEntity]:
|
||||
) -> list[MockNotifyEntity]:
|
||||
"""Set up the notify entities."""
|
||||
entity = MockNotifyEntity(name="test", entity_id="notify.test")
|
||||
entity2 = MockNotifyEntity(name="test2", entity_id="notify.test2")
|
||||
entities = [entity, entity2]
|
||||
entity_title_1 = MockNotifyEntity(
|
||||
is_title_supported=True, name="has_title_1", entity_id="notify.has_title_1"
|
||||
)
|
||||
entity_title_2 = MockNotifyEntity(
|
||||
is_title_supported=True, name="has_title_2", entity_id="notify.has_title_2"
|
||||
)
|
||||
entity_no_title_1 = MockNotifyEntity(
|
||||
is_title_supported=False, name="no_title_1", entity_id="notify.no_title_1"
|
||||
)
|
||||
entity_no_title_2 = MockNotifyEntity(
|
||||
is_title_supported=False, name="no_title_2", entity_id="notify.no_title_2"
|
||||
)
|
||||
entities = [entity_title_1, entity_title_2, entity_no_title_1, entity_no_title_2]
|
||||
test_entry = MockConfigEntry(domain="test")
|
||||
test_entry.add_to_hass(hass)
|
||||
mock_integration(
|
||||
@@ -352,19 +365,23 @@ async def mock_notifiers(
|
||||
|
||||
|
||||
async def test_notify_entity_group(
|
||||
hass: HomeAssistant, mock_notifiers: list[NotifyEntity]
|
||||
hass: HomeAssistant, mock_notifiers: list[MockNotifyEntity]
|
||||
) -> None:
|
||||
"""Test sending a message to a notify group."""
|
||||
entity, entity2 = mock_notifiers
|
||||
assert entity.send_message_mock_calls.call_count == 0
|
||||
assert entity2.send_message_mock_calls.call_count == 0
|
||||
entity_title_1, entity_title_2, entity_no_title_1, entity_no_title_2 = (
|
||||
mock_notifiers
|
||||
)
|
||||
for mock_notifier in mock_notifiers:
|
||||
assert mock_notifier.send_message_mock_calls.call_count == 0
|
||||
|
||||
# test group containing 1 member with title supported
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"group_type": "notify",
|
||||
"name": "Test Group",
|
||||
"entities": ["notify.test", "notify.test2"],
|
||||
"entities": ["notify.has_title_1"],
|
||||
"hide_members": True,
|
||||
},
|
||||
title="Test Group",
|
||||
@@ -384,14 +401,144 @@ async def test_notify_entity_group(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity.send_message_mock_calls.call_count == 1
|
||||
assert entity.send_message_mock_calls.call_args == call(
|
||||
assert entity_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_title_1.send_message_mock_calls.call_args == call(
|
||||
"Hello", title="Test notification"
|
||||
)
|
||||
assert entity2.send_message_mock_calls.call_count == 1
|
||||
assert entity2.send_message_mock_calls.call_args == call(
|
||||
|
||||
for mock_notifier in mock_notifiers:
|
||||
mock_notifier.send_message_mock_calls.reset_mock()
|
||||
|
||||
# test group containing 1 member with title supported but no title provided
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_MESSAGE: "Hello",
|
||||
ATTR_ENTITY_ID: "notify.test_group",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_title_1.send_message_mock_calls.call_args == call("Hello", title=None)
|
||||
|
||||
for mock_notifier in mock_notifiers:
|
||||
mock_notifier.send_message_mock_calls.reset_mock()
|
||||
|
||||
# test group containing 2 members with title supported
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"group_type": "notify",
|
||||
"name": "Test Group 2",
|
||||
"entities": ["notify.has_title_1", "notify.has_title_2"],
|
||||
"hide_members": True,
|
||||
},
|
||||
title="Test Group 2",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_MESSAGE: "Hello",
|
||||
ATTR_TITLE: "Test notification",
|
||||
ATTR_ENTITY_ID: "notify.test_group_2",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_title_1.send_message_mock_calls.call_args == call(
|
||||
"Hello", title="Test notification"
|
||||
)
|
||||
assert entity_title_2.send_message_mock_calls.call_count == 1
|
||||
assert entity_title_2.send_message_mock_calls.call_args == call(
|
||||
"Hello", title="Test notification"
|
||||
)
|
||||
|
||||
for mock_notifier in mock_notifiers:
|
||||
mock_notifier.send_message_mock_calls.reset_mock()
|
||||
|
||||
# test group containing 2 members: 1 title supported and 1 not supported
|
||||
# title is not supported since not all members support it
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"group_type": "notify",
|
||||
"name": "Test Group",
|
||||
"entities": ["notify.has_title_1", "notify.no_title_1"],
|
||||
"hide_members": True,
|
||||
},
|
||||
title="Test Group 3",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_MESSAGE: "Hello",
|
||||
ATTR_TITLE: "Test notification",
|
||||
ATTR_ENTITY_ID: "notify.test_group_3",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_title_1.send_message_mock_calls.call_args == call("Hello", title=None)
|
||||
assert entity_no_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_no_title_1.send_message_mock_calls.call_args == call(
|
||||
"Hello", title=None
|
||||
)
|
||||
|
||||
for mock_notifier in mock_notifiers:
|
||||
mock_notifier.send_message_mock_calls.reset_mock()
|
||||
|
||||
# test group containing 2 members: both not supporting title
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"group_type": "notify",
|
||||
"name": "Test Group",
|
||||
"entities": ["notify.no_title_1", "notify.no_title_2"],
|
||||
"hide_members": True,
|
||||
},
|
||||
title="Test Group 4",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_MESSAGE: "Hello",
|
||||
ATTR_TITLE: "Test notification",
|
||||
ATTR_ENTITY_ID: "notify.test_group_4",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity_no_title_1.send_message_mock_calls.call_count == 1
|
||||
assert entity_no_title_1.send_message_mock_calls.call_args == call(
|
||||
"Hello", title=None
|
||||
)
|
||||
assert entity_no_title_2.send_message_mock_calls.call_count == 1
|
||||
assert entity_no_title_2.send_message_mock_calls.call_args == call(
|
||||
"Hello", title=None
|
||||
)
|
||||
|
||||
|
||||
async def test_state_reporting(hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -1041,6 +1041,7 @@ async def test_supervisor_issues_free_space(
|
||||
fixable=False,
|
||||
placeholders={
|
||||
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
|
||||
"storage_url": "/config/storage",
|
||||
"free_space": "1.6",
|
||||
},
|
||||
)
|
||||
@@ -1090,6 +1091,7 @@ async def test_supervisor_issues_free_space_host_info_fail(
|
||||
fixable=False,
|
||||
placeholders={
|
||||
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
|
||||
"storage_url": "/config/storage",
|
||||
"free_space": "<2",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,9 +11,15 @@ from xknx import XKNX
|
||||
from xknx.core import XknxConnectionState, XknxConnectionType
|
||||
from xknx.dpt import DPTArray, DPTBinary
|
||||
from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT
|
||||
from xknx.telegram import Telegram, TelegramDirection
|
||||
from xknx.telegram import Telegram, TelegramDirection, tpci
|
||||
from xknx.telegram.address import GroupAddress, IndividualAddress
|
||||
from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite
|
||||
from xknx.telegram.apci import (
|
||||
APCI,
|
||||
GroupValueRead,
|
||||
GroupValueResponse,
|
||||
GroupValueWrite,
|
||||
SecureAPDU,
|
||||
)
|
||||
|
||||
from homeassistant.components.knx.const import (
|
||||
CONF_KNX_AUTOMATIC,
|
||||
@@ -312,6 +318,23 @@ class KNXTestKit:
|
||||
source=source,
|
||||
)
|
||||
|
||||
def receive_data_secure_issue(
|
||||
self,
|
||||
group_address: str,
|
||||
source: str | None = None,
|
||||
) -> None:
|
||||
"""Inject incoming telegram with undecodable data secure payload."""
|
||||
telegram = Telegram(
|
||||
destination_address=GroupAddress(group_address),
|
||||
direction=TelegramDirection.INCOMING,
|
||||
source_address=IndividualAddress(source or self.INDIVIDUAL_ADDRESS),
|
||||
tpci=tpci.TDataGroup(),
|
||||
payload=SecureAPDU.from_knx(
|
||||
bytes.fromhex("03f110002446cfef4ac085e7092ab062b44d")
|
||||
),
|
||||
)
|
||||
self.xknx.telegram_queue.received_data_secure_group_key_issue(telegram)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
|
||||
43
tests/components/knx/fixtures/config_store_date.json
Normal file
43
tests/components/knx/fixtures/config_store_date.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 2,
|
||||
"minor_version": 2,
|
||||
"key": "knx/config_store.json",
|
||||
"data": {
|
||||
"entities": {
|
||||
"date": {
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE8": {
|
||||
"entity": {
|
||||
"name": "date_with_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_date": {
|
||||
"write": "0/0/1",
|
||||
"state": "0/0/2",
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": true,
|
||||
"sync_state": "init"
|
||||
}
|
||||
},
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE9": {
|
||||
"entity": {
|
||||
"name": "date_without_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_date": {
|
||||
"write": "0/0/1",
|
||||
"state": null,
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": false,
|
||||
"sync_state": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
tests/components/knx/fixtures/config_store_datetime.json
Normal file
43
tests/components/knx/fixtures/config_store_datetime.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 2,
|
||||
"minor_version": 2,
|
||||
"key": "knx/config_store.json",
|
||||
"data": {
|
||||
"entities": {
|
||||
"datetime": {
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE8": {
|
||||
"entity": {
|
||||
"name": "datetime_with_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_datetime": {
|
||||
"write": "0/0/1",
|
||||
"state": "0/0/2",
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": true,
|
||||
"sync_state": "init"
|
||||
}
|
||||
},
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE9": {
|
||||
"entity": {
|
||||
"name": "datetime_without_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_datetime": {
|
||||
"write": "0/0/1",
|
||||
"state": null,
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": false,
|
||||
"sync_state": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
tests/components/knx/fixtures/config_store_time.json
Normal file
43
tests/components/knx/fixtures/config_store_time.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 2,
|
||||
"minor_version": 2,
|
||||
"key": "knx/config_store.json",
|
||||
"data": {
|
||||
"entities": {
|
||||
"time": {
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE8": {
|
||||
"entity": {
|
||||
"name": "time_with_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_time": {
|
||||
"write": "0/0/1",
|
||||
"state": "0/0/2",
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": true,
|
||||
"sync_state": "init"
|
||||
}
|
||||
},
|
||||
"knx_es_01K76NGZRMJA74CBRQF9KXNPE9": {
|
||||
"entity": {
|
||||
"name": "time_without_state_address",
|
||||
"device_info": null,
|
||||
"entity_category": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_time": {
|
||||
"write": "0/0/1",
|
||||
"state": null,
|
||||
"passive": []
|
||||
},
|
||||
"respond_to_read": false,
|
||||
"sync_state": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,6 +937,102 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[date]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': list([
|
||||
dict({
|
||||
'name': 'ga_date',
|
||||
'options': dict({
|
||||
'passive': True,
|
||||
'state': dict({
|
||||
'required': False,
|
||||
}),
|
||||
'validDPTs': list([
|
||||
dict({
|
||||
'main': 11,
|
||||
'sub': 1,
|
||||
}),
|
||||
]),
|
||||
'write': dict({
|
||||
'required': True,
|
||||
}),
|
||||
}),
|
||||
'required': True,
|
||||
'type': 'knx_group_address',
|
||||
}),
|
||||
dict({
|
||||
'default': False,
|
||||
'name': 'respond_to_read',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'boolean': dict({
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'allow_false': False,
|
||||
'default': True,
|
||||
'name': 'sync_state',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'type': 'knx_sync_state',
|
||||
}),
|
||||
]),
|
||||
'success': True,
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[datetime]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': list([
|
||||
dict({
|
||||
'name': 'ga_datetime',
|
||||
'options': dict({
|
||||
'passive': True,
|
||||
'state': dict({
|
||||
'required': False,
|
||||
}),
|
||||
'validDPTs': list([
|
||||
dict({
|
||||
'main': 19,
|
||||
'sub': 1,
|
||||
}),
|
||||
]),
|
||||
'write': dict({
|
||||
'required': True,
|
||||
}),
|
||||
}),
|
||||
'required': True,
|
||||
'type': 'knx_group_address',
|
||||
}),
|
||||
dict({
|
||||
'default': False,
|
||||
'name': 'respond_to_read',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'boolean': dict({
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'allow_false': False,
|
||||
'default': True,
|
||||
'name': 'sync_state',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'type': 'knx_sync_state',
|
||||
}),
|
||||
]),
|
||||
'success': True,
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[light]
|
||||
dict({
|
||||
'id': 1,
|
||||
@@ -1405,6 +1501,54 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[time]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': list([
|
||||
dict({
|
||||
'name': 'ga_time',
|
||||
'options': dict({
|
||||
'passive': True,
|
||||
'state': dict({
|
||||
'required': False,
|
||||
}),
|
||||
'validDPTs': list([
|
||||
dict({
|
||||
'main': 10,
|
||||
'sub': 1,
|
||||
}),
|
||||
]),
|
||||
'write': dict({
|
||||
'required': True,
|
||||
}),
|
||||
}),
|
||||
'required': True,
|
||||
'type': 'knx_group_address',
|
||||
}),
|
||||
dict({
|
||||
'default': False,
|
||||
'name': 'respond_to_read',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'boolean': dict({
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'allow_false': False,
|
||||
'default': True,
|
||||
'name': 'sync_state',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'type': 'knx_sync_state',
|
||||
}),
|
||||
]),
|
||||
'success': True,
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[tts]
|
||||
dict({
|
||||
'error': dict({
|
||||
|
||||
@@ -7,9 +7,10 @@ from homeassistant.components.date import (
|
||||
)
|
||||
from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS
|
||||
from homeassistant.components.knx.schema import DateSchema
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from . import KnxEntityGenerator
|
||||
from .conftest import KNXTestKit
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
@@ -89,3 +90,41 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) ->
|
||||
)
|
||||
state = hass.states.get("date.test")
|
||||
assert state.state == "2024-02-24"
|
||||
|
||||
|
||||
async def test_date_ui_create(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
create_ui_entity: KnxEntityGenerator,
|
||||
) -> None:
|
||||
"""Test creating a date entity."""
|
||||
await knx.setup_integration()
|
||||
await create_ui_entity(
|
||||
platform=Platform.DATE,
|
||||
entity_data={"name": "test"},
|
||||
knx_data={
|
||||
"ga_date": {"write": "0/0/1", "state": "0/0/2"},
|
||||
"respond_to_read": True,
|
||||
"sync_state": True,
|
||||
},
|
||||
)
|
||||
# created entity sends a read-request to the read address
|
||||
await knx.assert_read("0/0/2", response=(0x18, 0x02, 0x18))
|
||||
knx.assert_state("date.test", "2024-02-24")
|
||||
|
||||
|
||||
async def test_date_ui_load(knx: KNXTestKit) -> None:
|
||||
"""Test loading date entities from storage."""
|
||||
await knx.setup_integration(config_store_fixture="config_store_date.json")
|
||||
|
||||
# date_with_state_address
|
||||
await knx.assert_read("0/0/2", response=(0x18, 0x02, 0x18), ignore_order=True)
|
||||
|
||||
knx.assert_state(
|
||||
"date.date_with_state_address",
|
||||
"2024-02-24",
|
||||
)
|
||||
knx.assert_state(
|
||||
"date.date_without_state_address",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ from homeassistant.components.datetime import (
|
||||
)
|
||||
from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS
|
||||
from homeassistant.components.knx.schema import DateTimeSchema
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from . import KnxEntityGenerator
|
||||
from .conftest import KNXTestKit
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
@@ -93,3 +94,47 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) ->
|
||||
)
|
||||
state = hass.states.get("datetime.test")
|
||||
assert state.state == "2020-01-01T18:04:05+00:00"
|
||||
|
||||
|
||||
async def test_datetime_ui_create(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
create_ui_entity: KnxEntityGenerator,
|
||||
) -> None:
|
||||
"""Test creating a datetime entity."""
|
||||
await knx.setup_integration()
|
||||
await create_ui_entity(
|
||||
platform=Platform.DATETIME,
|
||||
entity_data={"name": "test"},
|
||||
knx_data={
|
||||
"ga_datetime": {"write": "0/0/1", "state": "0/0/2"},
|
||||
"respond_to_read": True,
|
||||
"sync_state": True,
|
||||
},
|
||||
)
|
||||
# created entity sends a read-request to the read address
|
||||
await knx.assert_read(
|
||||
"0/0/2", response=(0x7B, 0x07, 0x19, 0x49, 0x28, 0x08, 0x00, 0x00)
|
||||
)
|
||||
knx.assert_state("datetime.test", "2023-07-25T16:40:08+00:00")
|
||||
|
||||
|
||||
async def test_datetime_ui_load(knx: KNXTestKit) -> None:
|
||||
"""Test loading datetime entities from storage."""
|
||||
await knx.setup_integration(config_store_fixture="config_store_datetime.json")
|
||||
|
||||
# datetime_with_state_address
|
||||
await knx.assert_read(
|
||||
"0/0/2",
|
||||
response=(0x7B, 0x07, 0x19, 0x49, 0x28, 0x08, 0x00, 0x00),
|
||||
ignore_order=True,
|
||||
)
|
||||
|
||||
knx.assert_state(
|
||||
"datetime.datetime_with_state_address",
|
||||
"2023-07-25T16:40:08+00:00",
|
||||
)
|
||||
knx.assert_state(
|
||||
"datetime.datetime_without_state_address",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
133
tests/components/knx/test_repairs.py
Normal file
133
tests/components/knx/test_repairs.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Test repair flows for KNX integration."""
|
||||
|
||||
import pytest
|
||||
from xknx.exceptions.exception import InvalidSecureConfiguration
|
||||
|
||||
from homeassistant.components.knx import repairs
|
||||
from homeassistant.components.knx.const import (
|
||||
CONF_KNX_KNXKEY_PASSWORD,
|
||||
DOMAIN,
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .conftest import KNXTestKit
|
||||
from .test_config_flow import FIXTURE_UPLOAD_UUID, patch_file_upload
|
||||
|
||||
from tests.components.repairs import (
|
||||
async_process_repairs_platforms,
|
||||
get_repairs,
|
||||
process_repair_fix_flow,
|
||||
start_repair_fix_flow,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
|
||||
async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None:
|
||||
"""Test create_fix_flow raises on unknown issue_id."""
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await repairs.async_create_fix_flow(hass, "no_such_issue", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured_group_address",
|
||||
["1/2/5", "3/4/6"],
|
||||
)
|
||||
async def test_data_secure_group_key_issue_only_for_configured_group_address(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
configured_group_address: str,
|
||||
) -> None:
|
||||
"""Test that repair issue is only created for configured group addresses."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
"switch": {
|
||||
"name": "Test Switch",
|
||||
"address": configured_group_address,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert bool(issue_registry.issues) is False
|
||||
# An issue should only be created if this address is configured.
|
||||
knx.receive_data_secure_issue("1/2/5")
|
||||
assert bool(issue_registry.issues) is (configured_group_address == "1/2/5")
|
||||
|
||||
|
||||
async def test_data_secure_group_key_issue_repair_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
knx: KNXTestKit,
|
||||
) -> None:
|
||||
"""Test repair flow for DataSecure group key issue."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
"switch": [
|
||||
{"name": "Test 1", "address": "1/2/5"},
|
||||
{"name": "Test 2", "address": "11/0/0"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
knx.receive_data_secure_issue("11/0/0", source="1.0.1")
|
||||
knx.receive_data_secure_issue("1/2/5", source="1.0.10")
|
||||
knx.receive_data_secure_issue("1/2/5", source="1.0.1")
|
||||
_placeholders = {
|
||||
"addresses": "`1/2/5` from 1.0.1, 1.0.10\n`11/0/0` from 1.0.1", # check sorting
|
||||
"interface": "0.0.0",
|
||||
}
|
||||
issue_registry = ir.async_get(hass)
|
||||
issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
|
||||
assert issue is not None
|
||||
assert issue.translation_placeholders == _placeholders
|
||||
|
||||
issues = await get_repairs(hass, hass_ws_client)
|
||||
assert issues
|
||||
|
||||
await async_process_repairs_platforms(hass)
|
||||
client = await hass_client()
|
||||
flow = await start_repair_fix_flow(
|
||||
client, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
|
||||
)
|
||||
|
||||
flow_id = flow["flow_id"]
|
||||
assert flow["type"] == FlowResultType.FORM
|
||||
assert flow["step_id"] == "secure_knxkeys"
|
||||
assert flow["description_placeholders"] == _placeholders
|
||||
|
||||
# test error handling
|
||||
with patch_file_upload(
|
||||
side_effect=InvalidSecureConfiguration(),
|
||||
):
|
||||
flow = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
{
|
||||
repairs.CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
|
||||
CONF_KNX_KNXKEY_PASSWORD: "invalid_password_mocked",
|
||||
},
|
||||
)
|
||||
assert flow["type"] == FlowResultType.FORM
|
||||
assert flow["step_id"] == "secure_knxkeys"
|
||||
assert flow["errors"] == {CONF_KNX_KNXKEY_PASSWORD: "keyfile_invalid_signature"}
|
||||
|
||||
# test successful file upload
|
||||
with patch_file_upload():
|
||||
flow = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
{
|
||||
repairs.CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID,
|
||||
CONF_KNX_KNXKEY_PASSWORD: "password",
|
||||
},
|
||||
)
|
||||
assert flow["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
|
||||
is None
|
||||
)
|
||||
@@ -7,9 +7,10 @@ from homeassistant.components.time import (
|
||||
DOMAIN as TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from . import KnxEntityGenerator
|
||||
from .conftest import KNXTestKit
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
@@ -89,3 +90,41 @@ async def test_time_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) ->
|
||||
)
|
||||
state = hass.states.get("time.test")
|
||||
assert state.state == "12:00:00"
|
||||
|
||||
|
||||
async def test_time_ui_create(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
create_ui_entity: KnxEntityGenerator,
|
||||
) -> None:
|
||||
"""Test creating a time entity."""
|
||||
await knx.setup_integration()
|
||||
await create_ui_entity(
|
||||
platform=Platform.TIME,
|
||||
entity_data={"name": "test"},
|
||||
knx_data={
|
||||
"ga_time": {"write": "0/0/1", "state": "0/0/2"},
|
||||
"respond_to_read": True,
|
||||
"sync_state": True,
|
||||
},
|
||||
)
|
||||
# created entity sends a read-request to the read address
|
||||
await knx.assert_read("0/0/2", response=(0x01, 0x02, 0x03))
|
||||
knx.assert_state("time.test", "01:02:03")
|
||||
|
||||
|
||||
async def test_time_ui_load(knx: KNXTestKit) -> None:
|
||||
"""Test loading time entities from storage."""
|
||||
await knx.setup_integration(config_store_fixture="config_store_time.json")
|
||||
|
||||
# time_with_state_address
|
||||
await knx.assert_read("0/0/2", response=(0x01, 0x02, 0x03), ignore_order=True)
|
||||
|
||||
knx.assert_state(
|
||||
"time.time_with_state_address",
|
||||
"01:02:03",
|
||||
)
|
||||
knx.assert_state(
|
||||
"time.time_without_state_address",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user