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
8 changes: 3 additions & 5 deletions homeassistant/components/downloader/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,10 @@ def do_download() -> None:

else:
if filename is None and "content-disposition" in req.headers:
match = re.findall(
if match := re.search(
r"filename=(\S+)", req.headers["content-disposition"]
)

if match:
filename = match[0].strip("'\" ")
):
filename = match.group(1).strip("'\" ")

if not filename:
filename = os.path.basename(url).strip()
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/enphase_envoy/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,20 +220,24 @@ async def _async_setup_and_authenticate(self) -> None:
await envoy.setup()
assert envoy.serial_number is not None
self.envoy_serial_number = envoy.serial_number
_LOGGER.debug("Envoy setup complete for serial: %s", self.envoy_serial_number)
if token := self.config_entry.data.get(CONF_TOKEN):
with contextlib.suppress(*INVALID_AUTH_ERRORS):
# Always set the username and password
# so we can refresh the token if needed
await envoy.authenticate(
username=self.username, password=self.password, token=token
)
_LOGGER.debug("Authorized, validating token lifetime")
# The token is valid, but we still want
# to refresh it if it's stale right away
self._async_refresh_token_if_needed(dt_util.utcnow())
return
# token likely expired or firmware changed
# so we fall through to authenticate with
# username/password
_LOGGER.debug("setup and auth got INVALID_AUTH_ERRORS")
_LOGGER.debug("Authenticate with username/password only")
await self.envoy.authenticate(username=self.username, password=self.password)
# Password auth succeeded, so we can update the token
# if we are using EnvoyTokenAuth
Expand Down Expand Up @@ -262,13 +266,16 @@ async def _async_update_data(self) -> dict[str, Any]:
for tries in range(2):
try:
if not self._setup_complete:
_LOGGER.debug("update on try %s, setup not complete", tries)
await self._async_setup_and_authenticate()
self._async_mark_setup_complete()
# dump all received data in debug mode to assist troubleshooting
envoy_data = await envoy.update()
except INVALID_AUTH_ERRORS as err:
_LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err)
if self._setup_complete and tries == 0:
# token likely expired or firmware changed, try to re-authenticate
_LOGGER.debug("update on try %s, setup was complete, retry", tries)
self._setup_complete = False
continue
raise ConfigEntryAuthFailed(
Expand All @@ -280,6 +287,7 @@ async def _async_update_data(self) -> dict[str, Any]:
},
) from err
except EnvoyError as err:
_LOGGER.debug("update on try %s, EnvoyError %s", tries, err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="envoy_error",
Expand Down
20 changes: 18 additions & 2 deletions homeassistant/components/husqvarna_automower/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def _async_update_data(self) -> MowerDictionary:
"""Subscribe for websocket and poll data from the API."""
if not self.ws_connected:
await self.api.connect()
self.api.register_data_callback(self.callback)
self.api.register_data_callback(self.handle_websocket_updates)
self.ws_connected = True
try:
data = await self.api.get_status()
Expand All @@ -86,11 +86,27 @@ async def _async_update_data(self) -> MowerDictionary:
return data

@callback
def callback(self, ws_data: MowerDictionary) -> None:
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
self.async_set_updated_data(ws_data)
self._async_add_remove_devices_and_entities(ws_data)

@callback
def async_set_updated_data(self, data: MowerDictionary) -> None:
"""Override DataUpdateCoordinator to preserve fixed polling interval.

The built-in implementation resets the polling timer on every websocket
update. Since websockets do not deliver all required data (e.g. statistics
or work area details), we enforce a constant REST polling cadence.
"""
self.data = data
self.last_update_success = True
self.logger.debug(
"Manually updated %s data",
self.name,
)
self.async_update_listeners()

async def client_listen(
self,
hass: HomeAssistant,
Expand Down
7 changes: 2 additions & 5 deletions homeassistant/components/qbus/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,8 @@ def add_new_outputs(

def format_ref_id(ref_id: str) -> str | None:
"""Format the Qbus ref_id."""
matches: list[str] = re.findall(_REFID_REGEX, ref_id)

if len(matches) > 0:
if ref_id := matches[0]:
return ref_id.replace("/", "-")
if match := _REFID_REGEX.search(ref_id):
return match.group(1).replace("/", "-")

return None

Expand Down
18 changes: 8 additions & 10 deletions homeassistant/helpers/deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,17 +197,18 @@ def _print_deprecation_warning_internal_impl(

logger = logging.getLogger(module_name)
if breaks_in_ha_version:
breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}"
breaks_in = f" It will be removed in HA Core {breaks_in_ha_version}."
else:
breaks_in = ""
try:
integration_frame = get_integration_frame()
except MissingIntegrationFrame:
if log_when_no_integration_is_found:
logger.warning(
"%s is a deprecated %s%s. Use %s instead",
obj_name,
"The deprecated %s %s was %s.%s Use %s instead",
description,
obj_name,
verb,
breaks_in,
replacement,
)
Expand All @@ -219,25 +220,22 @@ def _print_deprecation_warning_internal_impl(
module=integration_frame.module,
)
logger.warning(
(
"%s was %s from %s, this is a deprecated %s%s. Use %s instead,"
" please %s"
),
("The deprecated %s %s was %s from %s.%s Use %s instead, please %s"),
description,
obj_name,
verb,
integration_frame.integration,
description,
breaks_in,
replacement,
report_issue,
)
else:
logger.warning(
"%s was %s from %s, this is a deprecated %s%s. Use %s instead",
"The deprecated %s %s was %s from %s.%s Use %s instead",
description,
obj_name,
verb,
integration_frame.integration,
description,
breaks_in,
replacement,
)
Expand Down
12 changes: 6 additions & 6 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1826,9 +1826,9 @@ def import_and_test_deprecated_constant(
module.__name__,
logging.WARNING,
(
f"{constant_name} was used from test_constant_deprecation,"
f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. "
f"Use {replacement_name} instead, please report "
f"The deprecated constant {constant_name} was used from "
"test_constant_deprecation. It will be removed in HA Core "
f"{breaks_in_ha_version}. Use {replacement_name} instead, please report "
"it to the author of the 'test_constant_deprecation' custom integration"
),
) in caplog.record_tuples
Expand Down Expand Up @@ -1860,9 +1860,9 @@ def import_and_test_deprecated_alias(
module.__name__,
logging.WARNING,
(
f"{alias_name} was used from test_constant_deprecation,"
f" this is a deprecated alias which will be removed in HA Core {breaks_in_ha_version}. "
f"Use {replacement_name} instead, please report "
f"The deprecated alias {alias_name} was used from "
"test_constant_deprecation. It will be removed in HA Core "
f"{breaks_in_ha_version}. Use {replacement_name} instead, please report "
"it to the author of the 'test_constant_deprecation' custom integration"
),
) in caplog.record_tuples
Expand Down
39 changes: 39 additions & 0 deletions tests/components/enphase_envoy/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,45 @@ async def test_coordinator_token_refresh_error(
assert entity_state.state == "116"


@respx.mock
@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00")
async def test_coordinator_first_update_auth_error(
hass: HomeAssistant,
mock_envoy: AsyncMock,
) -> None:
"""Test coordinator update error handling."""
current_token = encode(
# some time in future
payload={"name": "envoy", "exp": 1927314600},
key="secret",
algorithm="HS256",
)

# mock envoy with expired token in config
entry = MockConfigEntry(
domain=DOMAIN,
entry_id="45a36e55aaddb2007c5f6602e0c38e72",
title="Envoy 1234",
unique_id="1234",
data={
CONF_HOST: "1.1.1.1",
CONF_NAME: "Envoy 1234",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_TOKEN: current_token,
},
)
mock_envoy.auth = EnvoyTokenAuth(
"127.0.0.1",
token=current_token,
envoy_serial="1234",
cloud_username="test_username",
cloud_password="test_password",
)
mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test")
await setup_integration(hass, entry, ConfigEntryState.SETUP_ERROR)


async def test_config_no_unique_id(
hass: HomeAssistant,
mock_envoy: AsyncMock,
Expand Down
8 changes: 6 additions & 2 deletions tests/components/hassio/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1098,7 +1098,9 @@ def test_deprecated_function_is_hassio(
(
"homeassistant.components.hassio",
logging.WARNING,
"is_hassio is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.is_hassio instead",
"The deprecated function is_hassio was called. It will be "
"removed in HA Core 2025.11. Use homeassistant.helpers"
".hassio.is_hassio instead",
)
]

Expand All @@ -1114,7 +1116,9 @@ def test_deprecated_function_get_supervisor_ip(
(
"homeassistant.helpers.hassio",
logging.WARNING,
"get_supervisor_ip is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.get_supervisor_ip instead",
"The deprecated function get_supervisor_ip was called. It will "
"be removed in HA Core 2025.11. Use homeassistant.helpers"
".hassio.get_supervisor_ip instead",
)
]

Expand Down
73 changes: 71 additions & 2 deletions tests/components/husqvarna_automower/test_init.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Tests for init module."""

from asyncio import Event
from datetime import datetime
from collections.abc import Callable
from copy import deepcopy
from datetime import datetime, timedelta
import http
import time
from unittest.mock import AsyncMock, patch
Expand All @@ -20,7 +22,7 @@
from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN
from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.util import dt as dt_util

Expand Down Expand Up @@ -221,6 +223,73 @@ async def test_device_info(
assert reg_device == snapshot


async def test_constant_polling(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
values: dict[str, MowerAttributes],
freezer: FrozenDateTimeFactory,
) -> None:
"""Verify that receiving a WebSocket update does not interrupt the regular polling cycle.

The test simulates a WebSocket update that changes an entity's state, then advances time
to trigger a scheduled poll to confirm polled data also arrives.
"""
test_values = deepcopy(values)
callback_holder: dict[str, Callable] = {}

@callback
def fake_register_websocket_response(
cb: Callable[[dict[str, MowerAttributes]], None],
) -> None:
callback_holder["cb"] = cb

mock_automower_client.register_data_callback.side_effect = (
fake_register_websocket_response
)
await setup_integration(hass, mock_config_entry)
await hass.async_block_till_done()

assert mock_automower_client.register_data_callback.called
assert "cb" in callback_holder

state = hass.states.get("sensor.test_mower_1_battery")
assert state is not None
assert state.state == "100"
state = hass.states.get("sensor.test_mower_1_front_lawn_progress")
assert state is not None
assert state.state == "40"

test_values[TEST_MOWER_ID].battery.battery_percent = 77

freezer.tick(SCAN_INTERVAL - timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()

callback_holder["cb"](test_values)
await hass.async_block_till_done()

state = hass.states.get("sensor.test_mower_1_battery")
assert state is not None
assert state.state == "77"
state = hass.states.get("sensor.test_mower_1_front_lawn_progress")
assert state is not None
assert state.state == "40"

test_values[TEST_MOWER_ID].work_areas[123456].progress = 50
mock_automower_client.get_status.return_value = test_values
freezer.tick(timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_automower_client.get_status.assert_awaited()
state = hass.states.get("sensor.test_mower_1_battery")
assert state is not None
assert state.state == "77"
state = hass.states.get("sensor.test_mower_1_front_lawn_progress")
assert state is not None
assert state.state == "50"


async def test_coordinator_automatic_registry_cleanup(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
Expand Down
Loading
Loading