Merge branch 'dev' into ingress_dropping_close

This commit is contained in:
J. Nick Koston 2024-11-08 23:24:44 +00:00 committed by GitHub
commit 75cf02cad8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
497 changed files with 15505 additions and 3397 deletions

View File

@ -79,6 +79,7 @@ components: &components
- homeassistant/components/group/** - homeassistant/components/group/**
- homeassistant/components/hassio/** - homeassistant/components/hassio/**
- homeassistant/components/homeassistant/** - homeassistant/components/homeassistant/**
- homeassistant/components/homeassistant_hardware/**
- homeassistant/components/http/** - homeassistant/components/http/**
- homeassistant/components/image/** - homeassistant/components/image/**
- homeassistant/components/input_boolean/** - homeassistant/components/input_boolean/**

View File

@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3 uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
with: with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

View File

@ -622,13 +622,13 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v5.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@v4.1.2 uses: actions/cache/restore@v4.1.2
with: with:
@ -819,11 +819,7 @@ jobs:
needs: needs:
- info - info
- base - base
strategy: name: Split tests for full run
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
name: Split tests for full run Python ${{ matrix.python-version }}
steps: steps:
- name: Install additional OS dependencies - name: Install additional OS dependencies
run: | run: |
@ -836,11 +832,11 @@ jobs:
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v5.3.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
@ -858,7 +854,7 @@ jobs:
- name: Upload pytest_buckets - name: Upload pytest_buckets
uses: actions/upload-artifact@v4.4.3 uses: actions/upload-artifact@v4.4.3
with: with:
name: pytest_buckets-${{ matrix.python-version }} name: pytest_buckets
path: pytest_buckets.txt path: pytest_buckets.txt
overwrite: true overwrite: true
@ -923,7 +919,7 @@ jobs:
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v4.1.8
with: with:
name: pytest_buckets-${{ matrix.python-version }} name: pytest_buckets
- name: Compile English translations - name: Compile English translations
run: | run: |
. venv/bin/activate . venv/bin/activate
@ -949,6 +945,7 @@ jobs:
--timeout=9 \ --timeout=9 \
--durations=10 \ --durations=10 \
--numprocesses auto \ --numprocesses auto \
--snapshot-details \
--dist=loadfile \ --dist=loadfile \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
@ -1071,6 +1068,7 @@ jobs:
-qq \ -qq \
--timeout=20 \ --timeout=20 \
--numprocesses 1 \ --numprocesses 1 \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=10 \ --durations=10 \
@ -1199,6 +1197,7 @@ jobs:
-qq \ -qq \
--timeout=9 \ --timeout=9 \
--numprocesses 1 \ --numprocesses 1 \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=0 \ --durations=0 \
@ -1345,6 +1344,7 @@ jobs:
-qq \ -qq \
--timeout=9 \ --timeout=9 \
--numprocesses auto \ --numprocesses auto \
--snapshot-details \
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
--durations=0 \ --durations=0 \

View File

@ -330,6 +330,7 @@ homeassistant.components.mysensors.*
homeassistant.components.myuplink.* homeassistant.components.myuplink.*
homeassistant.components.nam.* homeassistant.components.nam.*
homeassistant.components.nanoleaf.* homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.* homeassistant.components.neato.*
homeassistant.components.nest.* homeassistant.components.nest.*
homeassistant.components.netatmo.* homeassistant.components.netatmo.*
@ -339,6 +340,7 @@ homeassistant.components.nfandroidtv.*
homeassistant.components.nightscout.* homeassistant.components.nightscout.*
homeassistant.components.nissan_leaf.* homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.* homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.number.* homeassistant.components.number.*

View File

@ -496,8 +496,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415 /homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415
/homeassistant/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p /homeassistant/components/fritzbox_callmonitor/ @cdce8p
@ -970,6 +970,8 @@ build.json @home-assistant/supervisor
/tests/components/nam/ @bieniu /tests/components/nam/ @bieniu
/homeassistant/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/neato/ @Santobert /homeassistant/components/neato/ @Santobert
/tests/components/neato/ @Santobert /tests/components/neato/ @Santobert
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM /homeassistant/components/nederlandse_spoorwegen/ @YarmoM
@ -1010,6 +1012,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
/tests/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe
/homeassistant/components/nordpool/ @gjohansson-ST
/tests/components/nordpool/ @gjohansson-ST
/homeassistant/components/notify/ @home-assistant/core /homeassistant/components/notify/ @home-assistant/core
/tests/components/notify/ @home-assistant/core /tests/components/notify/ @home-assistant/core
/homeassistant/components/notify_events/ @matrozov @papajojo /homeassistant/components/notify_events/ @matrozov @papajojo

View File

@ -7,12 +7,13 @@ FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop # Synchronize with homeassistant/core.py:async_stop
ENV \ ENV \
S6_SERVICES_GRACETIME=240000 \ S6_SERVICES_GRACETIME=240000 \
UV_SYSTEM_PYTHON=true UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
ARG QEMU_CPU ARG QEMU_CPU
# Install uv # Install uv
RUN pip3 install uv==0.4.28 RUN pip3 install uv==0.5.0
WORKDIR /usr/src WORKDIR /usr/src

View File

@ -30,11 +30,11 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
"""Return the contents of the restore backup file.""" """Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
try: try:
instruction_content = instruction_path.read_text(encoding="utf-8") instruction_content = json.loads(instruction_path.read_text(encoding="utf-8"))
return RestoreBackupFileContent( return RestoreBackupFileContent(
backup_file_path=Path(instruction_content.split(";")[0]) backup_file_path=Path(instruction_content["path"])
) )
except FileNotFoundError: except (FileNotFoundError, json.JSONDecodeError):
return None return None

View File

@ -1,6 +1,5 @@
"""The AEMET OpenData component.""" """The AEMET OpenData component."""
from dataclasses import dataclass
import logging import logging
from aemet_opendata.exceptions import AemetError, TownNotFound from aemet_opendata.exceptions import AemetError, TownNotFound
@ -13,20 +12,10 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from .const import CONF_STATION_UPDATES, PLATFORMS from .const import CONF_STATION_UPDATES, PLATFORMS
from .coordinator import WeatherUpdateCoordinator from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AemetConfigEntry = ConfigEntry[AemetData]
@dataclass
class AemetData:
"""Aemet runtime data."""
name: str
coordinator: WeatherUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool:
"""Set up AEMET OpenData as config entry.""" """Set up AEMET OpenData as config entry."""
@ -46,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo
except AemetError as err: except AemetError as err:
raise ConfigEntryNotReady(err) from err raise ConfigEntryNotReady(err) from err
weather_coordinator = WeatherUpdateCoordinator(hass, aemet) weather_coordinator = WeatherUpdateCoordinator(hass, entry, aemet)
await weather_coordinator.async_config_entry_first_refresh() await weather_coordinator.async_config_entry_first_refresh()
entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator) entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
@ -19,6 +20,7 @@ from aemet_opendata.helpers import dict_nested_value
from aemet_opendata.interface import AEMET from aemet_opendata.interface import AEMET
from homeassistant.components.weather import Forecast from homeassistant.components.weather import Forecast
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -29,6 +31,16 @@ _LOGGER = logging.getLogger(__name__)
API_TIMEOUT: Final[int] = 120 API_TIMEOUT: Final[int] = 120
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
type AemetConfigEntry = ConfigEntry[AemetData]
@dataclass
class AemetData:
"""Aemet runtime data."""
name: str
coordinator: WeatherUpdateCoordinator
class WeatherUpdateCoordinator(DataUpdateCoordinator): class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator.""" """Weather data update coordinator."""
@ -36,6 +48,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
entry: AemetConfigEntry,
aemet: AEMET, aemet: AEMET,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
@ -44,6 +57,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=WEATHER_UPDATE_INTERVAL, update_interval=WEATHER_UPDATE_INTERVAL,
) )

View File

@ -15,7 +15,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import AemetConfigEntry from .coordinator import AemetConfigEntry
TO_REDACT_CONFIG = [ TO_REDACT_CONFIG = [
CONF_API_KEY, CONF_API_KEY,

View File

@ -55,7 +55,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import AemetConfigEntry
from .const import ( from .const import (
ATTR_API_CONDITION, ATTR_API_CONDITION,
ATTR_API_FORECAST_CONDITION, ATTR_API_FORECAST_CONDITION,
@ -87,7 +86,7 @@ from .const import (
ATTR_API_WIND_SPEED, ATTR_API_WIND_SPEED,
CONDITIONS_MAP, CONDITIONS_MAP,
) )
from .coordinator import WeatherUpdateCoordinator from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
from .entity import AemetEntity from .entity import AemetEntity
@ -249,6 +248,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
name="Rain", name="Rain",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
), ),
AemetSensorEntityDescription( AemetSensorEntityDescription(
key=ATTR_API_RAIN_PROB, key=ATTR_API_RAIN_PROB,
@ -263,6 +263,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
name="Snow", name="Snow",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
), ),
AemetSensorEntityDescription( AemetSensorEntityDescription(
key=ATTR_API_SNOW_PROB, key=ATTR_API_SNOW_PROB,

View File

@ -27,9 +27,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AemetConfigEntry
from .const import CONDITIONS_MAP from .const import CONDITIONS_MAP
from .coordinator import WeatherUpdateCoordinator from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
from .entity import AemetEntity from .entity import AemetEntity

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/agent_dvr", "documentation": "https://www.home-assistant.io/integrations/agent_dvr",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["agent"], "loggers": ["agent"],
"requirements": ["agent-py==0.0.23"] "requirements": ["agent-py==0.0.24"]
} }

View File

@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==0.9.1"] "requirements": ["airthings-ble==0.9.2"]
} }

View File

@ -1083,7 +1083,13 @@ async def async_api_arm(
arm_state = directive.payload["armState"] arm_state = directive.payload["armState"]
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED: # Per Alexa Documentation: users are not allowed to switch from armed_away
# directly to another armed state without first disarming the system.
# https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming
if (
entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY
and arm_state != "ARMED_AWAY"
):
msg = "You must disarm the system before you can set the requested arm state." msg = "You must disarm the system before you can set the requested arm state."
raise AlexaSecurityPanelAuthorizationRequired(msg) raise AlexaSecurityPanelAuthorizationRequired(msg)

View File

@ -16,7 +16,6 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -46,9 +45,11 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> HomeassistantAnalyticsOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return HomeassistantAnalyticsOptionsFlowHandler(config_entry) return HomeassistantAnalyticsOptionsFlowHandler()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -132,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
"""Handle Homeassistant Analytics options.""" """Handle Homeassistant Analytics options."""
async def async_step_init( async def async_step_init(
@ -211,6 +212,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
), ),
}, },
), ),
self.options, self.config_entry.options,
), ),
) )

View File

@ -13,7 +13,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
@ -186,16 +186,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler(config_entry) return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlowWithConfigEntry): class OptionsFlowHandler(OptionsFlow):
"""Handle an option flow for Android Debug Bridge.""" """Handle an option flow for Android Debug Bridge."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
super().__init__(config_entry) self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._state_det_rules: dict[str, Any] = dict(
self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) config_entry.options.get(CONF_STATE_DETECTION_RULES, {})
self._state_det_rules: dict[str, Any] = self.options.setdefault(
CONF_STATE_DETECTION_RULES, {}
) )
self._conf_app_id: str | None = None self._conf_app_id: str | None = None
self._conf_rule_id: str | None = None self._conf_rule_id: str | None = None
@ -237,7 +235,7 @@ class OptionsFlowHandler(OptionsFlowWithConfigEntry):
SelectOptionDict(value=k, label=v) for k, v in apps_list.items() SelectOptionDict(value=k, label=v) for k, v in apps_list.items()
] ]
rules = [RULES_NEW_ID, *self._state_det_rules] rules = [RULES_NEW_ID, *self._state_det_rules]
options = self.options options = self.config_entry.options
data_schema = vol.Schema( data_schema = vol.Schema(
{ {

View File

@ -20,7 +20,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
@ -221,13 +221,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
return AndroidTVRemoteOptionsFlowHandler(config_entry) return AndroidTVRemoteOptionsFlowHandler(config_entry)
class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): class AndroidTVRemoteOptionsFlowHandler(OptionsFlow):
"""Android TV Remote options flow.""" """Android TV Remote options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
super().__init__(config_entry) self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {})
self._conf_app_id: str | None = None self._conf_app_id: str | None = None
@callback @callback

View File

@ -121,7 +121,6 @@ class AnthropicOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry
self.last_rendered_recommended = config_entry.options.get( self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False CONF_RECOMMENDED, False
) )

View File

@ -22,8 +22,8 @@ class EnhancedAudioChunk:
timestamp_ms: int timestamp_ms: int
"""Timestamp relative to start of audio stream (milliseconds)""" """Timestamp relative to start of audio stream (milliseconds)"""
is_speech: bool | None speech_probability: float | None
"""True if audio chunk likely contains speech, False if not, None if unknown""" """Probability that audio chunk contains speech (0-1), None if unknown"""
class AudioEnhancer(ABC): class AudioEnhancer(ABC):
@ -70,27 +70,27 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
) )
self.vad: MicroVad | None = None self.vad: MicroVad | None = None
self.threshold = 0.5
if self.is_vad_enabled: if self.is_vad_enabled:
self.vad = MicroVad() self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold) _LOGGER.debug("Initialized microVAD")
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk: def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples.""" """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
is_speech: bool | None = None speech_probability: float | None = None
assert len(audio) == BYTES_PER_CHUNK assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None: if self.vad is not None:
# Run VAD # Run VAD
speech_prob = self.vad.Process10ms(audio) speech_probability = self.vad.Process10ms(audio)
is_speech = speech_prob > self.threshold
if self.audio_processor is not None: if self.audio_processor is not None:
# Run noise suppression and auto gain # Run noise suppression and auto gain
audio = self.audio_processor.Process10ms(audio).audio audio = self.audio_processor.Process10ms(audio).audio
return EnhancedAudioChunk( return EnhancedAudioChunk(
audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech audio=audio,
timestamp_ms=timestamp_ms,
speech_probability=speech_probability,
) )

View File

@ -780,7 +780,9 @@ class PipelineRun:
# speaking the voice command. # speaking the voice command.
audio_chunks_for_stt.extend( audio_chunks_for_stt.extend(
EnhancedAudioChunk( EnhancedAudioChunk(
audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False audio=chunk_ts[0],
timestamp_ms=chunk_ts[1],
speech_probability=None,
) )
for chunk_ts in result.queued_audio for chunk_ts in result.queued_audio
) )
@ -827,7 +829,7 @@ class PipelineRun:
if wake_word_vad is not None: if wake_word_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not wake_word_vad.process(chunk_seconds, chunk.is_speech): if not wake_word_vad.process(chunk_seconds, chunk.speech_probability):
raise WakeWordTimeoutError( raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected" code="wake-word-timeout", message="Wake word was not detected"
) )
@ -955,7 +957,7 @@ class PipelineRun:
if stt_vad is not None: if stt_vad is not None:
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not stt_vad.process(chunk_seconds, chunk.is_speech): if not stt_vad.process(chunk_seconds, chunk.speech_probability):
# Silence detected at the end of voice command # Silence detected at the end of voice command
self.process_event( self.process_event(
PipelineEvent( PipelineEvent(
@ -1221,7 +1223,7 @@ class PipelineRun:
yield EnhancedAudioChunk( yield EnhancedAudioChunk(
audio=sub_chunk, audio=sub_chunk,
timestamp_ms=timestamp_ms, timestamp_ms=timestamp_ms,
is_speech=None, # no VAD speech_probability=None, # no VAD
) )
timestamp_ms += MS_PER_CHUNK timestamp_ms += MS_PER_CHUNK

View File

@ -75,7 +75,7 @@ class AudioBuffer:
class VoiceCommandSegmenter: class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands.""" """Segments an audio stream into voice commands."""
speech_seconds: float = 0.3 speech_seconds: float = 0.1
"""Seconds of speech before voice command has started.""" """Seconds of speech before voice command has started."""
command_seconds: float = 1.0 command_seconds: float = 1.0
@ -96,6 +96,12 @@ class VoiceCommandSegmenter:
timed_out: bool = False timed_out: bool = False
"""True a timeout occurred during voice command.""" """True a timeout occurred during voice command."""
before_command_speech_threshold: float = 0.2
"""Probability threshold for speech before voice command."""
in_command_speech_threshold: float = 0.5
"""Probability threshold for speech during voice command."""
_speech_seconds_left: float = 0.0 _speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started.""" """Seconds left before considering voice command as started."""
@ -124,7 +130,7 @@ class VoiceCommandSegmenter:
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self.in_command = False self.in_command = False
def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: def process(self, chunk_seconds: float, speech_probability: float | None) -> bool:
"""Process samples using external VAD. """Process samples using external VAD.
Returns False when command is done. Returns False when command is done.
@ -142,7 +148,12 @@ class VoiceCommandSegmenter:
self.timed_out = True self.timed_out = True
return False return False
if speech_probability is None:
speech_probability = 0.0
if not self.in_command: if not self.in_command:
# Before command
is_speech = speech_probability > self.before_command_speech_threshold
if is_speech: if is_speech:
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= chunk_seconds self._speech_seconds_left -= chunk_seconds
@ -160,24 +171,29 @@ class VoiceCommandSegmenter:
if self._reset_seconds_left <= 0: if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds self._speech_seconds_left = self.speech_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
elif not is_speech:
# Silence in command
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
self._command_seconds_left -= chunk_seconds
if (self._silence_seconds_left <= 0) and (self._command_seconds_left <= 0):
# Command finished successfully
self.reset()
_LOGGER.debug("Voice command finished")
return False
else: else:
# Speech in command. # In command
# Reset silence counter if enough speech. is_speech = speech_probability > self.in_command_speech_threshold
self._reset_seconds_left -= chunk_seconds if not is_speech:
self._command_seconds_left -= chunk_seconds # Silence in command
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
self._command_seconds_left -= chunk_seconds
if (self._silence_seconds_left <= 0) and (
self._command_seconds_left <= 0
):
# Command finished successfully
self.reset()
_LOGGER.debug("Voice command finished")
return False
else:
# Speech in command.
# Reset silence counter if enough speech.
self._reset_seconds_left -= chunk_seconds
self._command_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds
return True return True
@ -226,6 +242,9 @@ class VoiceActivityTimeout:
reset_seconds: float = 0.5 reset_seconds: float = 0.5
"""Seconds of speech before resetting timeout.""" """Seconds of speech before resetting timeout."""
speech_threshold: float = 0.5
"""Threshold for speech."""
_silence_seconds_left: float = 0.0 _silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped.""" """Seconds left before considering voice command as stopped."""
@ -241,12 +260,15 @@ class VoiceActivityTimeout:
self._silence_seconds_left = self.silence_seconds self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: def process(self, chunk_seconds: float, speech_probability: float | None) -> bool:
"""Process samples using external VAD. """Process samples using external VAD.
Returns False when timeout is reached. Returns False when timeout is reached.
""" """
if is_speech: if speech_probability is None:
speech_probability = 0.0
if speech_probability > self.speech_threshold:
# Speech # Speech
self._reset_seconds_left -= chunk_seconds self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0: if self._reset_seconds_left <= 0:

View File

@ -18,7 +18,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -59,9 +59,11 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> AxisOptionsFlowHandler: def async_get_options_flow(
config_entry: ConfigEntry,
) -> AxisOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return AxisOptionsFlowHandler(config_entry) return AxisOptionsFlowHandler()
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the Axis config flow.""" """Initialize the Axis config flow."""
@ -264,7 +266,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
return await self.async_step_user() return await self.async_step_user()
class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry): class AxisOptionsFlowHandler(OptionsFlow):
"""Handle Axis device options.""" """Handle Axis device options."""
config_entry: AxisConfigEntry config_entry: AxisConfigEntry
@ -282,8 +284,7 @@ class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the Axis device stream options.""" """Manage the Axis device stream options."""
if user_input is not None: if user_input is not None:
self.options.update(user_input) return self.async_create_entry(data=self.config_entry.options | user_input)
return self.async_create_entry(title="", data=self.options)
schema = {} schema = {}

View File

@ -124,7 +124,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_CONN_STRING, step_id=STEP_CONN_STRING,
data_schema=CONN_STRING_SCHEMA, data_schema=CONN_STRING_SCHEMA,
errors=errors, errors=errors,
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], description_placeholders={
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
},
last_step=True, last_step=True,
) )
@ -144,7 +146,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_SAS, step_id=STEP_SAS,
data_schema=SAS_SCHEMA, data_schema=SAS_SCHEMA,
errors=errors, errors=errors,
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], description_placeholders={
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
},
last_step=True, last_step=True,
) )

View File

@ -308,7 +308,7 @@ class BackupManager(BaseBackupManager):
def _write_restore_file() -> None: def _write_restore_file() -> None:
"""Write the restore file.""" """Write the restore file."""
Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text(
f"{backup.path.as_posix()};", json.dumps({"path": backup.path.as_posix()}),
encoding="utf-8", encoding="utf-8",
) )

View File

@ -21,41 +21,57 @@ class BangOlufsenSource:
name="Audio Streamer", name="Audio Streamer",
id="uriStreamer", id="uriStreamer",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
BLUETOOTH: Final[Source] = Source( BLUETOOTH: Final[Source] = Source(
name="Bluetooth", name="Bluetooth",
id="bluetooth", id="bluetooth",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
CHROMECAST: Final[Source] = Source( CHROMECAST: Final[Source] = Source(
name="Chromecast built-in", name="Chromecast built-in",
id="chromeCast", id="chromeCast",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
LINE_IN: Final[Source] = Source( LINE_IN: Final[Source] = Source(
name="Line-In", name="Line-In",
id="lineIn", id="lineIn",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
SPDIF: Final[Source] = Source( SPDIF: Final[Source] = Source(
name="Optical", name="Optical",
id="spdif", id="spdif",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
NET_RADIO: Final[Source] = Source( NET_RADIO: Final[Source] = Source(
name="B&O Radio", name="B&O Radio",
id="netRadio", id="netRadio",
is_seekable=False, is_seekable=False,
is_enabled=True,
is_playable=True,
) )
DEEZER: Final[Source] = Source( DEEZER: Final[Source] = Source(
name="Deezer", name="Deezer",
id="deezer", id="deezer",
is_seekable=True, is_seekable=True,
is_enabled=True,
is_playable=True,
) )
TIDAL: Final[Source] = Source( TIDAL: Final[Source] = Source(
name="Tidal", name="Tidal",
id="tidal", id="tidal",
is_seekable=True, is_seekable=True,
is_enabled=True,
is_playable=True,
) )
@ -170,20 +186,6 @@ VALID_MEDIA_TYPES: Final[tuple] = (
MediaType.CHANNEL, MediaType.CHANNEL,
) )
# Sources on the device that should not be selectable by the user
HIDDEN_SOURCE_IDS: Final[tuple] = (
"airPlay",
"bluetooth",
"chromeCast",
"generator",
"local",
"dlna",
"qplay",
"wpl",
"pl",
"beolink",
"usbIn",
)
# Fallback sources to use in case of API failure. # Fallback sources to use in case of API failure.
FALLBACK_SOURCES: Final[SourceArray] = SourceArray( FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
@ -191,7 +193,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="uriStreamer", id="uriStreamer",
is_enabled=True, is_enabled=True,
is_playable=False, is_playable=True,
name="Audio Streamer", name="Audio Streamer",
type=SourceTypeEnum(value="uriStreamer"), type=SourceTypeEnum(value="uriStreamer"),
is_seekable=False, is_seekable=False,
@ -199,7 +201,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="bluetooth", id="bluetooth",
is_enabled=True, is_enabled=True,
is_playable=False, is_playable=True,
name="Bluetooth", name="Bluetooth",
type=SourceTypeEnum(value="bluetooth"), type=SourceTypeEnum(value="bluetooth"),
is_seekable=False, is_seekable=False,
@ -207,7 +209,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source( Source(
id="spotify", id="spotify",
is_enabled=True, is_enabled=True,
is_playable=False, is_playable=True,
name="Spotify Connect", name="Spotify Connect",
type=SourceTypeEnum(value="spotify"), type=SourceTypeEnum(value="spotify"),
is_seekable=True, is_seekable=True,

View File

@ -0,0 +1,9 @@
{
"services": {
"beolink_join": { "service": "mdi:location-enter" },
"beolink_expand": { "service": "mdi:location-enter" },
"beolink_unexpand": { "service": "mdi:location-exit" },
"beolink_leave": { "service": "mdi:close-circle-outline" },
"beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" }
}
}

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION from mozart_api import __version__ as MOZART_API_VERSION
from mozart_api.exceptions import ApiException from mozart_api.exceptions import ApiException, NotFoundException
from mozart_api.models import ( from mozart_api.models import (
Action, Action,
Art, Art,
@ -38,6 +38,7 @@ from mozart_api.models import (
VolumeState, VolumeState,
) )
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
import voluptuous as vol
from homeassistant.components import media_source from homeassistant.components import media_source
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -55,10 +56,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, Platform from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from . import BangOlufsenConfigEntry from . import BangOlufsenConfigEntry
@ -70,7 +78,6 @@ from .const import (
CONNECTION_STATUS, CONNECTION_STATUS,
DOMAIN, DOMAIN,
FALLBACK_SOURCES, FALLBACK_SOURCES,
HIDDEN_SOURCE_IDS,
VALID_MEDIA_TYPES, VALID_MEDIA_TYPES,
BangOlufsenMediaType, BangOlufsenMediaType,
BangOlufsenSource, BangOlufsenSource,
@ -117,6 +124,58 @@ async def async_setup_entry(
] ]
) )
# Register actions.
platform = async_get_current_platform()
jid_regex = vol.Match(
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
)
platform.async_register_entity_service(
name="beolink_join",
schema={vol.Optional("beolink_jid"): jid_regex},
func="async_beolink_join",
)
platform.async_register_entity_service(
name="beolink_expand",
schema={
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
vol.Exclusive(
"beolink_jids",
"devices",
"Define either specific Beolink JIDs or all discovered",
): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_expand",
)
platform.async_register_entity_service(
name="beolink_unexpand",
schema={
vol.Required("beolink_jids"): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_unexpand",
)
platform.async_register_entity_service(
name="beolink_leave",
schema=None,
func="async_beolink_leave",
)
platform.async_register_entity_service(
name="beolink_allstandby",
schema=None,
func="async_beolink_allstandby",
)
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player.""" """Representation of a media player."""
@ -157,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible sources # Beolink compatible sources
self._beolink_sources: dict[str, bool] = {} self._beolink_sources: dict[str, bool] = {}
self._remote_leader: BeolinkLeader | None = None self._remote_leader: BeolinkLeader | None = None
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers.""" """Turn on the dispatchers."""
@ -166,9 +227,11 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
CONNECTION_STATUS: self._async_update_connection_state, CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes,
WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources,
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change, WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
@ -230,6 +293,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self._async_update_sound_modes() await self._async_update_sound_modes()
# Update beolink attributes and device name.
await self._async_update_name_and_beolink()
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update queue settings.""" """Update queue settings."""
# The WebSocket event listener is the main handler for connection state. # The WebSocket event listener is the main handler for connection state.
@ -243,7 +309,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
if queue_settings.shuffle is not None: if queue_settings.shuffle is not None:
self._attr_shuffle = queue_settings.shuffle self._attr_shuffle = queue_settings.shuffle
async def _async_update_sources(self) -> None: async def _async_update_sources(self, _: Source | None = None) -> None:
"""Get sources for the specific product.""" """Get sources for the specific product."""
# Audio sources # Audio sources
@ -270,10 +336,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._audio_sources = { self._audio_sources = {
source.id: source.name source.id: source.name
for source in cast(list[Source], sources.items) for source in cast(list[Source], sources.items)
if source.is_enabled if source.is_enabled and source.id and source.name and source.is_playable
and source.id
and source.name
and source.id not in HIDDEN_SOURCE_IDS
} }
# Some sources are not Beolink expandable, meaning that they can't be joined by # Some sources are not Beolink expandable, meaning that they can't be joined by
@ -375,9 +438,44 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self.async_write_ha_state() self.async_write_ha_state()
async def _async_update_name_and_beolink(self) -> None:
"""Update the device friendly name."""
beolink_self = await self._client.get_beolink_self()
# Update device name
device_registry = dr.async_get(self.hass)
assert self.device_entry is not None
device_registry.async_update_device(
device_id=self.device_entry.id,
name=beolink_self.friendly_name,
)
await self._async_update_beolink()
async def _async_update_beolink(self) -> None: async def _async_update_beolink(self) -> None:
"""Update the current Beolink leader, listeners, peers and self.""" """Update the current Beolink leader, listeners, peers and self."""
self._beolink_attributes = {}
assert self.device_entry is not None
assert self.device_entry.name is not None
# Add Beolink self
self._beolink_attributes = {
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
}
# Add Beolink peers
peers = await self._client.get_beolink_peers()
if len(peers) > 0:
self._beolink_attributes["beolink"]["peers"] = {}
for peer in peers:
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
peer.jid
)
# Add Beolink listeners / leader # Add Beolink listeners / leader
self._remote_leader = self._playback_metadata.remote_leader self._remote_leader = self._playback_metadata.remote_leader
@ -397,9 +495,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add self # Add self
group_members.append(self.entity_id) group_members.append(self.entity_id)
self._beolink_attributes["beolink"]["leader"] = {
self._remote_leader.friendly_name: self._remote_leader.jid,
}
# If not listener, check if leader. # If not listener, check if leader.
else: else:
beolink_listeners = await self._client.get_beolink_listeners() beolink_listeners = await self._client.get_beolink_listeners()
beolink_listeners_attribute = {}
# Check if the device is a leader. # Check if the device is a leader.
if len(beolink_listeners) > 0: if len(beolink_listeners) > 0:
@ -420,6 +523,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
for beolink_listener in beolink_listeners for beolink_listener in beolink_listeners
] ]
) )
# Update Beolink attributes
for beolink_listener in beolink_listeners:
for peer in peers:
if peer.jid == beolink_listener.jid:
# Get the friendly names for the listeners from the peers
beolink_listeners_attribute[peer.friendly_name] = (
beolink_listener.jid
)
break
self._beolink_attributes["beolink"]["listeners"] = (
beolink_listeners_attribute
)
self._attr_group_members = group_members self._attr_group_members = group_members
@ -605,6 +720,17 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
return self._source_change.name return self._source_change.name
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return information that is not returned anywhere else."""
attributes: dict[str, Any] = {}
# Add Beolink attributes
if self._beolink_attributes:
attributes.update(self._beolink_attributes)
return attributes
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Set the device to "networkStandby".""" """Set the device to "networkStandby"."""
await self._client.post_standby() await self._client.post_standby()
@ -876,23 +1002,30 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible B&O device. # Beolink compatible B&O device.
# Repeated presses / calls will cycle between compatible playing devices. # Repeated presses / calls will cycle between compatible playing devices.
if len(group_members) == 0: if len(group_members) == 0:
await self._async_beolink_join() await self.async_beolink_join()
return return
# Get JID for each group member # Get JID for each group member
jids = [self._get_beolink_jid(group_member) for group_member in group_members] jids = [self._get_beolink_jid(group_member) for group_member in group_members]
await self._async_beolink_expand(jids) await self.async_beolink_expand(jids)
async def async_unjoin_player(self) -> None: async def async_unjoin_player(self) -> None:
"""Unjoin Beolink session. End session if leader.""" """Unjoin Beolink session. End session if leader."""
await self._async_beolink_leave() await self.async_beolink_leave()
async def _async_beolink_join(self) -> None: # Custom actions:
async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
"""Join a Beolink multi-room experience.""" """Join a Beolink multi-room experience."""
await self._client.join_latest_beolink_experience() if beolink_jid is None:
await self._client.join_latest_beolink_experience()
else:
await self._client.join_beolink_peer(jid=beolink_jid)
async def _async_beolink_expand(self, beolink_jids: list[str]) -> None: async def async_beolink_expand(
self, beolink_jids: list[str] | None = None, all_discovered: bool = False
) -> None:
"""Expand a Beolink multi-room experience with a device or devices.""" """Expand a Beolink multi-room experience with a device or devices."""
# Ensure that the current source is expandable # Ensure that the current source is expandable
if not self._beolink_sources[cast(str, self._source_change.id)]: if not self._beolink_sources[cast(str, self._source_change.id)]:
raise ServiceValidationError( raise ServiceValidationError(
@ -904,10 +1037,37 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
}, },
) )
# Try to expand to all defined devices # Expand to all discovered devices
for beolink_jid in beolink_jids: if all_discovered:
await self._client.post_beolink_expand(jid=beolink_jid) peers = await self._client.get_beolink_peers()
async def _async_beolink_leave(self) -> None: for peer in peers:
try:
await self._client.post_beolink_expand(jid=peer.jid)
except NotFoundException:
_LOGGER.warning("Unable to expand to %s", peer.jid)
# Try to expand to all defined devices
elif beolink_jids:
for beolink_jid in beolink_jids:
try:
await self._client.post_beolink_expand(jid=beolink_jid)
except NotFoundException:
_LOGGER.warning(
"Unable to expand to %s. Is the device available on the network?",
beolink_jid,
)
async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None:
"""Unexpand a Beolink multi-room experience with a device or devices."""
# Unexpand all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_unexpand(jid=beolink_jid)
async def async_beolink_leave(self) -> None:
"""Leave the current Beolink experience.""" """Leave the current Beolink experience."""
await self._client.post_beolink_leave() await self._client.post_beolink_leave()
async def async_beolink_allstandby(self) -> None:
"""Set all connected Beolink devices to standby."""
await self._client.post_beolink_allstandby()

View File

@ -0,0 +1,79 @@
beolink_allstandby:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_expand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
all_discovered:
required: false
example: false
selector:
boolean:
jid_options:
collapsed: false
fields:
beolink_jids:
required: false
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:
beolink_join:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jid:
required: false
example: 1111.2222222.33333333@products.bang-olufsen.com
selector:
text:
beolink_leave:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
beolink_unexpand:
target:
entity:
integration: bang_olufsen
domain: media_player
device:
integration: bang_olufsen
fields:
jid_options:
collapsed: false
fields:
beolink_jids:
required: true
example: >-
[
1111.2222222.33333333@products.bang-olufsen.com,
4444.5555555.66666666@products.bang-olufsen.com
]
selector:
object:

View File

@ -1,4 +1,8 @@
{ {
"common": {
"jid_options_name": "JID options",
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity."
},
"config": { "config": {
"error": { "error": {
"api_exception": "[%key:common::config_flow::error::cannot_connect%]", "api_exception": "[%key:common::config_flow::error::cannot_connect%]",
@ -25,6 +29,68 @@
} }
} }
}, },
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
"description": "Set all Connected Beolink devices to standby."
},
"beolink_expand": {
"name": "Beolink expand",
"description": "Expand current Beolink experience.",
"fields": {
"all_discovered": {
"name": "All discovered",
"description": "Expand Beolink experience to all discovered devices."
},
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will join current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_join": {
"name": "Beolink join",
"description": "Join a Beolink experience.",
"fields": {
"beolink_jid": {
"name": "Beolink JID",
"description": "Manually specify Beolink JID to join."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
},
"beolink_leave": {
"name": "Beolink leave",
"description": "Leave a Beolink experience."
},
"beolink_unexpand": {
"name": "Beolink unexpand",
"description": "Unexpand from current Beolink experience.",
"fields": {
"beolink_jids": {
"name": "Beolink JIDs",
"description": "Specify which Beolink JIDs will leave from current Beolink experience."
}
},
"sections": {
"jid_options": {
"name": "[%key:component::bang_olufsen::common::jid_options_name%]",
"description": "[%key:component::bang_olufsen::common::jid_options_description%]"
}
}
}
},
"exceptions": { "exceptions": {
"m3u_invalid_format": { "m3u_invalid_format": {
"message": "Media sources with the .m3u extension are not supported." "message": "Media sources with the .m3u extension are not supported."

View File

@ -63,6 +63,9 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_playback_progress_notifications( self._client.get_playback_progress_notifications(
self.on_playback_progress_notification self.on_playback_progress_notification
) )
self._client.get_playback_source_notifications(
self.on_playback_source_notification
)
self._client.get_playback_state_notifications( self._client.get_playback_state_notifications(
self.on_playback_state_notification self.on_playback_state_notification
) )
@ -117,6 +120,11 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self.hass, self.hass,
f"{self._unique_id}_{WebsocketNotification.BEOLINK}", f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
) )
elif notification_type is WebsocketNotification.CONFIGURATION:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
)
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
async_dispatcher_send( async_dispatcher_send(
self.hass, self.hass,
@ -157,6 +165,14 @@ class BangOlufsenWebsocket(BangOlufsenBase):
notification, notification,
) )
def on_playback_source_notification(self, notification: Source) -> None:
"""Send playback_source dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
notification,
)
def on_source_change_notification(self, notification: Source) -> None: def on_source_change_notification(self, notification: Source) -> None:
"""Send source_change dispatch.""" """Send source_change dispatch."""
async_dispatcher_send( async_dispatcher_send(

View File

@ -364,12 +364,13 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
status = self._status.state match self._status.state:
if status in ("pause", "stop"): case "pause":
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
if status in ("stream", "play"): case "stream" | "play":
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE case _:
return MediaPlayerState.IDLE
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
@ -769,7 +770,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player.""" """Send volume_up command to media player."""
volume = int(volume * 100) volume = int(round(volume * 100))
volume = min(100, volume) volume = min(100, volume)
volume = max(0, volume) volume = max(0, volume)

View File

@ -7,7 +7,11 @@ from typing import Any
from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError from httpx import RequestError
import voluptuous as vol import voluptuous as vol
@ -17,7 +21,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try: try:
await auth.login() await auth.login()
except MyBMWCaptchaMissingError as ex:
raise MissingCaptcha from ex
except MyBMWAuthError as ex: except MyBMWAuthError as ex:
raise InvalidAuth from ex raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex: except (MyBMWAPIError, RequestError) as ex:
@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
CONF_GCID: info.get(CONF_GCID), CONF_GCID: info.get(CONF_GCID),
} }
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
@ -145,10 +153,10 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> BMWOptionsFlow: ) -> BMWOptionsFlow:
"""Return a MyBMW option flow.""" """Return a MyBMW option flow."""
return BMWOptionsFlow(config_entry) return BMWOptionsFlow()
class BMWOptionsFlow(OptionsFlowWithConfigEntry): class BMWOptionsFlow(OptionsFlow):
"""Handle a option flow for MyBMW.""" """Handle a option flow for MyBMW."""
async def async_step_init( async def async_step_init(
@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError): class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth.""" """Error to indicate there is invalid auth."""
class MissingCaptcha(HomeAssistantError):
"""Error to indicate the captcha token is missing."""

View File

@ -7,7 +7,12 @@ import logging
from bimmer_connected.account import MyBMWAccount from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError from bimmer_connected.models import (
GPSPosition,
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError from httpx import RequestError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
try: try:
await self.account.get_vehicles() await self.account.get_vehicles()
except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_captcha",
) from err
except MyBMWAuthError as err: except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues # Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success: if self.last_update_success:

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["bimmer_connected"], "loggers": ["bimmer_connected"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["bimmer-connected[china]==0.16.3"] "requirements": ["bimmer-connected[china]==0.16.4"]
} }

View File

@ -11,7 +11,8 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_captcha": "Captcha validation missing"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
@ -200,6 +201,9 @@
"exceptions": { "exceptions": {
"invalid_poi": { "invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}" "message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
"message": "Login requires captcha validation"
} }
} }
} }

View File

@ -16,7 +16,8 @@
"list_access": { "list_access": {
"default": "mdi:account-lock", "default": "mdi:account-lock",
"state": { "state": {
"shared": "mdi:account-group" "shared": "mdi:account-group",
"invitation": "mdi:account-multiple-plus"
} }
} }
}, },

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring", "documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["bring-api==0.9.0"] "requirements": ["bring-api==0.9.1"]
} }

View File

@ -79,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
translation_key=BringSensor.LIST_ACCESS, translation_key=BringSensor.LIST_ACCESS,
value_fn=lambda lst, _: lst["status"].lower(), value_fn=lambda lst, _: lst["status"].lower(),
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
options=["registered", "shared"], options=["registered", "shared", "invitation"],
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
), ),
) )

View File

@ -66,7 +66,8 @@
"name": "List access", "name": "List access",
"state": { "state": {
"registered": "Private", "registered": "Private",
"shared": "Shared" "shared": "Shared",
"invitation": "Invitation pending"
} }
} }
} }

View File

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==1.0.0"] "requirements": ["python-bsblan==1.2.1"]
} }

View File

@ -109,6 +109,7 @@ async def async_setup_platform(
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
coordinator = CalDavUpdateCoordinator( coordinator = CalDavUpdateCoordinator(
hass, hass,
None,
calendar=calendar, calendar=calendar,
days=days, days=days,
include_all_day=True, include_all_day=True,
@ -126,6 +127,7 @@ async def async_setup_platform(
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
coordinator = CalDavUpdateCoordinator( coordinator = CalDavUpdateCoordinator(
hass, hass,
None,
calendar=calendar, calendar=calendar,
days=days, days=days,
include_all_day=False, include_all_day=False,
@ -152,6 +154,7 @@ async def async_setup_entry(
async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass),
CalDavUpdateCoordinator( CalDavUpdateCoordinator(
hass, hass,
entry,
calendar=calendar, calendar=calendar,
days=CONFIG_ENTRY_DEFAULT_DAYS, days=CONFIG_ENTRY_DEFAULT_DAYS,
include_all_day=True, include_all_day=True,
@ -204,7 +207,8 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
if self._supports_offset: if self._supports_offset:
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"offset_reached": is_offset_reached( "offset_reached": is_offset_reached(
self._event.start_datetime_local, self.coordinator.offset self._event.start_datetime_local,
self.coordinator.offset, # type: ignore[arg-type]
) )
if self._event if self._event
else False else False

View File

@ -6,6 +6,9 @@ from datetime import date, datetime, time, timedelta
from functools import partial from functools import partial
import logging import logging
import re import re
from typing import TYPE_CHECKING
import caldav
from homeassistant.components.calendar import CalendarEvent, extract_offset from homeassistant.components.calendar import CalendarEvent, extract_offset
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -14,6 +17,9 @@ from homeassistant.util import dt as dt_util
from .api import get_attr_value from .api import get_attr_value
if TYPE_CHECKING:
from . import CalDavConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
@ -23,11 +29,20 @@ OFFSET = "!!"
class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
"""Class to utilize the calendar dav client object to get next event.""" """Class to utilize the calendar dav client object to get next event."""
def __init__(self, hass, calendar, days, include_all_day, search): def __init__(
self,
hass: HomeAssistant,
entry: CalDavConfigEntry | None,
calendar: caldav.Calendar,
days: int,
include_all_day: bool,
search: str | None,
) -> None:
"""Set up how we are going to search the WebDav calendar.""" """Set up how we are going to search the WebDav calendar."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=f"CalDAV {calendar.name}", name=f"CalDAV {calendar.name}",
update_interval=MIN_TIME_BETWEEN_UPDATES, update_interval=MIN_TIME_BETWEEN_UPDATES,
) )
@ -35,7 +50,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
self.days = days self.days = days
self.include_all_day = include_all_day self.include_all_day = include_all_day
self.search = search self.search = search
self.offset = None self.offset: timedelta | None = None
async def async_get_events( async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime self, hass: HomeAssistant, start_date: datetime, end_date: datetime
@ -109,7 +124,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
_start_of_tomorrow = start_of_tomorrow _start_of_tomorrow = start_of_tomorrow
if _start_of_today <= start_dt < _start_of_tomorrow: if _start_of_today <= start_dt < _start_of_tomorrow:
new_event = event.copy() new_event = event.copy()
new_vevent = new_event.instance.vevent new_vevent = new_event.instance.vevent # type: ignore[attr-defined]
if hasattr(new_vevent, "dtend"): if hasattr(new_vevent, "dtend"):
dur = new_vevent.dtend.value - new_vevent.dtstart.value dur = new_vevent.dtend.value - new_vevent.dtstart.value
new_vevent.dtend.value = start_dt + dur new_vevent.dtend.value = start_dt + dur

View File

@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr import attr
from propcache import cached_property, under_cached_property from propcache import cached_property, under_cached_property
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCIceServer from webrtc_models import RTCIceCandidate, RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@ -421,8 +421,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if hass.config.webrtc.ice_servers: if hass.config.webrtc.ice_servers:
return hass.config.webrtc.ice_servers return hass.config.webrtc.ice_servers
return [ return [
RTCIceServer(urls="stun:stun.home-assistant.io:80"), RTCIceServer(
RTCIceServer(urls="stun:stun.home-assistant.io:3478"), urls=[
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
),
] ]
async_register_ice_servers(hass, get_ice_servers) async_register_ice_servers(hass, get_ice_servers)
@ -472,6 +476,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_state: None = None # State is determined by is_on _attr_state: None = None # State is determined by is_on
_attr_supported_features: CameraEntityFeature = CameraEntityFeature(0) _attr_supported_features: CameraEntityFeature = CameraEntityFeature(0)
__supports_stream: CameraEntityFeature | None = None
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize a camera.""" """Initialize a camera."""
self._cache: dict[str, Any] = {} self._cache: dict[str, Any] = {}
@ -484,9 +490,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._create_stream_lock: asyncio.Lock | None = None self._create_stream_lock: asyncio.Lock | None = None
self._webrtc_provider: CameraWebRTCProvider | None = None self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._webrtc_sync_offer = ( self._supports_native_sync_webrtc = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
) )
self._supports_native_async_webrtc = (
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
@cached_property @cached_property
def entity_picture(self) -> str: def entity_picture(self) -> str:
@ -623,7 +633,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Integrations can override with a native WebRTC implementation. Integrations can override with a native WebRTC implementation.
""" """
if self._webrtc_sync_offer: if self._supports_native_sync_webrtc:
try: try:
answer = await self.async_handle_web_rtc_offer(offer_sdp) answer = await self.async_handle_web_rtc_offer(offer_sdp)
except ValueError as ex: except ValueError as ex:
@ -779,6 +789,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
async def async_internal_added_to_hass(self) -> None: async def async_internal_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """Run when entity about to be added to hass."""
await super().async_internal_added_to_hass() await super().async_internal_added_to_hass()
self.__supports_stream = (
self.supported_features_compat & CameraEntityFeature.STREAM
)
await self.async_refresh_providers(write_state=False) await self.async_refresh_providers(write_state=False)
async def async_refresh_providers(self, *, write_state: bool = True) -> None: async def async_refresh_providers(self, *, write_state: bool = True) -> None:
@ -788,18 +801,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
providers or inputs to the state attributes change. providers or inputs to the state attributes change.
""" """
old_provider = self._webrtc_provider old_provider = self._webrtc_provider
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)
old_legacy_provider = self._legacy_webrtc_provider old_legacy_provider = self._legacy_webrtc_provider
new_provider = None
new_legacy_provider = None new_legacy_provider = None
if new_provider is None:
# Only add the legacy provider if the new provider is not available # Skip all providers if the camera has a native WebRTC implementation
new_legacy_provider = await self._async_get_supported_webrtc_provider( if not (
async_get_supported_legacy_provider self._supports_native_sync_webrtc or self._supports_native_async_webrtc
):
# Camera doesn't have a native WebRTC implementation
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
) )
if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
)
if old_provider != new_provider or old_legacy_provider != new_legacy_provider: if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
self._webrtc_provider = new_provider self._webrtc_provider = new_provider
self._legacy_webrtc_provider = new_legacy_provider self._legacy_webrtc_provider = new_legacy_provider
@ -827,20 +847,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the WebRTC client configuration and extend it with the registered ice servers.""" """Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration() config = self._async_get_webrtc_client_configuration()
ice_servers = [ if not self._supports_native_sync_webrtc:
server # Until 2024.11, the frontend was not resolving any ice servers
for servers in self.hass.data.get(DATA_ICE_SERVERS, []) # The async approach was added 2024.11 and new integrations need to use it
for server in servers() ice_servers = [
] server
config.configuration.ice_servers.extend(ice_servers) for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
for server in servers()
]
config.configuration.ice_servers.extend(ice_servers)
config.get_candidates_upfront = ( config.get_candidates_upfront = (
self._webrtc_sync_offer or self._legacy_webrtc_provider is not None self._supports_native_sync_webrtc
or self._legacy_webrtc_provider is not None
) )
return config return config
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle a WebRTC candidate.""" """Handle a WebRTC candidate."""
if self._webrtc_provider: if self._webrtc_provider:
await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate) await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate)
@ -864,12 +890,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the camera capabilities.""" """Return the camera capabilities."""
frontend_stream_types = set() frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat: if CameraEntityFeature.STREAM in self.supported_features_compat:
if ( if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
type(self).async_handle_web_rtc_offer
!= Camera.async_handle_web_rtc_offer
or type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
):
# The camera has a native WebRTC implementation # The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC) frontend_stream_types.add(StreamType.WEB_RTC)
else: else:
@ -880,6 +901,21 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return CameraCapabilities(frontend_stream_types) return CameraCapabilities(frontend_stream_types)
@callback
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
Schedules async_refresh_providers if support of streams have changed.
"""
super().async_write_ha_state()
if self.__supports_stream != (
supports_stream := self.supported_features_compat
& CameraEntityFeature.STREAM
):
self.__supports_stream = supports_stream
self._invalidate_camera_capabilities_cache()
self.hass.async_create_task(self.async_refresh_providers())
class CameraView(HomeAssistantView): class CameraView(HomeAssistantView):
"""Base CameraView.""" """Base CameraView."""

View File

@ -11,7 +11,7 @@ import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCConfiguration, RTCIceServer from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage):
class WebRTCCandidate(WebRTCMessage): class WebRTCCandidate(WebRTCMessage):
"""WebRTC candidate.""" """WebRTC candidate."""
candidate: str candidate: RTCIceCandidate
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the message."""
return {
"type": self._get_type(),
"candidate": self.candidate.candidate,
}
@dataclass(frozen=True) @dataclass(frozen=True)
@ -138,7 +145,9 @@ class CameraWebRTCProvider(ABC):
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
@abstractmethod @abstractmethod
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate.""" """Handle the WebRTC candidate."""
@callback @callback
@ -319,7 +328,9 @@ async def ws_candidate(
) )
return return
await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"])
)
connection.send_message(websocket_api.result_message(msg["id"])) connection.send_message(websocket_api.result_message(msg["id"]))

View File

@ -41,7 +41,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> CastOptionsFlowHandler: ) -> CastOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return CastOptionsFlowHandler(config_entry) return CastOptionsFlowHandler()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -109,9 +109,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
class CastOptionsFlowHandler(OptionsFlow): class CastOptionsFlowHandler(OptionsFlow):
"""Handle Google Cast options.""" """Handle Google Cast options."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self) -> None:
"""Initialize Google Cast options flow.""" """Initialize Google Cast options flow."""
self.config_entry = config_entry
self.updated_config: dict[str, Any] = {} self.updated_config: dict[str, Any] = {}
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:

View File

@ -8,6 +8,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["hass_nabucasa"], "loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.83.0"], "requirements": ["hass-nabucasa==0.84.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -4,5 +4,5 @@
"codeowners": ["@Petro31"], "codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation", "documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated", "iot_class": "calculated",
"requirements": ["numpy==1.26.4"] "requirements": ["numpy==2.1.2"]
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.10.30"] "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
} }

View File

@ -213,18 +213,19 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize Crownstone options.""" """Initialize Crownstone options."""
super().__init__(OPTIONS_FLOW, self.async_create_new_entry) super().__init__(OPTIONS_FLOW, self.async_create_new_entry)
self.entry = config_entry self.options = config_entry.options.copy()
self.updated_options = config_entry.options.copy()
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage Crownstone options.""" """Manage Crownstone options."""
self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][
self.config_entry.entry_id
].cloud
spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data}
usb_path = self.entry.options.get(CONF_USB_PATH) usb_path = self.config_entry.options.get(CONF_USB_PATH)
usb_sphere = self.entry.options.get(CONF_USB_SPHERE) usb_sphere = self.config_entry.options.get(CONF_USB_SPHERE)
options_schema = vol.Schema( options_schema = vol.Schema(
{vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool}
@ -243,14 +244,14 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
if user_input[CONF_USE_USB_OPTION] and usb_path is None: if user_input[CONF_USE_USB_OPTION] and usb_path is None:
return await self.async_step_usb_config() return await self.async_step_usb_config()
if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: if not user_input[CONF_USE_USB_OPTION] and usb_path is not None:
self.updated_options[CONF_USB_PATH] = None self.options[CONF_USB_PATH] = None
self.updated_options[CONF_USB_SPHERE] = None self.options[CONF_USB_SPHERE] = None
elif ( elif (
CONF_USB_SPHERE_OPTION in user_input CONF_USB_SPHERE_OPTION in user_input
and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere
): ):
sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]]
self.updated_options[CONF_USB_SPHERE] = sphere_id self.options[CONF_USB_SPHERE] = sphere_id
return self.async_create_new_entry() return self.async_create_new_entry()
@ -260,7 +261,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
"""Create a new entry.""" """Create a new entry."""
# these attributes will only change when a usb was configured # these attributes will only change when a usb was configured
if self.usb_path is not None and self.usb_sphere_id is not None: if self.usb_path is not None and self.usb_sphere_id is not None:
self.updated_options[CONF_USB_PATH] = self.usb_path self.options[CONF_USB_PATH] = self.usb_path
self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id self.options[CONF_USB_SPHERE] = self.usb_sphere_id
return super().async_create_entry(title="", data=self.updated_options) return super().async_create_entry(title="", data=self.options)

View File

@ -74,9 +74,11 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> DeconzOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return DeconzOptionsFlowHandler(config_entry) return DeconzOptionsFlowHandler()
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the deCONZ config flow.""" """Initialize the deCONZ config flow."""
@ -299,11 +301,6 @@ class DeconzOptionsFlowHandler(OptionsFlow):
gateway: DeconzHub gateway: DeconzHub
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize deCONZ options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -315,8 +312,7 @@ class DeconzOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the deconz devices options.""" """Manage the deconz devices options."""
if user_input is not None: if user_input is not None:
self.options.update(user_input) return self.async_create_entry(data=self.config_entry.options | user_input)
return self.async_create_entry(title="", data=self.options)
schema_options = {} schema_options = {}
for option, default in ( for option, default in (

View File

@ -47,7 +47,6 @@ class OptionsFlowHandler(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options) self.options = dict(config_entry.options)
async def async_step_init( async def async_step_init(

View File

@ -14,7 +14,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
@ -101,7 +101,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> DnsIPOptionsFlowHandler: ) -> DnsIPOptionsFlowHandler:
"""Return Option handler.""" """Return Option handler."""
return DnsIPOptionsFlowHandler(config_entry) return DnsIPOptionsFlowHandler()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry): class DnsIPOptionsFlowHandler(OptionsFlow):
"""Handle a option config flow for dnsip integration.""" """Handle a option config flow for dnsip integration."""
async def async_step_init( async def async_step_init(

View File

@ -171,9 +171,11 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> DSMROptionFlowHandler: def async_get_options_flow(
config_entry: ConfigEntry,
) -> DSMROptionFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return DSMROptionFlowHandler(config_entry) return DSMROptionFlowHandler()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -311,10 +313,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
class DSMROptionFlowHandler(OptionsFlow): class DSMROptionFlowHandler(OptionsFlow):
"""Handle options.""" """Handle options."""
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.entry = entry
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -328,7 +326,7 @@ class DSMROptionFlowHandler(OptionsFlow):
{ {
vol.Optional( vol.Optional(
CONF_TIME_BETWEEN_UPDATE, CONF_TIME_BETWEEN_UPDATE,
default=self.entry.options.get( default=self.config_entry.options.get(
CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE
), ),
): vol.All(vol.Coerce(int), vol.Range(min=0)), ): vol.All(vol.Coerce(int), vol.Range(min=0)),

View File

@ -31,25 +31,26 @@ async def async_setup_entry(
"""Set up the ecobee thermostat switch entity.""" """Set up the ecobee thermostat switch entity."""
data: EcobeeData = hass.data[DOMAIN] data: EcobeeData = hass.data[DOMAIN]
async_add_entities( entities: list[SwitchEntity] = [
[ EcobeeVentilator20MinSwitch(
EcobeeVentilator20MinSwitch( data,
data, index,
index, (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"]))
(await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) or dt_util.get_default_time_zone(),
or dt_util.get_default_time_zone(), )
) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none"
]
entities.extend(
(
EcobeeSwitchAuxHeatOnly(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none" if thermostat["settings"]["hasHeatPump"]
], )
update_before_add=True,
) )
async_add_entities( async_add_entities(entities, update_before_add=True)
EcobeeSwitchAuxHeatOnly(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"]
)
class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):

View File

@ -14,7 +14,6 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -103,13 +102,12 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
return ElevenLabsOptionsFlow(config_entry) return ElevenLabsOptionsFlow(config_entry)
class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): class ElevenLabsOptionsFlow(OptionsFlow):
"""ElevenLabs options flow.""" """ElevenLabs options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
super().__init__(config_entry) self.api_key: str = config_entry.data[CONF_API_KEY]
self.api_key: str = self.config_entry.data[CONF_API_KEY]
# id -> name # id -> name
self.voices: dict[str, str] = {} self.voices: dict[str, str] = {}
self.models: dict[str, str] = {} self.models: dict[str, str] = {}
@ -170,7 +168,7 @@ class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry):
vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, vol.Required(CONF_CONFIGURE_VOICE, default=False): bool,
} }
), ),
self.options, self.config_entry.options,
) )
async def async_step_voice_settings( async def async_step_voice_settings(

View File

@ -5,8 +5,11 @@ from pyemoncms import EmoncmsClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER
from .coordinator import EmoncmsCoordinator from .coordinator import EmoncmsCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -14,6 +17,49 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]
def _migrate_unique_id(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str
) -> None:
"""Migrate to emoncms unique id if needed."""
ent_reg = er.async_get(hass)
entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id)
for entity in entry_entities:
if entity.unique_id.split("-")[0] == entry.entry_id:
feed_id = entity.unique_id.split("-")[-1]
LOGGER.debug(f"moving feed {feed_id} to hardware uuid")
ent_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}"
)
hass.config_entries.async_update_entry(
entry,
unique_id=emoncms_unique_id,
)
async def _check_unique_id_migration(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient
) -> None:
"""Check if we can migrate to the emoncms uuid."""
emoncms_unique_id = await emoncms_client.async_get_uuid()
if emoncms_unique_id:
if entry.unique_id != emoncms_unique_id:
_migrate_unique_id(hass, entry, emoncms_unique_id)
else:
async_create_issue(
hass,
DOMAIN,
"migrate database",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="migrate_database",
translation_placeholders={
"url": entry.data[CONF_URL],
"doc_url": EMONCMS_UUID_DOC_URL,
},
)
async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
"""Load a config entry.""" """Load a config entry."""
emoncms_client = EmoncmsClient( emoncms_client = EmoncmsClient(
@ -21,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b
entry.data[CONF_API_KEY], entry.data[CONF_API_KEY],
session=async_get_clientsession(hass), session=async_get_clientsession(hass),
) )
await _check_unique_id_migration(hass, entry, emoncms_client)
coordinator = EmoncmsCoordinator(hass, emoncms_client) coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View File

@ -1,5 +1,7 @@
"""Configflow for the emoncms integration.""" """Configflow for the emoncms integration."""
from __future__ import annotations
from typing import Any from typing import Any
from pyemoncms import EmoncmsClient from pyemoncms import EmoncmsClient
@ -9,10 +11,10 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -46,13 +48,10 @@ def sensor_name(url: str) -> str:
return f"emoncms@{sensorip}" return f"emoncms@{sensorip}"
async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]: async def get_feed_list(
emoncms_client: EmoncmsClient,
) -> dict[str, Any]:
"""Check connection to emoncms and return feed list if successful.""" """Check connection to emoncms and return feed list if successful."""
emoncms_client = EmoncmsClient(
url,
api_key,
session=async_get_clientsession(hass),
)
return await emoncms_client.async_request("/feed/list.json") return await emoncms_client.async_request("/feed/list.json")
@ -68,7 +67,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> OptionsFlowWithConfigEntry: ) -> EmoncmsOptionsFlow:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return EmoncmsOptionsFlow(config_entry) return EmoncmsOptionsFlow(config_entry)
@ -77,23 +76,28 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Initiate a flow via the UI.""" """Initiate a flow via the UI."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
description_placeholders = {}
if user_input is not None: if user_input is not None:
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match( self._async_abort_entries_match(
{ {
CONF_API_KEY: user_input[CONF_API_KEY], CONF_API_KEY: self.api_key,
CONF_URL: user_input[CONF_URL], CONF_URL: self.url,
} }
) )
result = await get_feed_list( emoncms_client = EmoncmsClient(
self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] self.url, self.api_key, session=async_get_clientsession(self.hass)
) )
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]: if not result[CONF_SUCCESS]:
errors["base"] = result[CONF_MESSAGE] errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}
else: else:
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
self.url = user_input[CONF_URL] await self.async_set_unique_id(await emoncms_client.async_get_uuid())
self.api_key = user_input[CONF_API_KEY] self._abort_if_unique_id_configured()
options = get_options(result[CONF_MESSAGE]) options = get_options(result[CONF_MESSAGE])
self.dropdown = { self.dropdown = {
"options": options, "options": options,
@ -113,6 +117,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
user_input, user_input,
), ),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )
async def async_step_choose_feeds( async def async_step_choose_feeds(
@ -167,32 +172,41 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
return result return result
class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry): class EmoncmsOptionsFlow(OptionsFlow):
"""Emoncms Options flow handler.""" """Emoncms Options flow handler."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize emoncms options flow."""
self._url = config_entry.data[CONF_URL]
self._api_key = config_entry.data[CONF_API_KEY]
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
data = self.options if self.options else self._config_entry.data description_placeholders = {}
url = data[CONF_URL] include_only_feeds = self.config_entry.options.get(
api_key = data[CONF_API_KEY] CONF_ONLY_INCLUDE_FEEDID,
include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []),
)
options: list = include_only_feeds options: list = include_only_feeds
result = await get_feed_list(self.hass, url, api_key) emoncms_client = EmoncmsClient(
self._url,
self._api_key,
session=async_get_clientsession(self.hass),
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]: if not result[CONF_SUCCESS]:
errors["base"] = result[CONF_MESSAGE] errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}
else: else:
options = get_options(result[CONF_MESSAGE]) options = get_options(result[CONF_MESSAGE])
dropdown = {"options": options, "mode": "dropdown", "multiple": True} dropdown = {"options": options, "mode": "dropdown", "multiple": True}
if user_input: if user_input:
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
return self.async_create_entry( return self.async_create_entry(
title=sensor_name(url),
data={ data={
CONF_URL: url,
CONF_API_KEY: api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
}, },
) )
@ -207,4 +221,5 @@ class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry):
} }
), ),
errors=errors, errors=errors,
description_placeholders=description_placeholders,
) )

View File

@ -7,6 +7,10 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message" CONF_MESSAGE = "message"
CONF_SUCCESS = "success" CONF_SUCCESS = "success"
DOMAIN = "emoncms" DOMAIN = "emoncms"
EMONCMS_UUID_DOC_URL = (
"https://docs.openenergymonitor.org/emoncms/update.html"
"#upgrading-to-a-version-producing-a-unique-identifier"
)
FEED_ID = "id" FEED_ID = "id"
FEED_NAME = "name" FEED_NAME = "name"
FEED_TAG = "tag" FEED_TAG = "tag"

View File

@ -138,29 +138,30 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the emoncms sensors.""" """Set up the emoncms sensors."""
config = entry.options if entry.options else entry.data name = sensor_name(entry.data[CONF_URL])
name = sensor_name(config[CONF_URL]) exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID)
exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) include_only_feeds = entry.options.get(
include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID)
)
if exclude_feeds is None and include_only_feeds is None: if exclude_feeds is None and include_only_feeds is None:
return return
coordinator = entry.runtime_data coordinator = entry.runtime_data
# uuid was added in emoncms database 11.5.7
unique_id = entry.unique_id if entry.unique_id else entry.entry_id
elems = coordinator.data elems = coordinator.data
if not elems: if not elems:
return return
sensors: list[EmonCmsSensor] = [] sensors: list[EmonCmsSensor] = []
for idx, elem in enumerate(elems): for idx, elem in enumerate(elems):
if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds: if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
continue continue
sensors.append( sensors.append(
EmonCmsSensor( EmonCmsSensor(
coordinator, coordinator,
entry.entry_id, unique_id,
elem["unit"], elem["unit"],
name, name,
idx, idx,
@ -175,7 +176,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__( def __init__(
self, self,
coordinator: EmoncmsCoordinator, coordinator: EmoncmsCoordinator,
entry_id: str, unique_id: str,
unit_of_measurement: str | None, unit_of_measurement: str | None,
name: str, name: str,
idx: int, idx: int,
@ -188,7 +189,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = self.coordinator.data[self.idx] elem = self.coordinator.data[self.idx]
self._attr_name = f"{name} {elem[FEED_NAME]}" self._attr_name = f"{name} {elem[FEED_NAME]}"
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}" self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}"
if unit_of_measurement in ("kWh", "Wh"): if unit_of_measurement in ("kWh", "Wh"):
self._attr_device_class = SensorDeviceClass.ENERGY self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._attr_state_class = SensorStateClass.TOTAL_INCREASING

View File

@ -1,5 +1,8 @@
{ {
"config": { "config": {
"error": {
"api_error": "An error occured in the pyemoncms API : {details}"
},
"step": { "step": {
"user": { "user": {
"data": { "data": {
@ -16,9 +19,15 @@
"include_only_feed_id": "Choose feeds to include" "include_only_feed_id": "Choose feeds to include"
} }
} }
},
"abort": {
"already_configured": "This server is already configured"
} }
}, },
"options": { "options": {
"error": {
"api_error": "[%key:component::emoncms::config::error::api_error%]"
},
"step": { "step": {
"init": { "init": {
"data": { "data": {
@ -35,6 +44,10 @@
"missing_include_only_feed_id": { "missing_include_only_feed_id": {
"title": "No feed synchronized with the {domain} sensor", "title": "No feed synchronized with the {domain} sensor",
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
},
"migrate_database": {
"title": "Upgrade your emoncms version",
"description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})"
} }
} }
} }

View File

@ -6,5 +6,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["sense_energy"], "loggers": ["sense_energy"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["sense-energy==0.13.2"] "requirements": ["sense-energy==0.13.3"]
} }

View File

@ -16,7 +16,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -66,9 +66,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> EnvoyOptionsFlowHandler: def async_get_options_flow(
config_entry: ConfigEntry,
) -> EnvoyOptionsFlowHandler:
"""Options flow handler for Enphase_Envoy.""" """Options flow handler for Enphase_Envoy."""
return EnvoyOptionsFlowHandler(config_entry) return EnvoyOptionsFlowHandler()
@callback @callback
def _async_generate_schema(self) -> vol.Schema: def _async_generate_schema(self) -> vol.Schema:
@ -288,7 +290,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
) )
class EnvoyOptionsFlowHandler(OptionsFlowWithConfigEntry): class EnvoyOptionsFlowHandler(OptionsFlow):
"""Envoy config flow options handler.""" """Envoy config flow options handler."""
async def async_step_init( async def async_step_init(

View File

@ -257,6 +257,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: MqttServiceInfo self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle MQTT discovery.""" """Handle MQTT discovery."""
if not discovery_info.payload:
return self.async_abort(reason="mqtt_missing_payload")
device_info = json_loads_object(discovery_info.payload) device_info = json_loads_object(discovery_info.payload)
if "mac" not in device_info: if "mac" not in device_info:
return self.async_abort(reason="mqtt_missing_mac") return self.async_abort(reason="mqtt_missing_mac")

View File

@ -570,7 +570,11 @@ def _async_setup_device_registry(
configuration_url = None configuration_url = None
if device_info.webserver_port > 0: if device_info.webserver_port > 0:
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
elif dashboard := async_get_dashboard(hass): elif (
(dashboard := async_get_dashboard(hass))
and dashboard.data
and dashboard.data.get(device_info.name)
):
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
manufacturer = "espressif" manufacturer = "espressif"

View File

@ -8,7 +8,8 @@
"service_received": "Action received", "service_received": "Action received",
"mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_mac": "Missing MAC address in MQTT properties.",
"mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties." "mqtt_missing_ip": "Missing IP address in MQTT properties.",
"mqtt_missing_payload": "Missing MQTT Payload."
}, },
"error": { "error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",

View File

@ -15,7 +15,6 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -46,9 +45,11 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlow:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return FeedReaderOptionsFlowHandler(config_entry) return FeedReaderOptionsFlowHandler()
def show_user_form( def show_user_form(
self, self,
@ -147,7 +148,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reconfigure_successful") return self.async_abort(reason="reconfigure_successful")
class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry): class FeedReaderOptionsFlowHandler(OptionsFlow):
"""Handle an options flow.""" """Handle an options flow."""
async def async_step_init( async def async_step_init(
@ -162,7 +163,9 @@ class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry):
{ {
vol.Optional( vol.Optional(
CONF_MAX_ENTRIES, CONF_MAX_ENTRIES,
default=self.options.get(CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES), default=self.config_entry.options.get(
CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES
),
): cv.positive_int, ): cv.positive_int,
} }
) )

View File

@ -4,5 +4,5 @@
"codeowners": [], "codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg", "documentation": "https://www.home-assistant.io/integrations/ffmpeg",
"integration_type": "system", "integration_type": "system",
"requirements": ["ha-ffmpeg==3.2.1"] "requirements": ["ha-ffmpeg==3.2.2"]
} }

View File

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyfibaro"], "loggers": ["pyfibaro"],
"requirements": ["pyfibaro==0.7.8"] "requirements": ["pyfibaro==0.8.0"]
} }

View File

@ -1,5 +1,7 @@
"""Config flow for file integration.""" """Config flow for file integration."""
from __future__ import annotations
from copy import deepcopy from copy import deepcopy
import os import os
from typing import Any from typing import Any
@ -11,7 +13,6 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_FILE_PATH, CONF_FILE_PATH,
@ -74,9 +75,11 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> FileOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return FileOptionsFlowHandler(config_entry) return FileOptionsFlowHandler()
async def validate_file_path(self, file_path: str) -> bool: async def validate_file_path(self, file_path: str) -> bool:
"""Ensure the file path is valid.""" """Ensure the file path is valid."""
@ -151,7 +154,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=title, data=data, options=options) return self.async_create_entry(title=title, data=data, options=options)
class FileOptionsFlowHandler(OptionsFlowWithConfigEntry): class FileOptionsFlowHandler(OptionsFlow):
"""Handle File options.""" """Handle File options."""
async def async_step_init( async def async_step_init(

View File

@ -71,9 +71,11 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> FluxLedOptionsFlow:
"""Get the options flow for the Flux LED component.""" """Get the options flow for the Flux LED component."""
return FluxLedOptionsFlow(config_entry) return FluxLedOptionsFlow()
async def async_step_dhcp( async def async_step_dhcp(
self, discovery_info: dhcp.DhcpServiceInfo self, discovery_info: dhcp.DhcpServiceInfo
@ -320,10 +322,6 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
class FluxLedOptionsFlow(OptionsFlow): class FluxLedOptionsFlow(OptionsFlow):
"""Handle flux_led options.""" """Handle flux_led options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the flux_led options flow."""
self._config_entry = config_entry
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -332,7 +330,7 @@ class FluxLedOptionsFlow(OptionsFlow):
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
options = self._config_entry.options options = self.config_entry.options
options_schema = vol.Schema( options_schema = vol.Schema(
{ {
vol.Optional( vol.Optional(

View File

@ -23,7 +23,6 @@ from homeassistant.config_entries import (
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
OptionsFlowWithConfigEntry,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -60,9 +59,11 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def async_get_options_flow(
config_entry: ConfigEntry,
) -> FritzBoxToolsOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return FritzBoxToolsOptionsFlowHandler(config_entry) return FritzBoxToolsOptionsFlowHandler()
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize FRITZ!Box Tools flow.""" """Initialize FRITZ!Box Tools flow."""
@ -393,7 +394,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
) )
class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): class FritzBoxToolsOptionsFlowHandler(OptionsFlow):
"""Handle an options flow.""" """Handle an options flow."""
async def async_step_init( async def async_step_init(
@ -404,19 +405,18 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry):
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
options = self.config_entry.options
data_schema = vol.Schema( data_schema = vol.Schema(
{ {
vol.Optional( vol.Optional(
CONF_CONSIDER_HOME, CONF_CONSIDER_HOME,
default=self.options.get( default=options.get(
CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
), ),
): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)),
vol.Optional( vol.Optional(
CONF_OLD_DISCOVERY, CONF_OLD_DISCOVERY,
default=self.options.get( default=options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY),
CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY
),
): bool, ): bool,
} }
) )

View File

@ -1,7 +1,7 @@
{ {
"domain": "fritz", "domain": "fritz",
"name": "AVM FRITZ!Box Tools", "name": "AVM FRITZ!Box Tools",
"codeowners": ["@mammuth", "@AaronDavidSchneider", "@chemelli74", "@mib1185"], "codeowners": ["@AaronDavidSchneider", "@chemelli74", "@mib1185"],
"config_flow": true, "config_flow": true,
"dependencies": ["network"], "dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/fritz", "documentation": "https://www.home-assistant.io/integrations/fritz",

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20241031.0"] "requirements": ["home-assistant-frontend==20241106.2"]
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["ayla-iot-unofficial==1.4.2"] "requirements": ["ayla-iot-unofficial==1.4.3"]
} }

View File

@ -324,7 +324,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> GenericOptionsFlowHandler: ) -> GenericOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return GenericOptionsFlowHandler(config_entry) return GenericOptionsFlowHandler()
def check_for_existing(self, options: dict[str, Any]) -> bool: def check_for_existing(self, options: dict[str, Any]) -> bool:
"""Check whether an existing entry is using the same URLs.""" """Check whether an existing entry is using the same URLs."""
@ -409,9 +409,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
class GenericOptionsFlowHandler(OptionsFlow): class GenericOptionsFlowHandler(OptionsFlow):
"""Handle Generic IP Camera options.""" """Handle Generic IP Camera options."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self) -> None:
"""Initialize Generic IP Camera options flow.""" """Initialize Generic IP Camera options flow."""
self.config_entry = config_entry
self.preview_cam: dict[str, Any] = {} self.preview_cam: dict[str, Any] = {}
self.user_input: dict[str, Any] = {} self.user_input: dict[str, Any] = {}

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic", "documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["ha-av==10.1.1", "Pillow==10.4.0"] "requirements": ["av==13.1.0", "Pillow==10.4.0"]
} }

View File

@ -1,11 +1,14 @@
"""The go2rtc component.""" """The go2rtc component."""
from __future__ import annotations
from dataclasses import dataclass
import logging import logging
import shutil import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Go2RtcRestClient from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import ( from go2rtc_client.ws import (
Go2RtcWsClient, Go2RtcWsClient,
ReceiveMessages, ReceiveMessages,
@ -15,6 +18,7 @@ from go2rtc_client.ws import (
WsError, WsError,
) )
import voluptuous as vol import voluptuous as vol
from webrtc_models import RTCIceCandidate
from homeassistant.components.camera import ( from homeassistant.components.camera import (
Camera, Camera,
@ -37,7 +41,13 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.package import is_docker_env from homeassistant.util.package import is_docker_env
from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_RTSP_PORT,
HA_MANAGED_URL,
)
from .server import Server from .server import Server
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -84,13 +94,22 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
@dataclass(frozen=True)
class Go2RtcData:
"""Data for go2rtc."""
url: str
managed: bool
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC.""" """Set up WebRTC."""
url: str | None = None url: str | None = None
managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass) await _remove_go2rtc_entries(hass)
return True return True
@ -113,16 +132,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
server = Server( server = Server(
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
) )
await server.start() try:
await server.start()
except Exception: # noqa: BLE001
_LOGGER.warning("Could not start go2rtc server", exc_info=True)
return False
async def on_stop(event: Event) -> None: async def on_stop(event: Event) -> None:
await server.stop() await server.stop()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = "http://localhost:1984/" url = HA_MANAGED_URL
managed = True
hass.data[_DATA_GO2RTC] = url hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed)
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
) )
@ -137,24 +161,32 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry.""" """Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC] data = hass.data[_DATA_GO2RTC]
# Validate the server URL # Validate the server URL
try: try:
client = Go2RtcRestClient(async_get_clientsession(hass), url) client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
await client.streams.list() await client.validate_server_version()
except Go2RtcClientError as err: except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS): if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {url}" f"Could not connect to go2rtc instance on {data.url}"
) from err ) from err
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) _LOGGER.warning(
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
except Go2RtcVersionError as err:
raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}"
) from err
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) _LOGGER.warning(
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
provider = WebRTCProvider(hass, url) provider = WebRTCProvider(hass, data)
async_register_webrtc_provider(hass, provider) async_register_webrtc_provider(hass, provider)
return True return True
@ -172,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider): class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider.""" """WebRTC provider."""
def __init__(self, hass: HomeAssistant, url: str) -> None: def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
"""Initialize the WebRTC provider.""" """Initialize the WebRTC provider."""
self._hass = hass self._hass = hass
self._url = url self._data = data
self._session = async_get_clientsession(hass) self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, url) self._rest_client = Go2RtcRestClient(self._session, data.url)
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
@property @property
@ -199,19 +231,46 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None: ) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient( self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._url, source=camera.entity_id self._session, self._data.url, source=camera.entity_id
) )
if not (stream_source := await camera.stream_source()):
send_message(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
)
return
streams = await self._rest_client.streams.list() streams = await self._rest_client.streams.list()
if camera.entity_id not in streams:
if not (stream_source := await camera.stream_source()): if self._data.managed:
send_message( # HA manages the go2rtc instance
WebRTCError( stream_original_name = f"{camera.entity_id}_original"
"go2rtc_webrtc_offer_failed", "Camera has no stream source" stream_redirect_sources = [
) f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}",
f"ffmpeg:{stream_original_name}#audio=opus",
]
if (
(stream_org := streams.get(stream_original_name)) is None
or not any(
stream_source == producer.url for producer in stream_org.producers
) )
return or (stream_redirect := streams.get(camera.entity_id)) is None
await self._rest_client.streams.add(camera.entity_id, stream_source) or stream_redirect_sources != [p.url for p in stream_redirect.producers]
):
await self._rest_client.streams.add(stream_original_name, stream_source)
await self._rest_client.streams.add(
camera.entity_id, stream_redirect_sources
)
# go2rtc instance is managed outside HA
elif (stream_org := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream_org.producers
):
await self._rest_client.streams.add(
camera.entity_id,
[stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"],
)
@callback @callback
def on_messages(message: ReceiveMessages) -> None: def on_messages(message: ReceiveMessages) -> None:
@ -219,7 +278,7 @@ class WebRTCProvider(CameraWebRTCProvider):
value: WebRTCMessage value: WebRTCMessage
match message: match message:
case WebRTCCandidate(): case WebRTCCandidate():
value = HAWebRTCCandidate(message.candidate) value = HAWebRTCCandidate(RTCIceCandidate(message.candidate))
case WebRTCAnswer(): case WebRTCAnswer():
value = HAWebRTCAnswer(message.sdp) value = HAWebRTCAnswer(message.sdp)
case WsError(): case WsError():
@ -231,11 +290,13 @@ class WebRTCProvider(CameraWebRTCProvider):
config = camera.async_get_webrtc_client_configuration() config = camera.async_get_webrtc_client_configuration()
await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers))
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate.""" """Handle the WebRTC candidate."""
if ws_client := self._sessions.get(session_id): if ws_client := self._sessions.get(session_id):
await ws_client.send(WebRTCCandidate(candidate)) await ws_client.send(WebRTCCandidate(candidate.candidate))
else: else:
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)

View File

@ -4,3 +4,6 @@ DOMAIN = "go2rtc"
CONF_DEBUG_UI = "debug_ui" CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
HA_MANAGED_RTSP_PORT = 18554

View File

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc", "documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system", "integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b3"], "requirements": ["go2rtc-client==0.1.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -1,40 +1,78 @@
"""Go2rtc server.""" """Go2rtc server."""
import asyncio import asyncio
from collections import deque
from contextlib import suppress
import logging import logging
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5 _TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30 _SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
_LOCALHOST_IP = "127.0.0.1" _LOCALHOST_IP = "127.0.0.1"
_LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
# Default configuration for HA # Default configuration for HA
# - Api is listening only on localhost # - Api is listening only on localhost
# - Disable rtsp listener # - Enable rtsp for localhost only as ffmpeg needs it
# - Clear default ice servers # - Clear default ice servers
_GO2RTC_CONFIG_FORMAT = r""" _GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
# Do not edit it manually
api: api:
listen: "{api_ip}:1984" listen: "{api_ip}:{api_port}"
rtsp: rtsp:
# ffmpeg needs rtsp for opus audio transcoding listen: "127.0.0.1:{rtsp_port}"
listen: "127.0.0.1:8554"
webrtc: webrtc:
listen: ":18555/tcp"
ice_servers: [] ice_servers: []
""" """
_LOG_LEVEL_MAP = {
"TRC": logging.DEBUG,
"DBG": logging.DEBUG,
"INF": logging.DEBUG,
"WRN": logging.WARNING,
"ERR": logging.WARNING,
"FTL": logging.ERROR,
"PNC": logging.ERROR,
}
class Go2RTCServerStartError(HomeAssistantError):
"""Raised when server does not start."""
_message = "Go2rtc server didn't start correctly"
class Go2RTCWatchdogError(HomeAssistantError):
"""Raised on watchdog error."""
def _create_temp_file(api_ip: str) -> str: def _create_temp_file(api_ip: str) -> str:
"""Create temporary config file.""" """Create temporary config file."""
# Set delete=False to prevent the file from being deleted when the file is closed # Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually # Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) file.write(
_GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
rtsp_port=HA_MANAGED_RTSP_PORT,
).encode()
)
return file.name return file.name
@ -47,14 +85,24 @@ class Server:
"""Initialize the server.""" """Initialize the server."""
self._hass = hass self._hass = hass
self._binary = binary self._binary = binary
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event() self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP self._api_ip = _LOCALHOST_IP
if enable_ui: if enable_ui:
# Listen on all interfaces for allowing access from all ips # Listen on all interfaces for allowing access from all ips
self._api_ip = "" self._api_ip = ""
self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = []
async def start(self) -> None: async def start(self) -> None:
"""Start the server."""
await self._start()
self._watchdog_task = asyncio.create_task(
self._watchdog(), name="Go2rtc respawn"
)
async def _start(self) -> None:
"""Start the server.""" """Start the server."""
_LOGGER.debug("Starting go2rtc server") _LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job( config_file = await self._hass.async_add_executor_job(
@ -82,8 +130,13 @@ class Server:
except TimeoutError as err: except TimeoutError as err:
msg = "Go2rtc server didn't start correctly" msg = "Go2rtc server didn't start correctly"
_LOGGER.exception(msg) _LOGGER.exception(msg)
await self.stop() self._log_server_output(logging.WARNING)
raise HomeAssistantError("Go2rtc server didn't start correctly") from err await self._stop()
raise Go2RTCServerStartError from err
# Check the server version
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
await client.validate_server_version()
async def _log_output(self, process: asyncio.subprocess.Process) -> None: async def _log_output(self, process: asyncio.subprocess.Process) -> None:
"""Log the output of the process.""" """Log the output of the process."""
@ -91,21 +144,111 @@ class Server:
async for line in process.stdout: async for line in process.stdout:
msg = line[:-1].decode().strip() msg = line[:-1].decode().strip()
_LOGGER.debug(msg) self._log_buffer.append(msg)
loglevel = logging.WARNING
if len(split_msg := msg.split(" ", 2)) == 3:
loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel)
_LOGGER.log(loglevel, msg)
if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg:
self._startup_complete.set() self._startup_complete.set()
def _log_server_output(self, loglevel: int) -> None:
"""Log captured process output, then clear the log buffer."""
for line in list(self._log_buffer): # Copy the deque to avoid mutation error
_LOGGER.log(loglevel, line)
self._log_buffer.clear()
async def _watchdog(self) -> None:
"""Keep respawning go2rtc servers.
A new go2rtc server is spawned if the process terminates or the API
stops responding.
"""
while True:
try:
monitor_process_task = asyncio.create_task(self._monitor_process())
self._watchdog_tasks.append(monitor_process_task)
monitor_process_task.add_done_callback(self._watchdog_tasks.remove)
monitor_api_task = asyncio.create_task(self._monitor_api())
self._watchdog_tasks.append(monitor_api_task)
monitor_api_task.add_done_callback(self._watchdog_tasks.remove)
try:
await asyncio.gather(monitor_process_task, monitor_api_task)
except Go2RTCWatchdogError:
_LOGGER.debug("Caught Go2RTCWatchdogError")
for task in self._watchdog_tasks:
if task.done():
if not task.cancelled():
task.exception()
continue
task.cancel()
await asyncio.sleep(_RESPAWN_COOLDOWN)
try:
await self._stop()
_LOGGER.warning("Go2rtc unexpectedly stopped, server log:")
self._log_server_output(logging.WARNING)
_LOGGER.debug("Spawning new go2rtc server")
with suppress(Go2RTCServerStartError):
await self._start()
except Exception:
_LOGGER.exception(
"Unexpected error when restarting go2rtc server"
)
except Exception:
_LOGGER.exception("Unexpected error in go2rtc server watchdog")
async def _monitor_process(self) -> None:
"""Raise if the go2rtc process terminates."""
_LOGGER.debug("Monitoring go2rtc server process")
if self._process:
await self._process.wait()
_LOGGER.debug("go2rtc server terminated")
raise Go2RTCWatchdogError("Process ended")
async def _monitor_api(self) -> None:
"""Raise if the go2rtc process terminates."""
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
_LOGGER.debug("Monitoring go2rtc API")
try:
while True:
await client.validate_server_version()
await asyncio.sleep(10)
except Exception as err:
_LOGGER.debug("go2rtc API did not reply", exc_info=True)
raise Go2RTCWatchdogError("API error") from err
async def _stop_watchdog(self) -> None:
"""Handle watchdog stop request."""
tasks: list[asyncio.Task] = []
if watchdog_task := self._watchdog_task:
self._watchdog_task = None
tasks.append(watchdog_task)
watchdog_task.cancel()
for task in self._watchdog_tasks:
tasks.append(task)
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the server and abort the watchdog task."""
_LOGGER.debug("Server stop requested")
await self._stop_watchdog()
await self._stop()
async def _stop(self) -> None:
"""Stop the server.""" """Stop the server."""
if self._process: if self._process:
_LOGGER.debug("Stopping go2rtc server") _LOGGER.debug("Stopping go2rtc server")
process = self._process process = self._process
self._process = None self._process = None
process.terminate() with suppress(ProcessLookupError):
process.terminate()
try: try:
await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT) await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT)
except TimeoutError: except TimeoutError:
_LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it") _LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it")
process.kill() with suppress(ProcessLookupError):
process.kill()
else: else:
_LOGGER.debug("Go2rtc server has been stopped") _LOGGER.debug("Go2rtc server has been stopped")

View File

@ -15,7 +15,7 @@ from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithConfigEntry, OptionsFlow,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
@ -135,10 +135,10 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> GoogleCloudOptionsFlowHandler: ) -> GoogleCloudOptionsFlowHandler:
"""Create the options flow.""" """Create the options flow."""
return GoogleCloudOptionsFlowHandler(config_entry) return GoogleCloudOptionsFlowHandler()
class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry): class GoogleCloudOptionsFlowHandler(OptionsFlow):
"""Google Cloud options flow.""" """Google Cloud options flow."""
async def async_step_init( async def async_step_init(
@ -169,7 +169,7 @@ class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
) )
), ),
**tts_options_schema( **tts_options_schema(
self.options, voices, from_config_flow=True self.config_entry.options, voices, from_config_flow=True
).schema, ).schema,
vol.Optional( vol.Optional(
CONF_STT_MODEL, CONF_STT_MODEL,
@ -182,6 +182,6 @@ class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
), ),
} }
), ),
self.options, self.config_entry.options,
), ),
) )

View File

@ -52,7 +52,7 @@ async def async_tts_voices(
def tts_options_schema( def tts_options_schema(
config_options: dict[str, Any], config_options: Mapping[str, Any],
voices: dict[str, list[str]], voices: dict[str, list[str]],
from_config_flow: bool = False, from_config_flow: bool = False,
) -> vol.Schema: ) -> vol.Schema:

View File

@ -163,7 +163,6 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry
self.last_rendered_recommended = config_entry.options.get( self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False CONF_RECOMMENDED, False
) )

View File

@ -30,6 +30,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.CALENDAR, Platform.CALENDAR,
Platform.SENSOR, Platform.SENSOR,

View File

@ -0,0 +1,85 @@
"""Binary sensor platform for Habitica integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ASSETS_URL
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
@dataclass(kw_only=True, frozen=True)
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Habitica Binary Sensor Description."""
value_fn: Callable[[dict[str, Any]], bool | None]
entity_picture: Callable[[dict[str, Any]], str | None]
class HabiticaBinarySensor(StrEnum):
"""Habitica Entities."""
PENDING_QUEST = "pending_quest"
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
"""Entity picture for pending quest invitation."""
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
return None
BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = (
HabiticaBinarySensorEntityDescription(
key=HabiticaBinarySensor.PENDING_QUEST,
translation_key=HabiticaBinarySensor.PENDING_QUEST,
value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"],
entity_picture=get_scroll_image_for_pending_quest_invitation,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the habitica binary sensors."""
coordinator = config_entry.runtime_data
async_add_entities(
HabiticaBinarySensorEntity(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS
)
class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity):
"""Representation of a Habitica binary sensor."""
entity_description: HabiticaBinarySensorEntityDescription
@property
def is_on(self) -> bool | None:
"""If the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.data.user)
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
if entity_picture := self.entity_description.entity_picture(
self.coordinator.data.user
):
return f"{ASSETS_URL}{entity_picture}"
return None

View File

@ -59,9 +59,9 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
except ClientResponseError as error: except ClientResponseError as error:
if error.status == HTTPStatus.TOO_MANY_REQUESTS: if error.status == HTTPStatus.TOO_MANY_REQUESTS:
_LOGGER.debug("Currently rate limited, skipping update") _LOGGER.debug("Rate limit exceeded, will try again later")
return self.data return self.data
raise UpdateFailed(f"Error communicating with API: {error}") from error raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
return HabiticaData(user=user_response, tasks=tasks_response) return HabiticaData(user=user_response, tasks=tasks_response)

View File

@ -135,6 +135,14 @@
"on": "mdi:sleep" "on": "mdi:sleep"
} }
} }
},
"binary_sensor": {
"pending_quest": {
"default": "mdi:script-outline",
"state": {
"on": "mdi:script-text-outline"
}
}
} }
}, },
"services": { "services": {

View File

@ -9,6 +9,7 @@ from typing import Any
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.const import ATTR_NAME, CONF_NAME
from homeassistant.core import ( from homeassistant.core import (
HomeAssistant, HomeAssistant,
@ -54,6 +55,21 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
) )
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
def async_setup_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Habitica integration.""" """Set up services for Habitica integration."""
@ -86,14 +102,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def cast_skill(call: ServiceCall) -> ServiceResponse: async def cast_skill(call: ServiceCall) -> ServiceResponse:
"""Skill action.""" """Skill action."""
entry: HabiticaConfigEntry | None entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
if not (
entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY])
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
coordinator = entry.runtime_data coordinator = entry.runtime_data
skill = { skill = {
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},

View File

@ -38,6 +38,11 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"pending_quest": {
"name": "Pending quest invitation"
}
},
"button": { "button": {
"run_cron": { "run_cron": {
"name": "Start my day" "name": "Start my day"
@ -204,10 +209,10 @@
"message": "Unable to create new to-do `{name}` for Habitica, please try again" "message": "Unable to create new to-do `{name}` for Habitica, please try again"
}, },
"setup_rate_limit_exception": { "setup_rate_limit_exception": {
"message": "Currently rate limited, try again later" "message": "Rate limit exceeded, try again later"
}, },
"service_call_unallowed": { "service_call_unallowed": {
"message": "Unable to carry out this action, because the required conditions are not met" "message": "Unable to complete action, the required conditions are not met"
}, },
"service_call_exception": { "service_call_exception": {
"message": "Unable to connect to Habitica, try again later" "message": "Unable to connect to Habitica, try again later"
@ -219,7 +224,10 @@
"message": "Unable to cast skill, your character does not have the skill or spell {skill}." "message": "Unable to cast skill, your character does not have the skill or spell {skill}."
}, },
"entry_not_found": { "entry_not_found": {
"message": "The selected character is currently not configured or loaded in Home Assistant." "message": "The selected character is not configured in Home Assistant."
},
"entry_not_loaded": {
"message": "The selected character is currently not loaded or disabled in Home Assistant."
}, },
"task_not_found": { "task_not_found": {
"message": "Unable to cast skill, could not find the task {task}" "message": "Unable to cast skill, could not find the task {task}"

View File

@ -103,6 +103,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components" PLACEHOLDER_KEY_COMPONENTS = "components"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
@ -136,17 +137,3 @@ class SupervisorEntityModel(StrEnum):
CORE = "Home Assistant Core" CORE = "Home Assistant Core"
SUPERVIOSR = "Home Assistant Supervisor" SUPERVIOSR = "Home Assistant Supervisor"
HOST = "Home Assistant Host" HOST = "Home Assistant Host"
class SupervisorIssueContext(StrEnum):
"""Context for supervisor issues."""
ADDON = "addon"
CORE = "core"
DNS_SERVER = "dns_server"
MOUNT = "mount"
OS = "os"
PLUGIN = "plugin"
SUPERVISOR = "supervisor"
STORE = "store"
SYSTEM = "system"

View File

@ -131,11 +131,11 @@ class HassIODiscovery(HomeAssistantView):
config=data.config, config=data.config,
name=addon_info.name, name=addon_info.name,
slug=data.addon, slug=data.addon,
uuid=str(data.uuid), uuid=data.uuid.hex,
), ),
discovery_key=discovery_flow.DiscoveryKey( discovery_key=discovery_flow.DiscoveryKey(
domain=DOMAIN, domain=DOMAIN,
key=str(data.uuid), key=data.uuid.hex,
version=1, version=1,
), ),
) )

View File

@ -91,15 +91,6 @@ async def async_create_backup(
return await hassio.send_command(command, payload=payload, timeout=None) return await hassio.send_command(command, payload=payload, timeout=None)
@bind_hass
@_api_bool
async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict:
"""Apply a suggestion from supervisor's resolution center."""
hassio: HassIO = hass.data[DOMAIN]
command = f"/resolution/suggestion/{suggestion_uuid}"
return await hassio.send_command(command, timeout=None)
@api_data @api_data
async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]:
"""Return settings specific to Home Assistant Green.""" """Return settings specific to Home Assistant Green."""
@ -245,26 +236,6 @@ class HassIO:
""" """
return self.send_command("/ingress/panels", method="get") return self.send_command("/ingress/panels", method="get")
@api_data
def get_resolution_info(self) -> Coroutine:
"""Return data for Supervisor resolution center.
This method returns a coroutine.
"""
return self.send_command("/resolution/info", method="get")
@api_data
def get_suggestions_for_issue(
self, issue_id: str
) -> Coroutine[Any, Any, dict[str, Any]]:
"""Return suggestions for issue from Supervisor resolution center.
This method returns a coroutine.
"""
return self.send_command(
f"/resolution/issue/{issue_id}/suggestions", method="get"
)
@_api_bool @_api_bool
async def update_hass_api( async def update_hass_api(
self, http_config: dict[str, Any], refresh_token: RefreshToken self, http_config: dict[str, Any], refresh_token: RefreshToken
@ -304,14 +275,6 @@ class HassIO:
"/supervisor/options", payload={"diagnostics": diagnostics} "/supervisor/options", payload={"diagnostics": diagnostics}
) )
@_api_bool
def apply_suggestion(self, suggestion_uuid: str) -> Coroutine:
"""Apply a suggestion from supervisor's resolution center.
This method returns a coroutine.
"""
return self.send_command(f"/resolution/suggestion/{suggestion_uuid}")
async def send_command( async def send_command(
self, self,
command: str, command: str,

View File

@ -7,6 +7,10 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any, NotRequired, TypedDict from typing import Any, NotRequired, TypedDict
from uuid import UUID
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import ContextType, Issue as SupervisorIssue
from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.core import HassJob, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -20,12 +24,8 @@ from homeassistant.helpers.issue_registry import (
from .const import ( from .const import (
ATTR_DATA, ATTR_DATA,
ATTR_HEALTHY, ATTR_HEALTHY,
ATTR_ISSUES,
ATTR_SUGGESTIONS,
ATTR_SUPPORTED, ATTR_SUPPORTED,
ATTR_UNHEALTHY,
ATTR_UNHEALTHY_REASONS, ATTR_UNHEALTHY_REASONS,
ATTR_UNSUPPORTED,
ATTR_UNSUPPORTED_REASONS, ATTR_UNSUPPORTED_REASONS,
ATTR_UPDATE_KEY, ATTR_UPDATE_KEY,
ATTR_WS_EVENT, ATTR_WS_EVENT,
@ -36,6 +36,7 @@ from .const import (
EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE, EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED, EVENT_SUPPORTED_CHANGED,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@ -44,10 +45,9 @@ from .const import (
PLACEHOLDER_KEY_REFERENCE, PLACEHOLDER_KEY_REFERENCE,
REQUEST_REFRESH_DELAY, REQUEST_REFRESH_DELAY,
UPDATE_KEY_SUPERVISOR, UPDATE_KEY_SUPERVISOR,
SupervisorIssueContext,
) )
from .coordinator import get_addons_info from .coordinator import get_addons_info
from .handler import HassIO, HassioAPIError from .handler import HassIO, get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy" ISSUE_KEY_UNHEALTHY = "unhealthy"
ISSUE_KEY_UNSUPPORTED = "unsupported" ISSUE_KEY_UNSUPPORTED = "unsupported"
@ -94,6 +94,7 @@ UNHEALTHY_REASONS = {
# Keys (type + context) of issues that when found should be made into a repair # Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = { ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_BOOT_FAIL,
"issue_mount_mount_failed", "issue_mount_mount_failed",
"issue_system_multiple_data_disks", "issue_system_multiple_data_disks",
"issue_system_reboot_required", "issue_system_reboot_required",
@ -118,9 +119,9 @@ class SuggestionDataType(TypedDict):
class Suggestion: class Suggestion:
"""Suggestion from Supervisor which resolves an issue.""" """Suggestion from Supervisor which resolves an issue."""
uuid: str uuid: UUID
type: str type: str
context: SupervisorIssueContext context: ContextType
reference: str | None = None reference: str | None = None
@property @property
@ -132,9 +133,9 @@ class Suggestion:
def from_dict(cls, data: SuggestionDataType) -> Suggestion: def from_dict(cls, data: SuggestionDataType) -> Suggestion:
"""Convert from dictionary representation.""" """Convert from dictionary representation."""
return cls( return cls(
uuid=data["uuid"], uuid=UUID(data["uuid"]),
type=data["type"], type=data["type"],
context=SupervisorIssueContext(data["context"]), context=ContextType(data["context"]),
reference=data["reference"], reference=data["reference"],
) )
@ -153,9 +154,9 @@ class IssueDataType(TypedDict):
class Issue: class Issue:
"""Issue from Supervisor.""" """Issue from Supervisor."""
uuid: str uuid: UUID
type: str type: str
context: SupervisorIssueContext context: ContextType
reference: str | None = None reference: str | None = None
suggestions: list[Suggestion] = field(default_factory=list, compare=False) suggestions: list[Suggestion] = field(default_factory=list, compare=False)
@ -169,9 +170,9 @@ class Issue:
"""Convert from dictionary representation.""" """Convert from dictionary representation."""
suggestions: list[SuggestionDataType] = data.get("suggestions", []) suggestions: list[SuggestionDataType] = data.get("suggestions", [])
return cls( return cls(
uuid=data["uuid"], uuid=UUID(data["uuid"]),
type=data["type"], type=data["type"],
context=SupervisorIssueContext(data["context"]), context=ContextType(data["context"]),
reference=data["reference"], reference=data["reference"],
suggestions=[ suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions Suggestion.from_dict(suggestion) for suggestion in suggestions
@ -188,7 +189,8 @@ class SupervisorIssues:
self._client = client self._client = client
self._unsupported_reasons: set[str] = set() self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set()
self._issues: dict[str, Issue] = {} self._issues: dict[UUID, Issue] = {}
self._supervisor_client = get_supervisor_client(hass)
@property @property
def unhealthy_reasons(self) -> set[str]: def unhealthy_reasons(self) -> set[str]:
@ -281,7 +283,7 @@ class SupervisorIssues:
async_create_issue( async_create_issue(
self._hass, self._hass,
DOMAIN, DOMAIN,
issue.uuid, issue.uuid.hex,
is_fixable=bool(issue.suggestions), is_fixable=bool(issue.suggestions),
severity=IssueSeverity.WARNING, severity=IssueSeverity.WARNING,
translation_key=issue.key, translation_key=issue.key,
@ -290,19 +292,37 @@ class SupervisorIssues:
self._issues[issue.uuid] = issue self._issues[issue.uuid] = issue
async def add_issue_from_data(self, data: IssueDataType) -> None: async def add_issue_from_data(self, data: SupervisorIssue) -> None:
"""Add issue from data to list after getting latest suggestions.""" """Add issue from data to list after getting latest suggestions."""
try: try:
data["suggestions"] = ( suggestions = (
await self._client.get_suggestions_for_issue(data["uuid"]) await self._supervisor_client.resolution.suggestions_for_issue(
)[ATTR_SUGGESTIONS] data.uuid
except HassioAPIError: )
)
except SupervisorError:
_LOGGER.error( _LOGGER.error(
"Could not get suggestions for supervisor issue %s, skipping it", "Could not get suggestions for supervisor issue %s, skipping it",
data["uuid"], data.uuid.hex,
) )
return return
self.add_issue(Issue.from_dict(data)) self.add_issue(
Issue(
uuid=data.uuid,
type=str(data.type),
context=data.context,
reference=data.reference,
suggestions=[
Suggestion(
uuid=suggestion.uuid,
type=str(suggestion.type),
context=suggestion.context,
reference=suggestion.reference,
)
for suggestion in suggestions
],
)
)
def remove_issue(self, issue: Issue) -> None: def remove_issue(self, issue: Issue) -> None:
"""Remove an issue from the list. Delete a repair if necessary.""" """Remove an issue from the list. Delete a repair if necessary."""
@ -310,13 +330,13 @@ class SupervisorIssues:
return return
if issue.key in ISSUE_KEYS_FOR_REPAIRS: if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_delete_issue(self._hass, DOMAIN, issue.uuid) async_delete_issue(self._hass, DOMAIN, issue.uuid.hex)
del self._issues[issue.uuid] del self._issues[issue.uuid]
def get_issue(self, issue_id: str) -> Issue | None: def get_issue(self, issue_id: str) -> Issue | None:
"""Get issue from key.""" """Get issue from key."""
return self._issues.get(issue_id) return self._issues.get(UUID(issue_id))
async def setup(self) -> None: async def setup(self) -> None:
"""Create supervisor events listener.""" """Create supervisor events listener."""
@ -329,8 +349,8 @@ class SupervisorIssues:
async def _update(self, _: datetime | None = None) -> None: async def _update(self, _: datetime | None = None) -> None:
"""Update issues from Supervisor resolution center.""" """Update issues from Supervisor resolution center."""
try: try:
data = await self._client.get_resolution_info() data = await self._supervisor_client.resolution.info()
except HassioAPIError as err: except SupervisorError as err:
_LOGGER.error("Failed to update supervisor issues: %r", err) _LOGGER.error("Failed to update supervisor issues: %r", err)
async_call_later( async_call_later(
self._hass, self._hass,
@ -338,18 +358,16 @@ class SupervisorIssues:
HassJob(self._update, cancel_on_shutdown=True), HassJob(self._update, cancel_on_shutdown=True),
) )
return return
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unhealthy_reasons = set(data.unhealthy)
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) self.unsupported_reasons = set(data.unsupported)
# Remove any cached issues that weren't returned # Remove any cached issues that weren't returned
for issue_id in set(self._issues.keys()) - { for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}:
issue["uuid"] for issue in data[ATTR_ISSUES]
}:
self.remove_issue(self._issues[issue_id]) self.remove_issue(self._issues[issue_id])
# Add/update any issues that came back # Add/update any issues that came back
await asyncio.gather( await asyncio.gather(
*[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]] *[self.add_issue_from_data(issue) for issue in data.issues]
) )
@callback @callback

View File

@ -6,6 +6,8 @@ from collections.abc import Callable, Coroutine
from types import MethodType from types import MethodType
from typing import Any from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import ContextType
import voluptuous as vol import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow from homeassistant.components.repairs import RepairsFlow
@ -14,14 +16,14 @@ from homeassistant.data_entry_flow import FlowResult
from . import get_addons_info, get_issues_info from . import get_addons_info, get_issues_info
from .const import ( from .const import (
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE, PLACEHOLDER_KEY_REFERENCE,
SupervisorIssueContext,
) )
from .handler import async_apply_suggestion from .handler import get_supervisor_client
from .issues import Issue, Suggestion from .issues import Issue, Suggestion
HELP_URLS = { HELP_URLS = {
@ -50,9 +52,10 @@ class SupervisorIssueRepairFlow(RepairsFlow):
_data: dict[str, Any] | None = None _data: dict[str, Any] | None = None
_issue: Issue | None = None _issue: Issue | None = None
def __init__(self, issue_id: str) -> None: def __init__(self, hass: HomeAssistant, issue_id: str) -> None:
"""Initialize repair flow.""" """Initialize repair flow."""
self._issue_id = issue_id self._issue_id = issue_id
self._supervisor_client = get_supervisor_client(hass)
super().__init__() super().__init__()
@property @property
@ -123,9 +126,12 @@ class SupervisorIssueRepairFlow(RepairsFlow):
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
return self._async_form_for_suggestion(suggestion) return self._async_form_for_suggestion(suggestion)
if await async_apply_suggestion(self.hass, suggestion.uuid): try:
return self.async_create_entry(data={}) await self._supervisor_client.resolution.apply_suggestion(suggestion.uuid)
return self.async_abort(reason="apply_suggestion_fail") except SupervisorError:
return self.async_abort(reason="apply_suggestion_fail")
return self.async_create_entry(data={})
@staticmethod @staticmethod
def _async_step( def _async_step(
@ -162,9 +168,9 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
if issue.key == self.issue.key or issue.type != self.issue.type: if issue.key == self.issue.key or issue.type != self.issue.type:
continue continue
if issue.context == SupervisorIssueContext.CORE: if issue.context == ContextType.CORE:
components.insert(0, "Home Assistant") components.insert(0, "Home Assistant")
elif issue.context == SupervisorIssueContext.ADDON: elif issue.context == ContextType.ADDON:
components.append( components.append(
next( next(
( (
@ -181,8 +187,8 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
return placeholders return placeholders
class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
"""Handler for detached addon issue fixing flows.""" """Handler for addon issue fixing flows."""
@property @property
def description_placeholders(self) -> dict[str, str] | None: def description_placeholders(self) -> dict[str, str] | None:
@ -209,8 +215,11 @@ async def async_create_fix_flow(
supervisor_issues = get_issues_info(hass) supervisor_issues = get_issues_info(hass)
issue = supervisor_issues and supervisor_issues.get_issue(issue_id) issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(issue_id) return DockerConfigIssueRepairFlow(hass, issue_id)
if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: if issue and issue.key in {
return DetachedAddonIssueRepairFlow(issue_id) ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
}:
return AddonIssueRepairFlow(hass, issue_id)
return SupervisorIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(hass, issue_id)

View File

@ -17,6 +17,23 @@
} }
}, },
"issues": { "issues": {
"issue_addon_boot_fail": {
"title": "Add-on failed to start at boot",
"fix_flow": {
"step": {
"fix_menu": {
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
"menu_options": {
"addon_execute_start": "Start",
"addon_disable_boot": "Disable"
}
}
},
"abort": {
"apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details."
}
}
},
"issue_addon_detached_addon_missing": { "issue_addon_detached_addon_missing": {
"title": "Missing repository for an installed add-on", "title": "Missing repository for an installed add-on",
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."

View File

@ -113,7 +113,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> HERETravelTimeOptionsFlow: ) -> HERETravelTimeOptionsFlow:
"""Get the options flow.""" """Get the options flow."""
return HERETravelTimeOptionsFlow(config_entry) return HERETravelTimeOptionsFlow()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -297,9 +297,8 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
class HERETravelTimeOptionsFlow(OptionsFlow): class HERETravelTimeOptionsFlow(OptionsFlow):
"""Handle HERE Travel Time options.""" """Handle HERE Travel Time options."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self) -> None:
"""Initialize HERE Travel Time options flow.""" """Initialize HERE Travel Time options flow."""
self.config_entry = config_entry
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
async def async_step_init( async def async_step_init(

View File

@ -182,7 +182,6 @@ class HiveOptionsFlowHandler(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize Hive options flow.""" """Initialize Hive options flow."""
self.hive = None self.hive = None
self.config_entry = config_entry
self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120)
async def async_step_init( async def async_step_init(

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.59", "babel==2.15.0"] "requirements": ["holidays==0.60", "babel==2.15.0"]
} }

View File

@ -13,7 +13,11 @@ from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .api import HomeConnectDevice from .api import HomeConnectDevice
from .const import ( from .const import (
@ -206,3 +210,9 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
"items": "\n".join([f"- {item}" for item in items]), "items": "\n".join([f"- {item}" for item in items]),
}, },
) )
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
async_delete_issue(
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
)

Some files were not shown because too many files have changed in this diff Show More