Compare commits

...

9 Commits

20 changed files with 270 additions and 43 deletions
@@ -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."]
}
+7 -2
View File
@@ -9,10 +9,12 @@ import time
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
@@ -325,7 +327,8 @@ class HTML5PushCallbackView(HomeAssistantView):
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(
@@ -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(
@@ -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]:
@@ -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
@@ -133,13 +134,22 @@ class LgTVDevice(MediaPlayerEntity):
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()
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz[nexity]==2.0.0"],
"requirements": ["pyoverkiz[nexity]==2.0.1"],
"zeroconf": [
{
"name": "gateway*",
@@ -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()
@@ -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
@@ -47,7 +47,7 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity):
"""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."""
@@ -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
+1 -1
View File
@@ -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"]
}
+4 -4
View File
@@ -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 (
@@ -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),
@@ -962,9 +962,9 @@ def _get_cached_action_parameters(
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)
@@ -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
@@ -104,4 +105,6 @@ class ConfigEntryExtension(BaseTemplateExtension):
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
+3 -3
View File
@@ -2120,7 +2120,7 @@ pydrawise==2026.4.0
pydroid-ipcam==3.0.0
# homeassistant.components.droplet
pydroplet==2.3.4
pydroplet==2.4.0
# homeassistant.components.ebox
pyebox==1.1.4
@@ -2438,7 +2438,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz[nexity]==2.0.0
pyoverkiz[nexity]==2.0.1
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -3436,7 +3436,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==4.1.0
yoto-api==4.2.0
# homeassistant.components.youless
youless-api==2.2.0
+10
View File
@@ -5,9 +5,11 @@ from http import HTTPStatus
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
@@ -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)
@@ -6,6 +6,8 @@ from unittest.mock import patch
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
@@ -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"
@@ -0,0 +1,70 @@
"""Tests for LG Netcast media player platform."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import MagicMock, patch
import xml.etree.ElementTree as ET
import pytest
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import MODEL_NAME, setup_lgnetcast
from tests.common import async_fire_time_changed
ENTITY_ID = f"{MP_DOMAIN}.{MODEL_NAME.lower()}"
def _make_channel(name: str, major: str) -> ET.Element:
"""Create a fake channel XML element."""
channel = ET.Element("data")
chname = ET.SubElement(channel, "chname")
chname.text = name
major_el = ET.SubElement(channel, "major")
major_el.text = major
return channel
@pytest.fixture(autouse=True)
def mock_lg_netcast() -> Generator[MagicMock]:
"""Mock LG Netcast library."""
with patch(
"homeassistant.components.lg_netcast.LgNetCastClient"
) as mock_client_class:
yield mock_client_class
async def test_source_list_duplicate_channel_names(
hass: HomeAssistant,
mock_lg_netcast: MagicMock,
) -> None:
"""Test that duplicate channel names are disambiguated in source list."""
client = mock_lg_netcast.return_value
client.get_volume.return_value = (20, False)
context_client = client.__enter__.return_value
channel_data = {
"cur_channel": None,
"channel_list": [
_make_channel("BBC One", "1"),
_make_channel("ITV", "3"),
_make_channel("BBC One", "101"),
],
}
context_client.query_data.side_effect = channel_data.get
await setup_lgnetcast(hass)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
await hass.async_block_till_done(wait_background_tasks=True)
entity = hass.states.get(ENTITY_ID)
assert entity is not None
source_list = entity.attributes.get("source_list")
assert source_list is not None
assert len(source_list) == 3
assert "ITV" in source_list
assert "BBC One (1)" in source_list
assert "BBC One (101)" in source_list
@@ -46,7 +46,7 @@ async def load_config_entry(
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
minor_version=3,
)
config_entry.add_to_hass(hass)
@@ -32,7 +32,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'panic',
'unique_id': 'yale_smart_alarm-panic',
'unique_id': '1-panic',
'unit_of_measurement': None,
})
# ---
+46 -2
View File
@@ -27,7 +27,7 @@ async def test_setup_entry(
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
minor_version=3,
)
entry.add_to_hass(hass)
@@ -87,7 +87,7 @@ async def test_migrate_entry(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data == ENTRY_CONFIG
assert entry.options == OPTIONS_CONFIG
@@ -95,3 +95,47 @@ async def test_migrate_entry(
lock = entity_registry.async_get(lock_entity_id)
assert lock.options == {"lock": {"default_code": "123456"}}
async def test_migrate_panic_button_unique_id(
hass: HomeAssistant,
get_client: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration of panic button unique_id from v2.2 to v2.3."""
entry = MockConfigEntry(
title=ENTRY_CONFIG["username"],
domain=DOMAIN,
source=SOURCE_USER,
data=ENTRY_CONFIG,
options=OPTIONS_CONFIG,
entry_id="1",
unique_id="username",
version=2,
minor_version=2,
)
entry.add_to_hass(hass)
entity_registry.async_get_or_create(
"button",
DOMAIN,
"yale_smart_alarm-panic",
config_entry=entry,
)
with patch(
"homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient",
return_value=get_client,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.minor_version == 3
migrated = entity_registry.async_get_entity_id("button", DOMAIN, "1-panic")
assert migrated is not None
old = entity_registry.async_get_entity_id(
"button", DOMAIN, "yale_smart_alarm-panic"
)
assert old is None
@@ -110,24 +110,37 @@ async def test_config_entry_id(
async def test_config_entry_attr(hass: HomeAssistant) -> None:
"""Test config entry attr."""
info = {
config_entry = MockConfigEntry(
domain="mock_light",
title="mock title",
source=config_entries.SOURCE_BLUETOOTH,
disabled_by=config_entries.ConfigEntryDisabler.USER,
pref_disable_polling=True,
)
config_entry.add_to_hass(hass)
expected = {
"domain": "mock_light",
"title": "mock title",
"source": config_entries.SOURCE_BLUETOOTH,
"disabled_by": config_entries.ConfigEntryDisabler.USER,
"pref_disable_polling": True,
"disabled_by": "user",
"pref_disable_polling": "True",
"state": "not_loaded",
}
config_entry = MockConfigEntry(**info)
config_entry.add_to_hass(hass)
info["state"] = config_entries.ConfigEntryState.NOT_LOADED
for key, value in info.items():
assert render(
hass,
"{{ config_entry_attr('" + config_entry.entry_id + "', '" + key + "') }}",
parse_result=False,
) == str(value)
for key, value in expected.items():
assert (
render(
hass,
"{{ config_entry_attr('"
+ config_entry.entry_id
+ "', '"
+ key
+ "') }}",
parse_result=False,
)
== value
)
for config_entry_id, key in (
(config_entry.entry_id, "invalid_key"),
+4 -4
View File
@@ -1234,7 +1234,7 @@ async def test_script_tool(
assert tool.name == "test_script"
assert (
tool.description
== "This is a test script. Aliases: ['script name', 'script alias']"
== "This is a test script. Aliases: ['script alias', 'script name']"
)
schema = {
vol.Required("beer", description="Number of beers"): cv.string,
@@ -1249,7 +1249,7 @@ async def test_script_tool(
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
"test_script": (
"This is a test script. Aliases: ['script name', 'script alias']",
"This is a test script. Aliases: ['script alias', 'script name']",
vol.Schema(schema),
),
"script_with_no_fields": (
@@ -1358,14 +1358,14 @@ async def test_script_tool(
assert tool.name == "test_script"
assert (
tool.description
== "This is a new test script. Aliases: ['script name', 'script alias']"
== "This is a new test script. Aliases: ['script alias', 'script name']"
)
schema = {vol.Required("beer", description="Number of beers"): cv.string}
assert tool.parameters.schema == schema
assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {
"test_script": (
"This is a new test script. Aliases: ['script name', 'script alias']",
"This is a new test script. Aliases: ['script alias', 'script name']",
vol.Schema(schema),
),
"script_with_no_fields": (