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 .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: python

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/gentex_homelink/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["homelink-integration-api==0.0.5"]
"requirements": ["homelink-integration-api==0.1.0"]
}
18 changes: 9 additions & 9 deletions homeassistant/components/lookin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""The lookin integration."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern

import asyncio
from collections.abc import Callable, Coroutine
Expand All @@ -23,6 +22,7 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.hass_dict import HassKey

from .const import (
DOMAIN,
Expand All @@ -37,8 +37,6 @@

LOGGER = logging.getLogger(__name__)

UDP_MANAGER = "udp_manager"


def _async_climate_updater(
lookin_protocol: LookInHttpProtocol,
Expand Down Expand Up @@ -90,9 +88,13 @@ async def async_stop(self) -> None:
self._subscriptions = None


# One UDP listener serves every lookin device, so the manager is shared between
# config entries rather than owned by any one of them.
UDP_MANAGER: HassKey[LookinUDPManager] = HassKey(DOMAIN)


async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool:
"""Set up lookin from a config entry."""
domain_data = hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
lookin_protocol = LookInHttpProtocol(
api_uri=f"http://{host}", session=async_get_clientsession(hass)
Expand Down Expand Up @@ -159,10 +161,8 @@ def _async_meteo_push_update(event: UDPEvent) -> None:
meteo.update_from_value(event.value)
meteo_coordinator.async_set_updated_data(meteo)

if UDP_MANAGER not in domain_data:
manager = domain_data[UDP_MANAGER] = LookinUDPManager()
else:
manager = domain_data[UDP_MANAGER]
if (manager := hass.data.get(UDP_MANAGER)) is None:
manager = hass.data[UDP_MANAGER] = LookinUDPManager()

lookin_udp_subs = await manager.async_get_subscriptions()

Expand Down Expand Up @@ -200,7 +200,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> b
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

if not hass.config_entries.async_loaded_entries(DOMAIN):
manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER]
manager = hass.data[UDP_MANAGER]
await manager.async_stop()
return unload_ok

Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/lutron/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ def handle_event(
action = LutronEventType.PRESS
else:
action = LutronEventType.RELEASE
elif event == Button.Event.PRESSED:
elif event in (Button.Event.PRESSED, Button.Event.RELEASED):
# Buttons carrying a hold action report only a release, never a press.
action = LutronEventType.SINGLE_PRESS

if action:
Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/lutron/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ def turn_on(self, **kwargs: Any) -> None:
if ATTR_TRANSITION in kwargs:
args["fade_time_seconds"] = kwargs[ATTR_TRANSITION]
self._lutron_device.set_level(**args)
# Publish now rather than waiting for the controller to report back.
self._publish_level()

@override
def turn_off(self, **kwargs: Any) -> None:
Expand All @@ -104,6 +106,18 @@ def turn_off(self, **kwargs: Any) -> None:
if ATTR_TRANSITION in kwargs:
args["fade_time_seconds"] = kwargs[ATTR_TRANSITION]
self._lutron_device.set_level(**args)
self._publish_level()

def _publish_level(self) -> None:
"""Publish the device's current level without waiting for a report.

Reads `last_level()` like `_update_callback` does, so a report that
lands around the same time cannot be overwritten by a separately held
assumed value -- whichever reached the library last is what gets
published.
"""
self._update_attrs()
self.schedule_update_ha_state()

@property
@override
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/miele/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]:
value_fn=lambda value: value.twin_dos_container_1_filling_level,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
),
MieleSensorDefinition(
Expand All @@ -736,6 +737,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]:
value_fn=lambda value: value.twin_dos_container_2_filling_level,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
),
MieleSensorDefinition(
Expand All @@ -745,6 +747,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]:
translation_key="power_disk_level",
value_fn=lambda value: value.power_disc_filling_level,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
Expand All @@ -755,6 +758,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]:
translation_key="salt_level",
value_fn=lambda value: value.salt_filling_level,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
Expand All @@ -765,6 +769,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]:
translation_key="rinse_aid_level",
value_fn=lambda value: value.rinse_aid_filling_level,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
Expand Down
21 changes: 20 additions & 1 deletion homeassistant/components/modbus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
Expand All @@ -26,7 +27,25 @@


