Merge branch 'dev' into mqtt-subentry-light

This commit is contained in:
Jan Bouwhuis 2025-04-13 14:38:24 +02:00 committed by GitHub
commit b517198773
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
894 changed files with 46329 additions and 11081 deletions

View File

@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@v4.5.0 uses: actions/dependency-review-action@v4.6.0
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.13 uses: github/codeql-action/init@v3.28.15
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.13 uses: github/codeql-action/analyze@v3.28.15
with: with:
category: "/language:python" category: "/language:python"

View File

@ -364,6 +364,7 @@ homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
homeassistant.components.ohme.*
homeassistant.components.onboarding.* homeassistant.components.onboarding.*
homeassistant.components.oncue.* homeassistant.components.oncue.*
homeassistant.components.onedrive.* homeassistant.components.onedrive.*

4
CODEOWNERS generated
View File

@ -704,6 +704,8 @@ build.json @home-assistant/supervisor
/tests/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core
/homeassistant/components/imap/ @jbouwh /homeassistant/components/imap/ @jbouwh
/tests/components/imap/ @jbouwh /tests/components/imap/ @jbouwh
/homeassistant/components/imeon_inverter/ @Imeon-Energy
/tests/components/imeon_inverter/ @Imeon-Energy
/homeassistant/components/imgw_pib/ @bieniu /homeassistant/components/imgw_pib/ @bieniu
/tests/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu
/homeassistant/components/improv_ble/ @emontnemery /homeassistant/components/improv_ble/ @emontnemery
@ -1480,8 +1482,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @Swamp-Ig /homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig
/homeassistant/components/sunweg/ @rokam
/tests/components/sunweg/ @rokam
/homeassistant/components/supla/ @mwegrzynek /homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen

View File

@ -53,6 +53,7 @@ from .components import (
logbook as logbook_pre_import, # noqa: F401 logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401
onboarding as onboarding_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401
person as person_pre_import, # noqa: F401
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
repairs as repairs_pre_import, # noqa: F401 repairs as repairs_pre_import, # noqa: F401
search as search_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401
@ -859,8 +860,14 @@ async def _async_set_up_integrations(
integrations, all_integrations = await _async_resolve_domains_and_preload( integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config hass, config
) )
all_domains = set(all_integrations) # Detect all cycles
domains = set(integrations) integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, all_integrations.values(), set(all_integrations)
)
)
all_domains = set(integrations_after_dependencies)
domains = set(integrations) & all_domains
_LOGGER.info( _LOGGER.info(
"Domains to be set up: %s | %s", "Domains to be set up: %s | %s",
@ -868,6 +875,8 @@ async def _async_set_up_integrations(
all_domains - domains, all_domains - domains,
) )
async_set_domains_to_be_loaded(hass, all_domains)
# Initialize recorder # Initialize recorder
if "recorder" in all_domains: if "recorder" in all_domains:
recorder.async_initialize_recorder(hass) recorder.async_initialize_recorder(hass)
@ -900,24 +909,12 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered = { stage_dep_domains_unfiltered = {
dep dep
for domain in stage_domains for domain in stage_domains
for dep in all_integrations[domain].all_dependencies for dep in integrations_after_dependencies[domain]
if dep not in stage_domains if dep not in stage_domains
} }
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains stage_all_domains = stage_domains | stage_dep_domains
stage_all_integrations = {
domain: all_integrations[domain] for domain in stage_all_domains
}
# Detect all cycles
stage_integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, stage_all_integrations.values(), stage_all_domains
)
)
stage_all_domains = set(stage_integrations_after_dependencies)
stage_domains &= stage_all_domains
stage_dep_domains &= stage_all_domains
_LOGGER.info( _LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s", "Setting up stage %s: %s | %s\nDependencies: %s | %s",
@ -928,8 +925,6 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered - stage_dep_domains, stage_dep_domains_unfiltered - stage_dep_domains,
) )
async_set_domains_to_be_loaded(hass, stage_all_domains)
if timeout is None: if timeout is None:
await _async_setup_multi_components(hass, stage_all_domains, config) await _async_setup_multi_components(hass, stage_all_domains, config)
continue continue

View File

@ -0,0 +1,5 @@
{
"domain": "eve",
"name": "Eve",
"iot_standards": ["matter"]
}

View File

@ -72,10 +72,10 @@
"level": { "level": {
"name": "Level", "name": "Level",
"state": { "state": {
"high": "High", "high": "[%key:common::state::high%]",
"low": "Low", "low": "[%key:common::state::low%]",
"moderate": "Moderate", "moderate": "Moderate",
"very_high": "Very high" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -89,10 +89,10 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -123,10 +123,10 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -167,10 +167,10 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -181,10 +181,10 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -195,10 +195,10 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }

View File

@ -68,8 +68,8 @@
"led_bar_mode": { "led_bar_mode": {
"name": "LED bar mode", "name": "LED bar mode",
"state": { "state": {
"off": "Off", "off": "[%key:common::state::off%]",
"co2": "Carbon dioxide", "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "Particulate matter" "pm": "Particulate matter"
} }
}, },
@ -143,8 +143,8 @@
"led_bar_mode": { "led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": { "state": {
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", "off": "[%key:common::state::off%]",
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
} }
}, },

View File

@ -16,8 +16,8 @@
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City", "city": "City",
"country": "Country", "state": "State",
"state": "State" "country": "[%key:common::config_flow::data::country%]"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@ -56,12 +56,12 @@
"sensor": { "sensor": {
"pollutant_label": { "pollutant_label": {
"state": { "state": {
"co": "Carbon monoxide", "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"n2": "Nitrogen dioxide", "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "Ozone", "o3": "[%key:component::sensor::entity_component::ozone::name%]",
"p1": "PM10", "p1": "[%key:component::sensor::entity_component::pm10::name%]",
"p2": "PM2.5", "p2": "[%key:component::sensor::entity_component::pm25::name%]",
"s2": "Sulfur dioxide" "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
} }
}, },
"pollutant_level": { "pollutant_level": {

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.9"] "requirements": ["aioairzone==1.0.0"]
} }

View File

@ -9,6 +9,8 @@ from aioairzone.const import (
AZD_HUMIDITY, AZD_HUMIDITY,
AZD_TEMP, AZD_TEMP,
AZD_TEMP_UNIT, AZD_TEMP_UNIT,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_SIGNAL,
AZD_WEBSERVER, AZD_WEBSERVER,
AZD_WIFI_RSSI, AZD_WIFI_RSSI,
AZD_ZONES, AZD_ZONES,
@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_SIGNAL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_signal",
),
) )

View File

@ -76,6 +76,9 @@
"sensor": { "sensor": {
"rssi": { "rssi": {
"name": "RSSI" "name": "RSSI"
},
"thermostat_signal": {
"name": "Signal strength"
} }
} }
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pydroid-ipcam==2.0.0"] "requirements": ["pydroid-ipcam==3.0.0"]
} }

View File

@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_key_command(key_code, direction) self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc
def _send_launch_app_command(self, app_link: str) -> None: def _send_launch_app_command(self, app_link: str) -> None:
@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_launch_app_command(app_link) self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc

View File

@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
await asyncio.sleep(delay_secs) await asyncio.sleep(delay_secs)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc

View File

@ -54,5 +54,10 @@
} }
} }
} }
},
"exceptions": {
"connection_closed": {
"message": "Connection to the Android TV device is closed"
}
} }
} }

View File

