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
1 change: 0 additions & 1 deletion homeassistant/brands/google.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"google_assistant_sdk",
"google_cloud",
"google_drive",
"google_gemini",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
Expand Down
61 changes: 59 additions & 2 deletions homeassistant/components/bluetooth/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@
import logging

from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import BluetoothAdapters, adapter_human_name, adapter_model
from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, HaScanner
from bluetooth_adapters import (
ADAPTER_TYPE,
BluetoothAdapters,
adapter_human_name,
adapter_model,
)
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothManager,
BluetoothScanningMode,
HaScanner,
)

from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
Expand Down Expand Up @@ -326,7 +337,53 @@ def on_scanner_start(self, scanner: BaseHaScanner) -> None:
# Only handle repair issues for local adapters (HaScanner instances)
if not isinstance(scanner, HaScanner):
return
self.async_check_degraded_mode(scanner)
self.async_check_scanning_mode(scanner)

@hass_callback
def async_check_scanning_mode(self, scanner: HaScanner) -> None:
"""Check if the scanner is running in passive mode when active mode is requested."""
passive_mode_issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"

# Check if scanner is NOT in passive mode when active mode was requested
if not (
scanner.requested_mode is BluetoothScanningMode.ACTIVE
and scanner.current_mode is BluetoothScanningMode.PASSIVE
):
# Delete passive mode issue if it exists and we're not in passive fallback
ir.async_delete_issue(self.hass, DOMAIN, passive_mode_issue_id)
return

# Create repair issue for passive mode fallback
adapter_name = adapter_human_name(
scanner.adapter, scanner.mac_address or "00:00:00:00:00:00"
)
adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter)
model = adapter_model(adapter_details) if adapter_details else None

# Determine adapter type for specific instructions
# Default to USB for any other type or unknown
if adapter_details and adapter_details.get(ADAPTER_TYPE) == "uart":
translation_key = "bluetooth_adapter_passive_mode_uart"
else:
translation_key = "bluetooth_adapter_passive_mode_usb"

ir.async_create_issue(
self.hass,
DOMAIN,
passive_mode_issue_id,
is_fixable=False, # Requires a reboot or unplug
severity=ir.IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders={
"adapter": adapter_name,
"model": model or "Unknown",
},
)

@hass_callback
def async_check_degraded_mode(self, scanner: HaScanner) -> None:
"""Check if we are in degraded mode and create/delete repair issues."""
issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"

# Delete any existing issue if not in degraded mode
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/bluetooth/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
"bluetooth_adapter_missing_permissions": {
"title": "Bluetooth adapter requires additional permissions",
"description": "The Bluetooth adapter **{adapter}** ({model}) is operating in degraded mode because your container needs additional permissions to fully access Bluetooth hardware.\n\nPlease follow the instructions in our documentation to add the required permissions:\n[Bluetooth permissions for Docker]({docs_url})\n\nAfter adding these permissions, restart your Home Assistant container for the changes to take effect."
},
"bluetooth_adapter_passive_mode_usb": {
"title": "Bluetooth USB adapter requires manual power cycle",
"description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the adapter requires a manual power cycle to recover.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Unplug the USB adapter**\n2. Wait 5 seconds\n3. **Plug it back in**\n4. Wait for Home Assistant to detect the adapter\n\nIf the issue persists after power cycling:\n- Try a different USB port\n- Check for kernel/firmware updates\n- Consider using a different Bluetooth adapter"
},
"bluetooth_adapter_passive_mode_uart": {
"title": "Bluetooth adapter requires system power cycle",
"description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the system requires a complete power cycle to recover the adapter.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Shut down the system completely** (not just a reboot)\n2. **Remove power** (unplug or turn off at the switch)\n3. Wait 10 seconds\n4. Restore power and boot the system\n\nIf the issue persists after power cycling:\n- Check for kernel/firmware updates\n- The onboard Bluetooth adapter may have hardware issues"
}
}
}
56 changes: 55 additions & 1 deletion homeassistant/components/doorbird/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType

Expand Down Expand Up @@ -103,6 +105,43 @@ def __init__(self) -> None:
"""Initialize the DoorBird config flow."""
self.discovery_schema: vol.Schema | None = None

async def _async_verify_existing_device_for_discovery(
self,
existing_entry: ConfigEntry,
host: str,
macaddress: str,
) -> None:
"""Verify discovered device matches existing entry before updating IP.

This method performs the following verification steps:
1. Ensures that the stored credentials work before updating the entry.
2. Verifies that the device at the discovered IP address has the expected MAC address.
"""
info, errors = await self._async_validate_or_error(
{
**existing_entry.data,
CONF_HOST: host,
}
)

if errors:
_LOGGER.debug(
"Cannot validate DoorBird at %s with existing credentials: %s",
host,
errors,
)
raise AbortFlow("cannot_connect")

# Verify the MAC address matches what was advertised
if format_mac(info["mac_addr"]) != format_mac(macaddress):
_LOGGER.debug(
"DoorBird at %s reports MAC %s but zeroconf advertised %s, ignoring",
host,
info["mac_addr"],
macaddress,
)
raise AbortFlow("wrong_device")

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
Expand Down Expand Up @@ -172,7 +211,22 @@ async def async_step_zeroconf(

await self.async_set_unique_id(macaddress)
host = discovery_info.host
self._abort_if_unique_id_configured(updates={CONF_HOST: host})

# Check if we have an existing entry for this MAC
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, macaddress
)

if existing_entry:
# Check if the host is actually changing
if existing_entry.data.get(CONF_HOST) != host:
await self._async_verify_existing_device_for_discovery(
existing_entry, host, macaddress
)

# All checks passed or no change needed, abort
# if already configured with potential IP update
self._abort_if_unique_id_configured(updates={CONF_HOST: host})

self._async_abort_entries_match({CONF_HOST: host})

Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/doorbird/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"link_local_address": "Link local addresses are not supported",
"not_doorbird_device": "This device is not a DoorBird",
"not_ipv4_address": "Only IPv4 addresses are supported",
"wrong_device": "Device MAC address does not match",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"flow_title": "{name} ({host})",
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/foscam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .const import CONF_RTSP_PORT, LOGGER
from .coordinator import FoscamConfigEntry, FoscamCoordinator

PLATFORMS = [Platform.CAMERA, Platform.SWITCH]
PLATFORMS = [Platform.CAMERA, Platform.NUMBER, Platform.SWITCH]


async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
Expand All @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo
entry.data[CONF_PASSWORD],
verbose=False,
)

coordinator = FoscamCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()

Expand Down
14 changes: 12 additions & 2 deletions homeassistant/components/foscam/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ class FoscamDeviceInfo:
is_open_white_light: bool
is_siren_alarm: bool

volume: int
device_volume: int
speak_volume: int
is_turn_off_volume: bool
is_turn_off_light: bool
supports_speak_volume_adjustment: bool

is_open_wdr: bool | None = None
is_open_hdr: bool | None = None
Expand Down Expand Up @@ -118,6 +119,14 @@ def gather_all_configs(self) -> FoscamDeviceInfo:
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
is_open_hdr = bool(int(mode))

ret_sw, software_capabilities = self.session.getSWCapabilities()

supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0
else False
)

return FoscamDeviceInfo(
dev_info=dev_info,
product_info=product_info,
Expand All @@ -127,10 +136,11 @@ def gather_all_configs(self) -> FoscamDeviceInfo:
is_asleep=is_asleep,
is_open_white_light=is_open_white_light_val,
is_siren_alarm=is_siren_alarm_val,
volume=volume_val,
device_volume=volume_val,
speak_volume=speak_volume_val,
is_turn_off_volume=is_turn_off_volume_val,
is_turn_off_light=is_turn_off_light_val,
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
is_open_wdr=is_open_wdr,
is_open_hdr=is_open_hdr,
)
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/foscam/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
class FoscamEntity(CoordinatorEntity[FoscamCoordinator]):
"""Base entity for Foscam camera."""