def get_hub(hass: HomeAssistant, name: str) -> ModbusHub:
"""Return modbus hub with name."""
"""Return modbus hub with name.

Deprecated. Use `async_get_unit` instead, which builds a connection from
credentials the integration holds rather than attaching to a hub the user
configured in YAML under a name the integration has to be told.
"""
report_usage(
"calls `modbus.get_hub`, which is deprecated in favour of "
"`modbus.async_get_unit`. Collect the connection details in your own "
"config flow and ask for a unit on them",
breaks_in_ha_version="2027.10",
core_behavior=ReportBehavior.IGNORE,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
# get_hub is defined here, so its own frame is the first one the stack
# walk meets. Without this it reports modbus every time, and the core
# behavior above then silences the caller it was meant to name.
exclude_integrations={DOMAIN},
)
return hass.data[DATA_MODBUS_HUBS][name]


Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/roborock/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==7.1.1",
"python-roborock==7.2.3",
"vacuum-map-parser-roborock==0.1.5"
]
}
42 changes: 38 additions & 4 deletions homeassistant/components/zwave_js/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
async_delete_issue,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import UNDEFINED

from .const import DOMAIN
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
Expand Down Expand Up @@ -684,10 +685,16 @@ def __init__(
if description:
self.entity_description = description

# Entity class attributes
self._attr_name = self.generate_name(
alternate_value_name=self.info.primary_value.metadata.states[self.state_key]
)
# Notification sensors are named after their notification state. A
# description may set its own name to override that.
if not hasattr(self, "entity_description") or (
self.entity_description.name is UNDEFINED
):
self._attr_name = self.generate_name(
alternate_value_name=self.info.primary_value.metadata.states[
self.state_key
]
)
self._attr_unique_id = f"{self._attr_unique_id}.{self.state_key}"

@property
Expand Down Expand Up @@ -870,6 +877,33 @@ def __init__(


DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
# Zooz ZSE43 Tilt/Shock Sensor. Its vibration sensor is reported
# through the Home Security "Cover status" notification, so expose
# that notification as a vibration sensor.
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
manufacturer_id={0x027A},
product_id={0xE003},
product_type={0x7000},
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Home Security"},
property_key={"Cover status"},
type={ValueType.NUMBER},
any_available_states_keys={3},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HOME_SECURITY)
},
),
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id 3 (product cover removed)
key=NOTIFICATION_HOME_SECURITY,
name="Vibration",
states={3},
device_class=BinarySensorDeviceClass.VIBRATION,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
Expand Down
4 changes: 2 additions & 2 deletions requirements_all.txt

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

35 changes: 35 additions & 0 deletions tests/components/lookin/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from unittest.mock import AsyncMock, MagicMock, patch

from homeassistant.components.lookin import UDP_MANAGER
from homeassistant.components.lookin.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST
Expand Down Expand Up @@ -81,3 +82,37 @@ async def test_controlled_device_links_to_lookin_device(
)
assert controlled_device is not None
assert controlled_device.via_device_id == lookin_device.id


async def test_udp_manager_outlives_the_config_entry(hass: HomeAssistant) -> None:
"""Test the shared UDP manager is reused rather than rebuilt per entry."""
device = _mocked_device()
remote = _mocked_remote()
protocol = _mocked_protocol(device, remote)

subscriptions = MagicMock()
subscriptions.subscribe_event = MagicMock(return_value=MagicMock())

entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID
)
entry.add_to_hass(hass)

with (
patch(f"{MODULE}.LookInHttpProtocol", return_value=protocol),
patch(f"{MODULE}.LookinUDPSubscriptions", return_value=subscriptions),
patch(f"{MODULE}.start_lookin_udp", AsyncMock(return_value=MagicMock())),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

manager = hass.data[UDP_MANAGER]
assert manager is not None

await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()

assert entry.state is ConfigEntryState.LOADED
# The manager is keyed globally, not on the entry, so a reload reuses it
# instead of leaving a second listener behind.
assert hass.data[UDP_MANAGER] is manager
7 changes: 7 additions & 0 deletions tests/components/lutron/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ def mock_lutron() -> Generator[MagicMock]:
light.is_dimmable = True
light.type = "LIGHT"
light.last_level.return_value = 0

# pylutron's Output.set_level() stores the new level, which last_level()
# then returns. Mirror that so the mock behaves like the library.
def _set_level(new_level, fade_time_seconds=None):
light.last_level.return_value = new_level

light.set_level.side_effect = _set_level
area.outputs.append(light)

# Mock a switch
Expand Down
21 changes: 21 additions & 0 deletions tests/components/lutron/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,24 @@ async def test_event_press_release(

assert len(events) == 2
assert events[1].data["action"] == "released"


async def test_event_release_only_button(
hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""A button that only ever reports a release still fires a single press."""
mock_config_entry.add_to_hass(hass)

button = mock_lutron.areas[0].keypads[0].buttons[0]
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

events = async_capture_events(hass, "lutron_event")

for call in button.subscribe.call_args_list:
callback = call[0][0]
callback(button, None, Button.Event.RELEASED, None)
await hass.async_block_till_done()

assert len(events) == 1
assert events[0].data["action"] == "single"
Loading
Loading