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/droplet/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.4"],
"requirements": ["pydroplet==2.4.0"],
"zeroconf": ["_droplet._tcp.local."]
}
9 changes: 7 additions & 2 deletions homeassistant/components/html5/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
import uuid
import warnings

from aiohttp import ClientError, ClientResponse, ClientSession, web
from aiohttp.hdrs import AUTHORIZATION
import jwt
from jwt.warnings import InsecureKeyLengthWarning
from py_vapid import Vapid
from pywebpush import WebPusher, WebPushException, webpush_async
import voluptuous as vol
Expand Down Expand Up @@ -325,7 +327,8 @@ def decode_jwt(self, token: str) -> web.Response | dict[str, Any]:
if target_check.get(ATTR_TARGET) in self.registrations:
possible_target = self.registrations[target_check[ATTR_TARGET]]
key = possible_target["subscription"]["keys"]["auth"]
with suppress(jwt.exceptions.DecodeError):
with suppress(jwt.exceptions.DecodeError), warnings.catch_warnings():
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
return jwt.decode(token, key, algorithms=["ES256", "HS256"])

return self.json_message(
Expand Down Expand Up @@ -585,7 +588,9 @@ def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str:
ATTR_TARGET: target,
ATTR_TAG: tag,
}
return jwt.encode(jwt_claims, jwt_secret)
with warnings.catch_warnings():
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
return jwt.encode(jwt_claims, jwt_secret)


async def async_setup_entry(
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/image_upload/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ def _generate_thumbnail_if_file_does_not_exist(
if not target_file.is_file():
image = ImageOps.exif_transpose(Image.open(original_path))
image.thumbnail(target_size)
image.save(target_path, format=content_type.partition("/")[-1])
save_format = content_type.partition("/")[-1]
if save_format == "jpeg" and image.mode not in ("RGB", "L", "CMYK"):
image = image.convert("RGB")
image.save(target_path, format=save_format)


def _validate_size_from_filename(filename: str) -> tuple[int, int]:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/immich/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ async def _async_update_data(self) -> ImmichData:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
translation_placeholders={"error": str(err)},
) from err

return ImmichData(
Expand Down
18 changes: 14 additions & 4 deletions homeassistant/components/lg_netcast/media_player.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Support for LG TV running on NetCast 3 or 4."""

from collections import Counter
from datetime import datetime
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -133,13 +134,22 @@ def update(self) -> None:

channel_list = client.query_data("channel_list")
if channel_list:
channel_names = []
channel_pairs = []
for channel in channel_list:
channel_name = channel.find("chname")
if channel_name is not None:
channel_names.append(str(channel_name.text))
self._sources = dict(zip(channel_names, channel_list, strict=False))
# sort source names by the major channel number
channel_pairs.append((str(channel_name.text), channel))

name_count = Counter(name for name, _ in channel_pairs)

self._sources = {}
for name, channel in channel_pairs:
if name_count[name] > 1:
major = channel.find("major")
if major is not None:
name = f"{name} ({major.text})"
self._sources[name] = channel

source_tuples = [
(k, source.find("major").text)
for k, source in self._sources.items()
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/withings/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Withings coordinator."""

from abc import abstractmethod
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from typing import TYPE_CHECKING

from aiowithings import (
Expand Down Expand Up @@ -270,7 +270,7 @@ async def _internal_update_data(self) -> Activity | None:
self._last_valid_update
)

today = date.today() # noqa: DTZ011
today = dt_util.now().date()
for activity in activities:
if activity.date == today:
self._previous_data = activity
Expand Down
17 changes: 15 additions & 2 deletions homeassistant/components/yale_smart_alarm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> boo

async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool:
"""Migrate old entry."""
LOGGER.debug("Migrating from version %s", entry.version)
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)

if entry.version == 1:
new_options = entry.options.copy()
Expand All @@ -55,6 +55,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bo
del new_data[CONF_NAME]
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)

LOGGER.debug("Migration to version %s successful", entry.version)
if entry.version == 2 and entry.minor_version == 2:
entity_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
for entity in entries:
if entity.unique_id == "yale_smart_alarm-panic":
entity_reg.async_update_entity(
entity.entity_id,
new_unique_id=f"{entry.entry_id}-panic",
)
hass.config_entries.async_update_entry(entry, minor_version=3)

LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)

