Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a0345b7
Add switch entities to SolarEdge Modbus (#180826)
frenck Aug 30, 2026
95cbf74
Switchbot Cloud: Add new supported devices[Lock Ultra Max] (#180486)
XiaoLing-git Aug 30, 2026
386833e
Allow Apple TV pairing PIN to accept leading zeroes (#179258)
sankalpsthakur Aug 30, 2026
20ea690
Bump protobuf to latest v6 (to prepare v7 bump) (#180626)
silamon Aug 30, 2026
fb8a2aa
Create one light entity per Modern Forms Gen4 light fixture (#180524)
wonderslug Aug 30, 2026
df4c7f0
Bump google-cloud-pubsub to 2.39.2 (#180623)
silamon Aug 30, 2026
f5aaa35
Stop exposing sleep timers and adaptive learning on Modern Forms Gen4…
wonderslug Aug 30, 2026
1b547ef
Use JSON fixture helpers in tests (#180832)
joostlek Aug 30, 2026
1bee69c
Migrate nextbus to config entry runtime data (#177452)
mstu01 Aug 30, 2026
644f621
Add color temperature support for Modern Forms Gen4 light fixtures (#…
wonderslug Aug 30, 2026
2434756
Use JSON fixture helpers in tests (5/6) (#180838)
joostlek Aug 30, 2026
36a70d7
Declare PARALLEL_UPDATES on the iCloud calendar platform (#180839)
FezVrasta Aug 30, 2026
968e26a
Fix mangled model names in Google Generative AI options (#178686)
noron12234 Aug 30, 2026
0dab420
Use async_create_background_task for the songpal notification listene…
noron12234 Aug 30, 2026
17495c0
Use BaseException instead of bare except in rainforest_raven (#176562)
nithesh259 Aug 30, 2026
9c81f63
Use JSON fixture helpers in tests (6/6) (#180840)
joostlek Aug 30, 2026
8f97c0d
Use JSON fixture helpers in tests (4/6) (#180836)
joostlek Aug 30, 2026
0739548
Bump pyHik to 0.4.4 (#180837)
ptarjan Aug 30, 2026
b46e113
Use JSON fixture helpers in tests (2/6) (#180833)
joostlek Aug 30, 2026
a9ba568
Add a restart button to Modern Forms (#180527)
wonderslug Aug 30, 2026
29223d3
Stop reporting max_volume in Lyngdorf diagnostics (#180828)
fishloa Aug 30, 2026
dfb236a
Use JSON fixture helpers in tests (3/6) (#180835)
joostlek Aug 30, 2026
ae9c2c8
Correctly display sn and mac in Midea device page (#180848)
rokam Aug 30, 2026
8202748
Iterate the unit conversion tables instead of parametrizing each row …
balloob Aug 30, 2026
4016b01
Report an uncalibrated Shelly roller by its last direction (#180485)
frenck Aug 30, 2026
d28987d
Update pipdeptree to 4.2.2 (#180346)
frenck Aug 30, 2026
d147e41
velbus: fix unique_id collision for Property sensor entities (#176343)
StefCoene Aug 30, 2026
d3fc4e6
Add a switch entity for the Sofar remote on/off control (#180383)
darkrain-nl Aug 30, 2026
a82743c
Use positional row access in recorder history queries (#175532)
frenck Aug 30, 2026
bb38892
Add pylint checker to use json helpers (#180829)
joostlek Aug 30, 2026
2174384
Resolve missing local addresses for Mitsubishi Comfort devices (#173270)
nikolairahimi Aug 30, 2026
46ae12c
Added AC support for Samsung Infrared (#173692)
elax46 Aug 30, 2026
8281b2f
Add diagnostics support for Sofar (#180854)
darkrain-nl Aug 30, 2026
2672896
Bump lyngdorf to 2.1.0 (#180834)
fishloa Aug 30, 2026
8ee1c48
Bump elgato to 6.1.1 (#180852)
frenck Aug 30, 2026
cc126ab
Fix swallowed exceptions in action handlers for Pushover (#177416)
mstu01 Aug 30, 2026
759b009
Add first device info for Vivotek integration (#171587)
HarlemSquirrel Aug 30, 2026
9d37238
Add entry type to Proxmox (#180858)
erwindouna Aug 30, 2026
004423f
Add an Elgato firmware update entity (#180786)
frenck Aug 30, 2026
82cfe53
Enable strict typing for Sofar (#180857)
darkrain-nl Aug 30, 2026
1c9a428
Bump solaredge-web to 0.4.0 (#180861)
tronikos Aug 30, 2026
345ebf6
Bump peblar to 2.0.0 (#180859)
frenck Aug 30, 2026
4ae28fc
Pick up SolarEdge Modbus meters and batteries wired in or out later (…
frenck Aug 30, 2026
342c9d4
Remove homeassistant auth legacy mode (#175613)
gjohansson-ST Aug 30, 2026
d948431
Bump aioaquarite to 0.11.0 (#180863)
fdebrus Aug 30, 2026
c01aac0
Add a reconfigure flow to the Sofar integration (#180856)
darkrain-nl Aug 30, 2026
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
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.smtp.*
homeassistant.components.snooz.*
homeassistant.components.sofar.*
homeassistant.components.solaredge_modbus.*
homeassistant.components.solarlog.*
homeassistant.components.sonarr.*
Expand Down
63 changes: 2 additions & 61 deletions homeassistant/auth/providers/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import asyncio
import base64
from collections.abc import Mapping
import logging
from typing import Any, cast, override

import bcrypt
Expand All @@ -12,7 +11,6 @@
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import Store

from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta
Expand Down Expand Up @@ -85,73 +83,19 @@ def __init__(self, hass: HomeAssistant) -> None:
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
)
self._data: dict[str, list[dict[str, str]]] | None = None
# Legacy mode will allow usernames to start/end with whitespace
# and will compare usernames case-insensitive.
# Deprecated in June 2019 and will be removed in 2026.7
self.is_legacy = False

@callback
def normalize_username(
self, username: str, *, force_normalize: bool = False
) -> str:
def normalize_username(self, username: str) -> str:
"""Normalize a username based on the mode."""
if self.is_legacy and not force_normalize:
return username

return username.strip().casefold()

async def async_load(self) -> None:
"""Load stored data."""
if (data := await self._store.async_load()) is None:
data = cast(dict[str, list[dict[str, str]]], {"users": []})

self._async_check_for_not_normalized_usernames(data)
self._data = data

@callback
def _async_check_for_not_normalized_usernames(
self, data: dict[str, list[dict[str, str]]]
) -> None:
not_normalized_usernames: set[str] = set()

for user in data["users"]:
username = user["username"]

if self.normalize_username(username, force_normalize=True) != username:
logging.getLogger(__name__).warning(
(
"Home Assistant auth provider is running in"
" legacy mode because we detected usernames"
" that are normalized (lowercase and without"
" spaces). Please change the username: '%s'."
),
username,
)
not_normalized_usernames.add(username)

if not_normalized_usernames:
self.is_legacy = True
ir.async_create_issue(
self.hass,
"auth",
"homeassistant_provider_not_normalized_usernames",
breaks_in_ha_version="2026.7.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="homeassistant_provider_not_normalized_usernames",
translation_placeholders={
"usernames": (
f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
)
},
learn_more_url="homeassistant://config/users",
)
else:
self.is_legacy = False
ir.async_delete_issue(
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
)

@property
def users(self) -> list[dict[str, str]]:
"""Return users."""
Expand Down Expand Up @@ -247,9 +191,7 @@ def _validate_new_username(self, new_username: str) -> None:

Raises InvalidUsername if the new username is invalid.
"""
normalized_username = self.normalize_username(
new_username, force_normalize=True
)
normalized_username = self.normalize_username(new_username)
if normalized_username != new_username:
raise InvalidUsername(
translation_key="username_not_normalized",
Expand Down Expand Up @@ -279,7 +221,6 @@ def change_username(self, username: str, new_username: str) -> None:
if self.normalize_username(user["username"]) == username:
user["username"] = new_username
assert self._data is not None
self._async_check_for_not_normalized_usernames(self._data)
break
else:
raise InvalidUser(translation_key="user_not_found")
Expand Down
31 changes: 19 additions & 12 deletions homeassistant/components/apple_tv/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
Expand All @@ -41,7 +42,7 @@

DEVICE_INPUT = "device_input"

INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=None): int})
INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=""): cv.string})

DEFAULT_START_OFF = False

Expand Down Expand Up @@ -513,17 +514,23 @@ async def async_step_pair_with_pin(
assert self.pairing
assert self.protocol
if user_input is not None:
try:
self.pairing.pin(user_input[CONF_PIN])
await self.pairing.finish()
self.credentials[self.protocol.value] = self.pairing.service.credentials
return await self.async_pair_next_protocol()
except exceptions.PairingError:
_LOGGER.exception("Authentication problem")
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
pin = user_input[CONF_PIN]
if not pin.isascii() or not pin.isdigit():
errors["pin"] = "invalid_pin"
else:
try:
self.pairing.pin(pin)
await self.pairing.finish()
self.credentials[self.protocol.value] = (
self.pairing.service.credentials
)
return await self.async_pair_next_protocol()
except exceptions.PairingError:
_LOGGER.exception("Authentication problem")
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

return self.async_show_form(
step_id="pair_with_pin",
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/apple_tv/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "Invalid PIN",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
Expand All @@ -34,7 +35,7 @@
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
},
"description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen. Leading zeros shall be omitted, i.e. enter 123 if the displayed code is 0123.",
"description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen, including any leading zeros.",
"title": "Pairing"
},
"password": {
Expand Down
6 changes: 0 additions & 6 deletions homeassistant/components/auth/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@
"message": "Username \"{new_username}\" is not normalized. Please make sure the username is lowercase and does not contain any whitespace."
}
},
"issues": {
"homeassistant_provider_not_normalized_usernames": {
"description": "The Home Assistant auth provider is running in legacy mode because we detected not normalized usernames. The legacy mode is deprecated and will be removed. Please change the following usernames:\n\n{usernames}\n\nNormalized usernames are case folded (lower case) and stripped of whitespaces.",
"title": "Not normalized usernames detected"
}
},
"mfa_setup": {
"notify": {
"abort": {
Expand Down
27 changes: 25 additions & 2 deletions homeassistant/components/elgato/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey

from .const import DOMAIN
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .coordinator import (
ElgatoConfigEntry,
ElgatoDataUpdateCoordinator,
ElgatoFirmwareCoordinator,
)
from .services import async_setup_services

ELGATO_KEY: HassKey[ElgatoFirmwareCoordinator] = HassKey(DOMAIN)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
Expand All @@ -17,12 +23,29 @@
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
"""Set up the component.

Elgato publishes one firmware catalog covering every model, so a single
coordinator serves every device rather than each config entry fetching
the same thing.
"""
async_setup_services(hass)

coordinator = ElgatoFirmwareCoordinator(hass)
hass.data[ELGATO_KEY] = coordinator

# Elgato's servers are not on the local network and a request to them can
# sit there for its full timeout, so nothing waits on this. The update
# entities fill themselves in once the answer arrives.
hass.async_create_background_task(
coordinator.async_refresh(), f"{DOMAIN}_firmware_refresh"
)

return True


Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/elgato/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
from .helpers import elgato_device_action

PARALLEL_UPDATES = 1

Expand Down Expand Up @@ -78,7 +78,7 @@ def __init__(
f"{coordinator.data.info.serial_number}_{description.key}"
)

@elgato_exception_handler
@elgato_device_action
@override
async def async_press(self) -> None:
"""Trigger button press on the Elgato device."""
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/elgato/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=10)

# Elgato publishes firmware a handful of times a year, bundled with a new
# Control Center release. Asking more often than this buys nothing.
FIRMWARE_SCAN_INTERVAL = timedelta(hours=12)

# Attributes
ATTR_ON = "on"
66 changes: 55 additions & 11 deletions homeassistant/components/elgato/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""DataUpdateCoordinator for Elgato."""

import asyncio
from dataclasses import dataclass
from typing import override

Expand All @@ -8,6 +9,8 @@
Elgato,
ElgatoConnectionError,
ElgatoError,
FirmwareCatalog,
FirmwareVersion,
Info,
Settings,
State,
Expand All @@ -19,7 +22,7 @@
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, LOGGER, SCAN_INTERVAL
from .const import DOMAIN, FIRMWARE_SCAN_INTERVAL, LOGGER, SCAN_INTERVAL

type ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator]

Expand All @@ -42,11 +45,14 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]):

def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None:
"""Initialize the coordinator."""
self.config_entry = entry
self.client = Elgato(
entry.data[CONF_HOST],
session=async_get_clientsession(hass),
)
# A firmware install gets the device to itself. It stops answering
# while it erases a flash slot, and enough traffic during that window
# takes its HTTP server down with it and restarts the light.
self.device_lock = asyncio.Lock()
super().__init__(
hass,
LOGGER,
Expand All @@ -59,15 +65,16 @@ def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None:
async def _async_update_data(self) -> ElgatoData:
"""Fetch data from the Elgato device."""
try:
if self.has_battery is None:
self.has_battery = await self.client.has_battery()

return ElgatoData(
battery=await self.client.battery() if self.has_battery else None,
info=await self.client.info(),
settings=await self.client.settings(),
state=await self.client.state(),
)
async with self.device_lock:
if self.has_battery is None:
self.has_battery = await self.client.has_battery()

return ElgatoData(
battery=await self.client.battery() if self.has_battery else None,
info=await self.client.info(),
settings=await self.client.settings(),
state=await self.client.state(),
)
except ElgatoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
Expand All @@ -78,3 +85,40 @@ async def _async_update_data(self) -> ElgatoData:
translation_domain=DOMAIN,
translation_key="unknown_error",
) from err


class ElgatoFirmwareCoordinator(DataUpdateCoordinator[dict[int, FirmwareVersion]]):
"""Class to manage fetching the firmware Elgato ships.

Elgato publishes one catalog covering every model, so this is shared by
all Elgato devices rather than set up per config entry. It also lives on
Elgato's servers rather than the local network, and changes a handful of
times a year, so it runs on its own cadence.
"""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the global Elgato firmware updater."""
self.catalog = FirmwareCatalog(session=async_get_clientsession(hass))
super().__init__(
hass,
LOGGER,
config_entry=None,
name=f"{DOMAIN}_firmware",
update_interval=FIRMWARE_SCAN_INTERVAL,
)

@override
async def _async_update_data(self) -> dict[int, FirmwareVersion]:
"""Fetch the firmware Elgato currently ships, per board type."""
try:
return await self.catalog.versions(refresh=True)
except ElgatoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="firmware_communication_error",
) from err
except ElgatoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="firmware_unknown_error",
) from err
Loading
Loading