_attr_has_entity_name = True

def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None:
"""Initialize the base Foscam entity."""
super().__init__(coordinator)
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/foscam/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
"wdr_switch": {
"default": "mdi:alpha-w-box"
}
},
"number": {
"device_volume": {
"default": "mdi:volume-source"
},
"speak_volume": {
"default": "mdi:account-voice"
}
}
}
}
93 changes: 93 additions & 0 deletions homeassistant/components/foscam/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Foscam number platform for Home Assistant."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from libpyfoscamcgi import FoscamCamera

from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .coordinator import FoscamConfigEntry, FoscamCoordinator
from .entity import FoscamEntity


@dataclass(frozen=True, kw_only=True)
class FoscamNumberEntityDescription(NumberEntityDescription):
"""A custom entity description with adjustable features."""

native_value_fn: Callable[[FoscamCoordinator], int]
set_value_fn: Callable[[FoscamCamera, float], Any]
exists_fn: Callable[[FoscamCoordinator], bool]


NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
FoscamNumberEntityDescription(
key="device_volume",
translation_key="device_volume",
native_min_value=0,
native_max_value=100,
native_step=1,
native_value_fn=lambda coordinator: coordinator.data.device_volume,
set_value_fn=lambda session, value: session.setAudioVolume(value),
exists_fn=lambda _: True,
),
FoscamNumberEntityDescription(
key="speak_volume",
translation_key="speak_volume",
native_min_value=0,
native_max_value=100,
native_step=1,
native_value_fn=lambda coordinator: coordinator.data.speak_volume,
set_value_fn=lambda session, value: session.setSpeakVolume(value),
exists_fn=lambda coordinator: coordinator.data.supports_speak_volume_adjustment,
),
]


async def async_setup_entry(
hass: HomeAssistant,
config_entry: FoscamConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up foscam number from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
FoscamVolumeNumberEntity(coordinator, description)
for description in NUMBER_DESCRIPTIONS
if description.exists_fn is None or description.exists_fn(coordinator)
)


class FoscamVolumeNumberEntity(FoscamEntity, NumberEntity):
"""Representation of a Foscam Smart AI number entity."""

entity_description: FoscamNumberEntityDescription

def __init__(
self,
coordinator: FoscamCoordinator,
description: FoscamNumberEntityDescription,
) -> None:
"""Initialize the data."""
entry_id = coordinator.config_entry.entry_id
super().__init__(coordinator, entry_id)

self.entity_description = description
self._attr_unique_id = f"{entry_id}_{description.key}"

@property
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.native_value_fn(self.coordinator)

async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
await self.hass.async_add_executor_job(
self.entity_description.set_value_fn, self.coordinator.session, value
)
await self.coordinator.async_request_refresh()
8 changes: 8 additions & 0 deletions homeassistant/components/foscam/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@
"wdr_switch": {
"name": "WDR"
}
},
"number": {
"device_volume": {
"name": "Device volume"
},
"speak_volume": {
"name": "Speak volume"
}
}
},
"services": {
Expand Down
2 changes: 0 additions & 2 deletions homeassistant/components/foscam/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ async def async_setup_entry(
"""Set up foscam switch from a config entry."""

coordinator = config_entry.runtime_data
await coordinator.async_config_entry_first_refresh()

entities = []

Expand All @@ -146,7 +145,6 @@ async def async_setup_entry(
class FoscamGenericSwitch(FoscamEntity, SwitchEntity):
"""A generic switch class for Foscam entities."""

_attr_has_entity_name = True
entity_description: FoscamSwitchEntityDescription

def __init__(
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/google_gemini/__init__.py

This file was deleted.

Loading
Loading