return True
2 changes: 1 addition & 1 deletion homeassistant/components/yale_smart_alarm/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def __init__(
"""Initialize the plug switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"yale_smart_alarm-{description.key}"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"

async def async_press(self) -> None:
"""Press the button."""
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/yale_smart_alarm/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Yale integration."""

VERSION = 2
MINOR_VERSION = 2
MINOR_VERSION = 3

@staticmethod
@callback
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/yoto/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==4.1.0"]
"requirements": ["yoto-api==4.2.0"]
}
8 changes: 4 additions & 4 deletions homeassistant/helpers/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ def _get_exposed_entities(
):
# Entity is in area
area_names.append(area_entry.name)
area_names.extend(area_entry.aliases)
area_names.extend(sorted(area_entry.aliases))
elif device_entry is not None:
# Check device area
if (
Expand All @@ -711,7 +711,7 @@ def _get_exposed_entities(
is not None
):
area_names.append(area_entry.name)
area_names.extend(area_entry.aliases)
area_names.extend(sorted(area_entry.aliases))

info: dict[str, Any] = {
"names": ", ".join(names),
Expand Down Expand Up @@ -962,9 +962,9 @@ def on_homeassistant_close(event: Event) -> None:
aliases = er.async_get_entity_aliases(hass, entity_entry)
if aliases:
if description:
description = description + ". Aliases: " + str(list(aliases))
description = description + ". Aliases: " + str(sorted(aliases))
else:
description = "Aliases: " + str(list(aliases))
description = "Aliases: " + str(sorted(aliases))

parameters_cache.setdefault(domain, {})[action] = (description, parameters)

Expand Down
5 changes: 4 additions & 1 deletion homeassistant/helpers/template/extensions/config_entries.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Config entry functions for Home Assistant templates."""

from collections.abc import Iterable
from enum import Enum
from typing import TYPE_CHECKING, Any

from homeassistant.exceptions import TemplateError
Expand Down Expand Up @@ -104,4 +105,6 @@ def config_entry_attr(self, config_entry_id: str, attr_name: str) -> Any:
if config_entry is None:
return None

return getattr(config_entry, attr_name)
if isinstance(result := getattr(config_entry, attr_name), Enum):
return result.value
return result
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.

10 changes: 10 additions & 0 deletions tests/components/html5/test_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import warnings

from aiohttp import ClientError
from aiohttp.hdrs import AUTHORIZATION
import jwt.warnings
import pytest
from pywebpush import WebPushException
from syrupy.assertion import SnapshotAssertion
Expand Down Expand Up @@ -1289,3 +1291,11 @@ async def test_html5_dismiss_message(
"data": {"jwt": "JWT"},
**expected_payload,
}


def test_add_jwt_no_insecure_key_warning() -> None:
"""Test that add_jwt does not emit InsecureKeyLengthWarning for short keys."""
short_key = "c2hvcnRfa2V5X2hlcmU="
with warnings.catch_warnings():
warnings.simplefilter("error", jwt.warnings.InsecureKeyLengthWarning)
html5.add_jwt(1234567890, "device", "tag", short_key)
56 changes: 56 additions & 0 deletions tests/components/image_upload/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from aiohttp import ClientSession, ClientWebSocketResponse
from freezegun.api import FrozenDateTimeFactory
from PIL import Image
import pytest

from homeassistant.components.image_upload import DOMAIN
from homeassistant.components.websocket_api import TYPE_RESULT
Expand Down Expand Up @@ -94,3 +96,57 @@ async def test_upload_image(

# Ensure removed from disk
assert not item_folder.is_dir()


@pytest.mark.parametrize(
("image_mode", "content_type"),
[
("RGBA", "image/jpeg"),
("LA", "image/jpeg"),
("P", "image/jpeg"),
],
)
async def test_upload_image_thumbnail_rgba_as_jpeg(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
hass_client: ClientSessionGenerator,
image_mode: str,
content_type: str,
) -> None:
"""Test thumbnail generation when image mode is incompatible with JPEG."""
now = dt_util.utcnow()
freezer.move_to(now)

with (
tempfile.TemporaryDirectory() as tempdir,
patch.object(hass.config, "path", return_value=tempdir),
):
assert await async_setup_component(hass, DOMAIN, {})
client: ClientSession = await hass_client()

with TEST_IMAGE.open("rb") as fp:
res = await client.post("/api/image/upload", data={"file": fp})

assert res.status == 200
item = await res.json()
image_id = item["id"]

tempdir = pathlib.Path(tempdir)
item_folder = tempdir / image_id

# Create an image file with the given mode to simulate the mismatch
original_path = item_folder / "original"
img = Image.new(image_mode, (300, 300))
img.save(original_path, format="png")

# Change the stored content_type to simulate the mismatch
hass.data[DOMAIN].data[image_id]["content_type"] = content_type

# Fetch the thumbnail; this should not raise an OSError
res = await client.get(f"/api/image/serve/{image_id}/256x256")
assert res.status == 200
assert (item_folder / "256x256").is_file()

# Verify the generated thumbnail is a valid JPEG
thumbnail = Image.open(item_folder / "256x256")
assert thumbnail.mode == "RGB"
32 changes: 32 additions & 0 deletions tests/components/immich/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

from unittest.mock import Mock, patch

from aiohttp import ContentTypeError, RequestInfo
from multidict import CIMultiDict, CIMultiDictProxy
import pytest
from syrupy.assertion import SnapshotAssertion
from yarl import URL

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -43,3 +46,32 @@ async def test_admin_sensors(
assert hass.states.get("sensor.mock_title_videos_count") is None
assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None
assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None


async def test_update_error_does_not_leak_api_key(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that API key is not leaked in error logs on connection failure."""
await setup_integration(hass, mock_config_entry)

api_key = "SECRET_API_KEY_12345"
headers = CIMultiDictProxy(
CIMultiDict({"x-api-key": api_key, "Host": "example.com"})
)
request_info = RequestInfo(
url=URL("https://example.com/api/server/about"),
method="GET",
headers=headers,
real_url=URL("https://example.com/api/server/about"),
)
mock_immich.server.async_get_about_info.side_effect = ContentTypeError(
request_info, (), status=503, message="Service Unavailable"
)

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

assert api_key not in caplog.text
Loading
Loading