@ -266,7 +266,7 @@ async def _transform_stream(
raise ValueError("Unexpected stop event without a current block") raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use": if current_block["type"] == "tool_use":
tool_block = cast(ToolUseBlockParam, current_block) tool_block = cast(ToolUseBlockParam, current_block)
tool_args = json.loads(current_tool_args) tool_args = json.loads(current_tool_args) if current_tool_args else {}
tool_block["input"] = tool_args tool_block["input"] = tool_args
yield { yield {
"tool_calls": [ "tool_calls": [

View File

@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
"""Initialize the APCUPSd binary device.""" """Initialize the APCUPSd binary device."""
super().__init__(coordinator, context=description.key.upper()) super().__init__(coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := coordinator.data.serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info self._attr_device_info = coordinator.device_info
@property @property

View File

@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
self._host = host self._host = host
self._port = port self._port = port
@property
def unique_device_id(self) -> str:
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
return self.data.serial_no or self.config_entry.entry_id
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the DeviceInfo of this APC UPS, if serial number is available.""" """Return the DeviceInfo of this APC UPS, if serial number is available."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)}, identifiers={(DOMAIN, self.unique_device_id)},
model=self.data.model, model=self.data.model,
manufacturer="APC", manufacturer="APC",
name=self.data.name or "APC UPS", name=self.data.name or "APC UPS",

View File

@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator=coordinator, context=description.key.upper()) super().__init__(coordinator=coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := coordinator.data.serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info self._attr_device_info = coordinator.device_info
# Initial update of attributes. # Initial update of attributes.

View File

@ -20,6 +20,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_IGNORE, SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_ZEROCONF, SOURCE_ZEROCONF,
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers), CONF_IDENTIFIERS: list(combined_identifiers),
}, },
) )
if entry.source != SOURCE_IGNORE: # Don't reload ignored entries or in the middle of reauth,
# e.g. if the user is entering a new PIN
if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
self.hass.config_entries.async_schedule_reload(entry.entry_id) self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist: if not allow_exist:
raise DeviceAlreadyConfigured raise DeviceAlreadyConfigured

View File

@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
config_entry: ApSystemsConfigEntry config_entry: ApSystemsConfigEntry
device_version: str device_version: str
battery_system: bool
def __init__( def __init__(
self, self,
@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
self.api.max_power = device_info.maxPower self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower self.api.min_power = device_info.minPower
self.device_version = device_info.devVer self.device_version = device_info.devVer
self.battery_system = device_info.isBatterySystem
async def _async_update_data(self) -> ApSystemsSensorData: async def _async_update_data(self) -> ApSystemsSensorData:
try: try:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems", "documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["apsystems-ez1==2.4.0"] "requirements": ["apsystems-ez1==2.5.0"]
} }

View File

@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
super().__init__(data) super().__init__(data)
self._api = data.coordinator.api self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_inverter_status" self._attr_unique_id = f"{data.device_id}_inverter_status"
if data.coordinator.battery_system:
self._attr_available = False
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update switch status and availability.""" """Update switch status and availability."""

View File

@ -36,9 +36,9 @@
"wi_fi_strength": { "wi_fi_strength": {
"name": "Wi-Fi strength", "name": "Wi-Fi strength",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"medium": "Medium", "medium": "[%key:common::state::medium%]",
"high": "High" "high": "[%key:common::state::high%]"
} }
} }
} }

View File

@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{ {
vol.Optional("message"): str, vol.Optional("message"): str,
vol.Optional("media_id"): str, vol.Optional("media_id"): str,
vol.Optional("preannounce_media_id"): vol.Any(str, None), vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
} }
), ),
cv.has_at_least_one_key("message", "media_id"), cv.has_at_least_one_key("message", "media_id"),
@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{ {
vol.Optional("start_message"): str, vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str, vol.Optional("start_media_id"): str,
vol.Optional("preannounce_media_id"): vol.Any(str, None), vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("extra_system_prompt"): str, vol.Optional("extra_system_prompt"): str,
} }
), ),

View File

@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
self, self,
message: str | None = None, message: str | None = None,
media_id: str | None = None, media_id: str | None = None,
preannounce_media_id: str | None = PREANNOUNCE_URL, preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None: ) -> None:
"""Play and show an announcement on the satellite. """Play and show an announcement on the satellite.
@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity):
If media_id is provided, it is played directly. It is possible If media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text. to omit the message and the satellite will not show any text.
If preannounce is True, a sound is played before the announcement.
If preannounce_media_id is provided, it overrides the default sound. If preannounce_media_id is provided, it overrides the default sound.
If preannounce_media_id is None, no sound is played.
Calls async_announce with message and media id. Calls async_announce with message and media id.
""" """
@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity):
message = "" message = ""
announcement = await self._resolve_announcement_media_id( announcement = await self._resolve_announcement_media_id(
message, media_id, preannounce_media_id message,
media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
) )
if self._is_announcing: if self._is_announcing:
@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
start_message: str | None = None, start_message: str | None = None,
start_media_id: str | None = None, start_media_id: str | None = None,
extra_system_prompt: str | None = None, extra_system_prompt: str | None = None,
preannounce_media_id: str | None = PREANNOUNCE_URL, preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
) -> None: ) -> None:
"""Start a conversation from the satellite. """Start a conversation from the satellite.
@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity):
If start_media_id is provided, it is played directly. It is possible If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text. to omit the message and the satellite will not show any text.
If preannounce_media_id is provided, it is played before the announcement. If preannounce is True, a sound is played before the start message or media.
If preannounce_media_id is None, no sound is played. If preannounce_media_id is provided, it overrides the default sound.
Calls async_start_conversation. Calls async_start_conversation.
""" """
@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity):
start_message = "" start_message = ""
announcement = await self._resolve_announcement_media_id( announcement = await self._resolve_announcement_media_id(
start_message, start_media_id, preannounce_media_id start_message,
start_media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
) )
if self._is_announcing: if self._is_announcing:

View File

@ -15,6 +15,11 @@ announce:
required: false required: false
selector: selector:
text: text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id: preannounce_media_id:
required: false required: false
selector: selector:
@ -40,6 +45,11 @@ start_conversation:
required: false required: false
selector: selector:
text: text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id: preannounce_media_id:
required: false required: false
selector: selector:

View File

@ -24,9 +24,13 @@
"name": "Media ID", "name": "Media ID",
"description": "The media ID to announce instead of using text-to-speech." "description": "The media ID to announce instead of using text-to-speech."
}, },
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the announcement."
},
"preannounce_media_id": { "preannounce_media_id": {
"name": "Preannounce Media ID", "name": "Preannounce media ID",
"description": "The media ID to play before the announcement." "description": "Custom media ID to play before the announcement."
} }
} }
}, },
@ -46,9 +50,13 @@
"name": "Extra system prompt", "name": "Extra system prompt",
"description": "Provide background information to the AI about the request." "description": "Provide background information to the AI about the request."
}, },
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the start message or media."
},
"preannounce_media_id": { "preannounce_media_id": {
"name": "Preannounce Media ID", "name": "Preannounce media ID",
"description": "The media ID to play before the start message or media." "description": "Custom media ID to play before the start message or media."
} }
} }
} }

View File

@ -199,7 +199,7 @@ async def websocket_test_connection(
hass.async_create_background_task( hass.async_create_background_task(
satellite.async_internal_announce( satellite.async_internal_announce(
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}", media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
preannounce_media_id=None, preannounce=False,
), ),
f"assist_satellite_connection_test_{msg['entity_id']}", f"assist_satellite_connection_test_{msg['entity_id']}",
) )

View File

@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
"""Find a blob by backup id.""" """Find a blob by backup id."""
async for blob in self._client.list_blobs(include="metadata"): async for blob in self._client.list_blobs(include="metadata"):
if ( if (
backup_id == blob.metadata.get("backup_id", "") blob.metadata is not None
and backup_id == blob.metadata.get("backup_id", "")
and blob.metadata.get("metadata_version") == METADATA_VERSION and blob.metadata.get("metadata_version") == METADATA_VERSION
): ):
return blob return blob

View File

@ -0,0 +1,136 @@
"""Backup onboarding views."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from functools import wraps
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, Concatenate
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.onboarding import (
BaseOnboardingView,
NoAuthBaseOnboardingView,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData
async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the backup views."""
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, BackupManager, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
"""Home Assistant API decorator to check onboarding and inject manager."""
@wraps(func)
async def with_backup(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check admin and call function."""
if self._data["done"]:
raise HTTPUnauthorized
manager = await async_get_backup_manager(request.app[KEY_HASS])
return await func(self, manager, request, *args, **kwargs)
return with_backup
class BackupInfoView(NoAuthBaseOnboardingView):
"""Get backup info view."""
url = "/api/onboarding/backup/info"
name = "api:onboarding:backup:info"
@with_backup_manager
async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Return backup info."""
backups, _ = await manager.async_get_backups()
return self.json(
{
"backups": list(backups.values()),
"state": manager.state,
"last_action_event": manager.last_action_event,
}
)
class RestoreBackupView(NoAuthBaseOnboardingView):
"""Restore backup view."""
url = "/api/onboarding/backup/restore"
name = "api:onboarding:backup:restore"
@RequestDataValidator(
vol.Schema(
{
vol.Required("backup_id"): str,
vol.Required("agent_id"): str,
vol.Optional("password"): str,
vol.Optional("restore_addons"): [str],
vol.Optional("restore_database", default=True): bool,
vol.Optional("restore_folders"): [vol.Coerce(Folder)],
}
)
)
@with_backup_manager
async def post(
self, manager: BackupManager, request: web.Request, data: dict[str, Any]
) -> web.Response:
"""Restore a backup."""
try:
await manager.async_restore_backup(
data["backup_id"],
agent_id=data["agent_id"],
password=data.get("password"),
restore_addons=data.get("restore_addons"),
restore_database=data["restore_database"],
restore_folders=data.get("restore_folders"),
restore_homeassistant=True,
)
except IncorrectPasswordError:
return self.json(
{"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
)
except HomeAssistantError as err:
return self.json(
{"code": "restore_failed", "message": str(err)},
status_code=HTTPStatus.BAD_REQUEST,
)
return web.Response(status=HTTPStatus.OK)
class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView):
"""Upload backup view."""
url = "/api/onboarding/backup/upload"
name = "api:onboarding:backup:upload"
@with_backup_manager
async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Upload a backup file."""
return await self._post(request)

View File

@ -26,9 +26,9 @@
"entity": { "entity": {
"sensor": { "sensor": {
"backup_manager_state": { "backup_manager_state": {
"name": "Backup Manager State", "name": "Backup Manager state",
"state": { "state": {
"idle": "Idle", "idle": "[%key:common::state::idle%]",
"create_backup": "Creating a backup", "create_backup": "Creating a backup",
"receive_backup": "Receiving a backup", "receive_backup": "Receiving a backup",
"restore_backup": "Restoring a backup" "restore_backup": "Restoring a backup"

View File

@ -103,8 +103,8 @@
"temperature_range": { "temperature_range": {
"name": "Temperature range", "name": "Temperature range",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"high": "High" "high": "[%key:common::state::high%]"
} }
} }
}, },

View File

@ -124,15 +124,15 @@
"battery": { "battery": {
"name": "Battery", "name": "Battery",
"state": { "state": {
"off": "Normal", "off": "[%key:common::state::normal%]",
"on": "Low" "on": "[%key:common::state::low%]"
} }
}, },
"battery_charging": { "battery_charging": {
"name": "Charging", "name": "Charging",
"state": { "state": {
"off": "Not charging", "off": "Not charging",
"on": "Charging" "on": "[%key:common::state::charging%]"
} }
}, },
"carbon_monoxide": { "carbon_monoxide": {
@ -145,7 +145,7 @@
"cold": { "cold": {
"name": "Cold", "name": "Cold",
"state": { "state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "off": "[%key:common::state::normal%]",
"on": "Cold" "on": "Cold"
} }
}, },
@ -180,7 +180,7 @@
"heat": { "heat": {
"name": "Heat", "name": "Heat",
"state": { "state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "off": "[%key:common::state::normal%]",
"on": "Hot" "on": "Hot"
} }
}, },

View File

@ -37,7 +37,7 @@
"vehicle_status": { "vehicle_status": {
"name": "Vehicle status", "name": "Vehicle status",
"state": { "state": {
"standby": "Standby", "standby": "[%key:common::state::standby%]",
"vehicle_detected": "Detected", "vehicle_detected": "Detected",
"ready": "Ready", "ready": "Ready",
"no_power": "No power", "no_power": "No power",

View File

@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return return
# presets and inputs might have the same name; presets have priority # presets and inputs might have the same name; presets have priority
url: str | None = None
for input_ in self._inputs: for input_ in self._inputs:
if input_.text == source: if input_.text == source:
url = input_.url await self._player.play_url(input_.url)
return
for preset in self._presets: for preset in self._presets:
if preset.name == source: if preset.name == source:
url = preset.url await self._player.load_preset(preset.id)
return
if url is None: raise ServiceValidationError(f"Source {source} not found")
raise ServiceValidationError(f"Source {source} not found")
await self._player.play_url(url)
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""

View File

@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0", "bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4", "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5", "bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.26.1", "bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0", "dbus-fast==2.43.0",
"habluetooth==3.37.0" "habluetooth==3.37.0"
] ]

View File

@ -6,7 +6,7 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive Region" "region": "ConnectedDrive region"
}, },
"data_description": { "data_description": {
"username": "The email address of your MyBMW/MINI Connected account.", "username": "The email address of your MyBMW/MINI Connected account.",
@ -113,10 +113,10 @@
}, },
"select": { "select": {
"ac_limit": { "ac_limit": {
"name": "AC Charging Limit" "name": "AC charging limit"
}, },
"charging_mode": { "charging_mode": {
"name": "Charging Mode", "name": "Charging mode",
"state": { "state": {
"immediate_charging": "Immediate charging", "immediate_charging": "Immediate charging",
"delayed_charging": "Delayed charging", "delayed_charging": "Delayed charging",
@ -181,7 +181,7 @@
"cooling": "Cooling", "cooling": "Cooling",
"heating": "Heating", "heating": "Heating",
"inactive": "Inactive", "inactive": "Inactive",
"standby": "Standby", "standby": "[%key:common::state::standby%]",
"ventilation": "Ventilation" "ventilation": "Ventilation"
} }
}, },

View File

@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN from .const import DOMAIN
@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_ACCESS_TOKEN] = token self._discovered[CONF_ACCESS_TOKEN] = token
try: try:
_, hub_name = await _validate_input(self.hass, self._discovered) bond_id, hub_name = await _validate_input(self.hass, self._discovered)
except InputValidationError: except InputValidationError:
return return
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered[CONF_NAME] = hub_name self._discovered[CONF_NAME] = hub_name
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by dhcp discovery."""
host = discovery_info.ip
bond_id = discovery_info.hostname.partition("-")[2].upper()
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_zeroconf( async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = discovery_info.host host: str = discovery_info.host
bond_id = name.partition(".")[0] bond_id = name.partition(".")[0]
await self.async_set_unique_id(bond_id) await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_any_discovery(
self, bond_id: str, host: str
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
for entry in self._async_current_entries(): for entry in self._async_current_entries():
if entry.unique_id != bond_id: if entry.unique_id != bond_id:
continue continue
updates = {CONF_HOST: host} updates = {CONF_HOST: host}
if entry.state == ConfigEntryState.SETUP_ERROR and ( if entry.state is ConfigEntryState.SETUP_ERROR and (
token := await async_get_token(self.hass, host) token := await async_get_token(self.hass, host)
): ):
updates[CONF_ACCESS_TOKEN] = token updates[CONF_ACCESS_TOKEN] = token
@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._discovered[CONF_HOST], CONF_HOST: self._discovered[CONF_HOST],
} }
try: try:
_, hub_name = await _validate_input(self.hass, data) bond_id, hub_name = await _validate_input(self.hass, data)
except InputValidationError as error: except InputValidationError as error:
errors["base"] = error.base errors["base"] = error.base
else: else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._discovered[CONF_HOST]}
)
return self.async_create_entry( return self.async_create_entry(
title=hub_name, title=hub_name,
data=data, data=data,
@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
except InputValidationError as error: except InputValidationError as error:
errors["base"] = error.base errors["base"] = error.base
else: else:
await self.async_set_unique_id(bond_id) await self.async_set_unique_id(bond_id, raise_on_progress=False)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry(title=hub_name, data=user_input) return self.async_create_entry(title=hub_name, data=user_input)
return self.async_show_form( return self.async_show_form(

View File

@ -3,6 +3,16 @@
"name": "Bond", "name": "Bond",
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
"config_flow": true, "config_flow": true,
"dhcp": [
{
"hostname": "bond-*",
"macaddress": "3C6A2C1*"
},
{
"hostname": "bond-*",
"macaddress": "F44E38*"
}
],
"documentation": "https://www.home-assistant.io/integrations/bond", "documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["bond_async"], "loggers": ["bond_async"],

View File

@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
type BoschAlarmConfigEntry = ConfigEntry[Panel] type BoschAlarmConfigEntry = ConfigEntry[Panel]
@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
await panel.connect() await panel.connect()
except (PermissionError, ValueError) as err: except (PermissionError, ValueError) as err:
await panel.disconnect() await panel.disconnect()
raise ConfigEntryNotReady from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
await panel.disconnect() await panel.disconnect()
raise ConfigEntryNotReady("Connection failed") from err raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = panel entry.runtime_data = panel

View File

@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState, AlarmControlPanelState,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry from . import BoschAlarmConfigEntry
from .const import DOMAIN from .entity import BoschAlarmAreaEntity
async def async_setup_entry( async def async_setup_entry(
@ -35,7 +34,7 @@ async def async_setup_entry(
) )
class AreaAlarmControlPanel(AlarmControlPanelEntity): class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
"""An alarm control panel entity for a bosch alarm panel.""" """An alarm control panel entity for a bosch alarm panel."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity.""" """Initialise a Bosch Alarm control panel entity."""
self.panel = panel super().__init__(panel, area_id, unique_id, False, False, True)
self._area = panel.areas[area_id] self._attr_unique_id = self._area_unique_id
self._area_id = area_id
self._attr_unique_id = f"{unique_id}_area_{area_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(
DOMAIN,
unique_id,
),
)
@property @property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:
@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
await self.panel.area_arm_all(self._area_id) await self.panel.area_arm_all(self._area_id)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Run when entity attached to hass."""
await super().async_added_to_hass()
self._area.status_observer.attach(self.schedule_update_ha_state)
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity removed from hass."""
await super().async_will_remove_from_hass()
self._area.status_observer.detach(self.schedule_update_ha_state)
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
import logging import logging
import ssl import ssl
from typing import Any from typing import Any
@ -10,7 +11,12 @@ from typing import Any
from bosch_alarm_mode2 import Panel from bosch_alarm_mode2 import Panel
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_CODE, CONF_CODE,
CONF_HOST, CONF_HOST,
@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
self._data = user_input self._data = user_input
self._data[CONF_MODEL] = model self._data[CONF_MODEL] = model
if self.source == SOURCE_RECONFIGURE:
if (
self._get_reconfigure_entry().data[CONF_MODEL]
!= self._data[CONF_MODEL]
):
return self.async_abort(reason="device_mismatch")
return await self.async_step_auth() return await self.async_step_auth()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfigure step."""
return await self.async_step_user()
async def async_step_auth( async def async_step_auth(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
if serial_number: if serial_number:
await self.async_set_unique_id(str(serial_number)) await self.async_set_unique_id(str(serial_number))
self._abort_if_unique_id_configured() if self.source == SOURCE_USER:
else: if serial_number:
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) self._abort_if_unique_id_configured()
return self.async_create_entry(title=f"Bosch {model}", data=self._data) else:
self._async_abort_entries_match(
{CONF_HOST: self._data[CONF_HOST]}
)
return self.async_create_entry(
title=f"Bosch {model}", data=self._data
)
if serial_number:
self._abort_if_unique_id_mismatch(reason="device_mismatch")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data=self._data,
)
return self.async_show_form( return self.async_show_form(
step_id="auth", step_id="auth",
data_schema=self.add_suggested_values_to_schema(schema, user_input), data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an authentication error."""
self._data = dict(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reauth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
reauth_entry = self._get_reauth_entry()
self._data.update(user_input)
try:
(_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)

View File

@ -0,0 +1,73 @@
"""Diagnostics for bosch alarm."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from . import BoschAlarmConfigEntry
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BoschAlarmConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"model": entry.runtime_data.model,
"serial_number": entry.runtime_data.serial_number,
"protocol_version": entry.runtime_data.protocol_version,
"firmware_version": entry.runtime_data.firmware_version,
"areas": [
{
"id": area_id,
"name": area.name,
"all_ready": area.all_ready,
"part_ready": area.part_ready,
"faults": area.faults,
"alarms": area.alarms,
"disarmed": area.is_disarmed(),
"arming": area.is_arming(),
"pending": area.is_pending(),
"part_armed": area.is_part_armed(),
"all_armed": area.is_all_armed(),
"armed": area.is_armed(),
"triggered": area.is_triggered(),
}
for area_id, area in entry.runtime_data.areas.items()
],
"points": [
{
"id": point_id,
"name": point.name,
"open": point.is_open(),
"normal": point.is_normal(),
}
for point_id, point in entry.runtime_data.points.items()
],
"doors": [
{
"id": door_id,
"name": door.name,
"open": door.is_open(),
"locked": door.is_locked(),
}
for door_id, door in entry.runtime_data.doors.items()
],
"outputs": [
{
"id": output_id,
"name": output.name,
"active": output.is_active(),
}
for output_id, output in entry.runtime_data.outputs.items()
],
"history_events": entry.runtime_data.events,
},
}

View File

@ -0,0 +1,88 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from bosch_alarm_mode2 import Panel
from homeassistant.components.sensor import Entity
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
PARALLEL_UPDATES = 0
class BoschAlarmEntity(Entity):
"""A base entity for a bosch alarm panel."""
_attr_has_entity_name = True
def __init__(self, panel: Panel, unique_id: str) -> None:
"""Set up a entity for a bosch alarm panel."""
self.panel = panel
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
observe_alarms: bool,
observe_ready: bool,
observe_status: bool,
) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._area_id = area_id
self._area_unique_id = f"{unique_id}_area_{area_id}"
self._observe_alarms = observe_alarms
self._observe_ready = observe_ready
self._observe_status = observe_status
self._area = panel.areas[area_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._area_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(DOMAIN, unique_id),
)
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.attach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.attach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.detach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.detach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.detach(self.schedule_update_ha_state)

View File

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"faulting_points": {
"default": "mdi:alert-circle-outline"
}
}
}
}

View File

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["bosch-alarm-mode2==0.4.3"] "requirements": ["bosch-alarm-mode2==0.4.6"]
} }

View File

@ -40,7 +40,7 @@ rules:
integration-owner: done integration-owner: done
log-when-unavailable: todo log-when-unavailable: todo
parallel-updates: todo parallel-updates: todo
reauthentication-flow: todo reauthentication-flow: done
test-coverage: done test-coverage: done
# Gold # Gold
@ -62,9 +62,9 @@ rules:
entity-category: todo entity-category: todo
entity-device-class: todo entity-device-class: todo
entity-disabled-by-default: todo entity-disabled-by-default: todo
entity-translations: todo entity-translations: done
exception-translations: todo exception-translations: todo
icon-translations: todo icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:
status: exempt status: exempt

View File

@ -0,0 +1,86 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.panel import Area
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
"""Describes Bosch Alarm sensor entity."""
value_fn: Callable[[Area], int]
observe_alarms: bool = False
observe_ready: bool = False
observe_status: bool = False
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
BoschAlarmSensorEntityDescription(
key="faulting_points",
translation_key="faulting_points",
value_fn=lambda area: area.faults,
observe_ready=True,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up bosch alarm sensors."""
panel = config_entry.runtime_data
unique_id = config_entry.unique_id or config_entry.entry_id
async_add_entities(
BoschAreaSensor(panel, area_id, unique_id, template)
for area_id in panel.areas
for template in SENSOR_TYPES
)
PARALLEL_UPDATES = 0
class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
"""An area sensor entity for a bosch alarm panel."""
entity_description: BoschAlarmSensorEntityDescription
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
entity_description: BoschAlarmSensorEntityDescription,
) -> None:
"""Set up an area sensor entity for a bosch alarm panel."""
super().__init__(
panel,
area_id,
unique_id,
entity_description.observe_alarms,
entity_description.observe_ready,
entity_description.observe_status,
)
self.entity_description = entity_description
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
@property
def native_value(self) -> int:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._area)

View File

@ -22,6 +22,18 @@
"installer_code": "The installer code from your panel", "installer_code": "The installer code from your panel",
"user_code": "The user code from your panel" "user_code": "The user code from your panel"
} }
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
},
"data_description": {
"password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
}
} }
}, },
"error": { "error": {
@ -30,7 +42,26 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"device_mismatch": "Please ensure you reconfigure against the same device."
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to panel."
},
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
},
"entity": {
"sensor": {
"faulting_points": {
"name": "Faulting points",
"unit_of_measurement": "points"
}
} }
} }
} }

