Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
bfa2da3
Mark geo_location entity type hints as mandatory (#163790)
epenet Feb 23, 2026
9c0c975
Mark light entity type hints as mandatory (#163794)
epenet Feb 23, 2026
6d6727e
Change weheat codeowner (#163860)
jesperraemaekers Feb 23, 2026
2f95d1e
Mark lock entity type hints as mandatory (#163796)
epenet Feb 23, 2026
6fba886
Replace Matter python client (#163704)
Apollon77 Feb 23, 2026
733d381
Add new MyNeomitis integration (#151377)
l-pr Feb 23, 2026
f4cab72
Minor type fixes (#163606)
liudger Feb 23, 2026
bd1b060
Add integration_type device to solarlog (#163628)
joostlek Feb 23, 2026
f564ad3
Add Matter KNX bridge fixture (#163875)
lboue Feb 23, 2026
9b2bcae
Bump Kaleidescape integration dependency to v1.1.3 (#163884)
SteveEasley Feb 23, 2026
ce71e54
Add airOS device reboot button (#163718)
CoMPaTech Feb 23, 2026
e96da42
Fix notification service exceptions fot Telegram bot (#163882)
Shulyaka Feb 23, 2026
ffeb759
Rename Litter-Robot integration to Whisker (#163826)
natekspencer Feb 23, 2026
3a27fa7
Teltonika quality scale: mark test-coverage done (#163707)
karlbeecken Feb 23, 2026
fa38f25
Enable strict typing in Velux integration (#163798)
wollew Feb 23, 2026
b712207
Add refrigerator temperature level select to whirlpool (#162110)
abmantis Feb 23, 2026
994eae8
Bump python-bsblan to 5.0.1 (#163840)
liudger Feb 23, 2026
e6c2d54
Improve Plugwise set_hvac_mode() logic (#163713)
bouwew Feb 23, 2026
a552266
Bump python-overseerr to 0.9.0 (#163883)
joostlek Feb 23, 2026
67395f1
Handle PyViCare device communication and server errors in ViCare inte…
lackas Feb 23, 2026
4c885e7
Fix ZHA number entity not using device class and mode (#163827)
TheJulianJES Feb 23, 2026
ea7732e
Add heat pump sensors to ViCare integration (#161422)
lackas Feb 23, 2026
6570b41
Add discovery for airOS devices (#154568)
CoMPaTech Feb 23, 2026
c2b74b7
Correct EnOcean integration type (#163725)
CFenner Feb 23, 2026
dd78da9
Improve config flow tests for Anthropic (#163757)
Shulyaka Feb 23, 2026
d732e3d
Add climate platform to Trane Local integration (#163571)
bdraco Feb 23, 2026
c62ceee
Update Teslemetry quality scale to silver (#163611)
Bre77 Feb 23, 2026
89ff86a
Add diagnostics to Proxmox (#163800)
erwindouna Feb 23, 2026
e57613a
Anthropic interleaved thinking (#163583)
Shulyaka Feb 23, 2026
25787d2
Add DeviceInfo to Google Translate (#163762)
tr4nt0r Feb 23, 2026
dc5eab6
Allow support of Graph QL 4.0 / Bump pytibber 0.36.0 (#163305)
jeeftor Feb 23, 2026
501e095
Add IntelliClima Select platform (#163637)
dvdinth Feb 23, 2026
1d5e8a9
Weheat energy logs update (#163621)
barryvdh Feb 23, 2026
49b8232
Add stale device removal to portainer (#160017)
erwindouna Feb 23, 2026
8927960
fix(snapcast): do not crash when stream is not found (#162439)
Links2004 Feb 23, 2026
9cc3c85
Homevolt switch platform (#163415)
Danielhiversen Feb 23, 2026
bc1837d
Portainer gold standard review (#155231)
erwindouna Feb 23, 2026
d581d65
Add handling of 2 IP addresses to homee (#162731)
Taraman17 Feb 23, 2026
bea8415
homee: add one-button-remote to event platform (#163690)
Taraman17 Feb 23, 2026
fb118ed
Add support for action buttons to ntfy integration (#152014)
tr4nt0r Feb 23, 2026
8f2bfa1
Add select entities to Liebherr integration (#163581)
mettolen Feb 23, 2026
bae4de3
Add Hikvision integration quality scale (#159252)
ptarjan Feb 23, 2026
1a16674
Update quality scale of Xbox integration to platinum 🏆️ (#155577)
tr4nt0r Feb 23, 2026
5611b45
Add debounce to Satel Integra alarm panel state (#163602)
Tommatheussen Feb 23, 2026
7e162cf
Update Anthropic models (#163897)
Shulyaka Feb 23, 2026
9212279
Bump aioesphomeapi 44.1.0 (#163894)
bdraco Feb 23, 2026
bb1956c
Portainer Platinum score (#163898)
erwindouna Feb 23, 2026
fc9bdb3
Bring aladdin_connect to Bronze quality scale (#163221)
JamieMagee Feb 23, 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 @@ -583,6 +583,7 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
Expand Down
6 changes: 4 additions & 2 deletions CODEOWNERS

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

1 change: 1 addition & 0 deletions homeassistant/components/airos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
]

Expand Down
73 changes: 73 additions & 0 deletions homeassistant/components/airos/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""AirOS button component for Home Assistant."""

from __future__ import annotations

import logging

from airos.exceptions import AirOSException

from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity

_LOGGER = logging.getLogger(__name__)

PARALLEL_UPDATES = 0

REBOOT_BUTTON = ButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_registry_enabled_default=False,
)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS button from a config entry."""
async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)])


class AirOSRebootButton(AirOSEntity, ButtonEntity):
"""Button to reboot device."""

entity_description: ButtonEntityDescription

def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: ButtonEntityDescription,
) -> None:
"""Initialize the AirOS client button."""
super().__init__(coordinator)

self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"

async def async_press(self) -> None:
"""Handle the button press to reboot the device."""
try:
await self.coordinator.airos_device.login()
result = await self.coordinator.airos_device.reboot()

except AirOSException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err

if not result:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reboot_failed",
) from None
205 changes: 200 additions & 5 deletions homeassistant/components/airos/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

from __future__ import annotations

import asyncio
from collections.abc import Mapping
import logging
from typing import Any

from airos.discovery import airos_discover_devices
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
)
import voluptuous as vol

Expand All @@ -36,15 +40,27 @@
TextSelectorType,
)

from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .const import (
DEFAULT_SSL,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
DEVICE_NAME,
DOMAIN,
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import AirOS8

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
# Discovery duration in seconds, airOS announces every 20 seconds
DISCOVER_INTERVAL: int = 30

STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
Expand All @@ -58,21 +74,40 @@
}
)

STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend(
{vol.Required(CONF_HOST): str}
)


class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""

VERSION = 2
MINOR_VERSION = 1

_discovery_task: asyncio.Task | None = None

def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
self.discovered_devices: dict[str, dict[str, Any]] = {}
self.discovery_abort_reason: str | None = None
self.selected_device_info: dict[str, Any] = {}

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self.errors = {}

return self.async_show_menu(
step_id="user", menu_options=["discovery", "manual"]
)

async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the manual input of host and credentials."""
self.errors = {}
Expand All @@ -84,7 +119,7 @@ async def async_step_user(
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=self.errors
)

async def _validate_and_get_device_info(
Expand Down Expand Up @@ -220,3 +255,163 @@ async def async_step_reconfigure(
),
errors=self.errors,
)

async def async_step_discovery(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Start the discovery process."""
if self._discovery_task and self._discovery_task.done():
self._discovery_task = None

# Handle appropriate 'errors' as abort through progress_done
if self.discovery_abort_reason:
return self.async_show_progress_done(
next_step_id=self.discovery_abort_reason
)

# Abort through progress_done if no devices were found
if not self.discovered_devices:
_LOGGER.debug(
"No (new or unconfigured) airOS devices found during discovery"
)
return self.async_show_progress_done(
next_step_id="discovery_no_devices"
)

# Skip selecting a device if only one new/unconfigured device was found
if len(self.discovered_devices) == 1:
self.selected_device_info = list(self.discovered_devices.values())[0]
return self.async_show_progress_done(next_step_id="configure_device")

return self.async_show_progress_done(next_step_id="select_device")

if not self._discovery_task:
self.discovered_devices = {}
self._discovery_task = self.hass.async_create_task(
self._async_run_discovery_with_progress()
)

# Show the progress bar and wait for discovery to complete
return self.async_show_progress(
step_id="discovery",
progress_action="discovering",
progress_task=self._discovery_task,
description_placeholders={"seconds": str(DISCOVER_INTERVAL)},
)

async def async_step_select_device(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Select a discovered device."""
if discovery_info is not None:
selected_mac = discovery_info[MAC_ADDRESS]
self.selected_device_info = self.discovered_devices[selected_mac]
return await self.async_step_configure_device()

list_options = {
mac: f"{device.get(HOSTNAME, mac)} ({device.get(IP_ADDRESS, DEVICE_NAME)})"
for mac, device in self.discovered_devices.items()
}

return self.async_show_form(
step_id="select_device",
data_schema=vol.Schema({vol.Required(MAC_ADDRESS): vol.In(list_options)}),
)

async def async_step_configure_device(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Configure the selected device."""
self.errors = {}

if user_input is not None:
config_data = {
**user_input,
CONF_HOST: self.selected_device_info[IP_ADDRESS],
}
validated_info = await self._validate_and_get_device_info(config_data)

if validated_info:
return self.async_create_entry(
title=validated_info["title"],
data=validated_info["data"],
)

device_name = self.selected_device_info.get(
HOSTNAME, self.selected_device_info.get(IP_ADDRESS, DEVICE_NAME)
)
return self.async_show_form(
step_id="configure_device",
data_schema=STEP_DISCOVERY_DATA_SCHEMA,
errors=self.errors,
description_placeholders={"device_name": device_name},
)

async def _async_run_discovery_with_progress(self) -> None:
"""Run discovery with an embedded progress update loop."""
progress_bar = self.hass.async_create_task(self._async_update_progress_bar())

known_mac_addresses = {
entry.unique_id.lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id
}

try:
devices = await airos_discover_devices(DISCOVER_INTERVAL)
except AirOSEndpointError:
self.discovery_abort_reason = "discovery_detect_error"
except AirOSListenerError:
self.discovery_abort_reason = "discovery_listen_error"
except Exception:
self.discovery_abort_reason = "discovery_failed"
_LOGGER.exception("An error occurred during discovery")
else:
self.discovered_devices = {
mac_addr: info
for mac_addr, info in devices.items()
if mac_addr.lower() not in known_mac_addresses
}
_LOGGER.debug(
"Discovery task finished. Found %s new devices",
len(self.discovered_devices),
)
finally:
progress_bar.cancel()

async def _async_update_progress_bar(self) -> None:
"""Update progress bar every second."""
try:
for i in range(DISCOVER_INTERVAL):
progress = (i + 1) / DISCOVER_INTERVAL
self.async_update_progress(progress)
await asyncio.sleep(1)
except asyncio.CancelledError:
pass

async def async_step_discovery_no_devices(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery finds no (unconfigured) devices."""
return self.async_abort(reason="no_devices_found")

async def async_step_discovery_listen_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery is unable to listen on the port."""
return self.async_abort(reason="listen_error")

async def async_step_discovery_detect_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery receives incorrect broadcasts."""
return self.async_abort(reason="detect_error")

async def async_step_discovery_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery fails for other reasons."""
return self.async_abort(reason="discovery_failed")
7 changes: 7 additions & 0 deletions homeassistant/components/airos/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@
DEFAULT_SSL = True

SECTION_ADVANCED_SETTINGS = "advanced_settings"

# Discovery related
DEFAULT_USERNAME = "ubnt"
HOSTNAME = "hostname"
IP_ADDRESS = "ip_address"
MAC_ADDRESS = "mac_address"
DEVICE_NAME = "airOS device"
Loading
Loading