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
6 changes: 3 additions & 3 deletions homeassistant/components/duco/entity.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""

from duco_connectivity.models import Node
from duco_connectivity.models import Node, NodeType

from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
Expand All @@ -25,7 +25,7 @@ def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
Expand All @@ -34,7 +34,7 @@ def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
Expand Down
18 changes: 12 additions & 6 deletions homeassistant/components/opentherm_gw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,14 @@ async def handle_report(status):
_LOGGER.debug("Received report: %s", status)
async_dispatcher_send(self.hass, self.update_signal, status)

boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
)
dev_reg.async_update_device(
boiler_device.id,
manufacturer=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
),
manufacturer=str(boiler_manufacturer)
if boiler_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_PRODUCT_TYPE
),
Expand All @@ -175,11 +178,14 @@ async def handle_report(status):
),
)

thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
)
dev_reg.async_update_device(
thermostat_device.id,
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
),
manufacturer=str(thermostat_manufacturer)
if thermostat_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_PRODUCT_TYPE
),
Expand Down
45 changes: 38 additions & 7 deletions homeassistant/components/roborock/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Roborock Coordinator."""

from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
Expand All @@ -21,7 +22,7 @@

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
Expand Down Expand Up @@ -117,6 +118,7 @@ def __init__(
# to the base class. This is reset on successful data update.
self._last_update_success_time: datetime | None = None
self._has_connected_locally: bool = False
self._unsubs: list[Callable[[], None]] = []

@cached_property
def dock_device_info(self) -> DeviceInfo:
Expand Down Expand Up @@ -169,6 +171,15 @@ async def _async_setup(self) -> None:
# Force a map refresh on first setup
self.last_home_update = dt_util.utcnow() - IMAGE_CACHE_INTERVAL

self._unsubs.append(
self.properties_api.status.add_update_listener(self._handle_trait_update)
)
self._unsubs.append(
self.properties_api.consumables.add_update_listener(
self._handle_trait_update
)
)

async def update_map(self) -> None:
"""Update the currently selected map."""
try:
Expand Down Expand Up @@ -266,12 +277,7 @@ async def _async_update_data(self) -> DeviceState | None:
self.last_update_state = self.properties_api.status.state_name
self._last_update_success_time = dt_util.utcnow()
_LOGGER.debug("Data update successful %s", self._last_update_success_time)
return DeviceState(
status=self.properties_api.status,
dnd_timer=self.properties_api.dnd,
consumable=self.properties_api.consumables,
clean_summary=self.properties_api.clean_summary,
)
return self._device_state

def _should_suppress_update_failure(self) -> bool:
"""Determine if we should suppress update failure reporting.
Expand All @@ -290,6 +296,31 @@ def _should_suppress_update_failure(self) -> bool:
_LOGGER.debug("Update failure duration: %s", failure_duration)
return failure_duration < MIN_UNAVAILABLE_DURATION

@property
def _device_state(self) -> DeviceState:
"""Return the current device state."""
return DeviceState(
status=self.properties_api.status,
dnd_timer=self.properties_api.dnd,
consumable=self.properties_api.consumables,
clean_summary=self.properties_api.clean_summary,
)

@callback
def _handle_trait_update(self) -> None:
"""Handle trait updates from push notifications."""
_LOGGER.debug("Trait updated, updating coordinator data")
self.async_set_updated_data(self._device_state)
# We optimize streaming updates to catch state transitions immediately, but
# secondary updates (like refreshing the map) can happen on their own interval.

async def async_shutdown(self) -> None:
"""Shutdown coordinator and unsubscribe update listeners."""
await super().async_shutdown()
for unsub in self._unsubs:
unsub()
self._unsubs.clear()

async def get_routines(self) -> list[HomeDataScene]:
"""Get routines."""
try:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/unifiprotect/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.5.0"]
"requirements": ["uiprotect==11.3.0"]
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions tests/components/roborock/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,3 +748,35 @@ async def test_all_devices_disabled(
)
assert device_entry is not None
assert device_entry.disabled


@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
async def test_v1_streaming_updates(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test that V1 push updates update entity states immediately."""
assert setup_entry.state is ConfigEntryState.LOADED

sensor_entity_id = "sensor.roborock_s7_maxv_battery"
state = hass.states.get(sensor_entity_id)
assert state is not None
assert state.state == "100"

# Verify that add_update_listener was called on the mock status trait
status_trait = fake_vacuum.v1_properties.status
assert status_trait.add_update_listener.called

# Get the registered callback
callback_func = status_trait.add_update_listener.call_args[0][0] # type: ignore[union-attr]

# Update a status attribute and trigger the callback
status_trait.battery = 85
callback_func()
await hass.async_block_till_done()

# Check if the state was updated in Home Assistant immediately
state = hass.states.get(sensor_entity_id)
assert state is not None
assert state.state == "85"
Loading