mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +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
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
- name: Dependency review
|
- name: Dependency review
|
||||||
uses: actions/dependency-review-action@v4.5.0
|
uses: actions/dependency-review-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
license-check: false # We use our own license audit checks
|
license-check: false # We use our own license audit checks
|
||||||
|
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.28.13
|
uses: github/codeql-action/init@v3.28.15
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.28.13
|
uses: github/codeql-action/analyze@v3.28.15
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
@ -364,6 +364,7 @@ homeassistant.components.notify.*
|
|||||||
homeassistant.components.notion.*
|
homeassistant.components.notion.*
|
||||||
homeassistant.components.number.*
|
homeassistant.components.number.*
|
||||||
homeassistant.components.nut.*
|
homeassistant.components.nut.*
|
||||||
|
homeassistant.components.ohme.*
|
||||||
homeassistant.components.onboarding.*
|
homeassistant.components.onboarding.*
|
||||||
homeassistant.components.oncue.*
|
homeassistant.components.oncue.*
|
||||||
homeassistant.components.onedrive.*
|
homeassistant.components.onedrive.*
|
||||||
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@ -704,6 +704,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/image_upload/ @home-assistant/core
|
/tests/components/image_upload/ @home-assistant/core
|
||||||
/homeassistant/components/imap/ @jbouwh
|
/homeassistant/components/imap/ @jbouwh
|
||||||
/tests/components/imap/ @jbouwh
|
/tests/components/imap/ @jbouwh
|
||||||
|
/homeassistant/components/imeon_inverter/ @Imeon-Energy
|
||||||
|
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||||
/homeassistant/components/imgw_pib/ @bieniu
|
/homeassistant/components/imgw_pib/ @bieniu
|
||||||
/tests/components/imgw_pib/ @bieniu
|
/tests/components/imgw_pib/ @bieniu
|
||||||
/homeassistant/components/improv_ble/ @emontnemery
|
/homeassistant/components/improv_ble/ @emontnemery
|
||||||
@ -1480,8 +1482,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/suez_water/ @ooii @jb101010-2
|
/tests/components/suez_water/ @ooii @jb101010-2
|
||||||
/homeassistant/components/sun/ @Swamp-Ig
|
/homeassistant/components/sun/ @Swamp-Ig
|
||||||
/tests/components/sun/ @Swamp-Ig
|
/tests/components/sun/ @Swamp-Ig
|
||||||
/homeassistant/components/sunweg/ @rokam
|
|
||||||
/tests/components/sunweg/ @rokam
|
|
||||||
/homeassistant/components/supla/ @mwegrzynek
|
/homeassistant/components/supla/ @mwegrzynek
|
||||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||||
|
@ -53,6 +53,7 @@ from .components import (
|
|||||||
logbook as logbook_pre_import, # noqa: F401
|
logbook as logbook_pre_import, # noqa: F401
|
||||||
lovelace as lovelace_pre_import, # noqa: F401
|
lovelace as lovelace_pre_import, # noqa: F401
|
||||||
onboarding as onboarding_pre_import, # noqa: F401
|
onboarding as onboarding_pre_import, # noqa: F401
|
||||||
|
person as person_pre_import, # noqa: F401
|
||||||
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
|
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
|
||||||
repairs as repairs_pre_import, # noqa: F401
|
repairs as repairs_pre_import, # noqa: F401
|
||||||
search as search_pre_import, # noqa: F401
|
search as search_pre_import, # noqa: F401
|
||||||
@ -859,8 +860,14 @@ async def _async_set_up_integrations(
|
|||||||
integrations, all_integrations = await _async_resolve_domains_and_preload(
|
integrations, all_integrations = await _async_resolve_domains_and_preload(
|
||||||
hass, config
|
hass, config
|
||||||
)
|
)
|
||||||
all_domains = set(all_integrations)
|
# Detect all cycles
|
||||||
domains = set(integrations)
|
integrations_after_dependencies = (
|
||||||
|
await loader.resolve_integrations_after_dependencies(
|
||||||
|
hass, all_integrations.values(), set(all_integrations)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
all_domains = set(integrations_after_dependencies)
|
||||||
|
domains = set(integrations) & all_domains
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Domains to be set up: %s | %s",
|
"Domains to be set up: %s | %s",
|
||||||
@ -868,6 +875,8 @@ async def _async_set_up_integrations(
|
|||||||
all_domains - domains,
|
all_domains - domains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async_set_domains_to_be_loaded(hass, all_domains)
|
||||||
|
|
||||||
# Initialize recorder
|
# Initialize recorder
|
||||||
if "recorder" in all_domains:
|
if "recorder" in all_domains:
|
||||||
recorder.async_initialize_recorder(hass)
|
recorder.async_initialize_recorder(hass)
|
||||||
@ -900,24 +909,12 @@ async def _async_set_up_integrations(
|
|||||||
stage_dep_domains_unfiltered = {
|
stage_dep_domains_unfiltered = {
|
||||||
dep
|
dep
|
||||||
for domain in stage_domains
|
for domain in stage_domains
|
||||||
for dep in all_integrations[domain].all_dependencies
|
for dep in integrations_after_dependencies[domain]
|
||||||
if dep not in stage_domains
|
if dep not in stage_domains
|
||||||
}
|
}
|
||||||
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
|
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
|
||||||
|
|
||||||
stage_all_domains = stage_domains | stage_dep_domains
|
stage_all_domains = stage_domains | stage_dep_domains
|
||||||
stage_all_integrations = {
|
|
||||||
domain: all_integrations[domain] for domain in stage_all_domains
|
|
||||||
}
|
|
||||||
# Detect all cycles
|
|
||||||
stage_integrations_after_dependencies = (
|
|
||||||
await loader.resolve_integrations_after_dependencies(
|
|
||||||
hass, stage_all_integrations.values(), stage_all_domains
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stage_all_domains = set(stage_integrations_after_dependencies)
|
|
||||||
stage_domains &= stage_all_domains
|
|
||||||
stage_dep_domains &= stage_all_domains
|
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
|
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
|
||||||
@ -928,8 +925,6 @@ async def _async_set_up_integrations(
|
|||||||
stage_dep_domains_unfiltered - stage_dep_domains,
|
stage_dep_domains_unfiltered - stage_dep_domains,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_set_domains_to_be_loaded(hass, stage_all_domains)
|
|
||||||
|
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||||
continue
|
continue
|
||||||
|
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": {
|
"level": {
|
||||||
"name": "Level",
|
"name": "Level",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "High",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "Moderate",
|
"moderate": "Moderate",
|
||||||
"very_high": "Very high"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,10 +89,10 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,10 +123,10 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,10 +167,10 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,10 +181,10 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,10 +195,10 @@
|
|||||||
"level": {
|
"level": {
|
||||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
|
"low": "[%key:common::state::low%]",
|
||||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||||
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
|
"very_high": "[%key:common::state::very_high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -68,8 +68,8 @@
|
|||||||
"led_bar_mode": {
|
"led_bar_mode": {
|
||||||
"name": "LED bar mode",
|
"name": "LED bar mode",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "Off",
|
"off": "[%key:common::state::off%]",
|
||||||
"co2": "Carbon dioxide",
|
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||||
"pm": "Particulate matter"
|
"pm": "Particulate matter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -143,8 +143,8 @@
|
|||||||
"led_bar_mode": {
|
"led_bar_mode": {
|
||||||
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
|
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]",
|
"off": "[%key:common::state::off%]",
|
||||||
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]",
|
"co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||||
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
|
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -16,8 +16,8 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"country": "Country",
|
"state": "State",
|
||||||
"state": "State"
|
"country": "[%key:common::config_flow::data::country%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@ -56,12 +56,12 @@
|
|||||||
"sensor": {
|
"sensor": {
|
||||||
"pollutant_label": {
|
"pollutant_label": {
|
||||||
"state": {
|
"state": {
|
||||||
"co": "Carbon monoxide",
|
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||||
"n2": "Nitrogen dioxide",
|
"n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||||
"o3": "Ozone",
|
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||||
"p1": "PM10",
|
"p1": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||||
"p2": "PM2.5",
|
"p2": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||||
"s2": "Sulfur dioxide"
|
"s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pollutant_level": {
|
"pollutant_level": {
|
||||||
|
@ -11,5 +11,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aioairzone"],
|
"loggers": ["aioairzone"],
|
||||||
"requirements": ["aioairzone==0.9.9"]
|
"requirements": ["aioairzone==1.0.0"]
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,8 @@ from aioairzone.const import (
|
|||||||
AZD_HUMIDITY,
|
AZD_HUMIDITY,
|
||||||
AZD_TEMP,
|
AZD_TEMP,
|
||||||
AZD_TEMP_UNIT,
|
AZD_TEMP_UNIT,
|
||||||
|
AZD_THERMOSTAT_BATTERY,
|
||||||
|
AZD_THERMOSTAT_SIGNAL,
|
||||||
AZD_WEBSERVER,
|
AZD_WEBSERVER,
|
||||||
AZD_WIFI_RSSI,
|
AZD_WIFI_RSSI,
|
||||||
AZD_ZONES,
|
AZD_ZONES,
|
||||||
@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
|||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
key=AZD_THERMOSTAT_BATTERY,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
key=AZD_THERMOSTAT_SIGNAL,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
translation_key="thermostat_signal",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,6 +76,9 @@
|
|||||||
"sensor": {
|
"sensor": {
|
||||||
"rssi": {
|
"rssi": {
|
||||||
"name": "RSSI"
|
"name": "RSSI"
|
||||||
|
},
|
||||||
|
"thermostat_signal": {
|
||||||
|
"name": "Signal strength"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pydroid-ipcam==2.0.0"]
|
"requirements": ["pydroid-ipcam==3.0.0"]
|
||||||
}
|
}
|
||||||
|
@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
|||||||
self._api.send_key_command(key_code, direction)
|
self._api.send_key_command(key_code, direction)
|
||||||
except ConnectionClosed as exc:
|
except ConnectionClosed as exc:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Connection to Android TV device is closed"
|
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def _send_launch_app_command(self, app_link: str) -> None:
|
def _send_launch_app_command(self, app_link: str) -> None:
|
||||||
@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
|
|||||||
self._api.send_launch_app_command(app_link)
|
self._api.send_launch_app_command(app_link)
|
||||||
except ConnectionClosed as exc:
|
except ConnectionClosed as exc:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Connection to Android TV device is closed"
|
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||||
) from exc
|
) from exc
|
||||||
|
@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import AndroidTVRemoteConfigEntry
|
from . import AndroidTVRemoteConfigEntry
|
||||||
from .const import CONF_APP_ICON, CONF_APP_NAME
|
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
|
||||||
from .entity import AndroidTVRemoteBaseEntity
|
from .entity import AndroidTVRemoteBaseEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
|||||||
await asyncio.sleep(delay_secs)
|
await asyncio.sleep(delay_secs)
|
||||||
except ConnectionClosed as exc:
|
except ConnectionClosed as exc:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Connection to Android TV device is closed"
|
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||||
) from exc
|
) from exc
|
||||||
|
@ -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")
|
raise ValueError("Unexpected stop event without a current block")
|
||||||
if current_block["type"] == "tool_use":
|
if current_block["type"] == "tool_use":
|
||||||
tool_block = cast(ToolUseBlockParam, current_block)
|
tool_block = cast(ToolUseBlockParam, current_block)
|
||||||
tool_args = json.loads(current_tool_args)
|
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||||
tool_block["input"] = tool_args
|
tool_block["input"] = tool_args
|
||||||
yield {
|
yield {
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
|
@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
|
|||||||
"""Initialize the APCUPSd binary device."""
|
"""Initialize the APCUPSd binary device."""
|
||||||
super().__init__(coordinator, context=description.key.upper())
|
super().__init__(coordinator, context=description.key.upper())
|
||||||
|
|
||||||
# Set up unique id and device info if serial number is available.
|
|
||||||
if (serial_no := coordinator.data.serial_no) is not None:
|
|
||||||
self._attr_unique_id = f"{serial_no}_{description.key}"
|
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
|
||||||
self._attr_device_info = coordinator.device_info
|
self._attr_device_info = coordinator.device_info
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
|
|||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_device_id(self) -> str:
|
||||||
|
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
|
||||||
|
return self.data.serial_no or self.config_entry.entry_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
|
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)},
|
identifiers={(DOMAIN, self.unique_device_id)},
|
||||||
model=self.data.model,
|
model=self.data.model,
|
||||||
manufacturer="APC",
|
manufacturer="APC",
|
||||||
name=self.data.name or "APC UPS",
|
name=self.data.name or "APC UPS",
|
||||||
|
@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
|
|||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
super().__init__(coordinator=coordinator, context=description.key.upper())
|
super().__init__(coordinator=coordinator, context=description.key.upper())
|
||||||
|
|
||||||
# Set up unique id and device info if serial number is available.
|
|
||||||
if (serial_no := coordinator.data.serial_no) is not None:
|
|
||||||
self._attr_unique_id = f"{serial_no}_{description.key}"
|
|
||||||
|
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
|
||||||
self._attr_device_info = coordinator.device_info
|
self._attr_device_info = coordinator.device_info
|
||||||
|
|
||||||
# Initial update of attributes.
|
# Initial update of attributes.
|
||||||
|
@ -20,6 +20,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import zeroconf
|
from homeassistant.components import zeroconf
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
SOURCE_IGNORE,
|
SOURCE_IGNORE,
|
||||||
|
SOURCE_REAUTH,
|
||||||
SOURCE_ZEROCONF,
|
SOURCE_ZEROCONF,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_IDENTIFIERS: list(combined_identifiers),
|
CONF_IDENTIFIERS: list(combined_identifiers),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if entry.source != SOURCE_IGNORE:
|
# Don't reload ignored entries or in the middle of reauth,
|
||||||
|
# e.g. if the user is entering a new PIN
|
||||||
|
if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
|
||||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
if not allow_exist:
|
if not allow_exist:
|
||||||
raise DeviceAlreadyConfigured
|
raise DeviceAlreadyConfigured
|
||||||
|
@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
|||||||
|
|
||||||
config_entry: ApSystemsConfigEntry
|
config_entry: ApSystemsConfigEntry
|
||||||
device_version: str
|
device_version: str
|
||||||
|
battery_system: bool
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
|||||||
self.api.max_power = device_info.maxPower
|
self.api.max_power = device_info.maxPower
|
||||||
self.api.min_power = device_info.minPower
|
self.api.min_power = device_info.minPower
|
||||||
self.device_version = device_info.devVer
|
self.device_version = device_info.devVer
|
||||||
|
self.battery_system = device_info.isBatterySystem
|
||||||
|
|
||||||
async def _async_update_data(self) -> ApSystemsSensorData:
|
async def _async_update_data(self) -> ApSystemsSensorData:
|
||||||
try:
|
try:
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/apsystems",
|
"documentation": "https://www.home-assistant.io/integrations/apsystems",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["apsystems-ez1==2.4.0"]
|
"requirements": ["apsystems-ez1==2.5.0"]
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
|
|||||||
super().__init__(data)
|
super().__init__(data)
|
||||||
self._api = data.coordinator.api
|
self._api = data.coordinator.api
|
||||||
self._attr_unique_id = f"{data.device_id}_inverter_status"
|
self._attr_unique_id = f"{data.device_id}_inverter_status"
|
||||||
|
if data.coordinator.battery_system:
|
||||||
|
self._attr_available = False
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update switch status and availability."""
|
"""Update switch status and availability."""
|
||||||
|
@ -36,9 +36,9 @@
|
|||||||
"wi_fi_strength": {
|
"wi_fi_strength": {
|
||||||
"name": "Wi-Fi strength",
|
"name": "Wi-Fi strength",
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"medium": "Medium",
|
"medium": "[%key:common::state::medium%]",
|
||||||
"high": "High"
|
"high": "[%key:common::state::high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
{
|
{
|
||||||
vol.Optional("message"): str,
|
vol.Optional("message"): str,
|
||||||
vol.Optional("media_id"): str,
|
vol.Optional("media_id"): str,
|
||||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
vol.Optional("preannounce"): bool,
|
||||||
|
vol.Optional("preannounce_media_id"): str,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
cv.has_at_least_one_key("message", "media_id"),
|
cv.has_at_least_one_key("message", "media_id"),
|
||||||
@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
{
|
{
|
||||||
vol.Optional("start_message"): str,
|
vol.Optional("start_message"): str,
|
||||||
vol.Optional("start_media_id"): str,
|
vol.Optional("start_media_id"): str,
|
||||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
vol.Optional("preannounce"): bool,
|
||||||
|
vol.Optional("preannounce_media_id"): str,
|
||||||
vol.Optional("extra_system_prompt"): str,
|
vol.Optional("extra_system_prompt"): str,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
self,
|
self,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
media_id: str | None = None,
|
media_id: str | None = None,
|
||||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
preannounce: bool = True,
|
||||||
|
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Play and show an announcement on the satellite.
|
"""Play and show an announcement on the satellite.
|
||||||
|
|
||||||
@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
If media_id is provided, it is played directly. It is possible
|
If media_id is provided, it is played directly. It is possible
|
||||||
to omit the message and the satellite will not show any text.
|
to omit the message and the satellite will not show any text.
|
||||||
|
|
||||||
|
If preannounce is True, a sound is played before the announcement.
|
||||||
If preannounce_media_id is provided, it overrides the default sound.
|
If preannounce_media_id is provided, it overrides the default sound.
|
||||||
If preannounce_media_id is None, no sound is played.
|
|
||||||
|
|
||||||
Calls async_announce with message and media id.
|
Calls async_announce with message and media id.
|
||||||
"""
|
"""
|
||||||
@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
message = ""
|
message = ""
|
||||||
|
|
||||||
announcement = await self._resolve_announcement_media_id(
|
announcement = await self._resolve_announcement_media_id(
|
||||||
message, media_id, preannounce_media_id
|
message,
|
||||||
|
media_id,
|
||||||
|
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._is_announcing:
|
if self._is_announcing:
|
||||||
@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
start_message: str | None = None,
|
start_message: str | None = None,
|
||||||
start_media_id: str | None = None,
|
start_media_id: str | None = None,
|
||||||
extra_system_prompt: str | None = None,
|
extra_system_prompt: str | None = None,
|
||||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
preannounce: bool = True,
|
||||||
|
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start a conversation from the satellite.
|
"""Start a conversation from the satellite.
|
||||||
|
|
||||||
@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
If start_media_id is provided, it is played directly. It is possible
|
If start_media_id is provided, it is played directly. It is possible
|
||||||
to omit the message and the satellite will not show any text.
|
to omit the message and the satellite will not show any text.
|
||||||
|
|
||||||
If preannounce_media_id is provided, it is played before the announcement.
|
If preannounce is True, a sound is played before the start message or media.
|
||||||
If preannounce_media_id is None, no sound is played.
|
If preannounce_media_id is provided, it overrides the default sound.
|
||||||
|
|
||||||
Calls async_start_conversation.
|
Calls async_start_conversation.
|
||||||
"""
|
"""
|
||||||
@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
start_message = ""
|
start_message = ""
|
||||||
|
|
||||||
announcement = await self._resolve_announcement_media_id(
|
announcement = await self._resolve_announcement_media_id(
|
||||||
start_message, start_media_id, preannounce_media_id
|
start_message,
|
||||||
|
start_media_id,
|
||||||
|
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._is_announcing:
|
if self._is_announcing:
|
||||||
|
@ -15,6 +15,11 @@ announce:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
preannounce:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
preannounce_media_id:
|
preannounce_media_id:
|
||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
@ -40,6 +45,11 @@ start_conversation:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
preannounce:
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
preannounce_media_id:
|
preannounce_media_id:
|
||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
|
@ -24,9 +24,13 @@
|
|||||||
"name": "Media ID",
|
"name": "Media ID",
|
||||||
"description": "The media ID to announce instead of using text-to-speech."
|
"description": "The media ID to announce instead of using text-to-speech."
|
||||||
},
|
},
|
||||||
|
"preannounce": {
|
||||||
|
"name": "Preannounce",
|
||||||
|
"description": "Play a sound before the announcement."
|
||||||
|
},
|
||||||
"preannounce_media_id": {
|
"preannounce_media_id": {
|
||||||
"name": "Preannounce Media ID",
|
"name": "Preannounce media ID",
|
||||||
"description": "The media ID to play before the announcement."
|
"description": "Custom media ID to play before the announcement."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -46,9 +50,13 @@
|
|||||||
"name": "Extra system prompt",
|
"name": "Extra system prompt",
|
||||||
"description": "Provide background information to the AI about the request."
|
"description": "Provide background information to the AI about the request."
|
||||||
},
|
},
|
||||||
|
"preannounce": {
|
||||||
|
"name": "Preannounce",
|
||||||
|
"description": "Play a sound before the start message or media."
|
||||||
|
},
|
||||||
"preannounce_media_id": {
|
"preannounce_media_id": {
|
||||||
"name": "Preannounce Media ID",
|
"name": "Preannounce media ID",
|
||||||
"description": "The media ID to play before the start message or media."
|
"description": "Custom media ID to play before the start message or media."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -199,7 +199,7 @@ async def websocket_test_connection(
|
|||||||
hass.async_create_background_task(
|
hass.async_create_background_task(
|
||||||
satellite.async_internal_announce(
|
satellite.async_internal_announce(
|
||||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
|
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
|
||||||
preannounce_media_id=None,
|
preannounce=False,
|
||||||
),
|
),
|
||||||
f"assist_satellite_connection_test_{msg['entity_id']}",
|
f"assist_satellite_connection_test_{msg['entity_id']}",
|
||||||
)
|
)
|
||||||
|
@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
|
|||||||
"""Find a blob by backup id."""
|
"""Find a blob by backup id."""
|
||||||
async for blob in self._client.list_blobs(include="metadata"):
|
async for blob in self._client.list_blobs(include="metadata"):
|
||||||
if (
|
if (
|
||||||
backup_id == blob.metadata.get("backup_id", "")
|
blob.metadata is not None
|
||||||
|
and backup_id == blob.metadata.get("backup_id", "")
|
||||||
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
||||||
):
|
):
|
||||||
return blob
|
return blob
|
||||||
|
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": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"backup_manager_state": {
|
"backup_manager_state": {
|
||||||
"name": "Backup Manager State",
|
"name": "Backup Manager state",
|
||||||
"state": {
|
"state": {
|
||||||
"idle": "Idle",
|
"idle": "[%key:common::state::idle%]",
|
||||||
"create_backup": "Creating a backup",
|
"create_backup": "Creating a backup",
|
||||||
"receive_backup": "Receiving a backup",
|
"receive_backup": "Receiving a backup",
|
||||||
"restore_backup": "Restoring a backup"
|
"restore_backup": "Restoring a backup"
|
||||||
|
@ -103,8 +103,8 @@
|
|||||||
"temperature_range": {
|
"temperature_range": {
|
||||||
"name": "Temperature range",
|
"name": "Temperature range",
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"high": "High"
|
"high": "[%key:common::state::high%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -124,15 +124,15 @@
|
|||||||
"battery": {
|
"battery": {
|
||||||
"name": "Battery",
|
"name": "Battery",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "Normal",
|
"off": "[%key:common::state::normal%]",
|
||||||
"on": "Low"
|
"on": "[%key:common::state::low%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"battery_charging": {
|
"battery_charging": {
|
||||||
"name": "Charging",
|
"name": "Charging",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "Not charging",
|
"off": "Not charging",
|
||||||
"on": "Charging"
|
"on": "[%key:common::state::charging%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"carbon_monoxide": {
|
"carbon_monoxide": {
|
||||||
@ -145,7 +145,7 @@
|
|||||||
"cold": {
|
"cold": {
|
||||||
"name": "Cold",
|
"name": "Cold",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
|
"off": "[%key:common::state::normal%]",
|
||||||
"on": "Cold"
|
"on": "Cold"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -180,7 +180,7 @@
|
|||||||
"heat": {
|
"heat": {
|
||||||
"name": "Heat",
|
"name": "Heat",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
|
"off": "[%key:common::state::normal%]",
|
||||||
"on": "Hot"
|
"on": "Hot"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
"vehicle_status": {
|
"vehicle_status": {
|
||||||
"name": "Vehicle status",
|
"name": "Vehicle status",
|
||||||
"state": {
|
"state": {
|
||||||
"standby": "Standby",
|
"standby": "[%key:common::state::standby%]",
|
||||||
"vehicle_detected": "Detected",
|
"vehicle_detected": "Detected",
|
||||||
"ready": "Ready",
|
"ready": "Ready",
|
||||||
"no_power": "No power",
|
"no_power": "No power",
|
||||||
|
@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
|||||||
return
|
return
|
||||||
|
|
||||||
# presets and inputs might have the same name; presets have priority
|
# presets and inputs might have the same name; presets have priority
|
||||||
url: str | None = None
|
|
||||||
for input_ in self._inputs:
|
for input_ in self._inputs:
|
||||||
if input_.text == source:
|
if input_.text == source:
|
||||||
url = input_.url
|
await self._player.play_url(input_.url)
|
||||||
|
return
|
||||||
for preset in self._presets:
|
for preset in self._presets:
|
||||||
if preset.name == source:
|
if preset.name == source:
|
||||||
url = preset.url
|
await self._player.load_preset(preset.id)
|
||||||
|
return
|
||||||
|
|
||||||
if url is None:
|
raise ServiceValidationError(f"Source {source} not found")
|
||||||
raise ServiceValidationError(f"Source {source} not found")
|
|
||||||
|
|
||||||
await self._player.play_url(url)
|
|
||||||
|
|
||||||
async def async_clear_playlist(self) -> None:
|
async def async_clear_playlist(self) -> None:
|
||||||
"""Clear players playlist."""
|
"""Clear players playlist."""
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
"bleak-retry-connector==3.9.0",
|
"bleak-retry-connector==3.9.0",
|
||||||
"bluetooth-adapters==0.21.4",
|
"bluetooth-adapters==0.21.4",
|
||||||
"bluetooth-auto-recovery==1.4.5",
|
"bluetooth-auto-recovery==1.4.5",
|
||||||
"bluetooth-data-tools==1.26.1",
|
"bluetooth-data-tools==1.27.0",
|
||||||
"dbus-fast==2.43.0",
|
"dbus-fast==2.43.0",
|
||||||
"habluetooth==3.37.0"
|
"habluetooth==3.37.0"
|
||||||
]
|
]
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"region": "ConnectedDrive Region"
|
"region": "ConnectedDrive region"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"username": "The email address of your MyBMW/MINI Connected account.",
|
"username": "The email address of your MyBMW/MINI Connected account.",
|
||||||
@ -113,10 +113,10 @@
|
|||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"ac_limit": {
|
"ac_limit": {
|
||||||
"name": "AC Charging Limit"
|
"name": "AC charging limit"
|
||||||
},
|
},
|
||||||
"charging_mode": {
|
"charging_mode": {
|
||||||
"name": "Charging Mode",
|
"name": "Charging mode",
|
||||||
"state": {
|
"state": {
|
||||||
"immediate_charging": "Immediate charging",
|
"immediate_charging": "Immediate charging",
|
||||||
"delayed_charging": "Delayed charging",
|
"delayed_charging": "Delayed charging",
|
||||||
@ -181,7 +181,7 @@
|
|||||||
"cooling": "Cooling",
|
"cooling": "Cooling",
|
||||||
"heating": "Heating",
|
"heating": "Heating",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"standby": "Standby",
|
"standby": "[%key:common::state::standby%]",
|
||||||
"ventilation": "Ventilation"
|
"ventilation": "Ventilation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._discovered[CONF_ACCESS_TOKEN] = token
|
self._discovered[CONF_ACCESS_TOKEN] = token
|
||||||
try:
|
try:
|
||||||
_, hub_name = await _validate_input(self.hass, self._discovered)
|
bond_id, hub_name = await _validate_input(self.hass, self._discovered)
|
||||||
except InputValidationError:
|
except InputValidationError:
|
||||||
return
|
return
|
||||||
|
await self.async_set_unique_id(bond_id)
|
||||||
|
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||||
self._discovered[CONF_NAME] = hub_name
|
self._discovered[CONF_NAME] = hub_name
|
||||||
|
|
||||||
|
async def async_step_dhcp(
|
||||||
|
self, discovery_info: DhcpServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle a flow initialized by dhcp discovery."""
|
||||||
|
host = discovery_info.ip
|
||||||
|
bond_id = discovery_info.hostname.partition("-")[2].upper()
|
||||||
|
await self.async_set_unique_id(bond_id)
|
||||||
|
return await self.async_step_any_discovery(bond_id, host)
|
||||||
|
|
||||||
async def async_step_zeroconf(
|
async def async_step_zeroconf(
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
host: str = discovery_info.host
|
host: str = discovery_info.host
|
||||||
bond_id = name.partition(".")[0]
|
bond_id = name.partition(".")[0]
|
||||||
await self.async_set_unique_id(bond_id)
|
await self.async_set_unique_id(bond_id)
|
||||||
|
return await self.async_step_any_discovery(bond_id, host)
|
||||||
|
|
||||||
|
async def async_step_any_discovery(
|
||||||
|
self, bond_id: str, host: str
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle a flow initialized by discovery."""
|
||||||
for entry in self._async_current_entries():
|
for entry in self._async_current_entries():
|
||||||
if entry.unique_id != bond_id:
|
if entry.unique_id != bond_id:
|
||||||
continue
|
continue
|
||||||
updates = {CONF_HOST: host}
|
updates = {CONF_HOST: host}
|
||||||
if entry.state == ConfigEntryState.SETUP_ERROR and (
|
if entry.state is ConfigEntryState.SETUP_ERROR and (
|
||||||
token := await async_get_token(self.hass, host)
|
token := await async_get_token(self.hass, host)
|
||||||
):
|
):
|
||||||
updates[CONF_ACCESS_TOKEN] = token
|
updates[CONF_ACCESS_TOKEN] = token
|
||||||
@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_HOST: self._discovered[CONF_HOST],
|
CONF_HOST: self._discovered[CONF_HOST],
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
_, hub_name = await _validate_input(self.hass, data)
|
bond_id, hub_name = await _validate_input(self.hass, data)
|
||||||
except InputValidationError as error:
|
except InputValidationError as error:
|
||||||
errors["base"] = error.base
|
errors["base"] = error.base
|
||||||
else:
|
else:
|
||||||
|
await self.async_set_unique_id(bond_id)
|
||||||
|
self._abort_if_unique_id_configured(
|
||||||
|
updates={CONF_HOST: self._discovered[CONF_HOST]}
|
||||||
|
)
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=hub_name,
|
title=hub_name,
|
||||||
data=data,
|
data=data,
|
||||||
@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
except InputValidationError as error:
|
except InputValidationError as error:
|
||||||
errors["base"] = error.base
|
errors["base"] = error.base
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(bond_id)
|
await self.async_set_unique_id(bond_id, raise_on_progress=False)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured(
|
||||||
|
updates={CONF_HOST: user_input[CONF_HOST]}
|
||||||
|
)
|
||||||
return self.async_create_entry(title=hub_name, data=user_input)
|
return self.async_create_entry(title=hub_name, data=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@ -3,6 +3,16 @@
|
|||||||
"name": "Bond",
|
"name": "Bond",
|
||||||
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
|
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dhcp": [
|
||||||
|
{
|
||||||
|
"hostname": "bond-*",
|
||||||
|
"macaddress": "3C6A2C1*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "bond-*",
|
||||||
|
"macaddress": "F44E38*"
|
||||||
|
}
|
||||||
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bond",
|
"documentation": "https://www.home-assistant.io/integrations/bond",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["bond_async"],
|
"loggers": ["bond_async"],
|
||||||
|
@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL]
|
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
|
||||||
|
|
||||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||||
|
|
||||||
@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
|||||||
await panel.connect()
|
await panel.connect()
|
||||||
except (PermissionError, ValueError) as err:
|
except (PermissionError, ValueError) as err:
|
||||||
await panel.disconnect()
|
await panel.disconnect()
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||||
|
) from err
|
||||||
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
|
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
|
||||||
await panel.disconnect()
|
await panel.disconnect()
|
||||||
raise ConfigEntryNotReady("Connection failed") from err
|
raise ConfigEntryNotReady(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="cannot_connect",
|
||||||
|
) from err
|
||||||
|
|
||||||
entry.runtime_data = panel
|
entry.runtime_data = panel
|
||||||
|
|
||||||
|
@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
AlarmControlPanelState,
|
AlarmControlPanelState,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import BoschAlarmConfigEntry
|
from . import BoschAlarmConfigEntry
|
||||||
from .const import DOMAIN
|
from .entity import BoschAlarmAreaEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -35,7 +34,7 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||||
"""An alarm control panel entity for a bosch alarm panel."""
|
"""An alarm control panel entity for a bosch alarm panel."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
|||||||
|
|
||||||
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
||||||
"""Initialise a Bosch Alarm control panel entity."""
|
"""Initialise a Bosch Alarm control panel entity."""
|
||||||
self.panel = panel
|
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||||
self._area = panel.areas[area_id]
|
self._attr_unique_id = self._area_unique_id
|
||||||
self._area_id = area_id
|
|
||||||
self._attr_unique_id = f"{unique_id}_area_{area_id}"
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
|
||||||
name=self._area.name,
|
|
||||||
manufacturer="Bosch Security Systems",
|
|
||||||
via_device=(
|
|
||||||
DOMAIN,
|
|
||||||
unique_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||||
@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
|||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
await self.panel.area_arm_all(self._area_id)
|
await self.panel.area_arm_all(self._area_id)
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return True if entity is available."""
|
|
||||||
return self.panel.connection_status()
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Run when entity attached to hass."""
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
self._area.status_observer.attach(self.schedule_update_ha_state)
|
|
||||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Run when entity removed from hass."""
|
|
||||||
await super().async_will_remove_from_hass()
|
|
||||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
|
||||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Mapping
|
||||||
import logging
|
import logging
|
||||||
import ssl
|
import ssl
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -10,7 +11,12 @@ from typing import Any
|
|||||||
from bosch_alarm_mode2 import Panel
|
from bosch_alarm_mode2 import Panel
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import (
|
||||||
|
SOURCE_RECONFIGURE,
|
||||||
|
SOURCE_USER,
|
||||||
|
ConfigFlow,
|
||||||
|
ConfigFlowResult,
|
||||||
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_CODE,
|
CONF_CODE,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
self._data = user_input
|
self._data = user_input
|
||||||
self._data[CONF_MODEL] = model
|
self._data[CONF_MODEL] = model
|
||||||
|
|
||||||
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
|
if (
|
||||||
|
self._get_reconfigure_entry().data[CONF_MODEL]
|
||||||
|
!= self._data[CONF_MODEL]
|
||||||
|
):
|
||||||
|
return self.async_abort(reason="device_mismatch")
|
||||||
return await self.async_step_auth()
|
return await self.async_step_auth()
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the reconfigure step."""
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
async def async_step_auth(
|
async def async_step_auth(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
if serial_number:
|
if serial_number:
|
||||||
await self.async_set_unique_id(str(serial_number))
|
await self.async_set_unique_id(str(serial_number))
|
||||||
self._abort_if_unique_id_configured()
|
if self.source == SOURCE_USER:
|
||||||
else:
|
if serial_number:
|
||||||
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
|
else:
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{CONF_HOST: self._data[CONF_HOST]}
|
||||||
|
)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"Bosch {model}", data=self._data
|
||||||
|
)
|
||||||
|
if serial_number:
|
||||||
|
self._abort_if_unique_id_mismatch(reason="device_mismatch")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reconfigure_entry(),
|
||||||
|
data=self._data,
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="auth",
|
step_id="auth",
|
||||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth(
|
||||||
|
self, entry_data: Mapping[str, Any]
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Perform reauth upon an authentication error."""
|
||||||
|
self._data = dict(entry_data)
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the reauth step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Each model variant requires a different authentication flow
|
||||||
|
if "Solution" in self._data[CONF_MODEL]:
|
||||||
|
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
|
||||||
|
elif "AMAX" in self._data[CONF_MODEL]:
|
||||||
|
schema = STEP_AUTH_DATA_SCHEMA_AMAX
|
||||||
|
else:
|
||||||
|
schema = STEP_AUTH_DATA_SCHEMA_BG
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
reauth_entry = self._get_reauth_entry()
|
||||||
|
self._data.update(user_input)
|
||||||
|
try:
|
||||||
|
(_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
|
||||||
|
except (PermissionError, ValueError) as e:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
_LOGGER.error("Authentication Error: %s", e)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ConnectionRefusedError,
|
||||||
|
ssl.SSLError,
|
||||||
|
TimeoutError,
|
||||||
|
) as e:
|
||||||
|
_LOGGER.error("Connection Error: %s", e)
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
reauth_entry,
|
||||||
|
data_updates=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
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",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["bosch-alarm-mode2==0.4.3"]
|
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ rules:
|
|||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: todo
|
log-when-unavailable: todo
|
||||||
parallel-updates: todo
|
parallel-updates: todo
|
||||||
reauthentication-flow: todo
|
reauthentication-flow: done
|
||||||
test-coverage: done
|
test-coverage: done
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
@ -62,9 +62,9 @@ rules:
|
|||||||
entity-category: todo
|
entity-category: todo
|
||||||
entity-device-class: todo
|
entity-device-class: todo
|
||||||
entity-disabled-by-default: todo
|
entity-disabled-by-default: todo
|
||||||
entity-translations: todo
|
entity-translations: done
|
||||||
exception-translations: todo
|
exception-translations: todo
|
||||||
icon-translations: todo
|
icon-translations: done
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: todo
|
||||||
repair-issues:
|
repair-issues:
|
||||||
status: exempt
|
status: exempt
|
||||||
|
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",
|
"installer_code": "The installer code from your panel",
|
||||||
"user_code": "The user code from your panel"
|
"user_code": "The user code from your panel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"data": {
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
|
||||||
|
"user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
|
||||||
|
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
|
||||||
|
"user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@ -30,7 +42,26 @@
|
|||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
|
"device_mismatch": "Please ensure you reconfigure against the same device."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"cannot_connect": {
|
||||||
|
"message": "Could not connect to panel."
|
||||||
|
},
|
||||||
|
"authentication_failed": {
|
||||||
|
"message": "Incorrect credentials for panel."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"faulting_points": {
|
||||||
|
"name": "Faulting points",
|
||||||
|
"unit_of_measurement": "points"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"email": "The email address associated with your Bring! account.",
|
"email": "The email address associated with your Bring! account.",
|
||||||
"password": "The password to login to your Bring! account."
|
"password": "The password to log in to your Bring! account."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
|
@ -12,6 +12,7 @@ from buienradar.constants import (
|
|||||||
CONDITION,
|
CONDITION,
|
||||||
CONTENT,
|
CONTENT,
|
||||||
DATA,
|
DATA,
|
||||||
|
FEELTEMPERATURE,
|
||||||
FORECAST,
|
FORECAST,
|
||||||
HUMIDITY,
|
HUMIDITY,
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
@ -22,6 +23,7 @@ from buienradar.constants import (
|
|||||||
TEMPERATURE,
|
TEMPERATURE,
|
||||||
VISIBILITY,
|
VISIBILITY,
|
||||||
WINDAZIMUTH,
|
WINDAZIMUTH,
|
||||||
|
WINDGUST,
|
||||||
WINDSPEED,
|
WINDSPEED,
|
||||||
)
|
)
|
||||||
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
|
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
|
||||||
@ -200,6 +202,14 @@ class BrData:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feeltemperature(self):
|
||||||
|
"""Return the feeltemperature, or None."""
|
||||||
|
try:
|
||||||
|
return float(self.data.get(FEELTEMPERATURE))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pressure(self):
|
def pressure(self):
|
||||||
"""Return the pressure, or None."""
|
"""Return the pressure, or None."""
|
||||||
@ -224,6 +234,14 @@ class BrData:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wind_gust(self):
|
||||||
|
"""Return the windgust, or None."""
|
||||||
|
try:
|
||||||
|
return float(self.data.get(WINDGUST))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wind_speed(self):
|
def wind_speed(self):
|
||||||
"""Return the windspeed, or None."""
|
"""Return the windspeed, or None."""
|
||||||
|
@ -9,6 +9,7 @@ from buienradar.constants import (
|
|||||||
MAX_TEMP,
|
MAX_TEMP,
|
||||||
MIN_TEMP,
|
MIN_TEMP,
|
||||||
RAIN,
|
RAIN,
|
||||||
|
RAIN_CHANCE,
|
||||||
WINDAZIMUTH,
|
WINDAZIMUTH,
|
||||||
WINDSPEED,
|
WINDSPEED,
|
||||||
)
|
)
|
||||||
@ -33,6 +34,7 @@ from homeassistant.components.weather import (
|
|||||||
ATTR_FORECAST_NATIVE_TEMP,
|
ATTR_FORECAST_NATIVE_TEMP,
|
||||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||||
|
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||||
ATTR_FORECAST_TIME,
|
ATTR_FORECAST_TIME,
|
||||||
ATTR_FORECAST_WIND_BEARING,
|
ATTR_FORECAST_WIND_BEARING,
|
||||||
Forecast,
|
Forecast,
|
||||||
@ -153,7 +155,9 @@ class BrWeather(WeatherEntity):
|
|||||||
)
|
)
|
||||||
self._attr_native_pressure = data.pressure
|
self._attr_native_pressure = data.pressure
|
||||||
self._attr_native_temperature = data.temperature
|
self._attr_native_temperature = data.temperature
|
||||||
|
self._attr_native_apparent_temperature = data.feeltemperature
|
||||||
self._attr_native_visibility = data.visibility
|
self._attr_native_visibility = data.visibility
|
||||||
|
self._attr_native_wind_gust_speed = data.wind_gust
|
||||||
self._attr_native_wind_speed = data.wind_speed
|
self._attr_native_wind_speed = data.wind_speed
|
||||||
self._attr_wind_bearing = data.wind_bearing
|
self._attr_wind_bearing = data.wind_bearing
|
||||||
|
|
||||||
@ -188,6 +192,7 @@ class BrWeather(WeatherEntity):
|
|||||||
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
|
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
|
||||||
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
|
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
|
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
|
||||||
|
ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE),
|
||||||
ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH),
|
ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH),
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
|
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"get_events": {
|
"get_events": {
|
||||||
"name": "Get events",
|
"name": "Get events",
|
||||||
"description": "Get events on a calendar within a time range.",
|
"description": "Retrieves events on a calendar within a time range.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"start_date_time": {
|
"start_date_time": {
|
||||||
"name": "Start time",
|
"name": "Start time",
|
||||||
|
@ -2,17 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Literal, cast
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
|
|
||||||
with suppress(Exception):
|
from turbojpeg import TurboJPEG
|
||||||
# TurboJPEG imports numpy which may or may not work so
|
|
||||||
# we have to guard the import here. We still want
|
|
||||||
# to import it at top level so it gets loaded
|
|
||||||
# in the import executor and not in the event loop.
|
|
||||||
from turbojpeg import TurboJPEG
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import Image
|
from . import Image
|
||||||
|
@ -98,13 +98,13 @@
|
|||||||
"name": "Preset",
|
"name": "Preset",
|
||||||
"state": {
|
"state": {
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"eco": "Eco",
|
"home": "[%key:common::state::home%]",
|
||||||
"away": "Away",
|
"away": "[%key:common::state::not_home%]",
|
||||||
|
"activity": "Activity",
|
||||||
"boost": "Boost",
|
"boost": "Boost",
|
||||||
"comfort": "Comfort",
|
"comfort": "Comfort",
|
||||||
"home": "[%key:common::state::home%]",
|
"eco": "Eco",
|
||||||
"sleep": "Sleep",
|
"sleep": "Sleep"
|
||||||
"activity": "Activity"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preset_modes": {
|
"preset_modes": {
|
||||||
@ -257,7 +257,7 @@
|
|||||||
"selector": {
|
"selector": {
|
||||||
"hvac_mode": {
|
"hvac_mode": {
|
||||||
"options": {
|
"options": {
|
||||||
"off": "Off",
|
"off": "[%key:common::state::off%]",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"cool": "Cool",
|
"cool": "Cool",
|
||||||
"dry": "Dry",
|
"dry": "Dry",
|
||||||
|
@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
|
|||||||
flow_id=flow_id, user_input=tokens
|
flow_id=flow_id, user_input=tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
self.hass.async_create_task(await_tokens())
|
# It's a background task because it should be cancelled on shutdown and there's nothing else
|
||||||
|
# we can do in such case. There's also no need to wait for this during setup.
|
||||||
|
self.hass.async_create_background_task(
|
||||||
|
await_tokens(), name="Awaiting OAuth tokens"
|
||||||
|
)
|
||||||
|
|
||||||
return authorize_url
|
return authorize_url
|
||||||
|
|
||||||
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from asyncio.exceptions import TimeoutError
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -53,10 +54,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await api.login()
|
await api.login()
|
||||||
except aiocomelit_exceptions.CannotConnect as err:
|
except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
|
||||||
raise CannotConnect from err
|
raise CannotConnect(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="cannot_connect",
|
||||||
|
translation_placeholders={"error": repr(err)},
|
||||||
|
) from err
|
||||||
except aiocomelit_exceptions.CannotAuthenticate as err:
|
except aiocomelit_exceptions.CannotAuthenticate as err:
|
||||||
raise InvalidAuth from err
|
raise InvalidAuth(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="cannot_authenticate",
|
||||||
|
translation_placeholders={"error": repr(err)},
|
||||||
|
) from err
|
||||||
finally:
|
finally:
|
||||||
await api.logout()
|
await api.logout()
|
||||||
await api.close()
|
await api.close()
|
||||||
|
@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject
|
|||||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||||
|
|
||||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
|
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
@ -98,13 +98,20 @@ class ComelitCoverEntity(
|
|||||||
"""Return if the cover is opening."""
|
"""Return if the cover is opening."""
|
||||||
return self._current_action("opening")
|
return self._current_action("opening")
|
||||||
|
|
||||||
|
async def _cover_set_state(self, action: int, state: int) -> None:
|
||||||
|
"""Set desired cover state."""
|
||||||
|
self._last_state = self.state
|
||||||
|
await self._api.set_device_status(COVER, self._device.index, action)
|
||||||
|
self.coordinator.data[COVER][self._device.index].status = state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close cover."""
|
"""Close cover."""
|
||||||
await self._api.set_device_status(COVER, self._device.index, STATE_OFF)
|
await self._cover_set_state(STATE_OFF, 2)
|
||||||
|
|
||||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
"""Open cover."""
|
"""Open cover."""
|
||||||
await self._api.set_device_status(COVER, self._device.index, STATE_ON)
|
await self._cover_set_state(STATE_ON, 1)
|
||||||
|
|
||||||
async def async_stop_cover(self, **_kwargs: Any) -> None:
|
async def async_stop_cover(self, **_kwargs: Any) -> None:
|
||||||
"""Stop the cover."""
|
"""Stop the cover."""
|
||||||
@ -112,13 +119,7 @@ class ComelitCoverEntity(
|
|||||||
return
|
return
|
||||||
|
|
||||||
action = STATE_ON if self.is_closing else STATE_OFF
|
action = STATE_ON if self.is_closing else STATE_OFF
|
||||||
await self._api.set_device_status(COVER, self._device.index, action)
|
await self._cover_set_state(action, 0)
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_coordinator_update(self) -> None:
|
|
||||||
"""Handle device update."""
|
|
||||||
self._last_state = self.state
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle entity which will be added."""
|
"""Handle entity which will be added."""
|
||||||
|
@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
|
|||||||
|
|
||||||
async def async_set_humidity(self, humidity: int) -> None:
|
async def async_set_humidity(self, humidity: int) -> None:
|
||||||
"""Set new target humidity."""
|
"""Set new target humidity."""
|
||||||
if self.mode == HumidifierComelitMode.OFF:
|
if not self._attr_is_on:
|
||||||
raise ServiceValidationError(
|
raise ServiceValidationError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="humidity_while_off",
|
translation_key="humidity_while_off",
|
||||||
@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
|
|||||||
await self.coordinator.api.set_humidity_status(
|
await self.coordinator.api.set_humidity_status(
|
||||||
self._device.index, self._set_command
|
self._device.index, self._set_command
|
||||||
)
|
)
|
||||||
|
self._attr_is_on = True
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off."""
|
"""Turn off."""
|
||||||
await self.coordinator.api.set_humidity_status(
|
await self.coordinator.api.set_humidity_status(
|
||||||
self._device.index, HumidifierComelitCommand.OFF
|
self._device.index, HumidifierComelitCommand.OFF
|
||||||
)
|
)
|
||||||
|
self._attr_is_on = False
|
||||||
|
self.async_write_ha_state()
|
||||||
|
@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
|
|||||||
async def _light_set_state(self, state: int) -> None:
|
async def _light_set_state(self, state: int) -> None:
|
||||||
"""Set desired light state."""
|
"""Set desired light state."""
|
||||||
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
|
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
|
||||||
await self.coordinator.async_request_refresh()
|
self.coordinator.data[LIGHT][self._device.index].status = state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the light on."""
|
"""Turn the light on."""
|
||||||
|
@ -42,9 +42,9 @@
|
|||||||
"sensor": {
|
"sensor": {
|
||||||
"zone_status": {
|
"zone_status": {
|
||||||
"state": {
|
"state": {
|
||||||
|
"open": "[%key:common::state::open%]",
|
||||||
"alarm": "Alarm",
|
"alarm": "Alarm",
|
||||||
"armed": "Armed",
|
"armed": "Armed",
|
||||||
"open": "Open",
|
|
||||||
"excluded": "Excluded",
|
"excluded": "Excluded",
|
||||||
"faulty": "Faulty",
|
"faulty": "Faulty",
|
||||||
"inhibited": "Inhibited",
|
"inhibited": "Inhibited",
|
||||||
@ -52,7 +52,9 @@
|
|||||||
"rest": "Rest",
|
"rest": "Rest",
|
||||||
"sabotated": "Sabotated"
|
"sabotated": "Sabotated"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
"humidifier": {
|
||||||
"humidifier": {
|
"humidifier": {
|
||||||
"name": "Humidifier"
|
"name": "Humidifier"
|
||||||
},
|
},
|
||||||
@ -67,6 +69,12 @@
|
|||||||
},
|
},
|
||||||
"invalid_clima_data": {
|
"invalid_clima_data": {
|
||||||
"message": "Invalid 'clima' data"
|
"message": "Invalid 'clima' data"
|
||||||
|
},
|
||||||
|
"cannot_connect": {
|
||||||
|
"message": "Error connecting: {error}"
|
||||||
|
},
|
||||||
|
"cannot_authenticate": {
|
||||||
|
"message": "Error authenticating: {error}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
|
|||||||
await self.coordinator.api.set_device_status(
|
await self.coordinator.api.set_device_status(
|
||||||
self._device.type, self._device.index, state
|
self._device.type, self._device.index, state
|
||||||
)
|
)
|
||||||
await self.coordinator.async_request_refresh()
|
self.coordinator.data[self._device.type][self._device.index].status = state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the switch on."""
|
"""Turn the switch on."""
|
||||||
|
@ -58,7 +58,8 @@ def async_setup(hass: HomeAssistant) -> bool:
|
|||||||
websocket_api.async_register_command(hass, config_entry_get_single)
|
websocket_api.async_register_command(hass, config_entry_get_single)
|
||||||
websocket_api.async_register_command(hass, config_entry_update)
|
websocket_api.async_register_command(hass, config_entry_update)
|
||||||
websocket_api.async_register_command(hass, config_entries_subscribe)
|
websocket_api.async_register_command(hass, config_entries_subscribe)
|
||||||
websocket_api.async_register_command(hass, config_entries_progress)
|
websocket_api.async_register_command(hass, config_entries_flow_progress)
|
||||||
|
websocket_api.async_register_command(hass, config_entries_flow_subscribe)
|
||||||
websocket_api.async_register_command(hass, ignore_config_flow)
|
websocket_api.async_register_command(hass, ignore_config_flow)
|
||||||
|
|
||||||
websocket_api.async_register_command(hass, config_subentry_delete)
|
websocket_api.async_register_command(hass, config_subentry_delete)
|
||||||
@ -357,7 +358,7 @@ class SubentryManagerFlowResourceView(
|
|||||||
|
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
|
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
|
||||||
def config_entries_progress(
|
def config_entries_flow_progress(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
connection: websocket_api.ActiveConnection,
|
connection: websocket_api.ActiveConnection,
|
||||||
msg: dict[str, Any],
|
msg: dict[str, Any],
|
||||||
@ -378,6 +379,66 @@ def config_entries_progress(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"})
|
||||||
|
def config_entries_flow_subscribe(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to non user created flows being initiated or removed.
|
||||||
|
|
||||||
|
When initiating the subscription, the current flows are sent to the client.
|
||||||
|
|
||||||
|
Example of a non-user initiated flow is a discovered Hue hub that
|
||||||
|
requires user interaction to finish setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_on_flow_init_remove(change_type: str, flow_id: str) -> None:
|
||||||
|
"""Forward config entry state events to websocket."""
|
||||||
|
if change_type == "removed":
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
[{"type": change_type, "flow_id": flow_id}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# change_type == "added"
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": change_type,
|
||||||
|
"flow_id": flow_id,
|
||||||
|
"flow": hass.config_entries.flow.async_get(flow_id),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
|
||||||
|
async_on_flow_init_remove
|
||||||
|
)
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
[
|
||||||
|
{"type": None, "flow_id": flw["flow_id"], "flow": flw}
|
||||||
|
for flw in hass.config_entries.flow.async_progress()
|
||||||
|
if flw["context"]["source"]
|
||||||
|
not in (
|
||||||
|
config_entries.SOURCE_RECONFIGURE,
|
||||||
|
config_entries.SOURCE_USER,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
def send_entry_not_found(
|
def send_entry_not_found(
|
||||||
connection: websocket_api.ActiveConnection, msg_id: int
|
connection: websocket_api.ActiveConnection, msg_id: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -354,6 +354,35 @@ class ChatLog:
|
|||||||
if self.delta_listener:
|
if self.delta_listener:
|
||||||
self.delta_listener(self, asdict(tool_result))
|
self.delta_listener(self, asdict(tool_result))
|
||||||
|
|
||||||
|
async def _async_expand_prompt_template(
|
||||||
|
self,
|
||||||
|
llm_context: llm.LLMContext,
|
||||||
|
prompt: str,
|
||||||
|
language: str,
|
||||||
|
user_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
return template.Template(prompt, self.hass).async_render(
|
||||||
|
{
|
||||||
|
"ha_name": self.hass.config.location_name,
|
||||||
|
"user_name": user_name,
|
||||||
|
"llm_context": llm_context,
|
||||||
|
},
|
||||||
|
parse_result=False,
|
||||||
|
)
|
||||||
|
except TemplateError as err:
|
||||||
|
LOGGER.error("Error rendering prompt: %s", err)
|
||||||
|
intent_response = intent.IntentResponse(language=language)
|
||||||
|
intent_response.async_set_error(
|
||||||
|
intent.IntentResponseErrorCode.UNKNOWN,
|
||||||
|
"Sorry, I had a problem with my template",
|
||||||
|
)
|
||||||
|
raise ConverseError(
|
||||||
|
"Error rendering prompt",
|
||||||
|
conversation_id=self.conversation_id,
|
||||||
|
response=intent_response,
|
||||||
|
) from err
|
||||||
|
|
||||||
async def async_update_llm_data(
|
async def async_update_llm_data(
|
||||||
self,
|
self,
|
||||||
conversing_domain: str,
|
conversing_domain: str,
|
||||||
@ -409,38 +438,28 @@ class ChatLog:
|
|||||||
):
|
):
|
||||||
user_name = user.name
|
user_name = user.name
|
||||||
|
|
||||||
try:
|
prompt_parts = []
|
||||||
prompt_parts = [
|
prompt_parts.append(
|
||||||
template.Template(
|
await self._async_expand_prompt_template(
|
||||||
llm.BASE_PROMPT
|
llm_context,
|
||||||
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||||
self.hass,
|
user_input.language,
|
||||||
).async_render(
|
user_name,
|
||||||
{
|
|
||||||
"ha_name": self.hass.config.location_name,
|
|
||||||
"user_name": user_name,
|
|
||||||
"llm_context": llm_context,
|
|
||||||
},
|
|
||||||
parse_result=False,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
except TemplateError as err:
|
|
||||||
LOGGER.error("Error rendering prompt: %s", err)
|
|
||||||
intent_response = intent.IntentResponse(language=user_input.language)
|
|
||||||
intent_response.async_set_error(
|
|
||||||
intent.IntentResponseErrorCode.UNKNOWN,
|
|
||||||
"Sorry, I had a problem with my template",
|
|
||||||
)
|
)
|
||||||
raise ConverseError(
|
)
|
||||||
"Error rendering prompt",
|
|
||||||
conversation_id=self.conversation_id,
|
|
||||||
response=intent_response,
|
|
||||||
) from err
|
|
||||||
|
|
||||||
if llm_api:
|
if llm_api:
|
||||||
prompt_parts.append(llm_api.api_prompt)
|
prompt_parts.append(llm_api.api_prompt)
|
||||||
|
|
||||||
|
prompt_parts.append(
|
||||||
|
await self._async_expand_prompt_template(
|
||||||
|
llm_context,
|
||||||
|
llm.BASE_PROMPT,
|
||||||
|
user_input.language,
|
||||||
|
user_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if extra_system_prompt := (
|
if extra_system_prompt := (
|
||||||
# Take new system prompt if one was given
|
# Take new system prompt if one was given
|
||||||
user_input.extra_system_prompt or self.extra_system_prompt
|
user_input.extra_system_prompt or self.extra_system_prompt
|
||||||
|
@ -38,10 +38,10 @@
|
|||||||
"name": "[%key:component::cover::title%]",
|
"name": "[%key:component::cover::title%]",
|
||||||
"state": {
|
"state": {
|
||||||
"open": "[%key:common::state::open%]",
|
"open": "[%key:common::state::open%]",
|
||||||
"opening": "Opening",
|
"opening": "[%key:common::state::opening%]",
|
||||||
"closed": "[%key:common::state::closed%]",
|
"closed": "[%key:common::state::closed%]",
|
||||||
"closing": "Closing",
|
"closing": "[%key:common::state::closing%]",
|
||||||
"stopped": "Stopped"
|
"stopped": "[%key:common::state::stopped%]"
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"current_position": {
|
"current_position": {
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||||
|
from homeassistant.util.ssl import client_context_no_verify
|
||||||
|
|
||||||
from .const import KEY_MAC, TIMEOUT
|
from .const import KEY_MAC, TIMEOUT
|
||||||
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
||||||
@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
|
|||||||
key=entry.data.get(CONF_API_KEY),
|
key=entry.data.get(CONF_API_KEY),
|
||||||
uuid=entry.data.get(CONF_UUID),
|
uuid=entry.data.get(CONF_UUID),
|
||||||
password=entry.data.get(CONF_PASSWORD),
|
password=entry.data.get(CONF_PASSWORD),
|
||||||
|
ssl_context=client_context_no_verify(),
|
||||||
)
|
)
|
||||||
_LOGGER.debug("Connection to %s successful", host)
|
_LOGGER.debug("Connection to %s successful", host)
|
||||||
except TimeoutError as err:
|
except TimeoutError as err:
|
||||||
|
@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|||||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
|
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
from homeassistant.util.ssl import client_context_no_verify
|
||||||
|
|
||||||
from .const import DOMAIN, KEY_MAC, TIMEOUT
|
from .const import DOMAIN, KEY_MAC, TIMEOUT
|
||||||
|
|
||||||
@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
key=key,
|
key=key,
|
||||||
uuid=uuid,
|
uuid=uuid,
|
||||||
password=password,
|
password=password,
|
||||||
|
ssl_context=client_context_no_verify(),
|
||||||
)
|
)
|
||||||
except (TimeoutError, ClientError):
|
except (TimeoutError, ClientError):
|
||||||
self.host = None
|
self.host = None
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pydaikin"],
|
"loggers": ["pydaikin"],
|
||||||
"requirements": ["pydaikin==2.14.1"],
|
"requirements": ["pydaikin==2.15.0"],
|
||||||
"zeroconf": ["_dkapi._tcp.local."]
|
"zeroconf": ["_dkapi._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls",
|
"description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["async_upnp_client"],
|
"loggers": ["async_upnp_client"],
|
||||||
"requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
|
"requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"dependencies": ["ssdp"],
|
"dependencies": ["ssdp"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["async-upnp-client==0.43.0"],
|
"requirements": ["async-upnp-client==0.44.0"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pydoods"],
|
"loggers": ["pydoods"],
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
|
"requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
|
||||||
}
|
}
|
||||||
|
@ -38,8 +38,8 @@
|
|||||||
"protect_mode": {
|
"protect_mode": {
|
||||||
"name": "Protect mode",
|
"name": "Protect mode",
|
||||||
"state": {
|
"state": {
|
||||||
"away": "Away",
|
"away": "[%key:common::state::not_home%]",
|
||||||
"home": "Home",
|
"home": "[%key:common::state::home%]",
|
||||||
"schedule": "Schedule"
|
"schedule": "Schedule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["dsmr_parser"],
|
"loggers": ["dsmr_parser"],
|
||||||
"requirements": ["dsmr-parser==1.4.2"]
|
"requirements": ["dsmr-parser==1.4.3"]
|
||||||
}
|
}
|
||||||
|
@ -51,8 +51,8 @@
|
|||||||
"electricity_active_tariff": {
|
"electricity_active_tariff": {
|
||||||
"name": "Active tariff",
|
"name": "Active tariff",
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"normal": "Normal"
|
"normal": "[%key:common::state::normal%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"electricity_delivered_tariff_1": {
|
"electricity_delivered_tariff_1": {
|
||||||
|
@ -140,8 +140,8 @@
|
|||||||
"electricity_tariff": {
|
"electricity_tariff": {
|
||||||
"name": "Electricity tariff",
|
"name": "Electricity tariff",
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"high": "High"
|
"high": "[%key:common::state::high%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"power_failure_count": {
|
"power_failure_count": {
|
||||||
|
@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
|||||||
one = timedelta(days=1)
|
one = timedelta(days=1)
|
||||||
if start_time is None:
|
if start_time is None:
|
||||||
# Max 3 years of data
|
# Max 3 years of data
|
||||||
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
|
start = dt_util.now(tz) - timedelta(days=3 * 365)
|
||||||
if agreement_date is None:
|
|
||||||
start = dt_util.now(tz) - timedelta(days=3 * 365)
|
|
||||||
else:
|
|
||||||
start = max(
|
|
||||||
agreement_date.replace(tzinfo=tz),
|
|
||||||
dt_util.now(tz) - timedelta(days=3 * 365),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
|
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
|
||||||
|
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
|
||||||
|
if agreement_date is not None:
|
||||||
|
start = max(agreement_date.replace(tzinfo=tz), start)
|
||||||
|
|
||||||
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
|
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
|
||||||
_LOGGER.debug("Data lookup range: %s - %s", start, end)
|
_LOGGER.debug("Data lookup range: %s - %s", start, end)
|
||||||
|
|
||||||
start_step = end - lookback
|
start_step = max(end - lookback, start)
|
||||||
end_step = end
|
end_step = end
|
||||||
usage: dict[datetime, dict[str, float | int]] = {}
|
usage: dict[datetime, dict[str, float | int]] = {}
|
||||||
while True:
|
while True:
|
||||||
|
@ -55,7 +55,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"name": "Entity",
|
||||||
"description": "Ecobee thermostat on which to create the vacation."
|
"description": "ecobee thermostat on which to create the vacation."
|
||||||
},
|
},
|
||||||
"vacation_name": {
|
"vacation_name": {
|
||||||
"name": "Vacation name",
|
"name": "Vacation name",
|
||||||
@ -101,7 +101,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"name": "Entity",
|
||||||
"description": "Ecobee thermostat on which to delete the vacation."
|
"description": "ecobee thermostat on which to delete the vacation."
|
||||||
},
|
},
|
||||||
"vacation_name": {
|
"vacation_name": {
|
||||||
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
|
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
|
||||||
@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"set_mic_mode": {
|
"set_mic_mode": {
|
||||||
"name": "Set mic mode",
|
"name": "Set mic mode",
|
||||||
"description": "Enables/disables Alexa microphone (only for Ecobee 4).",
|
"description": "Enables/disables Alexa microphone (only for ecobee 4).",
|
||||||
"fields": {
|
"fields": {
|
||||||
"mic_enabled": {
|
"mic_enabled": {
|
||||||
"name": "Mic enabled",
|
"name": "Mic enabled",
|
||||||
@ -177,7 +177,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"name": "Entity",
|
||||||
"description": "Ecobee thermostat on which to set active sensors."
|
"description": "ecobee thermostat on which to set active sensors."
|
||||||
},
|
},
|
||||||
"preset_mode": {
|
"preset_mode": {
|
||||||
"name": "Climate Name",
|
"name": "Climate Name",
|
||||||
@ -203,12 +203,12 @@
|
|||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"migrate_aux_heat": {
|
"migrate_aux_heat": {
|
||||||
"title": "Migration of Ecobee set_aux_heat action",
|
"title": "Migration of ecobee set_aux_heat action",
|
||||||
"fix_flow": {
|
"fix_flow": {
|
||||||
"step": {
|
"step": {
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
|
"description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
|
||||||
"title": "Disable legacy Ecobee set_aux_heat action"
|
"title": "Disable legacy ecobee set_aux_heat action"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,10 +23,8 @@ from homeassistant.components.climate import (
|
|||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
|
||||||
|
|
||||||
from . import EconetConfigEntry
|
from . import EconetConfigEntry
|
||||||
from .const import DOMAIN
|
|
||||||
from .entity import EcoNetEntity
|
from .entity import EcoNetEntity
|
||||||
|
|
||||||
ECONET_STATE_TO_HA = {
|
ECONET_STATE_TO_HA = {
|
||||||
@ -212,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
|||||||
"""Set the fan mode."""
|
"""Set the fan mode."""
|
||||||
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
|
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
|
||||||
|
|
||||||
def turn_aux_heat_on(self) -> None:
|
|
||||||
"""Turn auxiliary heater on."""
|
|
||||||
create_issue(
|
|
||||||
self.hass,
|
|
||||||
DOMAIN,
|
|
||||||
"migrate_aux_heat",
|
|
||||||
breaks_in_ha_version="2025.4.0",
|
|
||||||
is_fixable=True,
|
|
||||||
is_persistent=True,
|
|
||||||
translation_key="migrate_aux_heat",
|
|
||||||
severity=IssueSeverity.WARNING,
|
|
||||||
)
|
|
||||||
self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT)
|
|
||||||
|
|
||||||
def turn_aux_heat_off(self) -> None:
|
|
||||||
"""Turn auxiliary heater off."""
|
|
||||||
create_issue(
|
|
||||||
self.hass,
|
|
||||||
DOMAIN,
|
|
||||||
"migrate_aux_heat",
|
|
||||||
breaks_in_ha_version="2025.4.0",
|
|
||||||
is_fixable=True,
|
|
||||||
is_persistent=True,
|
|
||||||
translation_key="migrate_aux_heat",
|
|
||||||
severity=IssueSeverity.WARNING,
|
|
||||||
)
|
|
||||||
self._econet.set_mode(ThermostatOperationMode.HEATING)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_temp(self):
|
def min_temp(self):
|
||||||
"""Return the minimum temperature."""
|
"""Return the minimum temperature."""
|
||||||
|
@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
|
|||||||
def operation_list(self) -> list[str]:
|
def operation_list(self) -> list[str]:
|
||||||
"""List of available operation modes."""
|
"""List of available operation modes."""
|
||||||
econet_modes = self.water_heater.modes
|
econet_modes = self.water_heater.modes
|
||||||
op_list = []
|
operation_modes = set()
|
||||||
for mode in econet_modes:
|
for mode in econet_modes:
|
||||||
if (
|
if (
|
||||||
mode is not WaterHeaterOperationMode.UNKNOWN
|
mode is not WaterHeaterOperationMode.UNKNOWN
|
||||||
and mode is not WaterHeaterOperationMode.VACATION
|
and mode is not WaterHeaterOperationMode.VACATION
|
||||||
):
|
):
|
||||||
ha_mode = ECONET_STATE_TO_HA[mode]
|
ha_mode = ECONET_STATE_TO_HA[mode]
|
||||||
op_list.append(ha_mode)
|
operation_modes.add(ha_mode)
|
||||||
return op_list
|
return list(operation_modes)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_features(self) -> WaterHeaterEntityFeature:
|
def supported_features(self) -> WaterHeaterEntityFeature:
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"]
|
"requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
|
||||||
}
|
}
|
||||||
|
@ -176,9 +176,9 @@
|
|||||||
"water_amount": {
|
"water_amount": {
|
||||||
"name": "Water flow level",
|
"name": "Water flow level",
|
||||||
"state": {
|
"state": {
|
||||||
"high": "High",
|
"high": "[%key:common::state::high%]",
|
||||||
"low": "Low",
|
"low": "[%key:common::state::low%]",
|
||||||
"medium": "Medium",
|
"medium": "[%key:common::state::medium%]",
|
||||||
"ultrahigh": "Ultrahigh"
|
"ultrahigh": "Ultrahigh"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -229,9 +229,9 @@
|
|||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"fan_speed": {
|
"fan_speed": {
|
||||||
"state": {
|
"state": {
|
||||||
|
"normal": "[%key:common::state::normal%]",
|
||||||
"max": "Max",
|
"max": "Max",
|
||||||
"max_plus": "Max+",
|
"max_plus": "Max+",
|
||||||
"normal": "Normal",
|
|
||||||
"quiet": "Quiet"
|
"quiet": "Quiet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
|||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
|
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
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}
|
return {"index": self._element.index + 1}
|
||||||
|
|
||||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||||
pass
|
"""Handle changes to the element.
|
||||||
|
|
||||||
|
This method is called when the element changes. It should be
|
||||||
|
overridden by subclasses to handle the changes.
|
||||||
|
"""
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
|
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||||
@ -111,7 +115,7 @@ class ElkEntity(Entity):
|
|||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callback for ElkM1 changes and update entity state."""
|
"""Register callback for ElkM1 changes and update entity state."""
|
||||||
self._element.add_callback(self._element_callback)
|
self._element.add_callback(self._element_callback)
|
||||||
self._element_callback(self._element, {})
|
self._element_changed(self._element, {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
|
@ -4,12 +4,12 @@
|
|||||||
"choose_mode": {
|
"choose_mode": {
|
||||||
"description": "Please choose the connection mode to Elmax panels.",
|
"description": "Please choose the connection mode to Elmax panels.",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"cloud": "Connect to Elmax Panel via Elmax Cloud APIs",
|
"cloud": "Connect to Elmax panel via Elmax Cloud APIs",
|
||||||
"direct": "Connect to Elmax Panel via local/direct IP"
|
"direct": "Connect to Elmax panel via local/direct IP"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cloud": {
|
"cloud": {
|
||||||
"description": "Please login to the Elmax cloud using your credentials",
|
"description": "Please log in to the Elmax cloud using your credentials",
|
||||||
"data": {
|
"data": {
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]"
|
"username": "[%key:common::config_flow::data::username%]"
|
||||||
@ -28,7 +28,7 @@
|
|||||||
"direct": {
|
"direct": {
|
||||||
"description": "Specify the Elmax panel connection parameters below.",
|
"description": "Specify the Elmax panel connection parameters below.",
|
||||||
"data": {
|
"data": {
|
||||||
"panel_api_host": "Panel API Hostname or IP",
|
"panel_api_host": "Panel API hostname or IP",
|
||||||
"panel_api_port": "Panel API port",
|
"panel_api_port": "Panel API port",
|
||||||
"use_ssl": "Use SSL",
|
"use_ssl": "Use SSL",
|
||||||
"panel_pin": "Panel PIN code"
|
"panel_pin": "Panel PIN code"
|
||||||
@ -40,7 +40,7 @@
|
|||||||
"panels": {
|
"panels": {
|
||||||
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
|
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
|
||||||
"data": {
|
"data": {
|
||||||
"panel_name": "Panel Name",
|
"panel_name": "Panel name",
|
||||||
"panel_id": "Panel ID",
|
"panel_id": "Panel ID",
|
||||||
"panel_pin": "[%key:common::config_flow::data::pin%]"
|
"panel_pin": "[%key:common::config_flow::data::pin%]"
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the emulated roku component."""
|
"""Set up the emulated roku component."""
|
||||||
@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: EmulatedRokuConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Set up an emulated roku server from a config entry."""
|
"""Set up an emulated roku server from a config entry."""
|
||||||
config = config_entry.data
|
config = entry.data
|
||||||
|
name: str = config[CONF_NAME]
|
||||||
if DOMAIN not in hass.data:
|
listen_port: int = config[CONF_LISTEN_PORT]
|
||||||
hass.data[DOMAIN] = {}
|
host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
|
||||||
|
advertise_ip: str | None = config.get(CONF_ADVERTISE_IP)
|
||||||
name = config[CONF_NAME]
|
advertise_port: int | None = config.get(CONF_ADVERTISE_PORT)
|
||||||
listen_port = config[CONF_LISTEN_PORT]
|
upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST)
|
||||||
host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
|
|
||||||
advertise_ip = config.get(CONF_ADVERTISE_IP)
|
|
||||||
advertise_port = config.get(CONF_ADVERTISE_PORT)
|
|
||||||
upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST)
|
|
||||||
|
|
||||||
server = EmulatedRoku(
|
server = EmulatedRoku(
|
||||||
hass,
|
hass,
|
||||||
|
entry.entry_id,
|
||||||
name,
|
name,
|
||||||
host_ip,
|
host_ip,
|
||||||
listen_port,
|
listen_port,
|
||||||
@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
advertise_port,
|
advertise_port,
|
||||||
upnp_bind_multicast,
|
upnp_bind_multicast,
|
||||||
)
|
)
|
||||||
|
entry.runtime_data = server
|
||||||
hass.data[DOMAIN][name] = server
|
|
||||||
|
|
||||||
return await server.setup()
|
return await server.setup()
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(
|
||||||
|
hass: HomeAssistant, entry: EmulatedRokuConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
name = entry.data[CONF_NAME]
|
return await entry.runtime_data.unload()
|
||||||
server = hass.data[DOMAIN].pop(name)
|
|
||||||
return await server.unload()
|
|
||||||
|
@ -5,7 +5,13 @@ import logging
|
|||||||
from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer
|
from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer
|
||||||
|
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import CoreState, EventOrigin
|
from homeassistant.core import (
|
||||||
|
CALLBACK_TYPE,
|
||||||
|
CoreState,
|
||||||
|
Event,
|
||||||
|
EventOrigin,
|
||||||
|
HomeAssistant,
|
||||||
|
)
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
@ -27,16 +33,18 @@ class EmulatedRoku:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass,
|
hass: HomeAssistant,
|
||||||
name,
|
entry_id: str,
|
||||||
host_ip,
|
name: str,
|
||||||
listen_port,
|
host_ip: str,
|
||||||
advertise_ip,
|
listen_port: int,
|
||||||
advertise_port,
|
advertise_ip: str | None,
|
||||||
upnp_bind_multicast,
|
advertise_port: int | None,
|
||||||
):
|
upnp_bind_multicast: bool | None,
|
||||||
|
) -> None:
|
||||||
"""Initialize the properties."""
|
"""Initialize the properties."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
self.entry_id = entry_id
|
||||||
|
|
||||||
self.roku_usn = name
|
self.roku_usn = name
|
||||||
self.host_ip = host_ip
|
self.host_ip = host_ip
|
||||||
@ -47,21 +55,21 @@ class EmulatedRoku:
|
|||||||
|
|
||||||
self.bind_multicast = upnp_bind_multicast
|
self.bind_multicast = upnp_bind_multicast
|
||||||
|
|
||||||
self._api_server = None
|
self._api_server: EmulatedRokuServer | None = None
|
||||||
|
|
||||||
self._unsub_start_listener = None
|
self._unsub_start_listener: CALLBACK_TYPE | None = None
|
||||||
self._unsub_stop_listener = None
|
self._unsub_stop_listener: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
async def setup(self):
|
async def setup(self) -> bool:
|
||||||
"""Start the emulated_roku server."""
|
"""Start the emulated_roku server."""
|
||||||
|
|
||||||
class EventCommandHandler(EmulatedRokuCommandHandler):
|
class EventCommandHandler(EmulatedRokuCommandHandler):
|
||||||
"""emulated_roku command handler to turn commands into events."""
|
"""emulated_roku command handler to turn commands into events."""
|
||||||
|
|
||||||
def __init__(self, hass):
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
|
||||||
def on_keydown(self, roku_usn, key):
|
def on_keydown(self, roku_usn: str, key: str) -> None:
|
||||||
"""Handle keydown event."""
|
"""Handle keydown event."""
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
EVENT_ROKU_COMMAND,
|
EVENT_ROKU_COMMAND,
|
||||||
@ -73,7 +81,7 @@ class EmulatedRoku:
|
|||||||
EventOrigin.local,
|
EventOrigin.local,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_keyup(self, roku_usn, key):
|
def on_keyup(self, roku_usn: str, key: str) -> None:
|
||||||
"""Handle keyup event."""
|
"""Handle keyup event."""
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
EVENT_ROKU_COMMAND,
|
EVENT_ROKU_COMMAND,
|
||||||
@ -85,7 +93,7 @@ class EmulatedRoku:
|
|||||||
EventOrigin.local,
|
EventOrigin.local,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_keypress(self, roku_usn, key):
|
def on_keypress(self, roku_usn: str, key: str) -> None:
|
||||||
"""Handle keypress event."""
|
"""Handle keypress event."""
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
EVENT_ROKU_COMMAND,
|
EVENT_ROKU_COMMAND,
|
||||||
@ -97,7 +105,7 @@ class EmulatedRoku:
|
|||||||
EventOrigin.local,
|
EventOrigin.local,
|
||||||
)
|
)
|
||||||
|
|
||||||
def launch(self, roku_usn, app_id):
|
def launch(self, roku_usn: str, app_id: str) -> None:
|
||||||
"""Handle launch event."""
|
"""Handle launch event."""
|
||||||
self.hass.bus.async_fire(
|
self.hass.bus.async_fire(
|
||||||
EVENT_ROKU_COMMAND,
|
EVENT_ROKU_COMMAND,
|
||||||
@ -129,17 +137,19 @@ class EmulatedRoku:
|
|||||||
bind_multicast=self.bind_multicast,
|
bind_multicast=self.bind_multicast,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emulated_roku_stop(event):
|
async def emulated_roku_stop(event: Event | None) -> None:
|
||||||
"""Wrap the call to emulated_roku.close."""
|
"""Wrap the call to emulated_roku.close."""
|
||||||
LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
|
LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
|
||||||
self._unsub_stop_listener = None
|
self._unsub_stop_listener = None
|
||||||
|
assert self._api_server is not None
|
||||||
await self._api_server.close()
|
await self._api_server.close()
|
||||||
|
|
||||||
async def emulated_roku_start(event):
|
async def emulated_roku_start(event: Event | None) -> None:
|
||||||
"""Wrap the call to emulated_roku.start."""
|
"""Wrap the call to emulated_roku.start."""
|
||||||
try:
|
try:
|
||||||
LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
|
LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
|
||||||
self._unsub_start_listener = None
|
self._unsub_start_listener = None
|
||||||
|
assert self._api_server is not None
|
||||||
await self._api_server.start()
|
await self._api_server.start()
|
||||||
except OSError:
|
except OSError:
|
||||||
LOGGER.exception(
|
LOGGER.exception(
|
||||||
@ -165,7 +175,7 @@ class EmulatedRoku:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def unload(self):
|
async def unload(self) -> bool:
|
||||||
"""Unload the emulated_roku server."""
|
"""Unload the emulated_roku server."""
|
||||||
LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
|
LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
|
||||||
|
|
||||||
@ -177,6 +187,7 @@ class EmulatedRoku:
|
|||||||
self._unsub_stop_listener()
|
self._unsub_stop_listener()
|
||||||
self._unsub_stop_listener = None
|
self._unsub_stop_listener = None
|
||||||
|
|
||||||
|
assert self._api_server is not None
|
||||||
await self._api_server.close()
|
await self._api_server.close()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -25,6 +25,7 @@ from homeassistant.core import (
|
|||||||
split_entity_id,
|
split_entity_id,
|
||||||
valid_entity_id,
|
valid_entity_id,
|
||||||
)
|
)
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityNotFoundError(HomeAssistantError):
|
||||||
|
"""When a referenced entity was not found."""
|
||||||
|
|
||||||
|
|
||||||
class SensorManager:
|
class SensorManager:
|
||||||
"""Class to handle creation/removal of sensor data."""
|
"""Class to handle creation/removal of sensor data."""
|
||||||
|
|
||||||
@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine energy price
|
try:
|
||||||
if self._config["entity_energy_price"] is not None:
|
energy_price, energy_price_unit = self._get_energy_price(
|
||||||
energy_price_state = self.hass.states.get(
|
valid_units, default_price_unit
|
||||||
self._config["entity_energy_price"]
|
|
||||||
)
|
)
|
||||||
|
except EntityNotFoundError:
|
||||||
if energy_price_state is None:
|
return
|
||||||
return
|
except ValueError:
|
||||||
|
energy_price = None
|
||||||
try:
|
|
||||||
energy_price = float(energy_price_state.state)
|
|
||||||
except ValueError:
|
|
||||||
if self._last_energy_sensor_state is None:
|
|
||||||
# Initialize as it's the first time all required entities except
|
|
||||||
# price are in place. This means that the cost will update the first
|
|
||||||
# time the energy is updated after the price entity is in place.
|
|
||||||
self._reset(energy_state)
|
|
||||||
return
|
|
||||||
|
|
||||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
|
||||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
|
||||||
).partition("/")[2]
|
|
||||||
|
|
||||||
# For backwards compatibility we don't validate the unit of the price
|
|
||||||
# If it is not valid, we assume it's our default price unit.
|
|
||||||
if energy_price_unit not in valid_units:
|
|
||||||
energy_price_unit = default_price_unit
|
|
||||||
|
|
||||||
else:
|
|
||||||
energy_price = cast(float, self._config["number_energy_price"])
|
|
||||||
energy_price_unit = default_price_unit
|
|
||||||
|
|
||||||
if self._last_energy_sensor_state is None:
|
if self._last_energy_sensor_state is None:
|
||||||
# Initialize as it's the first time all required entities are in place.
|
# Initialize as it's the first time all required entities are in place or
|
||||||
|
# only the price is missing. In the later case, cost will update the first
|
||||||
|
# time the energy is updated after the price entity is in place.
|
||||||
self._reset(energy_state)
|
self._reset(energy_state)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if energy_price is None:
|
||||||
|
return
|
||||||
|
|
||||||
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||||
|
|
||||||
if energy_unit is None or energy_unit not in valid_units:
|
if energy_unit is None or energy_unit not in valid_units:
|
||||||
@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity):
|
|||||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||||
cur_value = cast(float, self._attr_native_value)
|
cur_value = cast(float, self._attr_native_value)
|
||||||
|
|
||||||
if energy_price_unit is None:
|
converted_energy_price = self._convert_energy_price(
|
||||||
converted_energy_price = energy_price
|
energy_price, energy_price_unit, energy_unit
|
||||||
else:
|
)
|
||||||
converter: Callable[[float, str, str], float]
|
|
||||||
if energy_unit in VALID_ENERGY_UNITS:
|
|
||||||
converter = unit_conversion.EnergyConverter.convert
|
|
||||||
else:
|
|
||||||
converter = unit_conversion.VolumeConverter.convert
|
|
||||||
|
|
||||||
converted_energy_price = converter(
|
|
||||||
energy_price,
|
|
||||||
energy_unit,
|
|
||||||
energy_price_unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._attr_native_value = (
|
self._attr_native_value = (
|
||||||
cur_value + (energy - old_energy_value) * converted_energy_price
|
cur_value + (energy - old_energy_value) * converted_energy_price
|
||||||
@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
|
|||||||
|
|
||||||
self._last_energy_sensor_state = energy_state
|
self._last_energy_sensor_state = energy_state
|
||||||
|
|
||||||
|
def _get_energy_price(
|
||||||
|
self, valid_units: set[str], default_unit: str | None
|
||||||
|
) -> tuple[float, str | None]:
|
||||||
|
"""Get the energy price.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EntityNotFoundError: When the energy price entity is not found.
|
||||||
|
ValueError: When the entity state is not a valid float.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._config["entity_energy_price"] is None:
|
||||||
|
return cast(float, self._config["number_energy_price"]), default_unit
|
||||||
|
|
||||||
|
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
|
||||||
|
if energy_price_state is None:
|
||||||
|
raise EntityNotFoundError
|
||||||
|
|
||||||
|
energy_price = float(energy_price_state.state)
|
||||||
|
|
||||||
|
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||||
|
).partition("/")[2]
|
||||||
|
|
||||||
|
# For backwards compatibility we don't validate the unit of the price
|
||||||
|
# If it is not valid, we assume it's our default price unit.
|
||||||
|
if energy_price_unit not in valid_units:
|
||||||
|
energy_price_unit = default_unit
|
||||||
|
|
||||||
|
return energy_price, energy_price_unit
|
||||||
|
|
||||||
|
def _convert_energy_price(
|
||||||
|
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
|
||||||
|
) -> float:
|
||||||
|
"""Convert the energy price to the correct unit."""
|
||||||
|
if energy_price_unit is None:
|
||||||
|
return energy_price
|
||||||
|
|
||||||
|
converter: Callable[[float, str, str], float]
|
||||||
|
if energy_unit in VALID_ENERGY_UNITS:
|
||||||
|
converter = unit_conversion.EnergyConverter.convert
|
||||||
|
else:
|
||||||
|
converter = unit_conversion.VolumeConverter.convert
|
||||||
|
|
||||||
|
return converter(energy_price, energy_unit, energy_price_unit)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
|
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
|
||||||
|
@ -16,7 +16,13 @@ from homeassistant.config_entries import (
|
|||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_TOKEN,
|
||||||
|
CONF_USERNAME,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
|
|||||||
|
|
||||||
INSTALLER_AUTH_USERNAME = "installer"
|
INSTALLER_AUTH_USERNAME = "installer"
|
||||||
|
|
||||||
|
AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
|
||||||
|
|
||||||
|
|
||||||
|
def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a dictionary without AVOID_REFLECT_KEYS."""
|
||||||
|
return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS}
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(
|
async def validate_input(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders["serial"] = serial
|
description_placeholders["serial"] = serial
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reauth_confirm",
|
step_id="reauth_confirm",
|
||||||
data_schema=self._async_generate_schema(),
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
self._async_generate_schema(),
|
||||||
|
without_avoid_reflect_keys(user_input or reauth_entry.data),
|
||||||
|
),
|
||||||
description_placeholders=description_placeholders,
|
description_placeholders=description_placeholders,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_SERIAL: self.unique_id,
|
CONF_SERIAL: self.unique_id,
|
||||||
CONF_HOST: host,
|
CONF_HOST: host,
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=self._async_generate_schema(),
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
self._async_generate_schema(),
|
||||||
|
without_avoid_reflect_keys(user_input or {}),
|
||||||
|
),
|
||||||
description_placeholders=description_placeholders,
|
description_placeholders=description_placeholders,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
}
|
}
|
||||||
description_placeholders["serial"] = serial
|
description_placeholders["serial"] = serial
|
||||||
|
|
||||||
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reconfigure",
|
step_id="reconfigure",
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
self._async_generate_schema(), suggested_values
|
self._async_generate_schema(),
|
||||||
|
without_avoid_reflect_keys(user_input or reconfigure_entry.data),
|
||||||
),
|
),
|
||||||
description_placeholders=description_placeholders,
|
description_placeholders=description_placeholders,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
|
@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for end_point in end_points:
|
for end_point in end_points:
|
||||||
response = await envoy.request(end_point)
|
try:
|
||||||
fixture_data[end_point] = response.text.replace("\n", "").replace(
|
response = await envoy.request(end_point)
|
||||||
serial, CLEAN_TEXT
|
fixture_data[end_point] = response.text.replace("\n", "").replace(
|
||||||
)
|
serial, CLEAN_TEXT
|
||||||
fixture_data[f"{end_point}_log"] = json_dumps(
|
)
|
||||||
{
|
fixture_data[f"{end_point}_log"] = json_dumps(
|
||||||
"headers": dict(response.headers.items()),
|
{
|
||||||
"code": response.status_code,
|
"headers": dict(response.headers.items()),
|
||||||
}
|
"code": response.status_code,
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
except EnvoyError as err:
|
||||||
|
fixture_data[f"{end_point}_log"] = {"Error": repr(err)}
|
||||||
return fixture_data
|
return fixture_data
|
||||||
|
|
||||||
|
|
||||||
@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics(
|
|||||||
|
|
||||||
fixture_data: dict[str, Any] = {}
|
fixture_data: dict[str, Any] = {}
|
||||||
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
|
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
|
||||||
try:
|
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
|
||||||
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
|
|
||||||
except EnvoyError as err:
|
|
||||||
fixture_data["Error"] = repr(err)
|
|
||||||
|
|
||||||
diagnostic_data: dict[str, Any] = {
|
diagnostic_data: dict[str, Any] = {
|
||||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyenphase"],
|
"loggers": ["pyenphase"],
|
||||||
"requirements": ["pyenphase==1.25.1"],
|
"requirements": ["pyenphase==1.25.5"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_enphase-envoy._tcp.local."
|
"type": "_enphase-envoy._tcp.local."
|
||||||
|
@ -22,5 +22,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eq3btsmart"],
|
"loggers": ["eq3btsmart"],
|
||||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"]
|
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"]
|
||||||
}
|
}
|
||||||
|
@ -310,12 +310,13 @@ class EsphomeAssistSatellite(
|
|||||||
self.entry_data.api_version
|
self.entry_data.api_version
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if feature_flags & VoiceAssistantFeature.SPEAKER:
|
if feature_flags & VoiceAssistantFeature.SPEAKER and (
|
||||||
media_id = tts_output["media_id"]
|
stream := tts.async_get_stream(self.hass, tts_output["token"])
|
||||||
|
):
|
||||||
self._tts_streaming_task = (
|
self._tts_streaming_task = (
|
||||||
self.config_entry.async_create_background_task(
|
self.config_entry.async_create_background_task(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._stream_tts_audio(media_id),
|
self._stream_tts_audio(stream),
|
||||||
"esphome_voice_assistant_tts",
|
"esphome_voice_assistant_tts",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -564,7 +565,7 @@ class EsphomeAssistSatellite(
|
|||||||
|
|
||||||
async def _stream_tts_audio(
|
async def _stream_tts_audio(
|
||||||
self,
|
self,
|
||||||
media_id: str,
|
tts_result: tts.ResultStream,
|
||||||
sample_rate: int = 16000,
|
sample_rate: int = 16000,
|
||||||
sample_width: int = 2,
|
sample_width: int = 2,
|
||||||
sample_channels: int = 1,
|
sample_channels: int = 1,
|
||||||
@ -579,15 +580,14 @@ class EsphomeAssistSatellite(
|
|||||||
if not self._is_running:
|
if not self._is_running:
|
||||||
return
|
return
|
||||||
|
|
||||||
extension, data = await tts.async_get_media_source_audio(
|
if tts_result.extension != "wav":
|
||||||
self.hass,
|
_LOGGER.error(
|
||||||
media_id,
|
"Only WAV audio can be streamed, got %s", tts_result.extension
|
||||||
)
|
)
|
||||||
|
|
||||||
if extension != "wav":
|
|
||||||
_LOGGER.error("Only WAV audio can be streamed, got %s", extension)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
|
||||||
|
|
||||||
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
|
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
|
||||||
if (
|
if (
|
||||||
(wav_file.getframerate() != sample_rate)
|
(wav_file.getframerate() != sample_rate)
|
||||||
|
@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self._password = ""
|
self._password = ""
|
||||||
return await self._async_authenticate_or_add()
|
return await self._async_authenticate_or_add()
|
||||||
|
|
||||||
|
if error is None and entry_data.get(CONF_NOISE_PSK):
|
||||||
|
return await self.async_step_reauth_encryption_removed_confirm()
|
||||||
return await self.async_step_reauth_confirm()
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_encryption_removed_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle reauthorization flow when encryption was removed."""
|
||||||
|
if user_input is not None:
|
||||||
|
self._noise_psk = None
|
||||||
|
return self._async_get_entry()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_encryption_removed_confirm",
|
||||||
|
description_placeholders={"name": self._name},
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
async def async_step_reauth_confirm(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user