mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Merge branch 'dev' into mqtt-subentry-light
This commit is contained in:
commit
b517198773
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.5.0
|
||||
uses: actions/dependency-review-action@v4.6.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.13
|
||||
uses: github/codeql-action/init@v3.28.15
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.13
|
||||
uses: github/codeql-action/analyze@v3.28.15
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
@ -364,6 +364,7 @@ homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
homeassistant.components.ohme.*
|
||||
homeassistant.components.onboarding.*
|
||||
homeassistant.components.oncue.*
|
||||
homeassistant.components.onedrive.*
|
||||
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@ -704,6 +704,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/image_upload/ @home-assistant/core
|
||||
/homeassistant/components/imap/ @jbouwh
|
||||
/tests/components/imap/ @jbouwh
|
||||
/homeassistant/components/imeon_inverter/ @Imeon-Energy
|
||||
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||
/homeassistant/components/imgw_pib/ @bieniu
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
@ -1480,8 +1482,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @Swamp-Ig
|
||||
/tests/components/sun/ @Swamp-Ig
|
||||
/homeassistant/components/sunweg/ @rokam
|
||||
/tests/components/sunweg/ @rokam
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
|
@ -53,6 +53,7 @@ from .components import (
|
||||
logbook as logbook_pre_import, # noqa: F401
|
||||
lovelace as lovelace_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
|
||||
repairs as repairs_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(
|
||||
hass, config
|
||||
)
|
||||
all_domains = set(all_integrations)
|
||||
domains = set(integrations)
|
||||
# Detect all cycles
|
||||
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(
|
||||
"Domains to be set up: %s | %s",
|
||||
@ -868,6 +875,8 @@ async def _async_set_up_integrations(
|
||||
all_domains - domains,
|
||||
)
|
||||
|
||||
async_set_domains_to_be_loaded(hass, all_domains)
|
||||
|
||||
# Initialize recorder
|
||||
if "recorder" in all_domains:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
@ -900,24 +909,12 @@ async def _async_set_up_integrations(
|
||||
stage_dep_domains_unfiltered = {
|
||||
dep
|
||||
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
|
||||
}
|
||||
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
|
||||
|
||||
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(
|
||||
"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,
|
||||
)
|
||||
|
||||
async_set_domains_to_be_loaded(hass, stage_all_domains)
|
||||
|
||||
if timeout is None:
|
||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||
continue
|
||||
|
5
homeassistant/brands/eve.json
Normal file
5
homeassistant/brands/eve.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "eve",
|
||||
"name": "Eve",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
@ -72,10 +72,10 @@
|
||||
"level": {
|
||||
"name": "Level",
|
||||
"state": {
|
||||
"high": "High",
|
||||
"low": "Low",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "Moderate",
|
||||
"very_high": "Very high"
|
||||
"very_high": "[%key:common::state::very_high%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,10 +89,10 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"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": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"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": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"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": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"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": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,8 +68,8 @@
|
||||
"led_bar_mode": {
|
||||
"name": "LED bar mode",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"co2": "Carbon dioxide",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"pm": "Particulate matter"
|
||||
}
|
||||
},
|
||||
@ -143,8 +143,8 @@
|
||||
"led_bar_mode": {
|
||||
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
|
||||
"state": {
|
||||
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]",
|
||||
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
|
||||
}
|
||||
},
|
||||
|
@ -16,8 +16,8 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"state": "State"
|
||||
"state": "State",
|
||||
"country": "[%key:common::config_flow::data::country%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@ -56,12 +56,12 @@
|
||||
"sensor": {
|
||||
"pollutant_label": {
|
||||
"state": {
|
||||
"co": "Carbon monoxide",
|
||||
"n2": "Nitrogen dioxide",
|
||||
"o3": "Ozone",
|
||||
"p1": "PM10",
|
||||
"p2": "PM2.5",
|
||||
"s2": "Sulfur dioxide"
|
||||
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"p1": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"p2": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
}
|
||||
},
|
||||
"pollutant_level": {
|
||||
|
@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==0.9.9"]
|
||||
"requirements": ["aioairzone==1.0.0"]
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ from aioairzone.const import (
|
||||
AZD_HUMIDITY,
|
||||
AZD_TEMP,
|
||||
AZD_TEMP_UNIT,
|
||||
AZD_THERMOSTAT_BATTERY,
|
||||
AZD_THERMOSTAT_SIGNAL,
|
||||
AZD_WEBSERVER,
|
||||
AZD_WIFI_RSSI,
|
||||
AZD_ZONES,
|
||||
@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -76,6 +76,9 @@
|
||||
"sensor": {
|
||||
"rssi": {
|
||||
"name": "RSSI"
|
||||
},
|
||||
"thermostat_signal": {
|
||||
"name": "Signal strength"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pydroid-ipcam==2.0.0"]
|
||||
"requirements": ["pydroid-ipcam==3.0.0"]
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
self._api.send_key_command(key_code, direction)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
||||
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)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
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
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
||||
await asyncio.sleep(delay_secs)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
@ -54,5 +54,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_closed": {
|
||||
"message": "Connection to the Android TV device is closed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -266,7 +266,7 @@ async def _transform_stream(
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
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
|
||||
yield {
|
||||
"tool_calls": [
|
||||
|
@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
|
||||
"""Initialize the APCUPSd binary device."""
|
||||
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._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
|
@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
|
||||
self._host = host
|
||||
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
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)},
|
||||
identifiers={(DOMAIN, self.unique_device_id)},
|
||||
model=self.data.model,
|
||||
manufacturer="APC",
|
||||
name=self.data.name or "APC UPS",
|
||||
|
@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
|
||||
"""Initialize the sensor."""
|
||||
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._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
# Initial update of attributes.
|
||||
|
@ -20,6 +20,7 @@ import voluptuous as vol
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_ZEROCONF,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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)
|
||||
if not allow_exist:
|
||||
raise DeviceAlreadyConfigured
|
||||
|
@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
||||
|
||||
config_entry: ApSystemsConfigEntry
|
||||
device_version: str
|
||||
battery_system: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
||||
self.api.max_power = device_info.maxPower
|
||||
self.api.min_power = device_info.minPower
|
||||
self.device_version = device_info.devVer
|
||||
self.battery_system = device_info.isBatterySystem
|
||||
|
||||
async def _async_update_data(self) -> ApSystemsSensorData:
|
||||
try:
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apsystems",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["apsystems-ez1==2.4.0"]
|
||||
"requirements": ["apsystems-ez1==2.5.0"]
|
||||
}
|
||||
|
@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
|
||||
super().__init__(data)
|
||||
self._api = data.coordinator.api
|
||||
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:
|
||||
"""Update switch status and availability."""
|
||||
|
@ -36,9 +36,9 @@
|
||||
"wi_fi_strength": {
|
||||
"name": "Wi-Fi strength",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"high": "[%key:common::state::high%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("message"): 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"),
|
||||
@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("start_message"): 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,
|
||||
}
|
||||
),
|
||||
|
@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self,
|
||||
message: 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:
|
||||
"""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
|
||||
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 None, no sound is played.
|
||||
|
||||
Calls async_announce with message and media id.
|
||||
"""
|
||||
@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
message = ""
|
||||
|
||||
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:
|
||||
@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_message: str | None = None,
|
||||
start_media_id: 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:
|
||||
"""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
|
||||
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_media_id is None, no sound is played.
|
||||
If preannounce is True, a sound is played before the start message or media.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_message = ""
|
||||
|
||||
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:
|
||||
|
@ -15,6 +15,11 @@ announce:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
@ -40,6 +45,11 @@ start_conversation:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
|
@ -24,9 +24,13 @@
|
||||
"name": "Media ID",
|
||||
"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": {
|
||||
"name": "Preannounce Media ID",
|
||||
"description": "The media ID to play before the announcement."
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the announcement."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -46,9 +50,13 @@
|
||||
"name": "Extra system prompt",
|
||||
"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": {
|
||||
"name": "Preannounce Media ID",
|
||||
"description": "The media ID to play before the start message or media."
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -199,7 +199,7 @@ async def websocket_test_connection(
|
||||
hass.async_create_background_task(
|
||||
satellite.async_internal_announce(
|
||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
|
||||
preannounce_media_id=None,
|
||||
preannounce=False,
|
||||
),
|
||||
f"assist_satellite_connection_test_{msg['entity_id']}",
|
||||
)
|
||||
|
@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Find a blob by backup id."""
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
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
|
||||
):
|
||||
return blob
|
||||
|
136
homeassistant/components/backup/onboarding.py
Normal file
136
homeassistant/components/backup/onboarding.py
Normal 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)
|
@ -26,9 +26,9 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"backup_manager_state": {
|
||||
"name": "Backup Manager State",
|
||||
"name": "Backup Manager state",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"create_backup": "Creating a backup",
|
||||
"receive_backup": "Receiving a backup",
|
||||
"restore_backup": "Restoring a backup"
|
||||
|
@ -103,8 +103,8 @@
|
||||
"temperature_range": {
|
||||
"name": "Temperature range",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"high": "High"
|
||||
"low": "[%key:common::state::low%]",
|
||||
"high": "[%key:common::state::high%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -124,15 +124,15 @@
|
||||
"battery": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"off": "Normal",
|
||||
"on": "Low"
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"on": "[%key:common::state::low%]"
|
||||
}
|
||||
},
|
||||
"battery_charging": {
|
||||
"name": "Charging",
|
||||
"state": {
|
||||
"off": "Not charging",
|
||||
"on": "Charging"
|
||||
"on": "[%key:common::state::charging%]"
|
||||
}
|
||||
},
|
||||
"carbon_monoxide": {
|
||||
@ -145,7 +145,7 @@
|
||||
"cold": {
|
||||
"name": "Cold",
|
||||
"state": {
|
||||
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"on": "Cold"
|
||||
}
|
||||
},
|
||||
@ -180,7 +180,7 @@
|
||||
"heat": {
|
||||
"name": "Heat",
|
||||
"state": {
|
||||
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"on": "Hot"
|
||||
}
|
||||
},
|
||||
|
@ -37,7 +37,7 @@
|
||||
"vehicle_status": {
|
||||
"name": "Vehicle status",
|
||||
"state": {
|
||||
"standby": "Standby",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"vehicle_detected": "Detected",
|
||||
"ready": "Ready",
|
||||
"no_power": "No power",
|
||||
|
@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
return
|
||||
|
||||
# presets and inputs might have the same name; presets have priority
|
||||
url: str | None = None
|
||||
for input_ in self._inputs:
|
||||
if input_.text == source:
|
||||
url = input_.url
|
||||
await self._player.play_url(input_.url)
|
||||
return
|
||||
for preset in self._presets:
|
||||
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")
|
||||
|
||||
await self._player.play_url(url)
|
||||
raise ServiceValidationError(f"Source {source} not found")
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear players playlist."""
|
||||
|
@ -19,7 +19,7 @@
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.4.5",
|
||||
"bluetooth-data-tools==1.26.1",
|
||||
"bluetooth-data-tools==1.27.0",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.37.0"
|
||||
]
|
||||
|
@ -6,7 +6,7 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "ConnectedDrive Region"
|
||||
"region": "ConnectedDrive region"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The email address of your MyBMW/MINI Connected account.",
|
||||
@ -113,10 +113,10 @@
|
||||
},
|
||||
"select": {
|
||||
"ac_limit": {
|
||||
"name": "AC Charging Limit"
|
||||
"name": "AC charging limit"
|
||||
},
|
||||
"charging_mode": {
|
||||
"name": "Charging Mode",
|
||||
"name": "Charging mode",
|
||||
"state": {
|
||||
"immediate_charging": "Immediate charging",
|
||||
"delayed_charging": "Delayed charging",
|
||||
@ -181,7 +181,7 @@
|
||||
"cooling": "Cooling",
|
||||
"heating": "Heating",
|
||||
"inactive": "Inactive",
|
||||
"standby": "Standby",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"ventilation": "Ventilation"
|
||||
}
|
||||
},
|
||||
|
@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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 .const import DOMAIN
|
||||
@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._discovered[CONF_ACCESS_TOKEN] = token
|
||||
try:
|
||||
_, hub_name = await _validate_input(self.hass, self._discovered)
|
||||
bond_id, hub_name = await _validate_input(self.hass, self._discovered)
|
||||
except InputValidationError:
|
||||
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
|
||||
|
||||
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(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
host: str = discovery_info.host
|
||||
bond_id = name.partition(".")[0]
|
||||
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():
|
||||
if entry.unique_id != bond_id:
|
||||
continue
|
||||
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)
|
||||
):
|
||||
updates[CONF_ACCESS_TOKEN] = token
|
||||
@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self._discovered[CONF_HOST],
|
||||
}
|
||||
try:
|
||||
_, hub_name = await _validate_input(self.hass, data)
|
||||
bond_id, hub_name = await _validate_input(self.hass, data)
|
||||
except InputValidationError as error:
|
||||
errors["base"] = error.base
|
||||
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(
|
||||
title=hub_name,
|
||||
data=data,
|
||||
@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except InputValidationError as error:
|
||||
errors["base"] = error.base
|
||||
else:
|
||||
await self.async_set_unique_id(bond_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
await self.async_set_unique_id(bond_id, raise_on_progress=False)
|
||||
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_show_form(
|
||||
|
@ -3,6 +3,16 @@
|
||||
"name": "Bond",
|
||||
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "bond-*",
|
||||
"macaddress": "3C6A2C1*"
|
||||
},
|
||||
{
|
||||
"hostname": "bond-*",
|
||||
"macaddress": "F44E38*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bond",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["bond_async"],
|
||||
|
@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||
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 .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]
|
||||
|
||||
@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
await panel.connect()
|
||||
except (PermissionError, ValueError) as err:
|
||||
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:
|
||||
await panel.disconnect()
|
||||
raise ConfigEntryNotReady("Connection failed") from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
entry.runtime_data = panel
|
||||
|
||||
|
@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
||||
|
||||
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
||||
"""Initialise a Bosch Alarm control panel entity."""
|
||||
self.panel = panel
|
||||
self._area = panel.areas[area_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,
|
||||
),
|
||||
)
|
||||
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||
self._attr_unique_id = self._area_unique_id
|
||||
|
||||
@property
|
||||
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:
|
||||
"""Send arm away command."""
|
||||
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)
|
||||
|
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
@ -10,7 +11,12 @@ from typing import Any
|
||||
from bosch_alarm_mode2 import Panel
|
||||
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 (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
self._data = user_input
|
||||
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 self.async_show_form(
|
||||
step_id="user",
|
||||
@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
self._abort_if_unique_id_configured()
|
||||
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 self.source == SOURCE_USER:
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_configured()
|
||||
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(
|
||||
step_id="auth",
|
||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||
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,
|
||||
)
|
||||
|
73
homeassistant/components/bosch_alarm/diagnostics.py
Normal file
73
homeassistant/components/bosch_alarm/diagnostics.py
Normal 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,
|
||||
},
|
||||
}
|
88
homeassistant/components/bosch_alarm/entity.py
Normal file
88
homeassistant/components/bosch_alarm/entity.py
Normal 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)
|
9
homeassistant/components/bosch_alarm/icons.json
Normal file
9
homeassistant/components/bosch_alarm/icons.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"faulting_points": {
|
||||
"default": "mdi:alert-circle-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.3"]
|
||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
@ -62,9 +62,9 @@ rules:
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
|
86
homeassistant/components/bosch_alarm/sensor.py
Normal file
86
homeassistant/components/bosch_alarm/sensor.py
Normal 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)
|
@ -22,6 +22,18 @@
|
||||
"installer_code": "The installer 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": {
|
||||
@ -30,7 +42,26 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"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": {
|
||||
|
@ -12,6 +12,7 @@ from buienradar.constants import (
|
||||
CONDITION,
|
||||
CONTENT,
|
||||
DATA,
|
||||
FEELTEMPERATURE,
|
||||
FORECAST,
|
||||
HUMIDITY,
|
||||
MESSAGE,
|
||||
@ -22,6 +23,7 @@ from buienradar.constants import (
|
||||
TEMPERATURE,
|
||||
VISIBILITY,
|
||||
WINDAZIMUTH,
|
||||
WINDGUST,
|
||||
WINDSPEED,
|
||||
)
|
||||
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
|
||||
@ -200,6 +202,14 @@ class BrData:
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def feeltemperature(self):
|
||||
"""Return the feeltemperature, or None."""
|
||||
try:
|
||||
return float(self.data.get(FEELTEMPERATURE))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def pressure(self):
|
||||
"""Return the pressure, or None."""
|
||||
@ -224,6 +234,14 @@ class BrData:
|
||||
except (ValueError, TypeError):
|
||||
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
|
||||
def wind_speed(self):
|
||||
"""Return the windspeed, or None."""
|
||||
|
@ -9,6 +9,7 @@ from buienradar.constants import (
|
||||
MAX_TEMP,
|
||||
MIN_TEMP,
|
||||
RAIN,
|
||||
RAIN_CHANCE,
|
||||
WINDAZIMUTH,
|
||||
WINDSPEED,
|
||||
)
|
||||
@ -33,6 +34,7 @@ from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
Forecast,
|
||||
@ -153,7 +155,9 @@ class BrWeather(WeatherEntity):
|
||||
)
|
||||
self._attr_native_pressure = data.pressure
|
||||
self._attr_native_temperature = data.temperature
|
||||
self._attr_native_apparent_temperature = data.feeltemperature
|
||||
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_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: data_in.get(MAX_TEMP),
|
||||
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_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
|
||||
}
|
||||
|
@ -74,7 +74,7 @@
|
||||
},
|
||||
"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": {
|
||||
"start_date_time": {
|
||||
"name": "Start time",
|
||||
|
@ -2,17 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
with suppress(Exception):
|
||||
# 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
|
||||
|
||||
from turbojpeg import TurboJPEG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Image
|
||||
|
@ -98,13 +98,13 @@
|
||||
"name": "Preset",
|
||||
"state": {
|
||||
"none": "None",
|
||||
"eco": "Eco",
|
||||
"away": "Away",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"activity": "Activity",
|
||||
"boost": "Boost",
|
||||
"comfort": "Comfort",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"sleep": "Sleep",
|
||||
"activity": "Activity"
|
||||
"eco": "Eco",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
},
|
||||
"preset_modes": {
|
||||
@ -257,7 +257,7 @@
|
||||
"selector": {
|
||||
"hvac_mode": {
|
||||
"options": {
|
||||
"off": "Off",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"dry": "Dry",
|
||||
|
@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
|
||||
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
|
||||
|
||||
|
110
homeassistant/components/cloud/onboarding.py
Normal file
110
homeassistant/components/cloud/onboarding.py
Normal 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})
|
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio.exceptions import TimeoutError
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
@ -53,10 +54,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
|
||||
try:
|
||||
await api.login()
|
||||
except aiocomelit_exceptions.CannotConnect as err:
|
||||
raise CannotConnect from err
|
||||
except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
|
||||
raise CannotConnect(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from 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:
|
||||
await api.logout()
|
||||
await api.close()
|
||||
|
@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||
|
||||
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.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@ -98,13 +98,20 @@ class ComelitCoverEntity(
|
||||
"""Return if the cover is 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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""Stop the cover."""
|
||||
@ -112,13 +119,7 @@ class ComelitCoverEntity(
|
||||
return
|
||||
|
||||
action = STATE_ON if self.is_closing else STATE_OFF
|
||||
await self._api.set_device_status(COVER, self._device.index, action)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle device update."""
|
||||
self._last_state = self.state
|
||||
self.async_write_ha_state()
|
||||
await self._cover_set_state(action, 0)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
|
@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
if self.mode == HumidifierComelitMode.OFF:
|
||||
if not self._attr_is_on:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="humidity_while_off",
|
||||
@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
|
||||
await self.coordinator.api.set_humidity_status(
|
||||
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:
|
||||
"""Turn off."""
|
||||
await self.coordinator.api.set_humidity_status(
|
||||
self._device.index, HumidifierComelitCommand.OFF
|
||||
)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
|
||||
async def _light_set_state(self, state: int) -> None:
|
||||
"""Set desired light 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:
|
||||
"""Turn the light on."""
|
||||
|
@ -42,9 +42,9 @@
|
||||
"sensor": {
|
||||
"zone_status": {
|
||||
"state": {
|
||||
"open": "[%key:common::state::open%]",
|
||||
"alarm": "Alarm",
|
||||
"armed": "Armed",
|
||||
"open": "Open",
|
||||
"excluded": "Excluded",
|
||||
"faulty": "Faulty",
|
||||
"inhibited": "Inhibited",
|
||||
@ -52,7 +52,9 @@
|
||||
"rest": "Rest",
|
||||
"sabotated": "Sabotated"
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"humidifier": {
|
||||
"humidifier": {
|
||||
"name": "Humidifier"
|
||||
},
|
||||
@ -67,6 +69,12 @@
|
||||
},
|
||||
"invalid_clima_data": {
|
||||
"message": "Invalid 'clima' data"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Error connecting: {error}"
|
||||
},
|
||||
"cannot_authenticate": {
|
||||
"message": "Error authenticating: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
|
||||
await self.coordinator.api.set_device_status(
|
||||
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:
|
||||
"""Turn the switch on."""
|
||||
|
@ -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_update)
|
||||
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, config_subentry_delete)
|
||||
@ -357,7 +358,7 @@ class SubentryManagerFlowResourceView(
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
|
||||
def config_entries_progress(
|
||||
def config_entries_flow_progress(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
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(
|
||||
connection: websocket_api.ActiveConnection, msg_id: int
|
||||
) -> None:
|
||||
|
@ -354,6 +354,35 @@ class ChatLog:
|
||||
if self.delta_listener:
|
||||
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(
|
||||
self,
|
||||
conversing_domain: str,
|
||||
@ -409,38 +438,28 @@ class ChatLog:
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
prompt_parts = [
|
||||
template.Template(
|
||||
llm.BASE_PROMPT
|
||||
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_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=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
prompt_parts = []
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
user_input.language,
|
||||
user_name,
|
||||
)
|
||||
raise ConverseError(
|
||||
"Error rendering prompt",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
)
|
||||
|
||||
if llm_api:
|
||||
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 := (
|
||||
# Take new system prompt if one was given
|
||||
user_input.extra_system_prompt or self.extra_system_prompt
|
||||
|
@ -38,10 +38,10 @@
|
||||
"name": "[%key:component::cover::title%]",
|
||||
"state": {
|
||||
"open": "[%key:common::state::open%]",
|
||||
"opening": "Opening",
|
||||
"opening": "[%key:common::state::opening%]",
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"closing": "Closing",
|
||||
"stopped": "Stopped"
|
||||
"closing": "[%key:common::state::closing%]",
|
||||
"stopped": "[%key:common::state::stopped%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"current_position": {
|
||||
|
@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
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 .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),
|
||||
uuid=entry.data.get(CONF_UUID),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
ssl_context=client_context_no_verify(),
|
||||
)
|
||||
_LOGGER.debug("Connection to %s successful", host)
|
||||
except TimeoutError as err:
|
||||
|
@ -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.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util.ssl import client_context_no_verify
|
||||
|
||||
from .const import DOMAIN, KEY_MAC, TIMEOUT
|
||||
|
||||
@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
key=key,
|
||||
uuid=uuid,
|
||||
password=password,
|
||||
ssl_context=client_context_no_verify(),
|
||||
)
|
||||
except (TimeoutError, ClientError):
|
||||
self.host = None
|
||||
|
@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.14.1"],
|
||||
"requirements": ["pydaikin==2.15.0"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"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": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
|
@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"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": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
@ -7,7 +7,7 @@
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.43.0"],
|
||||
"requirements": ["async-upnp-client==0.44.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
|
||||
}
|
||||
|
@ -38,8 +38,8 @@
|
||||
"protect_mode": {
|
||||
"name": "Protect mode",
|
||||
"state": {
|
||||
"away": "Away",
|
||||
"home": "Home",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
}
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.4.2"]
|
||||
"requirements": ["dsmr-parser==1.4.3"]
|
||||
}
|
||||
|
@ -51,8 +51,8 @@
|
||||
"electricity_active_tariff": {
|
||||
"name": "Active tariff",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"normal": "Normal"
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]"
|
||||
}
|
||||
},
|
||||
"electricity_delivered_tariff_1": {
|
||||
|
@ -140,8 +140,8 @@
|
||||
"electricity_tariff": {
|
||||
"name": "Electricity tariff",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"high": "High"
|
||||
"low": "[%key:common::state::low%]",
|
||||
"high": "[%key:common::state::high%]"
|
||||
}
|
||||
},
|
||||
"power_failure_count": {
|
||||
|
@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||
one = timedelta(days=1)
|
||||
if start_time is None:
|
||||
# Max 3 years of data
|
||||
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
|
||||
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),
|
||||
)
|
||||
start = dt_util.now(tz) - timedelta(days=3 * 365)
|
||||
else:
|
||||
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)
|
||||
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
|
||||
_LOGGER.debug("Data lookup range: %s - %s", start, end)
|
||||
|
||||
start_step = end - lookback
|
||||
start_step = max(end - lookback, start)
|
||||
end_step = end
|
||||
usage: dict[datetime, dict[str, float | int]] = {}
|
||||
while True:
|
||||
|
@ -55,7 +55,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to create the vacation."
|
||||
"description": "ecobee thermostat on which to create the vacation."
|
||||
},
|
||||
"vacation_name": {
|
||||
"name": "Vacation name",
|
||||
@ -101,7 +101,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to delete the vacation."
|
||||
"description": "ecobee thermostat on which to delete the vacation."
|
||||
},
|
||||
"vacation_name": {
|
||||
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
|
||||
@ -149,7 +149,7 @@
|
||||
},
|
||||
"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": {
|
||||
"mic_enabled": {
|
||||
"name": "Mic enabled",
|
||||
@ -177,7 +177,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to set active sensors."
|
||||
"description": "ecobee thermostat on which to set active sensors."
|
||||
},
|
||||
"preset_mode": {
|
||||
"name": "Climate Name",
|
||||
@ -203,12 +203,12 @@
|
||||
},
|
||||
"issues": {
|
||||
"migrate_aux_heat": {
|
||||
"title": "Migration of Ecobee set_aux_heat action",
|
||||
"title": "Migration of ecobee set_aux_heat action",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"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.",
|
||||
"title": "Disable legacy Ecobee set_aux_heat action"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,10 +23,8 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
|
||||
from . import EconetConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import EcoNetEntity
|
||||
|
||||
ECONET_STATE_TO_HA = {
|
||||
@ -212,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
"""Set the 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
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
|
@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
|
||||
def operation_list(self) -> list[str]:
|
||||
"""List of available operation modes."""
|
||||
econet_modes = self.water_heater.modes
|
||||
op_list = []
|
||||
operation_modes = set()
|
||||
for mode in econet_modes:
|
||||
if (
|
||||
mode is not WaterHeaterOperationMode.UNKNOWN
|
||||
and mode is not WaterHeaterOperationMode.VACATION
|
||||
):
|
||||
ha_mode = ECONET_STATE_TO_HA[mode]
|
||||
op_list.append(ha_mode)
|
||||
return op_list
|
||||
operation_modes.add(ha_mode)
|
||||
return list(operation_modes)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> WaterHeaterEntityFeature:
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"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"]
|
||||
}
|
||||
|
@ -176,9 +176,9 @@
|
||||
"water_amount": {
|
||||
"name": "Water flow level",
|
||||
"state": {
|
||||
"high": "High",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"ultrahigh": "Ultrahigh"
|
||||
}
|
||||
},
|
||||
@ -229,9 +229,9 @@
|
||||
"state_attributes": {
|
||||
"fan_speed": {
|
||||
"state": {
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"max": "Max",
|
||||
"max_plus": "Max+",
|
||||
"normal": "Normal",
|
||||
"quiet": "Quiet"
|
||||
}
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
18
homeassistant/components/eheimdigital/icons.json
Normal file
18
homeassistant/components/eheimdigital/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
114
homeassistant/components/eheimdigital/sensor.py
Normal file
114
homeassistant/components/eheimdigital/sensor.py
Normal 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)
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,11 @@ class ElkEntity(Entity):
|
||||
return {"index": self._element.index + 1}
|
||||
|
||||
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
|
||||
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:
|
||||
"""Register callback for ElkM1 changes and update entity state."""
|
||||
self._element.add_callback(self._element_callback)
|
||||
self._element_callback(self._element, {})
|
||||
self._element_changed(self._element, {})
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
@ -4,12 +4,12 @@
|
||||
"choose_mode": {
|
||||
"description": "Please choose the connection mode to Elmax panels.",
|
||||
"menu_options": {
|
||||
"cloud": "Connect to Elmax Panel via Elmax Cloud APIs",
|
||||
"direct": "Connect to Elmax Panel via local/direct IP"
|
||||
"cloud": "Connect to Elmax panel via Elmax Cloud APIs",
|
||||
"direct": "Connect to Elmax panel via local/direct IP"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"description": "Please login to the Elmax cloud using your credentials",
|
||||
"description": "Please log in to the Elmax cloud using your credentials",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
@ -28,7 +28,7 @@
|
||||
"direct": {
|
||||
"description": "Specify the Elmax panel connection parameters below.",
|
||||
"data": {
|
||||
"panel_api_host": "Panel API Hostname or IP",
|
||||
"panel_api_host": "Panel API hostname or IP",
|
||||
"panel_api_port": "Panel API port",
|
||||
"use_ssl": "Use SSL",
|
||||
"panel_pin": "Panel PIN code"
|
||||
@ -40,7 +40,7 @@
|
||||
"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.",
|
||||
"data": {
|
||||
"panel_name": "Panel Name",
|
||||
"panel_name": "Panel name",
|
||||
"panel_id": "Panel ID",
|
||||
"panel_pin": "[%key:common::config_flow::data::pin%]"
|
||||
}
|
||||
|
@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the emulated roku component."""
|
||||
@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
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."""
|
||||
config = config_entry.data
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
name = config[CONF_NAME]
|
||||
listen_port = config[CONF_LISTEN_PORT]
|
||||
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)
|
||||
config = entry.data
|
||||
name: str = config[CONF_NAME]
|
||||
listen_port: int = config[CONF_LISTEN_PORT]
|
||||
host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
|
||||
advertise_ip: str | None = config.get(CONF_ADVERTISE_IP)
|
||||
advertise_port: int | None = config.get(CONF_ADVERTISE_PORT)
|
||||
upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST)
|
||||
|
||||
server = EmulatedRoku(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
name,
|
||||
host_ip,
|
||||
listen_port,
|
||||
@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
advertise_port,
|
||||
upnp_bind_multicast,
|
||||
)
|
||||
|
||||
hass.data[DOMAIN][name] = server
|
||||
|
||||
entry.runtime_data = server
|
||||
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."""
|
||||
name = entry.data[CONF_NAME]
|
||||
server = hass.data[DOMAIN].pop(name)
|
||||
return await server.unload()
|
||||
return await entry.runtime_data.unload()
|
||||
|
@ -5,7 +5,13 @@ import logging
|
||||
from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer
|
||||
|
||||
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__)
|
||||
|
||||
@ -27,16 +33,18 @@ class EmulatedRoku:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass,
|
||||
name,
|
||||
host_ip,
|
||||
listen_port,
|
||||
advertise_ip,
|
||||
advertise_port,
|
||||
upnp_bind_multicast,
|
||||
):
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
name: str,
|
||||
host_ip: str,
|
||||
listen_port: int,
|
||||
advertise_ip: str | None,
|
||||
advertise_port: int | None,
|
||||
upnp_bind_multicast: bool | None,
|
||||
) -> None:
|
||||
"""Initialize the properties."""
|
||||
self.hass = hass
|
||||
self.entry_id = entry_id
|
||||
|
||||
self.roku_usn = name
|
||||
self.host_ip = host_ip
|
||||
@ -47,21 +55,21 @@ class EmulatedRoku:
|
||||
|
||||
self.bind_multicast = upnp_bind_multicast
|
||||
|
||||
self._api_server = None
|
||||
self._api_server: EmulatedRokuServer | None = None
|
||||
|
||||
self._unsub_start_listener = None
|
||||
self._unsub_stop_listener = None
|
||||
self._unsub_start_listener: CALLBACK_TYPE | None = None
|
||||
self._unsub_stop_listener: CALLBACK_TYPE | None = None
|
||||
|
||||
async def setup(self):
|
||||
async def setup(self) -> bool:
|
||||
"""Start the emulated_roku server."""
|
||||
|
||||
class EventCommandHandler(EmulatedRokuCommandHandler):
|
||||
"""emulated_roku command handler to turn commands into events."""
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
|
||||
def on_keydown(self, roku_usn, key):
|
||||
def on_keydown(self, roku_usn: str, key: str) -> None:
|
||||
"""Handle keydown event."""
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ROKU_COMMAND,
|
||||
@ -73,7 +81,7 @@ class EmulatedRoku:
|
||||
EventOrigin.local,
|
||||
)
|
||||
|
||||
def on_keyup(self, roku_usn, key):
|
||||
def on_keyup(self, roku_usn: str, key: str) -> None:
|
||||
"""Handle keyup event."""
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ROKU_COMMAND,
|
||||
@ -85,7 +93,7 @@ class EmulatedRoku:
|
||||
EventOrigin.local,
|
||||
)
|
||||
|
||||
def on_keypress(self, roku_usn, key):
|
||||
def on_keypress(self, roku_usn: str, key: str) -> None:
|
||||
"""Handle keypress event."""
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ROKU_COMMAND,
|
||||
@ -97,7 +105,7 @@ class EmulatedRoku:
|
||||
EventOrigin.local,
|
||||
)
|
||||
|
||||
def launch(self, roku_usn, app_id):
|
||||
def launch(self, roku_usn: str, app_id: str) -> None:
|
||||
"""Handle launch event."""
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ROKU_COMMAND,
|
||||
@ -129,17 +137,19 @@ class EmulatedRoku:
|
||||
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."""
|
||||
LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
|
||||
self._unsub_stop_listener = None
|
||||
assert self._api_server is not None
|
||||
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."""
|
||||
try:
|
||||
LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
|
||||
self._unsub_start_listener = None
|
||||
assert self._api_server is not None
|
||||
await self._api_server.start()
|
||||
except OSError:
|
||||
LOGGER.exception(
|
||||
@ -165,7 +175,7 @@ class EmulatedRoku:
|
||||
|
||||
return True
|
||||
|
||||
async def unload(self):
|
||||
async def unload(self) -> bool:
|
||||
"""Unload the emulated_roku server."""
|
||||
LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
|
||||
|
||||
@ -177,6 +187,7 @@ class EmulatedRoku:
|
||||
self._unsub_stop_listener()
|
||||
self._unsub_stop_listener = None
|
||||
|
||||
assert self._api_server is not None
|
||||
await self._api_server.close()
|
||||
|
||||
return True
|
||||
|
@ -25,6 +25,7 @@ from homeassistant.core import (
|
||||
split_entity_id,
|
||||
valid_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
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 to handle creation/removal of sensor data."""
|
||||
|
||||
@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Determine energy price
|
||||
if self._config["entity_energy_price"] is not None:
|
||||
energy_price_state = self.hass.states.get(
|
||||
self._config["entity_energy_price"]
|
||||
try:
|
||||
energy_price, energy_price_unit = self._get_energy_price(
|
||||
valid_units, default_price_unit
|
||||
)
|
||||
|
||||
if energy_price_state is None:
|
||||
return
|
||||
|
||||
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
|
||||
except EntityNotFoundError:
|
||||
return
|
||||
except ValueError:
|
||||
energy_price = 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)
|
||||
return
|
||||
|
||||
if energy_price is None:
|
||||
return
|
||||
|
||||
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
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)
|
||||
cur_value = cast(float, self._attr_native_value)
|
||||
|
||||
if energy_price_unit is None:
|
||||
converted_energy_price = energy_price
|
||||
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,
|
||||
)
|
||||
converted_energy_price = self._convert_energy_price(
|
||||
energy_price, energy_price_unit, energy_unit
|
||||
)
|
||||
|
||||
self._attr_native_value = (
|
||||
cur_value + (energy - old_energy_value) * converted_energy_price
|
||||
@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
|
||||
|
||||
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:
|
||||
"""Register callbacks."""
|
||||
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
|
||||
|
@ -16,7 +16,13 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
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.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
|
||||
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders["serial"] = serial
|
||||
return self.async_show_form(
|
||||
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,
|
||||
errors=errors,
|
||||
)
|
||||
@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SERIAL: self.unique_id,
|
||||
CONF_HOST: host,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
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,
|
||||
errors=errors,
|
||||
)
|
||||
@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
description_placeholders["serial"] = serial
|
||||
|
||||
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
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,
|
||||
errors=errors,
|
||||
|
@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
|
||||
]
|
||||
|
||||
for end_point in end_points:
|
||||
response = await envoy.request(end_point)
|
||||
fixture_data[end_point] = response.text.replace("\n", "").replace(
|
||||
serial, CLEAN_TEXT
|
||||
)
|
||||
fixture_data[f"{end_point}_log"] = json_dumps(
|
||||
{
|
||||
"headers": dict(response.headers.items()),
|
||||
"code": response.status_code,
|
||||
}
|
||||
)
|
||||
try:
|
||||
response = await envoy.request(end_point)
|
||||
fixture_data[end_point] = response.text.replace("\n", "").replace(
|
||||
serial, CLEAN_TEXT
|
||||
)
|
||||
fixture_data[f"{end_point}_log"] = json_dumps(
|
||||
{
|
||||
"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
|
||||
|
||||
|
||||
@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
fixture_data: dict[str, Any] = {}
|
||||
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
|
||||
try:
|
||||
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
|
||||
except EnvoyError as err:
|
||||
fixture_data["Error"] = repr(err)
|
||||
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
|
||||
|
||||
diagnostic_data: dict[str, Any] = {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
|
@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.25.1"],
|
||||
"requirements": ["pyenphase==1.25.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"]
|
||||
}
|
||||
|
@ -310,12 +310,13 @@ class EsphomeAssistSatellite(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
)
|
||||
if feature_flags & VoiceAssistantFeature.SPEAKER:
|
||||
media_id = tts_output["media_id"]
|
||||
if feature_flags & VoiceAssistantFeature.SPEAKER and (
|
||||
stream := tts.async_get_stream(self.hass, tts_output["token"])
|
||||
):
|
||||
self._tts_streaming_task = (
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._stream_tts_audio(media_id),
|
||||
self._stream_tts_audio(stream),
|
||||
"esphome_voice_assistant_tts",
|
||||
)
|
||||
)
|
||||
@ -564,7 +565,7 @@ class EsphomeAssistSatellite(
|
||||
|
||||
async def _stream_tts_audio(
|
||||
self,
|
||||
media_id: str,
|
||||
tts_result: tts.ResultStream,
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
sample_channels: int = 1,
|
||||
@ -579,15 +580,14 @@ class EsphomeAssistSatellite(
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
extension, data = await tts.async_get_media_source_audio(
|
||||
self.hass,
|
||||
media_id,
|
||||
)
|
||||
|
||||
if extension != "wav":
|
||||
_LOGGER.error("Only WAV audio can be streamed, got %s", extension)
|
||||
if tts_result.extension != "wav":
|
||||
_LOGGER.error(
|
||||
"Only WAV audio can be streamed, got %s", tts_result.extension
|
||||
)
|
||||
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:
|
||||
if (
|
||||
(wav_file.getframerate() != sample_rate)
|
||||
|
@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = ""
|
||||
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()
|
||||
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user