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/hassio/**
- homeassistant/components/homeassistant/**
- homeassistant/components/homeassistant_hardware/**
- homeassistant/components/http/**
- homeassistant/components/image/**
- homeassistant/components/input_boolean/**

View File

@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation
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:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

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

View File

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

View File

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

View File

@ -7,12 +7,13 @@ FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop
ENV \
S6_SERVICES_GRACETIME=240000 \
UV_SYSTEM_PYTHON=true
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.4.28
RUN pip3 install uv==0.5.0
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."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
try:
instruction_content = instruction_path.read_text(encoding="utf-8")
instruction_content = json.loads(instruction_path.read_text(encoding="utf-8"))
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

View File

@ -1,6 +1,5 @@
"""The AEMET OpenData component."""
from dataclasses import dataclass
import logging
from aemet_opendata.exceptions import AemetError, TownNotFound
@ -13,20 +12,10 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from .const import CONF_STATION_UPDATES, PLATFORMS
from .coordinator import WeatherUpdateCoordinator
from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator
_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:
"""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:
raise ConfigEntryNotReady(err) from err
weather_coordinator = WeatherUpdateCoordinator(hass, aemet)
weather_coordinator = WeatherUpdateCoordinator(hass, entry, aemet)
await weather_coordinator.async_config_entry_first_refresh()
entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"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"]
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."
raise AlexaSecurityPanelAuthorizationRequired(msg)

View File

@ -16,7 +16,6 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -46,9 +45,11 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return HomeassistantAnalyticsOptionsFlowHandler(config_entry)
return HomeassistantAnalyticsOptionsFlowHandler()
async def async_step_user(
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."""
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,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
OptionsFlow,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
from homeassistant.core import callback
@ -186,16 +186,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlowWithConfigEntry):
class OptionsFlowHandler(OptionsFlow):
"""Handle an option flow for Android Debug Bridge."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
super().__init__(config_entry)
self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {})
self._state_det_rules: dict[str, Any] = self.options.setdefault(
CONF_STATE_DETECTION_RULES, {}
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._state_det_rules: dict[str, Any] = dict(
config_entry.options.get(CONF_STATE_DETECTION_RULES, {})
)
self._conf_app_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()
]
rules = [RULES_NEW_ID, *self._state_det_rules]
options = self.options
options = self.config_entry.options
data_schema = vol.Schema(
{

View File

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

View File

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

View File

@ -22,8 +22,8 @@ class EnhancedAudioChunk:
timestamp_ms: int
"""Timestamp relative to start of audio stream (milliseconds)"""
is_speech: bool | None
"""True if audio chunk likely contains speech, False if not, None if unknown"""
speech_probability: float | None
"""Probability that audio chunk contains speech (0-1), None if unknown"""
class AudioEnhancer(ABC):
@ -70,27 +70,27 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
)
self.vad: MicroVad | None = None
self.threshold = 0.5
if self.is_vad_enabled:
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:
"""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
if self.vad is not None:
# Run VAD
speech_prob = self.vad.Process10ms(audio)
is_speech = speech_prob > self.threshold
speech_probability = self.vad.Process10ms(audio)
if self.audio_processor is not None:
# Run noise suppression and auto gain
audio = self.audio_processor.Process10ms(audio).audio
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.
audio_chunks_for_stt.extend(
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
)
@ -827,7 +829,7 @@ class PipelineRun:
if wake_word_vad is not None:
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(
code="wake-word-timeout", message="Wake word was not detected"
)
@ -955,7 +957,7 @@ class PipelineRun:
if stt_vad is not None:
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
self.process_event(
PipelineEvent(
@ -1221,7 +1223,7 @@ class PipelineRun:
yield EnhancedAudioChunk(
audio=sub_chunk,
timestamp_ms=timestamp_ms,
is_speech=None, # no VAD
speech_probability=None, # no VAD
)
timestamp_ms += MS_PER_CHUNK

View File

@ -75,7 +75,7 @@ class AudioBuffer:
class VoiceCommandSegmenter:
"""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."""
command_seconds: float = 1.0
@ -96,6 +96,12 @@ class VoiceCommandSegmenter:
timed_out: bool = False
"""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
"""Seconds left before considering voice command as started."""
@ -124,7 +130,7 @@ class VoiceCommandSegmenter:
self._reset_seconds_left = self.reset_seconds
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.
Returns False when command is done.
@ -142,7 +148,12 @@ class VoiceCommandSegmenter:
self.timed_out = True
return False
if speech_probability is None:
speech_probability = 0.0
if not self.in_command:
# Before command
is_speech = speech_probability > self.before_command_speech_threshold
if is_speech:
self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= chunk_seconds
@ -160,24 +171,29 @@ class VoiceCommandSegmenter:
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_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:
# 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
# In command
is_speech = speech_probability > self.in_command_speech_threshold
if 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:
# 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
@ -226,6 +242,9 @@ class VoiceActivityTimeout:
reset_seconds: float = 0.5
"""Seconds of speech before resetting timeout."""
speech_threshold: float = 0.5
"""Threshold for speech."""
_silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped."""
@ -241,12 +260,15 @@ class VoiceActivityTimeout:
self._silence_seconds_left = self.silence_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.
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
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:

View File

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

View File

@ -124,7 +124,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_CONN_STRING,
data_schema=CONN_STRING_SCHEMA,
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,
)
@ -144,7 +146,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
step_id=STEP_SAS,
data_schema=SAS_SCHEMA,
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,
)

View File

@ -308,7 +308,7 @@ class BackupManager(BaseBackupManager):
def _write_restore_file() -> None:
"""Write the restore file."""
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",
)

View File

@ -21,41 +21,57 @@ class BangOlufsenSource:
name="Audio Streamer",
id="uriStreamer",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
BLUETOOTH: Final[Source] = Source(
name="Bluetooth",
id="bluetooth",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
CHROMECAST: Final[Source] = Source(
name="Chromecast built-in",
id="chromeCast",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
LINE_IN: Final[Source] = Source(
name="Line-In",
id="lineIn",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
SPDIF: Final[Source] = Source(
name="Optical",
id="spdif",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
NET_RADIO: Final[Source] = Source(
name="B&O Radio",
id="netRadio",
is_seekable=False,
is_enabled=True,
is_playable=True,
)
DEEZER: Final[Source] = Source(
name="Deezer",
id="deezer",
is_seekable=True,
is_enabled=True,
is_playable=True,
)
TIDAL: Final[Source] = Source(
name="Tidal",
id="tidal",
is_seekable=True,
is_enabled=True,
is_playable=True,
)
@ -170,20 +186,6 @@ VALID_MEDIA_TYPES: Final[tuple] = (
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: Final[SourceArray] = SourceArray(
@ -191,7 +193,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source(
id="uriStreamer",
is_enabled=True,
is_playable=False,
is_playable=True,
name="Audio Streamer",
type=SourceTypeEnum(value="uriStreamer"),
is_seekable=False,
@ -199,7 +201,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source(
id="bluetooth",
is_enabled=True,
is_playable=False,
is_playable=True,
name="Bluetooth",
type=SourceTypeEnum(value="bluetooth"),
is_seekable=False,
@ -207,7 +209,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
Source(
id="spotify",
is_enabled=True,
is_playable=False,
is_playable=True,
name="Spotify Connect",
type=SourceTypeEnum(value="spotify"),
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 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 (
Action,
Art,
@ -38,6 +38,7 @@ from mozart_api.models import (
VolumeState,
)
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
import voluptuous as vol
from homeassistant.components import media_source
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.core import HomeAssistant, callback
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.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 . import BangOlufsenConfigEntry
@ -70,7 +78,6 @@ from .const import (
CONNECTION_STATUS,
DOMAIN,
FALLBACK_SOURCES,
HIDDEN_SOURCE_IDS,
VALID_MEDIA_TYPES,
BangOlufsenMediaType,
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):
"""Representation of a media player."""
@ -157,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible sources
self._beolink_sources: dict[str, bool] = {}
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:
"""Turn on the dispatchers."""
@ -166,9 +227,11 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes,
WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources,
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
@ -230,6 +293,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
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:
"""Update queue settings."""
# 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:
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."""
# Audio sources
@ -270,10 +336,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._audio_sources = {
source.id: source.name
for source in cast(list[Source], sources.items)
if source.is_enabled
and source.id
and source.name
and source.id not in HIDDEN_SOURCE_IDS
if source.is_enabled and source.id and source.name and source.is_playable
}
# 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()
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:
"""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
self._remote_leader = self._playback_metadata.remote_leader
@ -397,9 +495,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add self
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.
else:
beolink_listeners = await self._client.get_beolink_listeners()
beolink_listeners_attribute = {}
# Check if the device is a leader.
if len(beolink_listeners) > 0:
@ -420,6 +523,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
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
@ -605,6 +720,17 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
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:
"""Set the device to "networkStandby"."""
await self._client.post_standby()
@ -876,23 +1002,30 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible B&O device.
# Repeated presses / calls will cycle between compatible playing devices.
if len(group_members) == 0:
await self._async_beolink_join()
await self.async_beolink_join()
return
# Get JID for each group member
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:
"""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."""
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."""
# Ensure that the current source is expandable
if not self._beolink_sources[cast(str, self._source_change.id)]:
raise ServiceValidationError(
@ -904,10 +1037,37 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
},
)
# Try to expand to all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_expand(jid=beolink_jid)
# Expand to all discovered devices
if all_discovered:
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."""
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": {
"error": {
"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": {
"m3u_invalid_format": {
"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.on_playback_progress_notification
)
self._client.get_playback_source_notifications(
self.on_playback_source_notification
)
self._client.get_playback_state_notifications(
self.on_playback_state_notification
)
@ -117,6 +120,11 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self.hass,
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:
async_dispatcher_send(
self.hass,
@ -157,6 +165,14 @@ class BangOlufsenWebsocket(BangOlufsenBase):
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:
"""Send source_change dispatch."""
async_dispatcher_send(

View File

@ -364,12 +364,13 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master:
return MediaPlayerState.IDLE
status = self._status.state
if status in ("pause", "stop"):
return MediaPlayerState.PAUSED
if status in ("stream", "play"):
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
match self._status.state:
case "pause":
return MediaPlayerState.PAUSED
case "stream" | "play":
return MediaPlayerState.PLAYING
case _:
return MediaPlayerState.IDLE
@property
def media_title(self) -> str | None:
@ -769,7 +770,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player."""
volume = int(volume * 100)
volume = int(round(volume * 100))
volume = min(100, 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.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
import voluptuous as vol
@ -17,7 +21,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
OptionsFlow,
)
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try:
await auth.login()
except MyBMWCaptchaMissingError as ex:
raise MissingCaptcha from ex
except MyBMWAuthError as ex:
raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex:
@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
CONF_GCID: info.get(CONF_GCID),
}
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@ -145,10 +153,10 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry,
) -> BMWOptionsFlow:
"""Return a MyBMW option flow."""
return BMWOptionsFlow(config_entry)
return BMWOptionsFlow()
class BMWOptionsFlow(OptionsFlowWithConfigEntry):
class BMWOptionsFlow(OptionsFlow):
"""Handle a option flow for MyBMW."""
async def async_step_init(
@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""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.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 homeassistant.config_entries import ConfigEntry
@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
try:
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:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success:

View File

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

View File

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

View File

@ -16,7 +16,8 @@
"list_access": {
"default": "mdi:account-lock",
"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",
"integration_type": "service",
"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,
value_fn=lambda lst, _: lst["status"].lower(),
entity_category=EntityCategory.DIAGNOSTIC,
options=["registered", "shared"],
options=["registered", "shared", "invitation"],
device_class=SensorDeviceClass.ENUM,
),
)

View File

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

View File

@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"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)
coordinator = CalDavUpdateCoordinator(
hass,
None,
calendar=calendar,
days=days,
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)
coordinator = CalDavUpdateCoordinator(
hass,
None,
calendar=calendar,
days=days,
include_all_day=False,
@ -152,6 +154,7 @@ async def async_setup_entry(
async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass),
CalDavUpdateCoordinator(
hass,
entry,
calendar=calendar,
days=CONFIG_ENTRY_DEFAULT_DAYS,
include_all_day=True,
@ -204,7 +207,8 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
if self._supports_offset:
self._attr_extra_state_attributes = {
"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
else False

View File

@ -6,6 +6,9 @@ from datetime import date, datetime, time, timedelta
from functools import partial
import logging
import re
from typing import TYPE_CHECKING
import caldav
from homeassistant.components.calendar import CalendarEvent, extract_offset
from homeassistant.core import HomeAssistant
@ -14,6 +17,9 @@ from homeassistant.util import dt as dt_util
from .api import get_attr_value
if TYPE_CHECKING:
from . import CalDavConfigEntry
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
@ -23,11 +29,20 @@ OFFSET = "!!"
class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
"""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."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"CalDAV {calendar.name}",
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
@ -35,7 +50,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
self.days = days
self.include_all_day = include_all_day
self.search = search
self.offset = None
self.offset: timedelta | None = None
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
@ -109,7 +124,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
_start_of_tomorrow = start_of_tomorrow
if _start_of_today <= start_dt < _start_of_tomorrow:
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"):
dur = new_vevent.dtend.value - new_vevent.dtstart.value
new_vevent.dtend.value = start_dt + dur

View File

@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr
from propcache import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceServer
from webrtc_models import RTCIceCandidate, RTCIceServer
from homeassistant.components import websocket_api
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:
return hass.config.webrtc.ice_servers
return [
RTCIceServer(urls="stun:stun.home-assistant.io:80"),
RTCIceServer(urls="stun:stun.home-assistant.io:3478"),
RTCIceServer(
urls=[
"stun:stun.home-assistant.io:80",
"stun:stun.home-assistant.io:3478",
]
),
]
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_supported_features: CameraEntityFeature = CameraEntityFeature(0)
__supports_stream: CameraEntityFeature | None = None
def __init__(self) -> None:
"""Initialize a camera."""
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._webrtc_provider: CameraWebRTCProvider | 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
)
self._supports_native_async_webrtc = (
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
@cached_property
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.
"""
if self._webrtc_sync_offer:
if self._supports_native_sync_webrtc:
try:
answer = await self.async_handle_web_rtc_offer(offer_sdp)
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:
"""Run when entity about to be 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)
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.
"""
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
new_provider = None
new_legacy_provider = None
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
# Skip all providers if the camera has a native WebRTC implementation
if not (
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:
self._webrtc_provider = new_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."""
config = self._async_get_webrtc_client_configuration()
ice_servers = [
server
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
for server in servers()
]
config.configuration.ice_servers.extend(ice_servers)
if not self._supports_native_sync_webrtc:
# Until 2024.11, the frontend was not resolving any ice servers
# The async approach was added 2024.11 and new integrations need to use it
ice_servers = [
server
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 = (
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
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."""
if self._webrtc_provider:
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."""
frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat:
if (
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
):
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
# The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC)
else:
@ -880,6 +901,21 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
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):
"""Base CameraView."""

View File

@ -11,7 +11,7 @@ import logging
from typing import TYPE_CHECKING, Any, Protocol
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.core import HomeAssistant, callback
@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage):
class WebRTCCandidate(WebRTCMessage):
"""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)
@ -138,7 +145,9 @@ class CameraWebRTCProvider(ABC):
"""Handle the WebRTC offer and return the answer via the provided callback."""
@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."""
@callback
@ -319,7 +328,9 @@ async def ws_candidate(
)
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"]))

View File

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

View File

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

View File

@ -4,5 +4,5 @@
"codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation",
"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",
"integration_type": "system",
"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:
"""Initialize Crownstone options."""
super().__init__(OPTIONS_FLOW, self.async_create_new_entry)
self.entry = config_entry
self.updated_options = config_entry.options.copy()
self.options = config_entry.options.copy()
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""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}
usb_path = self.entry.options.get(CONF_USB_PATH)
usb_sphere = self.entry.options.get(CONF_USB_SPHERE)
usb_path = self.config_entry.options.get(CONF_USB_PATH)
usb_sphere = self.config_entry.options.get(CONF_USB_SPHERE)
options_schema = vol.Schema(
{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:
return await self.async_step_usb_config()
if not user_input[CONF_USE_USB_OPTION] and usb_path is not None:
self.updated_options[CONF_USB_PATH] = None
self.updated_options[CONF_USB_SPHERE] = None
self.options[CONF_USB_PATH] = None
self.options[CONF_USB_SPHERE] = None
elif (
CONF_USB_SPHERE_OPTION in user_input
and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere
):
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()
@ -260,7 +261,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow):
"""Create a new entry."""
# 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:
self.updated_options[CONF_USB_PATH] = self.usb_path
self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id
self.options[CONF_USB_PATH] = self.usb_path
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
@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."""
return DeconzOptionsFlowHandler(config_entry)
return DeconzOptionsFlowHandler()
def __init__(self) -> None:
"""Initialize the deCONZ config flow."""
@ -299,11 +301,6 @@ class DeconzOptionsFlowHandler(OptionsFlow):
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -315,8 +312,7 @@ class DeconzOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the deconz devices options."""
if user_input is not None:
self.options.update(user_input)
return self.async_create_entry(title="", data=self.options)
return self.async_create_entry(data=self.config_entry.options | user_input)
schema_options = {}
for option, default in (

View File

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

View File

@ -14,7 +14,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
OptionsFlow,
)
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback
@ -101,7 +101,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry,
) -> DnsIPOptionsFlowHandler:
"""Return Option handler."""
return DnsIPOptionsFlowHandler(config_entry)
return DnsIPOptionsFlowHandler()
async def async_step_user(
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."""
async def async_step_init(

View File

@ -171,9 +171,11 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return DSMROptionFlowHandler(config_entry)
return DSMROptionFlowHandler()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -311,10 +313,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
class DSMROptionFlowHandler(OptionsFlow):
"""Handle options."""
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.entry = entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -328,7 +326,7 @@ class DSMROptionFlowHandler(OptionsFlow):
{
vol.Optional(
CONF_TIME_BETWEEN_UPDATE,
default=self.entry.options.get(
default=self.config_entry.options.get(
CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE
),
): 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."""
data: EcobeeData = hass.data[DOMAIN]
async_add_entities(
[
EcobeeVentilator20MinSwitch(
data,
index,
(await dt_util.async_get_time_zone(thermostat["location"]["timeZone"]))
or dt_util.get_default_time_zone(),
)
entities: list[SwitchEntity] = [
EcobeeVentilator20MinSwitch(
data,
index,
(await dt_util.async_get_time_zone(thermostat["location"]["timeZone"]))
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)
if thermostat["settings"]["ventilatorType"] != "none"
],
update_before_add=True,
if thermostat["settings"]["hasHeatPump"]
)
)
async_add_entities(
EcobeeSwitchAuxHeatOnly(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"]
)
async_add_entities(entities, update_before_add=True)
class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):

View File

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

View File

@ -5,8 +5,11 @@ from pyemoncms import EmoncmsClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
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.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER
from .coordinator import EmoncmsCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -14,6 +17,49 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
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:
"""Load a config entry."""
emoncms_client = EmoncmsClient(
@ -21,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b
entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await _check_unique_id_migration(hass, entry, emoncms_client)
coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

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

View File

@ -7,6 +7,10 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message"
CONF_SUCCESS = "success"
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_NAME = "name"
FEED_TAG = "tag"

View File

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

View File

@ -1,5 +1,8 @@
{
"config": {
"error": {
"api_error": "An error occured in the pyemoncms API : {details}"
},
"step": {
"user": {
"data": {
@ -16,9 +19,15 @@
"include_only_feed_id": "Choose feeds to include"
}
}
},
"abort": {
"already_configured": "This server is already configured"
}
},
"options": {
"error": {
"api_error": "[%key:component::emoncms::config::error::api_error%]"
},
"step": {
"init": {
"data": {
@ -35,6 +44,10 @@
"missing_include_only_feed_id": {
"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."
},
"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",
"loggers": ["sense_energy"],
"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,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
@ -66,9 +66,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return EnvoyOptionsFlowHandler(config_entry)
return EnvoyOptionsFlowHandler()
@callback
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."""
async def async_step_init(

View File

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

View File

@ -570,7 +570,11 @@ def _async_setup_device_registry(
configuration_url = None
if device_info.webserver_port > 0:
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}"
manufacturer = "espressif"

View File

@ -8,7 +8,8 @@
"service_received": "Action received",
"mqtt_missing_mac": "Missing MAC address 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": {
"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,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
@ -46,9 +45,11 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return FeedReaderOptionsFlowHandler(config_entry)
return FeedReaderOptionsFlowHandler()
def show_user_form(
self,
@ -147,7 +148,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reconfigure_successful")
class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry):
class FeedReaderOptionsFlowHandler(OptionsFlow):
"""Handle an options flow."""
async def async_step_init(
@ -162,7 +163,9 @@ class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry):
{
vol.Optional(
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,
}
)

View File

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

View File

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

View File

@ -1,5 +1,7 @@
"""Config flow for file integration."""
from __future__ import annotations
from copy import deepcopy
import os
from typing import Any
@ -11,7 +13,6 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import (
CONF_FILE_PATH,
@ -74,9 +75,11 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return FileOptionsFlowHandler(config_entry)
return FileOptionsFlowHandler()
async def validate_file_path(self, file_path: str) -> bool:
"""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)
class FileOptionsFlowHandler(OptionsFlowWithConfigEntry):
class FileOptionsFlowHandler(OptionsFlow):
"""Handle File options."""
async def async_step_init(

View File

@ -71,9 +71,11 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@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."""
return FluxLedOptionsFlow(config_entry)
return FluxLedOptionsFlow()
async def async_step_dhcp(
self, discovery_info: dhcp.DhcpServiceInfo
@ -320,10 +322,6 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
class FluxLedOptionsFlow(OptionsFlow):
"""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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -332,7 +330,7 @@ class FluxLedOptionsFlow(OptionsFlow):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self._config_entry.options
options = self.config_entry.options
options_schema = vol.Schema(
{
vol.Optional(

View File

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

View File

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

View File

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

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"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,
) -> GenericOptionsFlowHandler:
"""Get the options flow for this handler."""
return GenericOptionsFlowHandler(config_entry)
return GenericOptionsFlowHandler()
def check_for_existing(self, options: dict[str, Any]) -> bool:
"""Check whether an existing entry is using the same URLs."""
@ -409,9 +409,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
class GenericOptionsFlowHandler(OptionsFlow):
"""Handle Generic IP Camera options."""
def __init__(self, config_entry: ConfigEntry) -> None:
def __init__(self) -> None:
"""Initialize Generic IP Camera options flow."""
self.config_entry = config_entry
self.preview_cam: dict[str, Any] = {}
self.user_input: dict[str, Any] = {}

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"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."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import (
Go2RtcWsClient,
ReceiveMessages,
@ -15,6 +18,7 @@ from go2rtc_client.ws import (
WsError,
)
import voluptuous as vol
from webrtc_models import RTCIceCandidate
from homeassistant.components.camera import (
Camera,
@ -37,7 +41,13 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
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
_LOGGER = logging.getLogger(__name__)
@ -84,13 +94,22 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
_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:
"""Set up WebRTC."""
url: str | None = None
managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass)
return True
@ -113,16 +132,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
server = Server(
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:
await server.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(
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:
"""Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC]
data = hass.data[_DATA_GO2RTC]
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.streams.list()
client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
await client.validate_server_version()
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {url}"
f"Could not connect to go2rtc instance on {data.url}"
) 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
except Go2RtcVersionError as err:
raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}"
) from err
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
provider = WebRTCProvider(hass, url)
provider = WebRTCProvider(hass, data)
async_register_webrtc_provider(hass, provider)
return True
@ -172,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
def __init__(self, hass: HomeAssistant, url: str) -> None:
def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
"""Initialize the WebRTC provider."""
self._hass = hass
self._url = url
self._data = data
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] = {}
@property
@ -199,19 +231,46 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
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()
if camera.entity_id not in streams:
if not (stream_source := await camera.stream_source()):
send_message(
WebRTCError(
"go2rtc_webrtc_offer_failed", "Camera has no stream source"
)
if self._data.managed:
# HA manages the go2rtc instance
stream_original_name = f"{camera.entity_id}_original"
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
await self._rest_client.streams.add(camera.entity_id, stream_source)
or (stream_redirect := streams.get(camera.entity_id)) is None
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
def on_messages(message: ReceiveMessages) -> None:
@ -219,7 +278,7 @@ class WebRTCProvider(CameraWebRTCProvider):
value: WebRTCMessage
match message:
case WebRTCCandidate():
value = HAWebRTCCandidate(message.candidate)
value = HAWebRTCCandidate(RTCIceCandidate(message.candidate))
case WebRTCAnswer():
value = HAWebRTCAnswer(message.sdp)
case WsError():
@ -231,11 +290,13 @@ class WebRTCProvider(CameraWebRTCProvider):
config = camera.async_get_webrtc_client_configuration()
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."""
if ws_client := self._sessions.get(session_id):
await ws_client.send(WebRTCCandidate(candidate))
await ws_client.send(WebRTCCandidate(candidate.candidate))
else:
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)

View File

@ -4,3 +4,6 @@ DOMAIN = "go2rtc"
CONF_DEBUG_UI = "debug_ui"
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",
"integration_type": "system",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.0.1b3"],
"requirements": ["go2rtc-client==0.1.0"],
"single_config_entry": true
}

View File

@ -1,40 +1,78 @@
"""Go2rtc server."""
import asyncio
from collections import deque
from contextlib import suppress
import logging
from tempfile import NamedTemporaryFile
from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant
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__)
_TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
_LOCALHOST_IP = "127.0.0.1"
_LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
# Default configuration for HA
# - Api is listening only on localhost
# - Disable rtsp listener
# - Enable rtsp for localhost only as ffmpeg needs it
# - 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:
listen: "{api_ip}:1984"
listen: "{api_ip}:{api_port}"
rtsp:
# ffmpeg needs rtsp for opus audio transcoding
listen: "127.0.0.1:8554"
listen: "127.0.0.1:{rtsp_port}"
webrtc:
listen: ":18555/tcp"
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:
"""Create temporary config file."""
# 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
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
@ -47,14 +85,24 @@ class Server:
"""Initialize the server."""
self._hass = hass
self._binary = binary
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP
if enable_ui:
# Listen on all interfaces for allowing access from all ips
self._api_ip = ""
self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = []
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."""
_LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job(
@ -82,8 +130,13 @@ class Server:
except TimeoutError as err:
msg = "Go2rtc server didn't start correctly"
_LOGGER.exception(msg)
await self.stop()
raise HomeAssistantError("Go2rtc server didn't start correctly") from err
self._log_server_output(logging.WARNING)
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:
"""Log the output of the process."""
@ -91,21 +144,111 @@ class Server:
async for line in process.stdout:
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:
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:
"""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."""
if self._process:
_LOGGER.debug("Stopping go2rtc server")
process = self._process
self._process = None
process.terminate()
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT)
except TimeoutError:
_LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it")
process.kill()
with suppress(ProcessLookupError):
process.kill()
else:
_LOGGER.debug("Go2rtc server has been stopped")

View File

@ -15,7 +15,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import (
@ -135,10 +135,10 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry: ConfigEntry,
) -> GoogleCloudOptionsFlowHandler:
"""Create the options flow."""
return GoogleCloudOptionsFlowHandler(config_entry)
return GoogleCloudOptionsFlowHandler()
class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
class GoogleCloudOptionsFlowHandler(OptionsFlow):
"""Google Cloud options flow."""
async def async_step_init(
@ -169,7 +169,7 @@ class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
)
),
**tts_options_schema(
self.options, voices, from_config_flow=True
self.config_entry.options, voices, from_config_flow=True
).schema,
vol.Optional(
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(
config_options: dict[str, Any],
config_options: Mapping[str, Any],
voices: dict[str, list[str]],
from_config_flow: bool = False,
) -> vol.Schema:

View File

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

View File

@ -30,6 +30,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
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"))
except ClientResponseError as error:
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
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)

View File

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

View File

@ -9,6 +9,7 @@ from typing import Any
from aiohttp import ClientResponseError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME, CONF_NAME
from homeassistant.core import (
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:
"""Set up services for Habitica integration."""
@ -86,14 +102,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def cast_skill(call: ServiceCall) -> ServiceResponse:
"""Skill action."""
entry: HabiticaConfigEntry | None
if not (
entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY])
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
skill = {
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},

View File

@ -38,6 +38,11 @@
}
},
"entity": {
"binary_sensor": {
"pending_quest": {
"name": "Pending quest invitation"
}
},
"button": {
"run_cron": {
"name": "Start my day"
@ -204,10 +209,10 @@
"message": "Unable to create new to-do `{name}` for Habitica, please try again"
},
"setup_rate_limit_exception": {
"message": "Currently rate limited, try again later"
"message": "Rate limit exceeded, try again later"
},
"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": {
"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}."
},
"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": {
"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_COMPONENTS = "components"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
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_REMOVED = "issue_addon_detached_addon_removed"
@ -136,17 +137,3 @@ class SupervisorEntityModel(StrEnum):
CORE = "Home Assistant Core"
SUPERVIOSR = "Home Assistant Supervisor"
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,
name=addon_info.name,
slug=data.addon,
uuid=str(data.uuid),
uuid=data.uuid.hex,
),
discovery_key=discovery_flow.DiscoveryKey(
domain=DOMAIN,
key=str(data.uuid),
key=data.uuid.hex,
version=1,
),
)

View File

@ -91,15 +91,6 @@ async def async_create_backup(
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
async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]:
"""Return settings specific to Home Assistant Green."""
@ -245,26 +236,6 @@ class HassIO:
"""
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
async def update_hass_api(
self, http_config: dict[str, Any], refresh_token: RefreshToken
@ -304,14 +275,6 @@ class HassIO:
"/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(
self,
command: str,

View File

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

View File

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

View File

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

View File

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

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"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.core import HomeAssistant
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 .const import (
@ -206,3 +210,9 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
"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