View File

@ -13,7 +13,7 @@
}, },
"data_description": { "data_description": {
"email": "The email address associated with your Bring! account.", "email": "The email address associated with your Bring! account.",
"password": "The password to login to your Bring! account." "password": "The password to log in to your Bring! account."
} }
}, },
"reauth_confirm": { "reauth_confirm": {

View File

@ -12,6 +12,7 @@ from buienradar.constants import (
CONDITION, CONDITION,
CONTENT, CONTENT,
DATA, DATA,
FEELTEMPERATURE,
FORECAST, FORECAST,
HUMIDITY, HUMIDITY,
MESSAGE, MESSAGE,
@ -22,6 +23,7 @@ from buienradar.constants import (
TEMPERATURE, TEMPERATURE,
VISIBILITY, VISIBILITY,
WINDAZIMUTH, WINDAZIMUTH,
WINDGUST,
WINDSPEED, WINDSPEED,
) )
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
@ -200,6 +202,14 @@ class BrData:
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
@property
def feeltemperature(self):
"""Return the feeltemperature, or None."""
try:
return float(self.data.get(FEELTEMPERATURE))
except (ValueError, TypeError):
return None
@property @property
def pressure(self): def pressure(self):
"""Return the pressure, or None.""" """Return the pressure, or None."""
@ -224,6 +234,14 @@ class BrData:
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
@property
def wind_gust(self):
"""Return the windgust, or None."""
try:
return float(self.data.get(WINDGUST))
except (ValueError, TypeError):
return None
@property @property
def wind_speed(self): def wind_speed(self):
"""Return the windspeed, or None.""" """Return the windspeed, or None."""

View File

@ -9,6 +9,7 @@ from buienradar.constants import (
MAX_TEMP, MAX_TEMP,
MIN_TEMP, MIN_TEMP,
RAIN, RAIN,
RAIN_CHANCE,
WINDAZIMUTH, WINDAZIMUTH,
WINDSPEED, WINDSPEED,
) )
@ -33,6 +34,7 @@ from homeassistant.components.weather import (
ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
Forecast, Forecast,
@ -153,7 +155,9 @@ class BrWeather(WeatherEntity):
) )
self._attr_native_pressure = data.pressure self._attr_native_pressure = data.pressure
self._attr_native_temperature = data.temperature self._attr_native_temperature = data.temperature
self._attr_native_apparent_temperature = data.feeltemperature
self._attr_native_visibility = data.visibility self._attr_native_visibility = data.visibility
self._attr_native_wind_gust_speed = data.wind_gust
self._attr_native_wind_speed = data.wind_speed self._attr_native_wind_speed = data.wind_speed
self._attr_wind_bearing = data.wind_bearing self._attr_wind_bearing = data.wind_bearing
@ -188,6 +192,7 @@ class BrWeather(WeatherEntity):
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE),
ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH),
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
} }

