Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.6.1",
"aioesphomeapi==45.12.0",
"esphome-dashboard-api==1.4.0",
"bleak-esphome==3.9.7"
],
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/hvv_departures/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,17 @@ rules:
docs-actions:
status: exempt
comment: The integration does not provide any custom actions.
docs-conditions:
status: exempt
comment: The integration does not have any conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions:
status: todo
comment: The integration documentation does not include a removal instructions section.
docs-triggers:
status: exempt
comment: The integration does not have any triggers.
entity-event-setup:
status: exempt
comment: The integration entities do not subscribe to any events.
Expand Down
10 changes: 2 additions & 8 deletions homeassistant/components/lg_thinq/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,7 @@ def _update_status(self) -> None:
value = self.data.value

if isinstance(value, time):
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
local_now = dt_util.now()
self._device_state = (
self.coordinator.data[self._device_state_id].value
if self._device_state_id in self.coordinator.data
Expand Down Expand Up @@ -865,10 +862,7 @@ async def async_update(self, now: datetime | None = None) -> None:

async def _async_update_and_schedule(self) -> None:
"""Update the state of the sensor."""
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
local_now = dt_util.now()
next_update = local_now + self.entity_description.update_interval
if (
self.coordinator.update_energy_at_time_of_day is not None
Expand Down
140 changes: 138 additions & 2 deletions homeassistant/components/matter/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@
from functools import wraps
from typing import Any, Concatenate

from matter_server.client.exceptions import ServerVersionTooOld
from matter_server.client.models.node import MatterNode
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import dataclass_to_dict
from matter_server.common.models import EventType, NetworkTopology
import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr

from .adapter import MatterAdapter
from .helpers import MissingNode, get_matter, node_from_ha_device_id
from .helpers import (
MissingNode,
get_matter,
get_node_device_identifier,
node_from_ha_device_id,
)

ID = "id"
TYPE = "type"
Expand All @@ -23,6 +31,9 @@

ERROR_NODE_NOT_FOUND = "node_not_found"

# minimum server schema version that provides network topology
TOPOLOGY_SCHEMA_VERSION = 13


@callback
def async_register_api(hass: HomeAssistant) -> None:
Expand All @@ -36,6 +47,8 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_open_commissioning_window)
websocket_api.async_register_command(hass, websocket_remove_matter_fabric)
websocket_api.async_register_command(hass, websocket_interview_node)
websocket_api.async_register_command(hass, websocket_network_topology)
websocket_api.async_register_command(hass, websocket_subscribe_network_topology)


def async_get_node(
Expand Down Expand Up @@ -115,6 +128,8 @@ async def async_handle_failed_command_func(
connection.send_error(msg[ID], str(err.error_code), err.args[0])
except MissingNode as err:
connection.send_error(msg[ID], ERROR_NODE_NOT_FOUND, err.args[0])
except ServerVersionTooOld as err:
connection.send_error(msg[ID], ERR_NOT_SUPPORTED, err.args[0])

return async_handle_failed_command_func

Expand Down Expand Up @@ -328,3 +343,124 @@ async def websocket_interview_node(
"""Interview a node."""
await matter.matter_client.interview_node(node_id=node.node_id)
connection.send_result(msg[ID])


@callback
def _topology_supported(
connection: ActiveConnection, msg: dict[str, Any], matter: MatterAdapter
) -> bool:
"""Check if the server supports network topology, send an error if not."""
server_info = matter.matter_client.server_info
if server_info is None or server_info.schema_version < TOPOLOGY_SCHEMA_VERSION:
connection.send_error(
msg[ID],
ERR_NOT_SUPPORTED,
"The Matter server does not support network topology "
f"(requires schema version {TOPOLOGY_SCHEMA_VERSION}).",
)
return False
return True


@callback
def _serialize_topology(
hass: HomeAssistant, matter: MatterAdapter, topology: NetworkTopology
) -> dict[str, Any]:
"""Serialize a topology snapshot, annotating nodes with HA device ids."""
server_info = matter.matter_client.server_info
dev_reg = dr.async_get(hass)
result: dict[str, Any] = dataclass_to_dict(topology)
for node in result["nodes"]:
device = None
if (node_id := node.get("node_id")) is not None and server_info is not None:
device = dev_reg.async_get_device_by_identifier(
get_node_device_identifier(server_info, node_id),
matter.config_entry.entry_id,
)
node["ha_device_id"] = device.id if device else None
return result


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "matter/network_topology",
vol.Optional("refresh", default=False): bool,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_matter_adapter
async def websocket_network_topology(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
matter: MatterAdapter,
) -> None:
"""Get the network topology graph."""
if not _topology_supported(connection, msg, matter):
return
topology = await matter.matter_client.get_network_topology(refresh=msg["refresh"])
connection.send_result(msg[ID], _serialize_topology(hass, matter, topology))


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "matter/subscribe_network_topology",
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_matter_adapter
async def websocket_subscribe_network_topology(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
matter: MatterAdapter,
) -> None:
"""Subscribe to network topology updates."""
if not _topology_supported(connection, msg, matter):
return

initial_sent = False
# updates are full snapshots, so only the newest buffered one matters
buffered: NetworkTopology | None = None

@callback
def forward_topology(event: EventType, topology: NetworkTopology) -> None:
nonlocal buffered
if not initial_sent:
buffered = topology
return
connection.send_message(
websocket_api.event_message(
msg[ID], _serialize_topology(hass, matter, topology)
)
)

# subscribe before the fetch: the fetch opts this client in server-side,
# and an update may arrive before the command result does
unsubscribe = matter.matter_client.subscribe_events(
callback=forward_topology,
event_filter=EventType.NETWORK_TOPOLOGY_UPDATED,
)
try:
topology = await matter.matter_client.get_network_topology()
except Exception:
unsubscribe()
raise
connection.subscriptions[msg[ID]] = unsubscribe
connection.send_result(msg[ID])
connection.send_message(
websocket_api.event_message(
msg[ID], _serialize_topology(hass, matter, topology)
)
)
if buffered is not None:
connection.send_message(
websocket_api.event_message(
msg[ID], _serialize_topology(hass, matter, buffered)
)
)
initial_sent = True
12 changes: 12 additions & 0 deletions homeassistant/components/matter/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ def get_device_id(
return f"{operational_instance_id}-{postfix}"


def get_node_device_identifier(
server_info: ServerInfoMessage, node_id: int
) -> tuple[str, str]:
"""Return the device registry identifier for the node-level device of a node."""
fabric_id_hex = f"{server_info.compressed_fabric_id:016X}"
node_id_hex = f"{node_id:016X}"
return (
DOMAIN,
f"{ID_TYPE_DEVICE_ID}_{fabric_id_hex}-{node_id_hex}-MatterNodeDevice",
)


@callback
def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode | None:
"""Get node id from ha device id."""
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/netatmo/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"quality_scale": "bronze",
"requirements": ["pyatmo==9.7.0"],
"requirements": ["pyatmo==9.9.0"],
"single_config_entry": true
}
52 changes: 40 additions & 12 deletions homeassistant/components/roborock/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType

from .const import DOMAIN
from .coordinator import (
RoborockConfigEntry,
RoborockCoordinatorType,
Expand All @@ -29,6 +31,7 @@
)
from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1
from .models import DeviceState
from .util import deprecate_entity

PARALLEL_UPDATES = 0

Expand Down Expand Up @@ -56,17 +59,6 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription):


BINARY_SENSOR_DESCRIPTIONS = [
RoborockBinarySensorDescription(
key="dry_status",
translation_key="mop_drying_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.status.dry_status,
is_dock_entity=True,
support_fn=lambda api: api.device_features.is_field_supported(
StatusV2, StatusField.DRY_STATUS
),
),
RoborockBinarySensorDescription(
key="water_box_carriage_status",
translation_key="mop_attached",
Expand Down Expand Up @@ -147,6 +139,16 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription):
]


MOP_DRYING_BINARY_SENSOR_DESCRIPTION = RoborockBinarySensorDescription(
key="dry_status",
translation_key="mop_drying_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.status.dry_status,
is_dock_entity=True,
)


ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [
RoborockBinarySensorDescriptionA01(
key="detergent_empty",
Expand Down Expand Up @@ -174,6 +176,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Roborock vacuum binary sensors."""
coordinators = config_entry.runtime_data
entity_registry = er.async_get(hass)

@callback
def async_add_coordinator_entities(
Expand All @@ -187,6 +190,31 @@ def async_add_coordinator_entities(
for description in BINARY_SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
)
mop_drying_unique_id = (
f"{MOP_DRYING_BINARY_SENSOR_DESCRIPTION.key}_{coordinator.duid_slug}"
)
mop_drying_issue_id = f"deprecated_mop_drying_{coordinator.duid_slug}"
if not coordinator.properties_api.device_features.dock_features.is_dryable:
# The sensor was created for every device reporting the drying
# status data point, so a dock that cannot dry always read off.
if entity_id := entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR, DOMAIN, mop_drying_unique_id
):
entity_registry.async_remove(entity_id)
ir.async_delete_issue(hass, DOMAIN, mop_drying_issue_id)
elif deprecate_entity(
hass,
entity_registry,
platform_domain=Platform.BINARY_SENSOR,
entity_unique_id=mop_drying_unique_id,
issue_id=mop_drying_issue_id,
translation_key="deprecated_mop_drying",
):
entities.append(
RoborockBinarySensorEntity(
coordinator, MOP_DRYING_BINARY_SENSOR_DESCRIPTION
)
)
elif isinstance(coordinator, RoborockWashingMachineUpdateCoordinator):
entities.extend(
RoborockBinarySensorEntityA01(coordinator, description)
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/roborock/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,14 @@
"cloud_api_used": {
"description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance.",
"title": "Cloud API used"
},
"deprecated_mop_drying": {
"description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nUpdate any dashboards, templates, automations or scripts to use the new switch entity, then disable `{entity_id}` to have it removed.",
"title": "The Roborock mop drying binary sensor is deprecated"
},
"deprecated_mop_drying_scripts": {
"description": "The `{entity_id}` ({entity_name}) binary sensor is deprecated and has been replaced by the **Mop drying** switch, which reports the same state and can also start and stop drying.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new switch entity, then disable `{entity_id}` to have it removed.",
"title": "[%key:component::roborock::issues::deprecated_mop_drying::title%]"
}
},
"options": {
Expand Down
Loading
Loading