View File

@ -74,7 +74,7 @@
}, },
"get_events": { "get_events": {
"name": "Get events", "name": "Get events",
"description": "Get events on a calendar within a time range.", "description": "Retrieves events on a calendar within a time range.",
"fields": { "fields": {
"start_date_time": { "start_date_time": {
"name": "Start time", "name": "Start time",

View File

@ -2,17 +2,10 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress
import logging import logging
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING, Literal, cast
with suppress(Exception): from turbojpeg import TurboJPEG
# TurboJPEG imports numpy which may or may not work so
# we have to guard the import here. We still want
# to import it at top level so it gets loaded
# in the import executor and not in the event loop.
from turbojpeg import TurboJPEG
if TYPE_CHECKING: if TYPE_CHECKING:
from . import Image from . import Image

View File

@ -98,13 +98,13 @@
"name": "Preset", "name": "Preset",
"state": { "state": {
"none": "None", "none": "None",
"eco": "Eco", "home": "[%key:common::state::home%]",
"away": "Away", "away": "[%key:common::state::not_home%]",
"activity": "Activity",
"boost": "Boost", "boost": "Boost",
"comfort": "Comfort", "comfort": "Comfort",
"home": "[%key:common::state::home%]", "eco": "Eco",
"sleep": "Sleep", "sleep": "Sleep"
"activity": "Activity"
} }
}, },
"preset_modes": { "preset_modes": {
@ -257,7 +257,7 @@
"selector": { "selector": {
"hvac_mode": { "hvac_mode": {
"options": { "options": {
"off": "Off", "off": "[%key:common::state::off%]",
"auto": "Auto", "auto": "Auto",
"cool": "Cool", "cool": "Cool",
"dry": "Dry", "dry": "Dry",

View File

@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
flow_id=flow_id, user_input=tokens flow_id=flow_id, user_input=tokens
) )
self.hass.async_create_task(await_tokens()) # It's a background task because it should be cancelled on shutdown and there's nothing else
# we can do in such case. There's also no need to wait for this during setup.
self.hass.async_create_background_task(
await_tokens(), name="Awaiting OAuth tokens"
)
return authorize_url return authorize_url

View File

@ -0,0 +1,110 @@
"""Cloud onboarding views."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
from homeassistant.components.http import KEY_HASS
from homeassistant.components.onboarding import (
BaseOnboardingView,
NoAuthBaseOnboardingView,
)
from homeassistant.core import HomeAssistant
from . import http_api as cloud_http
from .const import DATA_CLOUD
if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData
async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the cloud views."""
hass.http.register_view(CloudForgotPasswordView(data))
hass.http.register_view(CloudLoginView(data))
hass.http.register_view(CloudLogoutView(data))
hass.http.register_view(CloudStatusView(data))
def ensure_not_done[_ViewT: BaseOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
"""Home Assistant API decorator to check onboarding and cloud."""
@wraps(func)
async def _ensure_not_done(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check onboarding status, cloud and call function."""
if self._data["done"]:
# If at least one onboarding step is done, we don't allow accessing
# the cloud onboarding views.
raise HTTPUnauthorized
return await func(self, request, *args, **kwargs)
return _ensure_not_done
class CloudForgotPasswordView(
NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView
):
"""View to start Forgot Password flow."""
url = "/api/onboarding/cloud/forgot_password"
name = "api:onboarding:cloud:forgot_password"
@ensure_not_done
async def post(self, request: web.Request) -> web.Response:
"""Handle forgot password request."""
return await super()._post(request)
class CloudLoginView(NoAuthBaseOnboardingView, cloud_http.CloudLoginView):
"""Login to Home Assistant Cloud."""
url = "/api/onboarding/cloud/login"
name = "api:onboarding:cloud:login"
@ensure_not_done
async def post(self, request: web.Request) -> web.Response:
"""Handle login request."""
return await super()._post(request)
class CloudLogoutView(NoAuthBaseOnboardingView, cloud_http.CloudLogoutView):
"""Log out of the Home Assistant cloud."""
url = "/api/onboarding/cloud/logout"
name = "api:onboarding:cloud:logout"
@ensure_not_done
async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
return await super()._post(request)
class CloudStatusView(NoAuthBaseOnboardingView):
"""Get cloud status view."""
url = "/api/onboarding/cloud/status"
name = "api:onboarding:cloud:status"
@ensure_not_done
async def get(self, request: web.Request) -> web.Response:
"""Return cloud status."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
return self.json({"logged_in": cloud.is_logged_in})

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from asyncio.exceptions import TimeoutError
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
@ -53,10 +54,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try: try:
await api.login() await api.login()
except aiocomelit_exceptions.CannotConnect as err: except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
raise CannotConnect from err raise CannotConnect(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except aiocomelit_exceptions.CannotAuthenticate as err: except aiocomelit_exceptions.CannotAuthenticate as err:
raise InvalidAuth from err raise InvalidAuth(
translation_domain=DOMAIN,
translation_key="cannot_authenticate",
translation_placeholders={"error": repr(err)},
) from err
finally: finally:
await api.logout() await api.logout()
await api.close() await api.close()

View File

@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -98,13 +98,20 @@ class ComelitCoverEntity(
"""Return if the cover is opening.""" """Return if the cover is opening."""
return self._current_action("opening") return self._current_action("opening")
async def _cover_set_state(self, action: int, state: int) -> None:
"""Set desired cover state."""
self._last_state = self.state
await self._api.set_device_status(COVER, self._device.index, action)
self.coordinator.data[COVER][self._device.index].status = state
self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover.""" """Close cover."""
await self._api.set_device_status(COVER, self._device.index, STATE_OFF) await self._cover_set_state(STATE_OFF, 2)
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Open cover.""" """Open cover."""
await self._api.set_device_status(COVER, self._device.index, STATE_ON) await self._cover_set_state(STATE_ON, 1)
async def async_stop_cover(self, **_kwargs: Any) -> None: async def async_stop_cover(self, **_kwargs: Any) -> None:
"""Stop the cover.""" """Stop the cover."""
@ -112,13 +119,7 @@ class ComelitCoverEntity(
return return
action = STATE_ON if self.is_closing else STATE_OFF action = STATE_ON if self.is_closing else STATE_OFF
await self._api.set_device_status(COVER, self._device.index, action) await self._cover_set_state(action, 0)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle device update."""
self._last_state = self.state
self.async_write_ha_state()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle entity which will be added.""" """Handle entity which will be added."""

View File

@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
async def async_set_humidity(self, humidity: int) -> None: async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity.""" """Set new target humidity."""
if self.mode == HumidifierComelitMode.OFF: if not self._attr_is_on:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="humidity_while_off", translation_key="humidity_while_off",
@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
await self.coordinator.api.set_humidity_status( await self.coordinator.api.set_humidity_status(
self._device.index, self._set_command self._device.index, self._set_command
) )
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off.""" """Turn off."""
await self.coordinator.api.set_humidity_status( await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.OFF self._device.index, HumidifierComelitCommand.OFF
) )
self._attr_is_on = False
self.async_write_ha_state()

View File

@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
async def _light_set_state(self, state: int) -> None: async def _light_set_state(self, state: int) -> None:
"""Set desired light state.""" """Set desired light state."""
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
await self.coordinator.async_request_refresh() self.coordinator.data[LIGHT][self._device.index].status = state
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on.""" """Turn the light on."""

View File

@ -42,9 +42,9 @@
"sensor": { "sensor": {
"zone_status": { "zone_status": {
"state": { "state": {
"open": "[%key:common::state::open%]",
"alarm": "Alarm", "alarm": "Alarm",
"armed": "Armed", "armed": "Armed",
"open": "Open",
"excluded": "Excluded", "excluded": "Excluded",
"faulty": "Faulty", "faulty": "Faulty",
"inhibited": "Inhibited", "inhibited": "Inhibited",
@ -52,7 +52,9 @@
"rest": "Rest", "rest": "Rest",
"sabotated": "Sabotated" "sabotated": "Sabotated"
} }
}, }
},
"humidifier": {
"humidifier": { "humidifier": {
"name": "Humidifier" "name": "Humidifier"
}, },
@ -67,6 +69,12 @@
}, },
"invalid_clima_data": { "invalid_clima_data": {
"message": "Invalid 'clima' data" "message": "Invalid 'clima' data"
},
"cannot_connect": {
"message": "Error connecting: {error}"
},
"cannot_authenticate": {
"message": "Error authenticating: {error}"
} }
} }
} }

View File

@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
await self.coordinator.api.set_device_status( await self.coordinator.api.set_device_status(
self._device.type, self._device.index, state self._device.type, self._device.index, state
) )
await self.coordinator.async_request_refresh() self.coordinator.data[self._device.type][self._device.index].status = state
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on.""" """Turn the switch on."""

View File

@ -58,7 +58,8 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_get_single)
websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entry_update)
websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_subscribe)
websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, config_entries_flow_progress)
websocket_api.async_register_command(hass, config_entries_flow_subscribe)
websocket_api.async_register_command(hass, ignore_config_flow) websocket_api.async_register_command(hass, ignore_config_flow)
websocket_api.async_register_command(hass, config_subentry_delete) websocket_api.async_register_command(hass, config_subentry_delete)
@ -357,7 +358,7 @@ class SubentryManagerFlowResourceView(
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.websocket_command({"type": "config_entries/flow/progress"}) @websocket_api.websocket_command({"type": "config_entries/flow/progress"})
def config_entries_progress( def config_entries_flow_progress(
hass: HomeAssistant, hass: HomeAssistant,
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
@ -378,6 +379,66 @@ def config_entries_progress(
) )
@websocket_api.require_admin
@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"})
def config_entries_flow_subscribe(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to non user created flows being initiated or removed.
When initiating the subscription, the current flows are sent to the client.
Example of a non-user initiated flow is a discovered Hue hub that
requires user interaction to finish setup.
"""
@callback
def async_on_flow_init_remove(change_type: str, flow_id: str) -> None:
"""Forward config entry state events to websocket."""
if change_type == "removed":
connection.send_message(
websocket_api.event_message(
msg["id"],
[{"type": change_type, "flow_id": flow_id}],
)
)
return
# change_type == "added"
connection.send_message(
websocket_api.event_message(
msg["id"],
[
{
"type": change_type,
"flow_id": flow_id,
"flow": hass.config_entries.flow.async_get(flow_id),
}
],
)
)
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
async_on_flow_init_remove
)
connection.send_message(
websocket_api.event_message(
msg["id"],
[
{"type": None, "flow_id": flw["flow_id"], "flow": flw}
for flw in hass.config_entries.flow.async_progress()
if flw["context"]["source"]
not in (
config_entries.SOURCE_RECONFIGURE,
config_entries.SOURCE_USER,
)
],
)
)
connection.send_result(msg["id"])
def send_entry_not_found( def send_entry_not_found(
connection: websocket_api.ActiveConnection, msg_id: int connection: websocket_api.ActiveConnection, msg_id: int
) -> None: ) -> None:

View File

@ -354,6 +354,35 @@ class ChatLog:
if self.delta_listener: if self.delta_listener:
self.delta_listener(self, asdict(tool_result)) self.delta_listener(self, asdict(tool_result))
async def _async_expand_prompt_template(
self,
llm_context: llm.LLMContext,
prompt: str,
language: str,
user_name: str | None = None,
) -> str:
try:
return template.Template(prompt, self.hass).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
async def async_update_llm_data( async def async_update_llm_data(
self, self,
conversing_domain: str, conversing_domain: str,
@ -409,38 +438,28 @@ class ChatLog:
): ):
user_name = user.name user_name = user.name
try: prompt_parts = []
prompt_parts = [ prompt_parts.append(
template.Template( await self._async_expand_prompt_template(
llm.BASE_PROMPT llm_context,
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass, user_input.language,
).async_render( user_name,
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
) )
raise ConverseError( )
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
if llm_api: if llm_api:
prompt_parts.append(llm_api.api_prompt) prompt_parts.append(llm_api.api_prompt)
prompt_parts.append(
await self._async_expand_prompt_template(
llm_context,
llm.BASE_PROMPT,
user_input.language,
user_name,
)
)
if extra_system_prompt := ( if extra_system_prompt := (
# Take new system prompt if one was given # Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt user_input.extra_system_prompt or self.extra_system_prompt

View File

@ -38,10 +38,10 @@
"name": "[%key:component::cover::title%]", "name": "[%key:component::cover::title%]",
"state": { "state": {
"open": "[%key:common::state::open%]", "open": "[%key:common::state::open%]",
"opening": "Opening", "opening": "[%key:common::state::opening%]",
"closed": "[%key:common::state::closed%]", "closed": "[%key:common::state::closed%]",
"closing": "Closing", "closing": "[%key:common::state::closing%]",
"stopped": "Stopped" "stopped": "[%key:common::state::stopped%]"
}, },
"state_attributes": { "state_attributes": {
"current_position": { "current_position": {

View File

@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.ssl import client_context_no_verify
from .const import KEY_MAC, TIMEOUT from .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator from .coordinator import DaikinConfigEntry, DaikinCoordinator
@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
key=entry.data.get(CONF_API_KEY), key=entry.data.get(CONF_API_KEY),
uuid=entry.data.get(CONF_UUID), uuid=entry.data.get(CONF_UUID),
password=entry.data.get(CONF_PASSWORD), password=entry.data.get(CONF_PASSWORD),
ssl_context=client_context_no_verify(),
) )
_LOGGER.debug("Connection to %s successful", host) _LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err: except TimeoutError as err:

View File

@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import client_context_no_verify
from .const import DOMAIN, KEY_MAC, TIMEOUT from .const import DOMAIN, KEY_MAC, TIMEOUT
@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
key=key, key=key,
uuid=uuid, uuid=uuid,
password=password, password=password,
ssl_context=client_context_no_verify(),
) )
except (TimeoutError, ClientError): except (TimeoutError, ClientError):
self.host = None self.host = None

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin", "documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydaikin"], "loggers": ["pydaikin"],
"requirements": ["pydaikin==2.14.1"], "requirements": ["pydaikin==2.15.0"],
"zeroconf": ["_dkapi._tcp.local."] "zeroconf": ["_dkapi._tcp.local."]
} }

View File

@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",

View File

@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@ -7,7 +7,7 @@
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.43.0"], "requirements": ["async-upnp-client==0.44.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1", "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@ -6,5 +6,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydoods"], "loggers": ["pydoods"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"] "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
} }

View File

@ -38,8 +38,8 @@
"protect_mode": { "protect_mode": {
"name": "Protect mode", "name": "Protect mode",
"state": { "state": {
"away": "Away", "away": "[%key:common::state::not_home%]",
"home": "Home", "home": "[%key:common::state::home%]",
"schedule": "Schedule" "schedule": "Schedule"
} }
} }

View File

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["dsmr_parser"], "loggers": ["dsmr_parser"],
"requirements": ["dsmr-parser==1.4.2"] "requirements": ["dsmr-parser==1.4.3"]
} }

View File

@ -51,8 +51,8 @@
"electricity_active_tariff": { "electricity_active_tariff": {
"name": "Active tariff", "name": "Active tariff",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"normal": "Normal" "normal": "[%key:common::state::normal%]"
} }
}, },
"electricity_delivered_tariff_1": { "electricity_delivered_tariff_1": {

View File

@ -140,8 +140,8 @@
"electricity_tariff": { "electricity_tariff": {
"name": "Electricity tariff", "name": "Electricity tariff",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"high": "High" "high": "[%key:common::state::high%]"
} }
}, },
"power_failure_count": { "power_failure_count": {

View File

@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
one = timedelta(days=1) one = timedelta(days=1)
if start_time is None: if start_time is None:
# Max 3 years of data # Max 3 years of data
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) start = dt_util.now(tz) - timedelta(days=3 * 365)
if agreement_date is None:
start = dt_util.now(tz) - timedelta(days=3 * 365)
else:
start = max(
agreement_date.replace(tzinfo=tz),
dt_util.now(tz) - timedelta(days=3 * 365),
)
else: else:
start = datetime.fromtimestamp(start_time, tz=tz) - lookback start = datetime.fromtimestamp(start_time, tz=tz) - lookback
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
if agreement_date is not None:
start = max(agreement_date.replace(tzinfo=tz), start)
start = start.replace(hour=0, minute=0, second=0, microsecond=0) start = start.replace(hour=0, minute=0, second=0, microsecond=0)
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
_LOGGER.debug("Data lookup range: %s - %s", start, end) _LOGGER.debug("Data lookup range: %s - %s", start, end)
start_step = end - lookback start_step = max(end - lookback, start)
end_step = end end_step = end
usage: dict[datetime, dict[str, float | int]] = {} usage: dict[datetime, dict[str, float | int]] = {}
while True: while True:

View File

@ -55,7 +55,7 @@
"fields": { "fields": {
"entity_id": { "entity_id": {
"name": "Entity", "name": "Entity",
"description": "Ecobee thermostat on which to create the vacation." "description": "ecobee thermostat on which to create the vacation."
}, },
"vacation_name": { "vacation_name": {
"name": "Vacation name", "name": "Vacation name",
@ -101,7 +101,7 @@
"fields": { "fields": {
"entity_id": { "entity_id": {
"name": "Entity", "name": "Entity",
"description": "Ecobee thermostat on which to delete the vacation." "description": "ecobee thermostat on which to delete the vacation."
}, },
"vacation_name": { "vacation_name": {
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
@ -149,7 +149,7 @@
}, },
"set_mic_mode": { "set_mic_mode": {
"name": "Set mic mode", "name": "Set mic mode",
"description": "Enables/disables Alexa microphone (only for Ecobee 4).", "description": "Enables/disables Alexa microphone (only for ecobee 4).",
"fields": { "fields": {
"mic_enabled": { "mic_enabled": {
"name": "Mic enabled", "name": "Mic enabled",
@ -177,7 +177,7 @@
"fields": { "fields": {
"entity_id": { "entity_id": {
"name": "Entity", "name": "Entity",
"description": "Ecobee thermostat on which to set active sensors." "description": "ecobee thermostat on which to set active sensors."
}, },
"preset_mode": { "preset_mode": {
"name": "Climate Name", "name": "Climate Name",
@ -203,12 +203,12 @@
}, },
"issues": { "issues": {
"migrate_aux_heat": { "migrate_aux_heat": {
"title": "Migration of Ecobee set_aux_heat action", "title": "Migration of ecobee set_aux_heat action",
"fix_flow": { "fix_flow": {
"step": { "step": {
"confirm": { "confirm": {
"description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Disable legacy Ecobee set_aux_heat action" "title": "Disable legacy ecobee set_aux_heat action"
} }
} }
} }

View File

@ -23,10 +23,8 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from . import EconetConfigEntry from . import EconetConfigEntry
from .const import DOMAIN
from .entity import EcoNetEntity from .entity import EcoNetEntity
ECONET_STATE_TO_HA = { ECONET_STATE_TO_HA = {
@ -212,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
"""Set the fan mode.""" """Set the fan mode."""
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
breaks_in_ha_version="2025.4.0",
is_fixable=True,
is_persistent=True,
translation_key="migrate_aux_heat",
severity=IssueSeverity.WARNING,
)
self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT)
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
breaks_in_ha_version="2025.4.0",
is_fixable=True,
is_persistent=True,
translation_key="migrate_aux_heat",
severity=IssueSeverity.WARNING,
)
self._econet.set_mode(ThermostatOperationMode.HEATING)
@property @property
def min_temp(self): def min_temp(self):
"""Return the minimum temperature.""" """Return the minimum temperature."""

View File

@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
def operation_list(self) -> list[str]: def operation_list(self) -> list[str]:
"""List of available operation modes.""" """List of available operation modes."""
econet_modes = self.water_heater.modes econet_modes = self.water_heater.modes
op_list = [] operation_modes = set()
for mode in econet_modes: for mode in econet_modes:
if ( if (
mode is not WaterHeaterOperationMode.UNKNOWN mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION and mode is not WaterHeaterOperationMode.VACATION
): ):
ha_mode = ECONET_STATE_TO_HA[mode] ha_mode = ECONET_STATE_TO_HA[mode]
op_list.append(ha_mode) operation_modes.add(ha_mode)
return op_list return list(operation_modes)
@property @property
def supported_features(self) -> WaterHeaterEntityFeature: def supported_features(self) -> WaterHeaterEntityFeature:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"] "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
} }

View File

@ -176,9 +176,9 @@
"water_amount": { "water_amount": {
"name": "Water flow level", "name": "Water flow level",
"state": { "state": {
"high": "High", "high": "[%key:common::state::high%]",
"low": "Low", "low": "[%key:common::state::low%]",
"medium": "Medium", "medium": "[%key:common::state::medium%]",
"ultrahigh": "Ultrahigh" "ultrahigh": "Ultrahigh"
} }
}, },
@ -229,9 +229,9 @@
"state_attributes": { "state_attributes": {
"fan_speed": { "fan_speed": {
"state": { "state": {
"normal": "[%key:common::state::normal%]",
"max": "Max", "max": "Max",
"max_plus": "Max+", "max_plus": "Max+",
"normal": "Normal",
"quiet": "Quiet" "quiet": "Quiet"
} }
}, },

View File

@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
async def async_setup_entry( async def async_setup_entry(

View File

@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"current_speed": {
"default": "mdi:pump"
},
"service_hours": {
"default": "mdi:wrench-clock"
},
"error_code": {
"default": "mdi:alert-octagon",
"state": {
"no_error": "mdi:check-circle"
}
}
}
}
}

View File

@ -0,0 +1,114 @@
"""EHEIM Digital sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
"""Class describing EHEIM Digital sensor entities."""
value_fn: Callable[[_DeviceT_co], float | str | None]
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
native_unit_of_measurement=PERCENTAGE,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](
coordinator, device, description
)
for description in CLASSICVARIO_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSensor(
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
):
"""Represent a EHEIM Digital sensor entity."""
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalSensorDescription[_DeviceT_co],
) -> None:
"""Initialize an EHEIM Digital number entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
def _async_update_attrs(self) -> None:
self._attr_native_value = self.entity_description.value_fn(self._device)

View File

@ -46,6 +46,22 @@
} }
} }
} }
},
"sensor": {
"current_speed": {
"name": "Current speed"
},
"service_hours": {
"name": "Remaining hours until service"
},
"error_code": {
"name": "Error code",
"state": {
"no_error": "No error",
"rotor_stuck": "Rotor stuck",
"air_in_filter": "Air in filter"
}
}
} }
} }
} }

View File

@ -100,7 +100,11 @@ class ElkEntity(Entity):
return {"index": self._element.index + 1} return {"index": self._element.index + 1}
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
pass """Handle changes to the element.
This method is called when the element changes. It should be
overridden by subclasses to handle the changes.
"""
@callback @callback
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
@ -111,7 +115,7 @@ class ElkEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callback for ElkM1 changes and update entity state.""" """Register callback for ElkM1 changes and update entity state."""
self._element.add_callback(self._element_callback) self._element.add_callback(self._element_callback)
self._element_callback(self._element, {}) self._element_changed(self._element, {})
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:

View File

@ -4,12 +4,12 @@
"choose_mode": { "choose_mode": {
"description": "Please choose the connection mode to Elmax panels.", "description": "Please choose the connection mode to Elmax panels.",
"menu_options": { "menu_options": {
"cloud": "Connect to Elmax Panel via Elmax Cloud APIs", "cloud": "Connect to Elmax panel via Elmax Cloud APIs",
"direct": "Connect to Elmax Panel via local/direct IP" "direct": "Connect to Elmax panel via local/direct IP"
} }
}, },
"cloud": { "cloud": {
"description": "Please login to the Elmax cloud using your credentials", "description": "Please log in to the Elmax cloud using your credentials",
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]" "username": "[%key:common::config_flow::data::username%]"
@ -28,7 +28,7 @@
"direct": { "direct": {
"description": "Specify the Elmax panel connection parameters below.", "description": "Specify the Elmax panel connection parameters below.",
"data": { "data": {
"panel_api_host": "Panel API Hostname or IP", "panel_api_host": "Panel API hostname or IP",
"panel_api_port": "Panel API port", "panel_api_port": "Panel API port",
"use_ssl": "Use SSL", "use_ssl": "Use SSL",
"panel_pin": "Panel PIN code" "panel_pin": "Panel PIN code"
@ -40,7 +40,7 @@
"panels": { "panels": {
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
"data": { "data": {
"panel_name": "Panel Name", "panel_name": "Panel name",
"panel_id": "Panel ID", "panel_id": "Panel ID",
"panel_pin": "[%key:common::config_flow::data::pin%]" "panel_pin": "[%key:common::config_flow::data::pin%]"
} }

View File

@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the emulated roku component.""" """Set up the emulated roku component."""
@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(
hass: HomeAssistant, entry: EmulatedRokuConfigEntry
) -> bool:
"""Set up an emulated roku server from a config entry.""" """Set up an emulated roku server from a config entry."""
config = config_entry.data config = entry.data
name: str = config[CONF_NAME]
if DOMAIN not in hass.data: listen_port: int = config[CONF_LISTEN_PORT]
hass.data[DOMAIN] = {} host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
advertise_ip: str | None = config.get(CONF_ADVERTISE_IP)
name = config[CONF_NAME] advertise_port: int | None = config.get(CONF_ADVERTISE_PORT)
listen_port = config[CONF_LISTEN_PORT] upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST)
host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
advertise_ip = config.get(CONF_ADVERTISE_IP)
advertise_port = config.get(CONF_ADVERTISE_PORT)
upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST)
server = EmulatedRoku( server = EmulatedRoku(
hass, hass,
entry.entry_id,
name, name,
host_ip, host_ip,
listen_port, listen_port,
@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
advertise_port, advertise_port,
upnp_bind_multicast, upnp_bind_multicast,
) )
entry.runtime_data = server
hass.data[DOMAIN][name] = server
return await server.setup() return await server.setup()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: EmulatedRokuConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
name = entry.data[CONF_NAME] return await entry.runtime_data.unload()
server = hass.data[DOMAIN].pop(name)
return await server.unload()

View File

@ -5,7 +5,13 @@ import logging
from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CoreState, EventOrigin from homeassistant.core import (
CALLBACK_TYPE,
CoreState,
Event,
EventOrigin,
HomeAssistant,
)
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
@ -27,16 +33,18 @@ class EmulatedRoku:
def __init__( def __init__(
self, self,
hass, hass: HomeAssistant,
name, entry_id: str,
host_ip, name: str,
listen_port, host_ip: str,
advertise_ip, listen_port: int,
advertise_port, advertise_ip: str | None,
upnp_bind_multicast, advertise_port: int | None,
): upnp_bind_multicast: bool | None,
) -> None:
"""Initialize the properties.""" """Initialize the properties."""
self.hass = hass self.hass = hass
self.entry_id = entry_id
self.roku_usn = name self.roku_usn = name
self.host_ip = host_ip self.host_ip = host_ip
@ -47,21 +55,21 @@ class EmulatedRoku:
self.bind_multicast = upnp_bind_multicast self.bind_multicast = upnp_bind_multicast
self._api_server = None self._api_server: EmulatedRokuServer | None = None
self._unsub_start_listener = None self._unsub_start_listener: CALLBACK_TYPE | None = None
self._unsub_stop_listener = None self._unsub_stop_listener: CALLBACK_TYPE | None = None
async def setup(self): async def setup(self) -> bool:
"""Start the emulated_roku server.""" """Start the emulated_roku server."""
class EventCommandHandler(EmulatedRokuCommandHandler): class EventCommandHandler(EmulatedRokuCommandHandler):
"""emulated_roku command handler to turn commands into events.""" """emulated_roku command handler to turn commands into events."""
def __init__(self, hass): def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass self.hass = hass
def on_keydown(self, roku_usn, key): def on_keydown(self, roku_usn: str, key: str) -> None:
"""Handle keydown event.""" """Handle keydown event."""
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_ROKU_COMMAND, EVENT_ROKU_COMMAND,
@ -73,7 +81,7 @@ class EmulatedRoku:
EventOrigin.local, EventOrigin.local,
) )
def on_keyup(self, roku_usn, key): def on_keyup(self, roku_usn: str, key: str) -> None:
"""Handle keyup event.""" """Handle keyup event."""
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_ROKU_COMMAND, EVENT_ROKU_COMMAND,
@ -85,7 +93,7 @@ class EmulatedRoku:
EventOrigin.local, EventOrigin.local,
) )
def on_keypress(self, roku_usn, key): def on_keypress(self, roku_usn: str, key: str) -> None:
"""Handle keypress event.""" """Handle keypress event."""
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_ROKU_COMMAND, EVENT_ROKU_COMMAND,
@ -97,7 +105,7 @@ class EmulatedRoku:
EventOrigin.local, EventOrigin.local,
) )
def launch(self, roku_usn, app_id): def launch(self, roku_usn: str, app_id: str) -> None:
"""Handle launch event.""" """Handle launch event."""
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_ROKU_COMMAND, EVENT_ROKU_COMMAND,
@ -129,17 +137,19 @@ class EmulatedRoku:
bind_multicast=self.bind_multicast, bind_multicast=self.bind_multicast,
) )
async def emulated_roku_stop(event): async def emulated_roku_stop(event: Event | None) -> None:
"""Wrap the call to emulated_roku.close.""" """Wrap the call to emulated_roku.close."""
LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
self._unsub_stop_listener = None self._unsub_stop_listener = None
assert self._api_server is not None
await self._api_server.close() await self._api_server.close()
async def emulated_roku_start(event): async def emulated_roku_start(event: Event | None) -> None:
"""Wrap the call to emulated_roku.start.""" """Wrap the call to emulated_roku.start."""
try: try:
LOGGER.debug("Starting emulated_roku %s", self.roku_usn) LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
self._unsub_start_listener = None self._unsub_start_listener = None
assert self._api_server is not None
await self._api_server.start() await self._api_server.start()
except OSError: except OSError:
LOGGER.exception( LOGGER.exception(
@ -165,7 +175,7 @@ class EmulatedRoku:
return True return True
async def unload(self): async def unload(self) -> bool:
"""Unload the emulated_roku server.""" """Unload the emulated_roku server."""
LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
@ -177,6 +187,7 @@ class EmulatedRoku:
self._unsub_stop_listener() self._unsub_stop_listener()
self._unsub_stop_listener = None self._unsub_stop_listener = None
assert self._api_server is not None
await self._api_server.close() await self._api_server.close()
return True return True

View File

@ -25,6 +25,7 @@ from homeassistant.core import (
split_entity_id, split_entity_id,
valid_entity_id, valid_entity_id,
) )
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event
@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = (
) )
class EntityNotFoundError(HomeAssistantError):
"""When a referenced entity was not found."""
class SensorManager: class SensorManager:
"""Class to handle creation/removal of sensor data.""" """Class to handle creation/removal of sensor data."""
@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
except ValueError: except ValueError:
return return
# Determine energy price try:
if self._config["entity_energy_price"] is not None: energy_price, energy_price_unit = self._get_energy_price(
energy_price_state = self.hass.states.get( valid_units, default_price_unit
self._config["entity_energy_price"]
) )
except EntityNotFoundError:
if energy_price_state is None: return
return except ValueError:
energy_price = None
try:
energy_price = float(energy_price_state.state)
except ValueError:
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities except
# price are in place. This means that the cost will update the first
# time the energy is updated after the price entity is in place.
self._reset(energy_state)
return
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_price_unit
else:
energy_price = cast(float, self._config["number_energy_price"])
energy_price_unit = default_price_unit
if self._last_energy_sensor_state is None: if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place. # Initialize as it's the first time all required entities are in place or
# only the price is missing. In the later case, cost will update the first
# time the energy is updated after the price entity is in place.
self._reset(energy_state) self._reset(energy_state)
return return
if energy_price is None:
return
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if energy_unit is None or energy_unit not in valid_units: if energy_unit is None or energy_unit not in valid_units:
@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity):
old_energy_value = float(self._last_energy_sensor_state.state) old_energy_value = float(self._last_energy_sensor_state.state)
cur_value = cast(float, self._attr_native_value) cur_value = cast(float, self._attr_native_value)
if energy_price_unit is None: converted_energy_price = self._convert_energy_price(
converted_energy_price = energy_price energy_price, energy_price_unit, energy_unit
else: )
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
converted_energy_price = converter(
energy_price,
energy_unit,
energy_price_unit,
)
self._attr_native_value = ( self._attr_native_value = (
cur_value + (energy - old_energy_value) * converted_energy_price cur_value + (energy - old_energy_value) * converted_energy_price
@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
self._last_energy_sensor_state = energy_state self._last_energy_sensor_state = energy_state
def _get_energy_price(
self, valid_units: set[str], default_unit: str | None
) -> tuple[float, str | None]:
"""Get the energy price.
Raises:
EntityNotFoundError: When the energy price entity is not found.
ValueError: When the entity state is not a valid float.
"""
if self._config["entity_energy_price"] is None:
return cast(float, self._config["number_energy_price"]), default_unit
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
if energy_price_state is None:
raise EntityNotFoundError
energy_price = float(energy_price_state.state)
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_unit
return energy_price, energy_price_unit
def _convert_energy_price(
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
) -> float:
"""Convert the energy price to the correct unit."""
if energy_price_unit is None:
return energy_price
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
return converter(energy_price, energy_unit, energy_price_unit)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key]) energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])

View File

@ -16,7 +16,13 @@ from homeassistant.config_entries import (
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
INSTALLER_AUTH_USERNAME = "installer" INSTALLER_AUTH_USERNAME = "installer"
AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]:
"""Return a dictionary without AVOID_REFLECT_KEYS."""
return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS}
async def validate_input( async def validate_input(
hass: HomeAssistant, hass: HomeAssistant,
@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders["serial"] = serial description_placeholders["serial"] = serial
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="reauth_confirm",
data_schema=self._async_generate_schema(), data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(),
without_avoid_reflect_keys(user_input or reauth_entry.data),
),
description_placeholders=description_placeholders, description_placeholders=description_placeholders,
errors=errors, errors=errors,
) )
@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SERIAL: self.unique_id, CONF_SERIAL: self.unique_id,
CONF_HOST: host, CONF_HOST: host,
} }
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=self._async_generate_schema(), data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(),
without_avoid_reflect_keys(user_input or {}),
),
description_placeholders=description_placeholders, description_placeholders=description_placeholders,
errors=errors, errors=errors,
) )
@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
} }
description_placeholders["serial"] = serial description_placeholders["serial"] = serial
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(), suggested_values self._async_generate_schema(),
without_avoid_reflect_keys(user_input or reconfigure_entry.data),
), ),
description_placeholders=description_placeholders, description_placeholders=description_placeholders,
errors=errors, errors=errors,

View File

@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
] ]
for end_point in end_points: for end_point in end_points:
response = await envoy.request(end_point) try:
fixture_data[end_point] = response.text.replace("\n", "").replace( response = await envoy.request(end_point)
serial, CLEAN_TEXT fixture_data[end_point] = response.text.replace("\n", "").replace(
) serial, CLEAN_TEXT
fixture_data[f"{end_point}_log"] = json_dumps( )
{ fixture_data[f"{end_point}_log"] = json_dumps(
"headers": dict(response.headers.items()), {
"code": response.status_code, "headers": dict(response.headers.items()),
} "code": response.status_code,
) }
)
except EnvoyError as err:
fixture_data[f"{end_point}_log"] = {"Error": repr(err)}
return fixture_data return fixture_data
@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics(
fixture_data: dict[str, Any] = {} fixture_data: dict[str, Any] = {}
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
try: fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
except EnvoyError as err:
fixture_data["Error"] = repr(err)
diagnostic_data: dict[str, Any] = { diagnostic_data: dict[str, Any] = {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT), "config_entry": async_redact_data(entry.as_dict(), TO_REDACT),

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"requirements": ["pyenphase==1.25.1"], "requirements": ["pyenphase==1.25.5"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -22,5 +22,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"]
} }

View File

@ -310,12 +310,13 @@ class EsphomeAssistSatellite(
self.entry_data.api_version self.entry_data.api_version
) )
) )
if feature_flags & VoiceAssistantFeature.SPEAKER: if feature_flags & VoiceAssistantFeature.SPEAKER and (
media_id = tts_output["media_id"] stream := tts.async_get_stream(self.hass, tts_output["token"])
):
self._tts_streaming_task = ( self._tts_streaming_task = (
self.config_entry.async_create_background_task( self.config_entry.async_create_background_task(
self.hass, self.hass,
self._stream_tts_audio(media_id), self._stream_tts_audio(stream),
"esphome_voice_assistant_tts", "esphome_voice_assistant_tts",
) )
) )
@ -564,7 +565,7 @@ class EsphomeAssistSatellite(
async def _stream_tts_audio( async def _stream_tts_audio(
self, self,
media_id: str, tts_result: tts.ResultStream,
sample_rate: int = 16000, sample_rate: int = 16000,
sample_width: int = 2, sample_width: int = 2,
sample_channels: int = 1, sample_channels: int = 1,
@ -579,15 +580,14 @@ class EsphomeAssistSatellite(
if not self._is_running: if not self._is_running:
return return
extension, data = await tts.async_get_media_source_audio( if tts_result.extension != "wav":
self.hass, _LOGGER.error(
media_id, "Only WAV audio can be streamed, got %s", tts_result.extension
) )
if extension != "wav":
_LOGGER.error("Only WAV audio can be streamed, got %s", extension)
return return
data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
if ( if (
(wav_file.getframerate() != sample_rate) (wav_file.getframerate() != sample_rate)

View File

@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = "" self._password = ""
return await self._async_authenticate_or_add() return await self._async_authenticate_or_add()
if error is None and entry_data.get(CONF_NOISE_PSK):
return await self.async_step_reauth_encryption_removed_confirm()
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
async def async_step_reauth_encryption_removed_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthorization flow when encryption was removed."""
if user_input is not None:
self._noise_psk = None
return self._async_get_entry()
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
description_placeholders={"name": self._name},
)
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

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