mirror of
https://github.com/home-assistant/core.git
synced 2025-08-09 23:55:07 +02:00
Merge branch 'dev' into heatpump
This commit is contained in:
@@ -73,6 +73,10 @@ omit =
|
|||||||
homeassistant/components/apple_tv/browse_media.py
|
homeassistant/components/apple_tv/browse_media.py
|
||||||
homeassistant/components/apple_tv/media_player.py
|
homeassistant/components/apple_tv/media_player.py
|
||||||
homeassistant/components/apple_tv/remote.py
|
homeassistant/components/apple_tv/remote.py
|
||||||
|
homeassistant/components/aprilaire/__init__.py
|
||||||
|
homeassistant/components/aprilaire/climate.py
|
||||||
|
homeassistant/components/aprilaire/coordinator.py
|
||||||
|
homeassistant/components/aprilaire/entity.py
|
||||||
homeassistant/components/aqualogic/*
|
homeassistant/components/aqualogic/*
|
||||||
homeassistant/components/aquostv/media_player.py
|
homeassistant/components/aquostv/media_player.py
|
||||||
homeassistant/components/arcam_fmj/__init__.py
|
homeassistant/components/arcam_fmj/__init__.py
|
||||||
@@ -484,6 +488,7 @@ omit =
|
|||||||
homeassistant/components/gpsd/sensor.py
|
homeassistant/components/gpsd/sensor.py
|
||||||
homeassistant/components/greenwave/light.py
|
homeassistant/components/greenwave/light.py
|
||||||
homeassistant/components/growatt_server/__init__.py
|
homeassistant/components/growatt_server/__init__.py
|
||||||
|
homeassistant/components/growatt_server/const.py
|
||||||
homeassistant/components/growatt_server/sensor.py
|
homeassistant/components/growatt_server/sensor.py
|
||||||
homeassistant/components/growatt_server/sensor_types/*
|
homeassistant/components/growatt_server/sensor_types/*
|
||||||
homeassistant/components/gstreamer/media_player.py
|
homeassistant/components/gstreamer/media_player.py
|
||||||
@@ -872,6 +877,7 @@ omit =
|
|||||||
homeassistant/components/notion/__init__.py
|
homeassistant/components/notion/__init__.py
|
||||||
homeassistant/components/notion/binary_sensor.py
|
homeassistant/components/notion/binary_sensor.py
|
||||||
homeassistant/components/notion/sensor.py
|
homeassistant/components/notion/sensor.py
|
||||||
|
homeassistant/components/notion/util.py
|
||||||
homeassistant/components/nsw_fuel_station/sensor.py
|
homeassistant/components/nsw_fuel_station/sensor.py
|
||||||
homeassistant/components/nuki/__init__.py
|
homeassistant/components/nuki/__init__.py
|
||||||
homeassistant/components/nuki/binary_sensor.py
|
homeassistant/components/nuki/binary_sensor.py
|
||||||
@@ -1533,6 +1539,7 @@ omit =
|
|||||||
homeassistant/components/vicare/entity.py
|
homeassistant/components/vicare/entity.py
|
||||||
homeassistant/components/vicare/number.py
|
homeassistant/components/vicare/number.py
|
||||||
homeassistant/components/vicare/sensor.py
|
homeassistant/components/vicare/sensor.py
|
||||||
|
homeassistant/components/vicare/types.py
|
||||||
homeassistant/components/vicare/utils.py
|
homeassistant/components/vicare/utils.py
|
||||||
homeassistant/components/vicare/water_heater.py
|
homeassistant/components/vicare/water_heater.py
|
||||||
homeassistant/components/vilfo/__init__.py
|
homeassistant/components/vilfo/__init__.py
|
||||||
@@ -1691,8 +1698,10 @@ omit =
|
|||||||
homeassistant/components/myuplink/__init__.py
|
homeassistant/components/myuplink/__init__.py
|
||||||
homeassistant/components/myuplink/api.py
|
homeassistant/components/myuplink/api.py
|
||||||
homeassistant/components/myuplink/application_credentials.py
|
homeassistant/components/myuplink/application_credentials.py
|
||||||
|
homeassistant/components/myuplink/binary_sensor.py
|
||||||
homeassistant/components/myuplink/coordinator.py
|
homeassistant/components/myuplink/coordinator.py
|
||||||
homeassistant/components/myuplink/entity.py
|
homeassistant/components/myuplink/entity.py
|
||||||
|
homeassistant/components/myuplink/helpers.py
|
||||||
homeassistant/components/myuplink/sensor.py
|
homeassistant/components/myuplink/sensor.py
|
||||||
|
|
||||||
|
|
||||||
|
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -1079,7 +1079,7 @@ jobs:
|
|||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
- name: Upload coverage to Codecov (full coverage)
|
- name: Upload coverage to Codecov (full coverage)
|
||||||
if: needs.info.outputs.test_full_suite == 'true'
|
if: needs.info.outputs.test_full_suite == 'true'
|
||||||
uses: Wandalen/wretry.action@v1.3.0
|
uses: Wandalen/wretry.action@v1.4.4
|
||||||
with:
|
with:
|
||||||
action: codecov/codecov-action@v3.1.3
|
action: codecov/codecov-action@v3.1.3
|
||||||
with: |
|
with: |
|
||||||
@@ -1090,7 +1090,7 @@ jobs:
|
|||||||
attempt_delay: 30000
|
attempt_delay: 30000
|
||||||
- name: Upload coverage to Codecov (partial coverage)
|
- name: Upload coverage to Codecov (partial coverage)
|
||||||
if: needs.info.outputs.test_full_suite == 'false'
|
if: needs.info.outputs.test_full_suite == 'false'
|
||||||
uses: Wandalen/wretry.action@v1.3.0
|
uses: Wandalen/wretry.action@v1.4.4
|
||||||
with:
|
with:
|
||||||
action: codecov/codecov-action@v3.1.3
|
action: codecov/codecov-action@v3.1.3
|
||||||
with: |
|
with: |
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.1.1
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.23.2
|
uses: github/codeql-action/init@v3.24.1
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.23.2
|
uses: github/codeql-action/analyze@v3.24.1
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.1.15
|
rev: v0.2.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args:
|
args:
|
||||||
|
@@ -80,6 +80,7 @@ homeassistant.components.anthemav.*
|
|||||||
homeassistant.components.apache_kafka.*
|
homeassistant.components.apache_kafka.*
|
||||||
homeassistant.components.apcupsd.*
|
homeassistant.components.apcupsd.*
|
||||||
homeassistant.components.api.*
|
homeassistant.components.api.*
|
||||||
|
homeassistant.components.apple_tv.*
|
||||||
homeassistant.components.apprise.*
|
homeassistant.components.apprise.*
|
||||||
homeassistant.components.aprs.*
|
homeassistant.components.aprs.*
|
||||||
homeassistant.components.aqualogic.*
|
homeassistant.components.aqualogic.*
|
||||||
|
@@ -104,6 +104,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/application_credentials/ @home-assistant/core
|
/tests/components/application_credentials/ @home-assistant/core
|
||||||
/homeassistant/components/apprise/ @caronc
|
/homeassistant/components/apprise/ @caronc
|
||||||
/tests/components/apprise/ @caronc
|
/tests/components/apprise/ @caronc
|
||||||
|
/homeassistant/components/aprilaire/ @chamberlain2007
|
||||||
|
/tests/components/aprilaire/ @chamberlain2007
|
||||||
/homeassistant/components/aprs/ @PhilRW
|
/homeassistant/components/aprs/ @PhilRW
|
||||||
/tests/components/aprs/ @PhilRW
|
/tests/components/aprs/ @PhilRW
|
||||||
/homeassistant/components/aranet/ @aschmitz @thecode
|
/homeassistant/components/aranet/ @aschmitz @thecode
|
||||||
@@ -584,6 +586,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/humidifier/ @home-assistant/core @Shulyaka
|
/tests/components/humidifier/ @home-assistant/core @Shulyaka
|
||||||
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||||
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||||
|
/homeassistant/components/husqvarna_automower/ @Thomas55555
|
||||||
|
/tests/components/husqvarna_automower/ @Thomas55555
|
||||||
/homeassistant/components/huum/ @frwickst
|
/homeassistant/components/huum/ @frwickst
|
||||||
/tests/components/huum/ @frwickst
|
/tests/components/huum/ @frwickst
|
||||||
/homeassistant/components/hvv_departures/ @vigonotion
|
/homeassistant/components/hvv_departures/ @vigonotion
|
||||||
@@ -786,8 +790,6 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/media_source/ @hunterjm
|
/homeassistant/components/media_source/ @hunterjm
|
||||||
/tests/components/media_source/ @hunterjm
|
/tests/components/media_source/ @hunterjm
|
||||||
/homeassistant/components/mediaroom/ @dgomes
|
/homeassistant/components/mediaroom/ @dgomes
|
||||||
/homeassistant/components/melcloud/ @vilppuvuorinen
|
|
||||||
/tests/components/melcloud/ @vilppuvuorinen
|
|
||||||
/homeassistant/components/melissa/ @kennedyshead
|
/homeassistant/components/melissa/ @kennedyshead
|
||||||
/tests/components/melissa/ @kennedyshead
|
/tests/components/melissa/ @kennedyshead
|
||||||
/homeassistant/components/melnor/ @vanstinator
|
/homeassistant/components/melnor/ @vanstinator
|
||||||
@@ -1460,7 +1462,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/valve/ @home-assistant/core
|
/tests/components/valve/ @home-assistant/core
|
||||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||||
/tests/components/velbus/ @Cereal2nd @brefra
|
/tests/components/velbus/ @Cereal2nd @brefra
|
||||||
/homeassistant/components/velux/ @Julius2342
|
/homeassistant/components/velux/ @Julius2342 @DeerMaximum
|
||||||
|
/tests/components/velux/ @Julius2342 @DeerMaximum
|
||||||
/homeassistant/components/venstar/ @garbled1 @jhollowe
|
/homeassistant/components/venstar/ @garbled1 @jhollowe
|
||||||
/tests/components/venstar/ @garbled1 @jhollowe
|
/tests/components/venstar/ @garbled1 @jhollowe
|
||||||
/homeassistant/components/versasense/ @imstevenxyz
|
/homeassistant/components/versasense/ @imstevenxyz
|
||||||
|
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0
|
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.1
|
||||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1
|
||||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1
|
||||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.1
|
||||||
codenotary:
|
codenotary:
|
||||||
signer: notary@home-assistant.io
|
signer: notary@home-assistant.io
|
||||||
base_image: notary@home-assistant.io
|
base_image: notary@home-assistant.io
|
||||||
|
@@ -35,6 +35,7 @@ from .helpers import (
|
|||||||
recorder,
|
recorder,
|
||||||
restore_state,
|
restore_state,
|
||||||
template,
|
template,
|
||||||
|
translation,
|
||||||
)
|
)
|
||||||
from .helpers.dispatcher import async_dispatcher_send
|
from .helpers.dispatcher import async_dispatcher_send
|
||||||
from .helpers.typing import ConfigType
|
from .helpers.typing import ConfigType
|
||||||
@@ -217,7 +218,7 @@ async def async_setup_hass(
|
|||||||
)
|
)
|
||||||
# Ask integrations to shut down. It's messy but we can't
|
# Ask integrations to shut down. It's messy but we can't
|
||||||
# do a clean stop without knowing what is broken
|
# do a clean stop without knowing what is broken
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
async with hass.timeout.async_timeout(10):
|
async with hass.timeout.async_timeout(10):
|
||||||
await hass.async_stop()
|
await hass.async_stop()
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
|||||||
platform.uname().processor # pylint: disable=expression-not-assigned
|
platform.uname().processor # pylint: disable=expression-not-assigned
|
||||||
|
|
||||||
# Load the registries and cache the result of platform.uname().processor
|
# Load the registries and cache the result of platform.uname().processor
|
||||||
|
translation.async_setup(hass)
|
||||||
entity.async_setup(hass)
|
entity.async_setup(hass)
|
||||||
template.async_setup(hass)
|
template.async_setup(hass)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
@@ -738,7 +740,7 @@ async def _async_set_up_integrations(
|
|||||||
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
|
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
|
||||||
):
|
):
|
||||||
await async_setup_multi_components(hass, stage_1_domains, config)
|
await async_setup_multi_components(hass, stage_1_domains, config)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning("Setup timed out for stage 1 - moving forward")
|
_LOGGER.warning("Setup timed out for stage 1 - moving forward")
|
||||||
|
|
||||||
# Add after dependencies when setting up stage 2 domains
|
# Add after dependencies when setting up stage 2 domains
|
||||||
@@ -751,7 +753,7 @@ async def _async_set_up_integrations(
|
|||||||
STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
|
STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
|
||||||
):
|
):
|
||||||
await async_setup_multi_components(hass, stage_2_domains, config)
|
await async_setup_multi_components(hass, stage_2_domains, config)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning("Setup timed out for stage 2 - moving forward")
|
_LOGGER.warning("Setup timed out for stage 2 - moving forward")
|
||||||
|
|
||||||
# Wrap up startup
|
# Wrap up startup
|
||||||
@@ -759,7 +761,7 @@ async def _async_set_up_integrations(
|
|||||||
try:
|
try:
|
||||||
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
|
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning("Setup timed out for bootstrap - moving forward")
|
_LOGGER.warning("Setup timed out for bootstrap - moving forward")
|
||||||
|
|
||||||
watch_task.cancel()
|
watch_task.cancel()
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"domain": "tplink",
|
"domain": "tplink",
|
||||||
"name": "TP-Link",
|
"name": "TP-Link",
|
||||||
"integrations": ["tplink", "tplink_omada", "tplink_lte"],
|
"integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"],
|
||||||
"iot_standards": ["matter"]
|
"iot_standards": ["matter"]
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Adds config flow for AccuWeather."""
|
"""Adds config flow for AccuWeather."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
longitude=user_input[CONF_LONGITUDE],
|
longitude=user_input[CONF_LONGITUDE],
|
||||||
)
|
)
|
||||||
await accuweather.async_get_location()
|
await accuweather.async_get_location()
|
||||||
except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError):
|
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidApiKeyError:
|
except InvalidApiKeyError:
|
||||||
errors[CONF_API_KEY] = "invalid_api_key"
|
errors[CONF_API_KEY] = "invalid_api_key"
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for Rollease Acmeda Automate Pulse Hub."""
|
"""Config flow for Rollease Acmeda Automate Pulse Hub."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -42,7 +41,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
}
|
}
|
||||||
|
|
||||||
hubs: list[aiopulse.Hub] = []
|
hubs: list[aiopulse.Hub] = []
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(TimeoutError):
|
||||||
async with timeout(5):
|
async with timeout(5):
|
||||||
async for hub in aiopulse.Hub.discover():
|
async for hub in aiopulse.Hub.discover():
|
||||||
if hub.id not in already_configured:
|
if hub.id not in already_configured:
|
||||||
|
@@ -303,7 +303,7 @@ class AdsEntity(Entity):
|
|||||||
try:
|
try:
|
||||||
async with timeout(10):
|
async with timeout(10):
|
||||||
await self._event.wait()
|
await self._event.wait()
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)
|
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
new_data = entry.data.copy()
|
new_data = entry.data.copy()
|
||||||
del new_data[CONF_RADIUS]
|
del new_data[CONF_RADIUS]
|
||||||
|
|
||||||
entry.version = 2
|
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry, data=new_data, options=new_options
|
entry, data=new_data, options=new_options, version=2
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.info("Migration to version %s successful", entry.version)
|
_LOGGER.info("Migration to version %s successful", entry.version)
|
||||||
|
@@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
|||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.FAN_MODE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
)
|
)
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
def __init__(self, coordinator, ac_number, info):
|
def __init__(self, coordinator, ac_number, info):
|
||||||
"""Initialize the climate device."""
|
"""Initialize the climate device."""
|
||||||
@@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
|||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
_attr_supported_features = (
|
||||||
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
|
)
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_hvac_modes = AT_GROUP_MODES
|
_attr_hvac_modes = AT_GROUP_MODES
|
||||||
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
def __init__(self, coordinator, group_number, info):
|
def __init__(self, coordinator, group_number, info):
|
||||||
"""Initialize the climate device."""
|
"""Initialize the climate device."""
|
||||||
|
@@ -120,15 +120,12 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
|
|||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_target_temperature_step = 1
|
_attr_target_temperature_step = 1
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
|
|
||||||
class Airtouch5AC(Airtouch5ClimateEntity):
|
class Airtouch5AC(Airtouch5ClimateEntity):
|
||||||
"""Representation of the AC unit. Used to control the overall HVAC Mode."""
|
"""Representation of the AC unit. Used to control the overall HVAC Mode."""
|
||||||
|
|
||||||
_attr_supported_features = (
|
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None:
|
def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None:
|
||||||
"""Initialise the Climate Entity."""
|
"""Initialise the Climate Entity."""
|
||||||
super().__init__(client)
|
super().__init__(client)
|
||||||
@@ -152,6 +149,14 @@ class Airtouch5AC(Airtouch5ClimateEntity):
|
|||||||
if ability.supports_mode_heat:
|
if ability.supports_mode_heat:
|
||||||
self._attr_hvac_modes.append(HVACMode.HEAT)
|
self._attr_hvac_modes.append(HVACMode.HEAT)
|
||||||
|
|
||||||
|
self._attr_supported_features = (
|
||||||
|
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
||||||
|
)
|
||||||
|
if len(self.hvac_modes) > 1:
|
||||||
|
self._attr_supported_features |= (
|
||||||
|
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||||
|
)
|
||||||
|
|
||||||
self._attr_fan_modes = []
|
self._attr_fan_modes = []
|
||||||
if ability.supports_fan_speed_quiet:
|
if ability.supports_fan_speed_quiet:
|
||||||
self._attr_fan_modes.append(FAN_DIFFUSE)
|
self._attr_fan_modes.append(FAN_DIFFUSE)
|
||||||
@@ -262,7 +267,10 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
|
|||||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY]
|
_attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY]
|
||||||
_attr_preset_modes = [PRESET_NONE, PRESET_BOOST]
|
_attr_preset_modes = [PRESET_NONE, PRESET_BOOST]
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.PRESET_MODE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@@ -242,7 +242,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
# 1 -> 2: One geography per config entry
|
# 1 -> 2: One geography per config entry
|
||||||
if version == 1:
|
if version == 1:
|
||||||
version = entry.version = 2
|
version = 2
|
||||||
|
|
||||||
# Update the config entry to only include the first geography (there is always
|
# Update the config entry to only include the first geography (there is always
|
||||||
# guaranteed to be at least one):
|
# guaranteed to be at least one):
|
||||||
@@ -255,6 +255,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
unique_id=first_id,
|
unique_id=first_id,
|
||||||
title=f"Cloud API ({first_id})",
|
title=f"Cloud API ({first_id})",
|
||||||
data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography},
|
data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography},
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
|
|
||||||
# For any geographies that remain, create a new config entry for each one:
|
# For any geographies that remain, create a new config entry for each one:
|
||||||
@@ -379,7 +380,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
entry.version = version
|
hass.config_entries.async_update_entry(entry, version=version)
|
||||||
|
|
||||||
LOGGER.info("Migration to version %s successful", version)
|
LOGGER.info("Migration to version %s successful", version)
|
||||||
|
|
||||||
|
@@ -144,11 +144,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
|
|||||||
"""Define an Airzone Cloud climate."""
|
"""Define an Airzone Cloud climate."""
|
||||||
|
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
_attr_supported_features = (
|
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
|
||||||
| ClimateEntityFeature.TURN_OFF
|
|
||||||
| ClimateEntityFeature.TURN_ON
|
|
||||||
)
|
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
@@ -180,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
|
|||||||
class AirzoneDeviceClimate(AirzoneClimate):
|
class AirzoneDeviceClimate(AirzoneClimate):
|
||||||
"""Define an Airzone Cloud Device base class."""
|
"""Define an Airzone Cloud Device base class."""
|
||||||
|
|
||||||
|
_attr_supported_features = (
|
||||||
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
|
)
|
||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
params = {
|
params = {
|
||||||
@@ -217,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate):
|
|||||||
class AirzoneDeviceGroupClimate(AirzoneClimate):
|
class AirzoneDeviceGroupClimate(AirzoneClimate):
|
||||||
"""Define an Airzone Cloud DeviceGroup base class."""
|
"""Define an Airzone Cloud DeviceGroup base class."""
|
||||||
|
|
||||||
|
_attr_supported_features = (
|
||||||
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
|
)
|
||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
params = {
|
params = {
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
"""The aladdin_connect component."""
|
"""The aladdin_connect component."""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await acc.login()
|
await acc.login()
|
||||||
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex:
|
except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex:
|
||||||
raise ConfigEntryNotReady("Can not connect to host") from ex
|
raise ConfigEntryNotReady("Can not connect to host") from ex
|
||||||
except Aladdin.InvalidPasswordError as ex:
|
except Aladdin.InvalidPasswordError as ex:
|
||||||
raise ConfigEntryAuthFailed("Incorrect Password") from ex
|
raise ConfigEntryAuthFailed("Incorrect Password") from ex
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for Aladdin Connect cover integration."""
|
"""Config flow for Aladdin Connect cover integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await acc.login()
|
await acc.login()
|
||||||
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex:
|
except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex:
|
||||||
raise ex
|
raise ex
|
||||||
|
|
||||||
except Aladdin.InvalidPasswordError as ex:
|
except Aladdin.InvalidPasswordError as ex:
|
||||||
@@ -81,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
|
|
||||||
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError):
|
except (ClientError, TimeoutError, Aladdin.ConnectionError):
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -117,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
|
|
||||||
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError):
|
except (ClientError, TimeoutError, Aladdin.ConnectionError):
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@@ -122,7 +122,7 @@ class Auth:
|
|||||||
allow_redirects=True,
|
allow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
_LOGGER.error("Timeout calling LWA to get auth token")
|
_LOGGER.error("Timeout calling LWA to get auth token")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@@ -29,12 +29,20 @@ class AbstractConfig(ABC):
|
|||||||
"""Initialize abstract config."""
|
"""Initialize abstract config."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._enable_proactive_mode_lock = asyncio.Lock()
|
self._enable_proactive_mode_lock = asyncio.Lock()
|
||||||
|
self._on_deinitialize: list[CALLBACK_TYPE] = []
|
||||||
|
|
||||||
async def async_initialize(self) -> None:
|
async def async_initialize(self) -> None:
|
||||||
"""Perform async initialization of config."""
|
"""Perform async initialization of config."""
|
||||||
self._store = AlexaConfigStore(self.hass)
|
self._store = AlexaConfigStore(self.hass)
|
||||||
await self._store.async_load()
|
await self._store.async_load()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_deinitialize(self) -> None:
|
||||||
|
"""Remove listeners."""
|
||||||
|
_LOGGER.debug("async_deinitialize")
|
||||||
|
while self._on_deinitialize:
|
||||||
|
self._on_deinitialize.pop()()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_auth(self) -> bool:
|
def supports_auth(self) -> bool:
|
||||||
"""Return if config supports auth."""
|
"""Return if config supports auth."""
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Alexa state report code."""
|
"""Alexa state report code."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
@@ -375,7 +374,7 @@ async def async_send_changereport_message(
|
|||||||
allow_redirects=True,
|
allow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
|
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -531,7 +530,7 @@ async def async_send_doorbell_event_message(
|
|||||||
allow_redirects=True,
|
allow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
|
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@@ -3,18 +3,46 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import amberelectric
|
import amberelectric
|
||||||
from amberelectric.api import amber_api
|
from amberelectric.api import amber_api
|
||||||
from amberelectric.model.site import Site
|
from amberelectric.model.site import Site, SiteStatus
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_API_TOKEN
|
from homeassistant.const import CONF_API_TOKEN
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
SelectOptionDict,
|
||||||
|
SelectSelector,
|
||||||
|
SelectSelectorConfig,
|
||||||
|
SelectSelectorMode,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN
|
from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN
|
||||||
|
|
||||||
API_URL = "https://app.amber.com.au/developers"
|
API_URL = "https://app.amber.com.au/developers"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_site_selector_name(site: Site) -> str:
|
||||||
|
"""Generate the name to show in the site drop down in the configuration flow."""
|
||||||
|
if site.status == SiteStatus.CLOSED:
|
||||||
|
return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return]
|
||||||
|
if site.status == SiteStatus.PENDING:
|
||||||
|
return site.nmi + " (Pending)" # type: ignore[no-any-return]
|
||||||
|
return site.nmi # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_sites(sites: list[Site]) -> list[Site]:
|
||||||
|
"""Deduplicates the list of sites."""
|
||||||
|
filtered: list[Site] = []
|
||||||
|
filtered_nmi: set[str] = set()
|
||||||
|
|
||||||
|
for site in sorted(sites, key=lambda site: site.status.value):
|
||||||
|
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
|
||||||
|
filtered.append(site)
|
||||||
|
filtered_nmi.add(site.nmi)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow."""
|
"""Handle a config flow."""
|
||||||
|
|
||||||
@@ -31,7 +59,7 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
|
api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sites: list[Site] = api.get_sites()
|
sites: list[Site] = filter_sites(api.get_sites())
|
||||||
if len(sites) == 0:
|
if len(sites) == 0:
|
||||||
self._errors[CONF_API_TOKEN] = "no_site"
|
self._errors[CONF_API_TOKEN] = "no_site"
|
||||||
return None
|
return None
|
||||||
@@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
assert self._sites is not None
|
assert self._sites is not None
|
||||||
assert self._api_token is not None
|
assert self._api_token is not None
|
||||||
|
|
||||||
api_token = self._api_token
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
site_nmi = user_input[CONF_SITE_NMI]
|
site_id = user_input[CONF_SITE_ID]
|
||||||
sites = [site for site in self._sites if site.nmi == site_nmi]
|
|
||||||
site = sites[0]
|
|
||||||
site_id = site.id
|
|
||||||
name = user_input.get(CONF_SITE_NAME, site_id)
|
name = user_input.get(CONF_SITE_NAME, site_id)
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
data={
|
data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token},
|
||||||
CONF_SITE_ID: site_id,
|
|
||||||
CONF_API_TOKEN: api_token,
|
|
||||||
CONF_SITE_NMI: site.nmi,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
user_input = {
|
|
||||||
CONF_API_TOKEN: api_token,
|
|
||||||
CONF_SITE_NMI: "",
|
|
||||||
CONF_SITE_NAME: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="site",
|
step_id="site",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(
|
vol.Required(CONF_SITE_ID): SelectSelector(
|
||||||
CONF_SITE_NMI, default=user_input[CONF_SITE_NMI]
|
SelectSelectorConfig(
|
||||||
): vol.In([site.nmi for site in self._sites]),
|
options=[
|
||||||
vol.Optional(
|
SelectOptionDict(
|
||||||
CONF_SITE_NAME, default=user_input[CONF_SITE_NAME]
|
value=site.id,
|
||||||
): str,
|
label=generate_site_selector_name(site),
|
||||||
|
)
|
||||||
|
for site in self._sites
|
||||||
|
],
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_SITE_NAME): str,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
errors=self._errors,
|
errors=self._errors,
|
||||||
|
@@ -6,7 +6,6 @@ from homeassistant.const import Platform
|
|||||||
DOMAIN = "amberelectric"
|
DOMAIN = "amberelectric"
|
||||||
CONF_SITE_NAME = "site_name"
|
CONF_SITE_NAME = "site_name"
|
||||||
CONF_SITE_ID = "site_id"
|
CONF_SITE_ID = "site_id"
|
||||||
CONF_SITE_NMI = "site_nmi"
|
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by Amber Electric"
|
ATTRIBUTION = "Data provided by Amber Electric"
|
||||||
|
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
|
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["amberelectric"],
|
"loggers": ["amberelectric"],
|
||||||
"requirements": ["amberelectric==1.0.4"]
|
"requirements": ["amberelectric==1.1.0"]
|
||||||
}
|
}
|
||||||
|
@@ -111,7 +111,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
en_reg = er.async_get(hass)
|
en_reg = er.async_get(hass)
|
||||||
en_reg.async_clear_config_entry(entry.entry_id)
|
en_reg.async_clear_config_entry(entry.entry_id)
|
||||||
|
|
||||||
version = entry.version = 2
|
version = 2
|
||||||
|
hass.config_entries.async_update_entry(entry, version=version)
|
||||||
|
|
||||||
LOGGER.info("Migration to version %s successful", version)
|
LOGGER.info("Migration to version %s successful", version)
|
||||||
|
|
||||||
|
@@ -329,7 +329,7 @@ class Analytics:
|
|||||||
response.status,
|
response.status,
|
||||||
self.endpoint,
|
self.endpoint,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
LOGGER.error(
|
LOGGER.error(
|
||||||
|
@@ -3,11 +3,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from python_homeassistant_analytics import HomeassistantAnalyticsClient
|
from python_homeassistant_analytics import (
|
||||||
|
HomeassistantAnalyticsClient,
|
||||||
|
HomeassistantAnalyticsConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN
|
from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN
|
||||||
@@ -28,7 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Set up Homeassistant Analytics from a config entry."""
|
"""Set up Homeassistant Analytics from a config entry."""
|
||||||
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
|
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
|
||||||
|
|
||||||
integrations = await client.get_integrations()
|
try:
|
||||||
|
integrations = await client.get_integrations()
|
||||||
|
except HomeassistantAnalyticsConnectionError as ex:
|
||||||
|
raise ConfigEntryNotReady("Could not fetch integration list") from ex
|
||||||
|
|
||||||
names = {}
|
names = {}
|
||||||
for integration in entry.options[CONF_TRACKED_INTEGRATIONS]:
|
for integration in entry.options[CONF_TRACKED_INTEGRATIONS]:
|
||||||
|
@@ -53,10 +53,25 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
self._async_abort_entries_match()
|
self._async_abort_entries_match()
|
||||||
if user_input:
|
errors: dict[str, str] = {}
|
||||||
return self.async_create_entry(
|
if user_input is not None:
|
||||||
title="Home Assistant Analytics Insights", data={}, options=user_input
|
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||||
)
|
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||||
|
):
|
||||||
|
errors["base"] = "no_integrations_selected"
|
||||||
|
else:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Home Assistant Analytics Insights",
|
||||||
|
data={},
|
||||||
|
options={
|
||||||
|
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||||
|
CONF_TRACKED_INTEGRATIONS, []
|
||||||
|
),
|
||||||
|
CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get(
|
||||||
|
CONF_TRACKED_CUSTOM_INTEGRATIONS, []
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client = HomeassistantAnalyticsClient(
|
client = HomeassistantAnalyticsClient(
|
||||||
session=async_get_clientsession(self.hass)
|
session=async_get_clientsession(self.hass)
|
||||||
@@ -78,16 +93,17 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
]
|
]
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
|
errors=errors,
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=options,
|
options=options,
|
||||||
multiple=True,
|
multiple=True,
|
||||||
sort=True,
|
sort=True,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=list(custom_integrations),
|
options=list(custom_integrations),
|
||||||
multiple=True,
|
multiple=True,
|
||||||
@@ -106,8 +122,24 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Manage the options."""
|
"""Manage the options."""
|
||||||
if user_input:
|
errors: dict[str, str] = {}
|
||||||
return self.async_create_entry(title="", data=user_input)
|
if user_input is not None:
|
||||||
|
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||||
|
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||||
|
):
|
||||||
|
errors["base"] = "no_integrations_selected"
|
||||||
|
else:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="",
|
||||||
|
data={
|
||||||
|
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||||
|
CONF_TRACKED_INTEGRATIONS, []
|
||||||
|
),
|
||||||
|
CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get(
|
||||||
|
CONF_TRACKED_CUSTOM_INTEGRATIONS, []
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client = HomeassistantAnalyticsClient(
|
client = HomeassistantAnalyticsClient(
|
||||||
session=async_get_clientsession(self.hass)
|
session=async_get_clientsession(self.hass)
|
||||||
@@ -129,17 +161,18 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
|||||||
]
|
]
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
|
errors=errors,
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=options,
|
options=options,
|
||||||
multiple=True,
|
multiple=True,
|
||||||
sort=True,
|
sort=True,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=list(custom_integrations),
|
options=list(custom_integrations),
|
||||||
multiple=True,
|
multiple=True,
|
||||||
|
@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
@@ -93,6 +94,7 @@ class HomeassistantAnalyticsSensor(
|
|||||||
"""Home Assistant Analytics Sensor."""
|
"""Home Assistant Analytics Sensor."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
entity_description: AnalyticsSensorEntityDescription
|
entity_description: AnalyticsSensorEntityDescription
|
||||||
|
|
||||||
|
@@ -3,25 +3,41 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"tracked_integrations": "Integrations"
|
"tracked_integrations": "Integrations",
|
||||||
|
"tracked_custom_integrations": "Custom integrations"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"tracked_integrations": "Select the integrations you want to track",
|
||||||
|
"tracked_custom_integrations": "Select the custom integrations you want to track"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"no_integration_selected": "You must select at least one integration to track"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]"
|
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]",
|
||||||
|
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]",
|
||||||
|
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""The Android TV Remote integration."""
|
"""The Android TV Remote integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
except InvalidAuth as exc:
|
except InvalidAuth as exc:
|
||||||
# The Android TV is hard reset or the certificate and key files were deleted.
|
# The Android TV is hard reset or the certificate and key files were deleted.
|
||||||
raise ConfigEntryAuthFailed from exc
|
raise ConfigEntryAuthFailed from exc
|
||||||
except (CannotConnect, ConnectionClosed, asyncio.TimeoutError) as exc:
|
except (CannotConnect, ConnectionClosed, TimeoutError) as exc:
|
||||||
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
|
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
|
||||||
# later. If device gets a new IP address the zeroconf flow will update the config.
|
# later. If device gets a new IP address the zeroconf flow will update the config.
|
||||||
raise ConfigEntryNotReady from exc
|
raise ConfigEntryNotReady from exc
|
||||||
|
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["py-aosmith==1.0.6"]
|
"requirements": ["py-aosmith==1.0.8"]
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for APCUPSd integration."""
|
"""Config flow for APCUPSd integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -54,7 +53,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
coordinator = APCUPSdCoordinator(self.hass, host, port)
|
coordinator = APCUPSdCoordinator(self.hass, host, port)
|
||||||
await coordinator.async_request_refresh()
|
await coordinator.async_request_refresh()
|
||||||
|
|
||||||
if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)):
|
if isinstance(coordinator.last_exception, (UpdateFailed, TimeoutError)):
|
||||||
errors = {"base": "cannot_connect"}
|
errors = {"base": "cannot_connect"}
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=_SCHEMA, errors=errors
|
step_id="user", data_schema=_SCHEMA, errors=errors
|
||||||
|
@@ -175,7 +175,7 @@ class APIEventStream(HomeAssistantView):
|
|||||||
msg = f"data: {payload}\n\n"
|
msg = f"data: {payload}\n\n"
|
||||||
_LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip())
|
_LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip())
|
||||||
await response.write(msg.encode("UTF-8"))
|
await response.write(msg.encode("UTF-8"))
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
await to_write.put(STREAM_PING_PAYLOAD)
|
await to_write.put(STREAM_PING_PAYLOAD)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -222,7 +222,7 @@ class APIStatesView(HomeAssistantView):
|
|||||||
if entity_perm(state.entity_id, "read")
|
if entity_perm(state.entity_id, "read")
|
||||||
)
|
)
|
||||||
response = web.Response(
|
response = web.Response(
|
||||||
body=b"[" + b",".join(states) + b"]",
|
body=b"".join((b"[", b",".join(states), b"]")),
|
||||||
content_type=CONTENT_TYPE_JSON,
|
content_type=CONTENT_TYPE_JSON,
|
||||||
zlib_executor_size=32768,
|
zlib_executor_size=32768,
|
||||||
)
|
)
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
"""The Apple TV integration."""
|
"""The Apple TV integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from random import randrange
|
from random import randrange
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from pyatv import connect, exceptions, scan
|
from pyatv import connect, exceptions, scan
|
||||||
from pyatv.conf import AppleTV
|
from pyatv.conf import AppleTV
|
||||||
@@ -25,8 +27,8 @@ from homeassistant.const import (
|
|||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
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 homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
@@ -40,7 +42,8 @@ from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_NAME = "Apple TV"
|
DEFAULT_NAME_TV = "Apple TV"
|
||||||
|
DEFAULT_NAME_HP = "HomePod"
|
||||||
|
|
||||||
BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
|
BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
|
||||||
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||||
@@ -56,14 +59,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
manager = AppleTVManager(hass, entry)
|
manager = AppleTVManager(hass, entry)
|
||||||
|
|
||||||
if manager.is_on:
|
if manager.is_on:
|
||||||
await manager.connect_once(raise_missing_credentials=True)
|
address = entry.data[CONF_ADDRESS]
|
||||||
if not manager.atv:
|
|
||||||
address = entry.data[CONF_ADDRESS]
|
try:
|
||||||
raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery")
|
await manager.async_first_connect()
|
||||||
|
except (
|
||||||
|
exceptions.AuthenticationError,
|
||||||
|
exceptions.InvalidCredentialsError,
|
||||||
|
exceptions.NoCredentialsError,
|
||||||
|
) as ex:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
f"{address}: Authentication failed, try reconfiguring device: {ex}"
|
||||||
|
) from ex
|
||||||
|
except (
|
||||||
|
asyncio.CancelledError,
|
||||||
|
exceptions.ConnectionLostError,
|
||||||
|
exceptions.ConnectionFailedError,
|
||||||
|
) as ex:
|
||||||
|
raise ConfigEntryNotReady(f"{address}: {ex}") from ex
|
||||||
|
except (
|
||||||
|
exceptions.ProtocolError,
|
||||||
|
exceptions.NoServiceError,
|
||||||
|
exceptions.PairingError,
|
||||||
|
exceptions.BackOffError,
|
||||||
|
exceptions.DeviceIdMissingError,
|
||||||
|
) as ex:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error setting up apple_tv at %s: %s", address, ex, exc_info=ex
|
||||||
|
)
|
||||||
|
raise ConfigEntryNotReady(f"{address}: {ex}") from ex
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager
|
hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager
|
||||||
|
|
||||||
async def on_hass_stop(event):
|
async def on_hass_stop(event: Event) -> None:
|
||||||
"""Stop push updates when hass stops."""
|
"""Stop push updates when hass stops."""
|
||||||
await manager.disconnect()
|
await manager.disconnect()
|
||||||
|
|
||||||
@@ -94,33 +122,29 @@ class AppleTVEntity(Entity):
|
|||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
atv: AppleTVInterface | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
|
||||||
self, name: str, identifier: str | None, manager: "AppleTVManager"
|
|
||||||
) -> None:
|
|
||||||
"""Initialize device."""
|
"""Initialize device."""
|
||||||
self.atv: AppleTVInterface = None # type: ignore[assignment]
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert identifier is not None
|
|
||||||
self._attr_unique_id = identifier
|
self._attr_unique_id = identifier
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, identifier)},
|
identifiers={(DOMAIN, identifier)},
|
||||||
name=name,
|
name=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle when an entity is about to be added to Home Assistant."""
|
"""Handle when an entity is about to be added to Home Assistant."""
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_connected(atv):
|
def _async_connected(atv: AppleTVInterface) -> None:
|
||||||
"""Handle that a connection was made to a device."""
|
"""Handle that a connection was made to a device."""
|
||||||
self.atv = atv
|
self.atv = atv
|
||||||
self.async_device_connected(atv)
|
self.async_device_connected(atv)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_disconnected():
|
def _async_disconnected() -> None:
|
||||||
"""Handle that a connection to a device was lost."""
|
"""Handle that a connection to a device was lost."""
|
||||||
self.async_device_disconnected()
|
self.async_device_disconnected()
|
||||||
self.atv = None
|
self.atv = None
|
||||||
@@ -143,10 +167,10 @@ class AppleTVEntity(Entity):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def async_device_connected(self, atv):
|
def async_device_connected(self, atv: AppleTVInterface) -> None:
|
||||||
"""Handle when connection is made to device."""
|
"""Handle when connection is made to device."""
|
||||||
|
|
||||||
def async_device_disconnected(self):
|
def async_device_disconnected(self) -> None:
|
||||||
"""Handle when connection was lost to device."""
|
"""Handle when connection was lost to device."""
|
||||||
|
|
||||||
|
|
||||||
@@ -158,22 +182,23 @@ class AppleTVManager(DeviceListener):
|
|||||||
in case of problems.
|
in case of problems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
atv: AppleTVInterface | None = None
|
||||||
|
_connection_attempts = 0
|
||||||
|
_connection_was_lost = False
|
||||||
|
_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||||
"""Initialize power manager."""
|
"""Initialize power manager."""
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.atv: AppleTVInterface | None = None
|
|
||||||
self.is_on = not config_entry.options.get(CONF_START_OFF, False)
|
self.is_on = not config_entry.options.get(CONF_START_OFF, False)
|
||||||
self._connection_attempts = 0
|
|
||||||
self._connection_was_lost = False
|
|
||||||
self._task = None
|
|
||||||
|
|
||||||
async def init(self):
|
async def init(self) -> None:
|
||||||
"""Initialize power management."""
|
"""Initialize power management."""
|
||||||
if self.is_on:
|
if self.is_on:
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
|
||||||
def connection_lost(self, _):
|
def connection_lost(self, exception: Exception) -> None:
|
||||||
"""Device was unexpectedly disconnected.
|
"""Device was unexpectedly disconnected.
|
||||||
|
|
||||||
This is a callback function from pyatv.interface.DeviceListener.
|
This is a callback function from pyatv.interface.DeviceListener.
|
||||||
@@ -184,14 +209,14 @@ class AppleTVManager(DeviceListener):
|
|||||||
self._connection_was_lost = True
|
self._connection_was_lost = True
|
||||||
self._handle_disconnect()
|
self._handle_disconnect()
|
||||||
|
|
||||||
def connection_closed(self):
|
def connection_closed(self) -> None:
|
||||||
"""Device connection was (intentionally) closed.
|
"""Device connection was (intentionally) closed.
|
||||||
|
|
||||||
This is a callback function from pyatv.interface.DeviceListener.
|
This is a callback function from pyatv.interface.DeviceListener.
|
||||||
"""
|
"""
|
||||||
self._handle_disconnect()
|
self._handle_disconnect()
|
||||||
|
|
||||||
def _handle_disconnect(self):
|
def _handle_disconnect(self) -> None:
|
||||||
"""Handle that the device disconnected and restart connect loop."""
|
"""Handle that the device disconnected and restart connect loop."""
|
||||||
if self.atv:
|
if self.atv:
|
||||||
self.atv.close()
|
self.atv.close()
|
||||||
@@ -199,12 +224,12 @@ class AppleTVManager(DeviceListener):
|
|||||||
self._dispatch_send(SIGNAL_DISCONNECTED)
|
self._dispatch_send(SIGNAL_DISCONNECTED)
|
||||||
self._start_connect_loop()
|
self._start_connect_loop()
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> None:
|
||||||
"""Connect to device."""
|
"""Connect to device."""
|
||||||
self.is_on = True
|
self.is_on = True
|
||||||
self._start_connect_loop()
|
self._start_connect_loop()
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from device."""
|
"""Disconnect from device."""
|
||||||
_LOGGER.debug("Disconnecting from device")
|
_LOGGER.debug("Disconnecting from device")
|
||||||
self.is_on = False
|
self.is_on = False
|
||||||
@@ -218,7 +243,7 @@ class AppleTVManager(DeviceListener):
|
|||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("An error occurred while disconnecting")
|
_LOGGER.exception("An error occurred while disconnecting")
|
||||||
|
|
||||||
def _start_connect_loop(self):
|
def _start_connect_loop(self) -> None:
|
||||||
"""Start background connect loop to device."""
|
"""Start background connect loop to device."""
|
||||||
if not self._task and self.atv is None and self.is_on:
|
if not self._task and self.atv is None and self.is_on:
|
||||||
self._task = asyncio.create_task(self._connect_loop())
|
self._task = asyncio.create_task(self._connect_loop())
|
||||||
@@ -227,11 +252,25 @@ class AppleTVManager(DeviceListener):
|
|||||||
"Not starting connect loop (%s, %s)", self.atv is None, self.is_on
|
"Not starting connect loop (%s, %s)", self.atv is None, self.is_on
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _connect_once(self, raise_missing_credentials: bool) -> None:
|
||||||
|
"""Connect to device once."""
|
||||||
|
if conf := await self._scan():
|
||||||
|
await self._connect(conf, raise_missing_credentials)
|
||||||
|
|
||||||
|
async def async_first_connect(self) -> None:
|
||||||
|
"""Connect to device for the first time."""
|
||||||
|
connect_ok = False
|
||||||
|
try:
|
||||||
|
await self._connect_once(raise_missing_credentials=True)
|
||||||
|
connect_ok = True
|
||||||
|
finally:
|
||||||
|
if not connect_ok:
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
async def connect_once(self, raise_missing_credentials: bool) -> None:
|
async def connect_once(self, raise_missing_credentials: bool) -> None:
|
||||||
"""Try to connect once."""
|
"""Try to connect once."""
|
||||||
try:
|
try:
|
||||||
if conf := await self._scan():
|
await self._connect_once(raise_missing_credentials)
|
||||||
await self._connect(conf, raise_missing_credentials)
|
|
||||||
except exceptions.AuthenticationError:
|
except exceptions.AuthenticationError:
|
||||||
self.config_entry.async_start_reauth(self.hass)
|
self.config_entry.async_start_reauth(self.hass)
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
@@ -244,9 +283,9 @@ class AppleTVManager(DeviceListener):
|
|||||||
pass
|
pass
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Failed to connect")
|
_LOGGER.exception("Failed to connect")
|
||||||
self.atv = None
|
await self.disconnect()
|
||||||
|
|
||||||
async def _connect_loop(self):
|
async def _connect_loop(self) -> None:
|
||||||
"""Connect loop background task function."""
|
"""Connect loop background task function."""
|
||||||
_LOGGER.debug("Starting connect loop")
|
_LOGGER.debug("Starting connect loop")
|
||||||
|
|
||||||
@@ -255,7 +294,8 @@ class AppleTVManager(DeviceListener):
|
|||||||
while self.is_on and self.atv is None:
|
while self.is_on and self.atv is None:
|
||||||
await self.connect_once(raise_missing_credentials=False)
|
await self.connect_once(raise_missing_credentials=False)
|
||||||
if self.atv is not None:
|
if self.atv is not None:
|
||||||
break
|
# Calling self.connect_once may have set self.atv
|
||||||
|
break # type: ignore[unreachable]
|
||||||
self._connection_attempts += 1
|
self._connection_attempts += 1
|
||||||
backoff = min(
|
backoff = min(
|
||||||
max(
|
max(
|
||||||
@@ -352,13 +392,17 @@ class AppleTVManager(DeviceListener):
|
|||||||
self._connection_was_lost = False
|
self._connection_was_lost = False
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_setup_device_registry(self):
|
def _async_setup_device_registry(self) -> None:
|
||||||
attrs = {
|
attrs = {
|
||||||
ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)},
|
ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)},
|
||||||
ATTR_MANUFACTURER: "Apple",
|
ATTR_MANUFACTURER: "Apple",
|
||||||
ATTR_NAME: self.config_entry.data[CONF_NAME],
|
ATTR_NAME: self.config_entry.data[CONF_NAME],
|
||||||
}
|
}
|
||||||
attrs[ATTR_SUGGESTED_AREA] = attrs[ATTR_NAME].removesuffix(f" {DEFAULT_NAME}")
|
attrs[ATTR_SUGGESTED_AREA] = (
|
||||||
|
attrs[ATTR_NAME]
|
||||||
|
.removesuffix(f" {DEFAULT_NAME_TV}")
|
||||||
|
.removesuffix(f" {DEFAULT_NAME_HP}")
|
||||||
|
)
|
||||||
|
|
||||||
if self.atv:
|
if self.atv:
|
||||||
dev_info = self.atv.device_info
|
dev_info = self.atv.device_info
|
||||||
@@ -379,18 +423,18 @@ class AppleTVManager(DeviceListener):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connecting(self):
|
def is_connecting(self) -> bool:
|
||||||
"""Return true if connection is in progress."""
|
"""Return true if connection is in progress."""
|
||||||
return self._task is not None
|
return self._task is not None
|
||||||
|
|
||||||
def _address_updated(self, address):
|
def _address_updated(self, address: str) -> None:
|
||||||
"""Update cached address in config entry."""
|
"""Update cached address in config entry."""
|
||||||
_LOGGER.debug("Changing address to %s", address)
|
_LOGGER.debug("Changing address to %s", address)
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
self.config_entry, data={**self.config_entry.data, CONF_ADDRESS: address}
|
self.config_entry, data={**self.config_entry.data, CONF_ADDRESS: address}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _dispatch_send(self, signal, *args):
|
def _dispatch_send(self, signal: str, *args: Any) -> None:
|
||||||
"""Dispatch a signal to all entities managed by this manager."""
|
"""Dispatch a signal to all entities managed by this manager."""
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
self.hass, f"{signal}_{self.config_entry.unique_id}", *args
|
self.hass, f"{signal}_{self.config_entry.unique_id}", *args
|
||||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
from random import randrange
|
from random import randrange
|
||||||
@@ -13,12 +13,13 @@ from pyatv import exceptions, pair, scan
|
|||||||
from pyatv.const import DeviceModel, PairingRequirement, Protocol
|
from pyatv.const import DeviceModel, PairingRequirement, Protocol
|
||||||
from pyatv.convert import model_str, protocol_str
|
from pyatv.convert import model_str, protocol_str
|
||||||
from pyatv.helpers import get_unique_id
|
from pyatv.helpers import get_unique_id
|
||||||
|
from pyatv.interface import BaseConfig, PairingHandler
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components import zeroconf
|
from homeassistant.components import zeroconf
|
||||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN
|
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||||
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
|
||||||
@@ -49,10 +50,12 @@ OPTIONS_FLOW = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def device_scan(hass, identifier, loop):
|
async def device_scan(
|
||||||
|
hass: HomeAssistant, identifier: str | None, loop: asyncio.AbstractEventLoop
|
||||||
|
) -> tuple[BaseConfig | None, list[str] | None]:
|
||||||
"""Scan for a specific device using identifier as filter."""
|
"""Scan for a specific device using identifier as filter."""
|
||||||
|
|
||||||
def _filter_device(dev):
|
def _filter_device(dev: BaseConfig) -> bool:
|
||||||
if identifier is None:
|
if identifier is None:
|
||||||
return True
|
return True
|
||||||
if identifier == str(dev.address):
|
if identifier == str(dev.address):
|
||||||
@@ -61,9 +64,12 @@ async def device_scan(hass, identifier, loop):
|
|||||||
return True
|
return True
|
||||||
return any(service.identifier == identifier for service in dev.services)
|
return any(service.identifier == identifier for service in dev.services)
|
||||||
|
|
||||||
def _host_filter():
|
def _host_filter() -> list[str] | None:
|
||||||
|
if identifier is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return [ip_address(identifier)]
|
ip_address(identifier)
|
||||||
|
return [identifier]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -84,6 +90,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
|
scan_filter: str | None = None
|
||||||
|
atv: BaseConfig | None = None
|
||||||
|
atv_identifiers: list[str] | None = None
|
||||||
|
protocol: Protocol | None = None
|
||||||
|
pairing: PairingHandler | None = None
|
||||||
|
protocols_to_pair: deque[Protocol] | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
@@ -92,18 +105,12 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get options flow for this handler."""
|
"""Get options flow for this handler."""
|
||||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
"""Initialize a new AppleTVConfigFlow."""
|
"""Initialize a new AppleTVConfigFlow."""
|
||||||
self.scan_filter = None
|
self.credentials: dict[int, str | None] = {} # Protocol -> credentials
|
||||||
self.atv = None
|
|
||||||
self.atv_identifiers = None
|
|
||||||
self.protocol = None
|
|
||||||
self.pairing = None
|
|
||||||
self.credentials = {} # Protocol -> credentials
|
|
||||||
self.protocols_to_pair = deque()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_identifier(self):
|
def device_identifier(self) -> str | None:
|
||||||
"""Return a identifier for the config entry.
|
"""Return a identifier for the config entry.
|
||||||
|
|
||||||
A device has multiple unique identifiers, but Home Assistant only supports one
|
A device has multiple unique identifiers, but Home Assistant only supports one
|
||||||
@@ -118,6 +125,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
existing config entry. If that's the case, the unique_id from that entry is
|
existing config entry. If that's the case, the unique_id from that entry is
|
||||||
re-used, otherwise the newly discovered identifier is used instead.
|
re-used, otherwise the newly discovered identifier is used instead.
|
||||||
"""
|
"""
|
||||||
|
assert self.atv
|
||||||
all_identifiers = set(self.atv.all_identifiers)
|
all_identifiers = set(self.atv.all_identifiers)
|
||||||
if unique_id := self._entry_unique_id_from_identifers(all_identifiers):
|
if unique_id := self._entry_unique_id_from_identifers(all_identifiers):
|
||||||
return unique_id
|
return unique_id
|
||||||
@@ -143,7 +151,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self.context["identifier"] = self.unique_id
|
self.context["identifier"] = self.unique_id
|
||||||
return await self.async_step_reconfigure()
|
return await self.async_step_reconfigure()
|
||||||
|
|
||||||
async def async_step_reconfigure(self, user_input=None):
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Inform user that reconfiguration is about to start."""
|
"""Inform user that reconfiguration is about to start."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self.async_find_device_wrapper(
|
return await self.async_find_device_wrapper(
|
||||||
@@ -152,7 +162,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self.async_show_form(step_id="reconfigure")
|
return self.async_show_form(step_id="reconfigure")
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors = {}
|
errors = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
@@ -170,6 +182,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
self.device_identifier, raise_on_progress=False
|
self.device_identifier, raise_on_progress=False
|
||||||
)
|
)
|
||||||
|
assert self.atv
|
||||||
self.context["all_identifiers"] = self.atv.all_identifiers
|
self.context["all_identifiers"] = self.atv.all_identifiers
|
||||||
return await self.async_step_confirm()
|
return await self.async_step_confirm()
|
||||||
|
|
||||||
@@ -275,8 +288,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
context["all_identifiers"].append(unique_id)
|
context["all_identifiers"].append(unique_id)
|
||||||
raise AbortFlow("already_in_progress")
|
raise AbortFlow("already_in_progress")
|
||||||
|
|
||||||
async def async_found_zeroconf_device(self, user_input=None):
|
async def async_found_zeroconf_device(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle device found after Zeroconf discovery."""
|
"""Handle device found after Zeroconf discovery."""
|
||||||
|
assert self.atv
|
||||||
self.context["all_identifiers"] = self.atv.all_identifiers
|
self.context["all_identifiers"] = self.atv.all_identifiers
|
||||||
# Also abort if an integration with this identifier already exists
|
# Also abort if an integration with this identifier already exists
|
||||||
await self.async_set_unique_id(self.device_identifier)
|
await self.async_set_unique_id(self.device_identifier)
|
||||||
@@ -288,7 +304,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self.context["identifier"] = self.unique_id
|
self.context["identifier"] = self.unique_id
|
||||||
return await self.async_step_confirm()
|
return await self.async_step_confirm()
|
||||||
|
|
||||||
async def async_find_device_wrapper(self, next_func, allow_exist=False):
|
async def async_find_device_wrapper(
|
||||||
|
self,
|
||||||
|
next_func: Callable[[], Awaitable[FlowResult]],
|
||||||
|
allow_exist: bool = False,
|
||||||
|
) -> FlowResult:
|
||||||
"""Find a specific device and call another function when done.
|
"""Find a specific device and call another function when done.
|
||||||
|
|
||||||
This function will do error handling and bail out when an error
|
This function will do error handling and bail out when an error
|
||||||
@@ -306,7 +326,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return await next_func()
|
return await next_func()
|
||||||
|
|
||||||
async def async_find_device(self, allow_exist=False):
|
async def async_find_device(self, allow_exist: bool = False) -> None:
|
||||||
"""Scan for the selected device to discover services."""
|
"""Scan for the selected device to discover services."""
|
||||||
self.atv, self.atv_identifiers = await device_scan(
|
self.atv, self.atv_identifiers = await device_scan(
|
||||||
self.hass, self.scan_filter, self.hass.loop
|
self.hass, self.scan_filter, self.hass.loop
|
||||||
@@ -357,8 +377,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if not allow_exist:
|
if not allow_exist:
|
||||||
raise DeviceAlreadyConfigured()
|
raise DeviceAlreadyConfigured()
|
||||||
|
|
||||||
async def async_step_confirm(self, user_input=None):
|
async def async_step_confirm(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle user-confirmation of discovered node."""
|
"""Handle user-confirmation of discovered node."""
|
||||||
|
assert self.atv
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
expected_identifier_count = len(self.context["all_identifiers"])
|
expected_identifier_count = len(self.context["all_identifiers"])
|
||||||
# If number of services found during device scan mismatch number of
|
# If number of services found during device scan mismatch number of
|
||||||
@@ -384,7 +407,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_pair_next_protocol(self):
|
async def async_pair_next_protocol(self) -> FlowResult:
|
||||||
"""Start pairing process for the next available protocol."""
|
"""Start pairing process for the next available protocol."""
|
||||||
await self._async_cleanup()
|
await self._async_cleanup()
|
||||||
|
|
||||||
@@ -393,8 +416,16 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self._async_get_entry()
|
return await self._async_get_entry()
|
||||||
|
|
||||||
self.protocol = self.protocols_to_pair.popleft()
|
self.protocol = self.protocols_to_pair.popleft()
|
||||||
|
assert self.atv
|
||||||
service = self.atv.get_service(self.protocol)
|
service = self.atv.get_service(self.protocol)
|
||||||
|
|
||||||
|
if service is None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s does not support pairing (cannot find a corresponding service)",
|
||||||
|
self.protocol,
|
||||||
|
)
|
||||||
|
return await self.async_pair_next_protocol()
|
||||||
|
|
||||||
# Service requires a password
|
# Service requires a password
|
||||||
if service.requires_password:
|
if service.requires_password:
|
||||||
return await self.async_step_password()
|
return await self.async_step_password()
|
||||||
@@ -413,7 +444,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.debug("%s requires pairing", self.protocol)
|
_LOGGER.debug("%s requires pairing", self.protocol)
|
||||||
|
|
||||||
# Protocol specific arguments
|
# Protocol specific arguments
|
||||||
pair_args = {}
|
pair_args: dict[str, Any] = {}
|
||||||
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
|
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
|
||||||
pair_args["name"] = "Home Assistant"
|
pair_args["name"] = "Home Assistant"
|
||||||
if self.protocol == Protocol.DMAP:
|
if self.protocol == Protocol.DMAP:
|
||||||
@@ -448,8 +479,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return await self.async_step_pair_no_pin()
|
return await self.async_step_pair_no_pin()
|
||||||
|
|
||||||
async def async_step_protocol_disabled(self, user_input=None):
|
async def async_step_protocol_disabled(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Inform user that a protocol is disabled and cannot be paired."""
|
"""Inform user that a protocol is disabled and cannot be paired."""
|
||||||
|
assert self.protocol
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self.async_pair_next_protocol()
|
return await self.async_pair_next_protocol()
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@@ -457,9 +491,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders={"protocol": protocol_str(self.protocol)},
|
description_placeholders={"protocol": protocol_str(self.protocol)},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_pair_with_pin(self, user_input=None):
|
async def async_step_pair_with_pin(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle pairing step where a PIN is required from the user."""
|
"""Handle pairing step where a PIN is required from the user."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
assert self.pairing
|
||||||
|
assert self.protocol
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
self.pairing.pin(user_input[CONF_PIN])
|
self.pairing.pin(user_input[CONF_PIN])
|
||||||
@@ -480,8 +518,12 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders={"protocol": protocol_str(self.protocol)},
|
description_placeholders={"protocol": protocol_str(self.protocol)},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_pair_no_pin(self, user_input=None):
|
async def async_step_pair_no_pin(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle step where user has to enter a PIN on the device."""
|
"""Handle step where user has to enter a PIN on the device."""
|
||||||
|
assert self.pairing
|
||||||
|
assert self.protocol
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
await self.pairing.finish()
|
await self.pairing.finish()
|
||||||
if self.pairing.has_paired:
|
if self.pairing.has_paired:
|
||||||
@@ -497,12 +539,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
step_id="pair_no_pin",
|
step_id="pair_no_pin",
|
||||||
description_placeholders={
|
description_placeholders={
|
||||||
"protocol": protocol_str(self.protocol),
|
"protocol": protocol_str(self.protocol),
|
||||||
"pin": pin,
|
"pin": str(pin),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_service_problem(self, user_input=None):
|
async def async_step_service_problem(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Inform user that a service will not be added."""
|
"""Inform user that a service will not be added."""
|
||||||
|
assert self.protocol
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self.async_pair_next_protocol()
|
return await self.async_pair_next_protocol()
|
||||||
|
|
||||||
@@ -511,8 +556,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders={"protocol": protocol_str(self.protocol)},
|
description_placeholders={"protocol": protocol_str(self.protocol)},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_password(self, user_input=None):
|
async def async_step_password(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Inform user that password is not supported."""
|
"""Inform user that password is not supported."""
|
||||||
|
assert self.protocol
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await self.async_pair_next_protocol()
|
return await self.async_pair_next_protocol()
|
||||||
|
|
||||||
@@ -521,18 +569,20 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders={"protocol": protocol_str(self.protocol)},
|
description_placeholders={"protocol": protocol_str(self.protocol)},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_cleanup(self):
|
async def _async_cleanup(self) -> None:
|
||||||
"""Clean up allocated resources."""
|
"""Clean up allocated resources."""
|
||||||
if self.pairing is not None:
|
if self.pairing is not None:
|
||||||
await self.pairing.close()
|
await self.pairing.close()
|
||||||
self.pairing = None
|
self.pairing = None
|
||||||
|
|
||||||
async def _async_get_entry(self):
|
async def _async_get_entry(self) -> FlowResult:
|
||||||
"""Return config entry or update existing config entry."""
|
"""Return config entry or update existing config entry."""
|
||||||
# Abort if no protocols were paired
|
# Abort if no protocols were paired
|
||||||
if not self.credentials:
|
if not self.credentials:
|
||||||
return self.async_abort(reason="setup_failed")
|
return self.async_abort(reason="setup_failed")
|
||||||
|
|
||||||
|
assert self.atv
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
CONF_NAME: self.atv.name,
|
CONF_NAME: self.atv.name,
|
||||||
CONF_CREDENTIALS: self.credentials,
|
CONF_CREDENTIALS: self.credentials,
|
||||||
|
@@ -16,7 +16,15 @@ from pyatv.const import (
|
|||||||
ShuffleState,
|
ShuffleState,
|
||||||
)
|
)
|
||||||
from pyatv.helpers import is_streamable
|
from pyatv.helpers import is_streamable
|
||||||
from pyatv.interface import AppleTV, Playing
|
from pyatv.interface import (
|
||||||
|
AppleTV,
|
||||||
|
AudioListener,
|
||||||
|
OutputDevice,
|
||||||
|
Playing,
|
||||||
|
PowerListener,
|
||||||
|
PushListener,
|
||||||
|
PushUpdater,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.components import media_source
|
from homeassistant.components import media_source
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
@@ -101,7 +109,9 @@ async def async_setup_entry(
|
|||||||
async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)])
|
async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)])
|
||||||
|
|
||||||
|
|
||||||
class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
class AppleTvMediaPlayer(
|
||||||
|
AppleTVEntity, MediaPlayerEntity, PowerListener, AudioListener, PushListener
|
||||||
|
):
|
||||||
"""Representation of an Apple TV media player."""
|
"""Representation of an Apple TV media player."""
|
||||||
|
|
||||||
_attr_supported_features = SUPPORT_APPLE_TV
|
_attr_supported_features = SUPPORT_APPLE_TV
|
||||||
@@ -116,9 +126,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
def async_device_connected(self, atv: AppleTV) -> None:
|
def async_device_connected(self, atv: AppleTV) -> None:
|
||||||
"""Handle when connection is made to device."""
|
"""Handle when connection is made to device."""
|
||||||
# NB: Do not use _is_feature_available here as it only works when playing
|
# NB: Do not use _is_feature_available here as it only works when playing
|
||||||
if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
||||||
self.atv.push_updater.listener = self
|
atv.push_updater.listener = self
|
||||||
self.atv.push_updater.start()
|
atv.push_updater.start()
|
||||||
|
|
||||||
self._attr_supported_features = SUPPORT_BASE
|
self._attr_supported_features = SUPPORT_BASE
|
||||||
|
|
||||||
@@ -126,7 +136,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
# "Unsupported" are considered here as the state of such a feature can never
|
# "Unsupported" are considered here as the state of such a feature can never
|
||||||
# change after a connection has been established, i.e. an unsupported feature
|
# change after a connection has been established, i.e. an unsupported feature
|
||||||
# can never change to be supported.
|
# can never change to be supported.
|
||||||
all_features = self.atv.features.all_features()
|
all_features = atv.features.all_features()
|
||||||
for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
|
for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
|
||||||
feature_info = all_features.get(feature_name)
|
feature_info = all_features.get(feature_name)
|
||||||
if feature_info and feature_info.state != FeatureState.Unsupported:
|
if feature_info and feature_info.state != FeatureState.Unsupported:
|
||||||
@@ -136,16 +146,18 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
# metadata update arrives (sometime very soon after this callback returns)
|
# metadata update arrives (sometime very soon after this callback returns)
|
||||||
|
|
||||||
# Listen to power updates
|
# Listen to power updates
|
||||||
self.atv.power.listener = self
|
atv.power.listener = self
|
||||||
|
|
||||||
# Listen to volume updates
|
# Listen to volume updates
|
||||||
self.atv.audio.listener = self
|
atv.audio.listener = self
|
||||||
|
|
||||||
if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList):
|
if atv.features.in_state(FeatureState.Available, FeatureName.AppList):
|
||||||
self.hass.create_task(self._update_app_list())
|
self.hass.create_task(self._update_app_list())
|
||||||
|
|
||||||
async def _update_app_list(self) -> None:
|
async def _update_app_list(self) -> None:
|
||||||
_LOGGER.debug("Updating app list")
|
_LOGGER.debug("Updating app list")
|
||||||
|
if not self.atv:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
apps = await self.atv.apps.app_list()
|
apps = await self.atv.apps.app_list()
|
||||||
except exceptions.NotSupportedError:
|
except exceptions.NotSupportedError:
|
||||||
@@ -189,33 +201,56 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def playstatus_update(self, _, playing: Playing) -> None:
|
def playstatus_update(self, updater: PushUpdater, playstatus: Playing) -> None:
|
||||||
"""Print what is currently playing when it changes."""
|
"""Print what is currently playing when it changes.
|
||||||
self._playing = playing
|
|
||||||
|
This is a callback function from pyatv.interface.PushListener.
|
||||||
|
"""
|
||||||
|
self._playing = playstatus
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def playstatus_error(self, _, exception: Exception) -> None:
|
def playstatus_error(self, updater: PushUpdater, exception: Exception) -> None:
|
||||||
"""Inform about an error and restart push updates."""
|
"""Inform about an error and restart push updates.
|
||||||
|
|
||||||
|
This is a callback function from pyatv.interface.PushListener.
|
||||||
|
"""
|
||||||
_LOGGER.warning("A %s error occurred: %s", exception.__class__, exception)
|
_LOGGER.warning("A %s error occurred: %s", exception.__class__, exception)
|
||||||
self._playing = None
|
self._playing = None
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None:
|
def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None:
|
||||||
"""Update power state when it changes."""
|
"""Update power state when it changes.
|
||||||
|
|
||||||
|
This is a callback function from pyatv.interface.PowerListener.
|
||||||
|
"""
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def volume_update(self, old_level: float, new_level: float) -> None:
|
def volume_update(self, old_level: float, new_level: float) -> None:
|
||||||
"""Update volume when it changes."""
|
"""Update volume when it changes.
|
||||||
|
|
||||||
|
This is a callback function from pyatv.interface.AudioListener.
|
||||||
|
"""
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def outputdevices_update(
|
||||||
|
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]
|
||||||
|
) -> None:
|
||||||
|
"""Output devices were updated.
|
||||||
|
|
||||||
|
This is a callback function from pyatv.interface.AudioListener.
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_id(self) -> str | None:
|
def app_id(self) -> str | None:
|
||||||
"""ID of the current running app."""
|
"""ID of the current running app."""
|
||||||
if self._is_feature_available(FeatureName.App) and (
|
if (
|
||||||
app := self.atv.metadata.app
|
self.atv
|
||||||
|
and self._is_feature_available(FeatureName.App)
|
||||||
|
and (app := self.atv.metadata.app) is not None
|
||||||
):
|
):
|
||||||
return app.identifier
|
return app.identifier
|
||||||
return None
|
return None
|
||||||
@@ -223,8 +258,10 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def app_name(self) -> str | None:
|
def app_name(self) -> str | None:
|
||||||
"""Name of the current running app."""
|
"""Name of the current running app."""
|
||||||
if self._is_feature_available(FeatureName.App) and (
|
if (
|
||||||
app := self.atv.metadata.app
|
self.atv
|
||||||
|
and self._is_feature_available(FeatureName.App)
|
||||||
|
and (app := self.atv.metadata.app) is not None
|
||||||
):
|
):
|
||||||
return app.name
|
return app.name
|
||||||
return None
|
return None
|
||||||
@@ -255,7 +292,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def volume_level(self) -> float | None:
|
def volume_level(self) -> float | None:
|
||||||
"""Volume level of the media player (0..1)."""
|
"""Volume level of the media player (0..1)."""
|
||||||
if self._is_feature_available(FeatureName.Volume):
|
if self.atv and self._is_feature_available(FeatureName.Volume):
|
||||||
return self.atv.audio.volume / 100.0 # from percent
|
return self.atv.audio.volume / 100.0 # from percent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -286,6 +323,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
"""Send the play_media command to the media player."""
|
"""Send the play_media command to the media player."""
|
||||||
# If input (file) has a file format supported by pyatv, then stream it with
|
# If input (file) has a file format supported by pyatv, then stream it with
|
||||||
# RAOP. Otherwise try to play it with regular AirPlay.
|
# RAOP. Otherwise try to play it with regular AirPlay.
|
||||||
|
if not self.atv:
|
||||||
|
return
|
||||||
if media_type in {MediaType.APP, MediaType.URL}:
|
if media_type in {MediaType.APP, MediaType.URL}:
|
||||||
await self.atv.apps.launch_app(media_id)
|
await self.atv.apps.launch_app(media_id)
|
||||||
return
|
return
|
||||||
@@ -313,7 +352,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
"""Hash value for media image."""
|
"""Hash value for media image."""
|
||||||
state = self.state
|
state = self.state
|
||||||
if (
|
if (
|
||||||
self._playing
|
self.atv
|
||||||
|
and self._playing
|
||||||
and self._is_feature_available(FeatureName.Artwork)
|
and self._is_feature_available(FeatureName.Artwork)
|
||||||
and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE}
|
and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE}
|
||||||
):
|
):
|
||||||
@@ -323,7 +363,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
|
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
|
||||||
"""Fetch media image of current playing image."""
|
"""Fetch media image of current playing image."""
|
||||||
state = self.state
|
state = self.state
|
||||||
if self._playing and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE}:
|
if (
|
||||||
|
self.atv
|
||||||
|
and self._playing
|
||||||
|
and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE}
|
||||||
|
):
|
||||||
artwork = await self.atv.metadata.artwork()
|
artwork = await self.atv.metadata.artwork()
|
||||||
if artwork:
|
if artwork:
|
||||||
return artwork.bytes, artwork.mimetype
|
return artwork.bytes, artwork.mimetype
|
||||||
@@ -439,20 +483,24 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn the media player on."""
|
"""Turn the media player on."""
|
||||||
if self._is_feature_available(FeatureName.TurnOn):
|
if self.atv and self._is_feature_available(FeatureName.TurnOn):
|
||||||
await self.atv.power.turn_on()
|
await self.atv.power.turn_on()
|
||||||
|
|
||||||
async def async_turn_off(self) -> None:
|
async def async_turn_off(self) -> None:
|
||||||
"""Turn the media player off."""
|
"""Turn the media player off."""
|
||||||
if (self._is_feature_available(FeatureName.TurnOff)) and (
|
if (
|
||||||
not self._is_feature_available(FeatureName.PowerState)
|
self.atv
|
||||||
or self.atv.power.power_state == PowerState.On
|
and (self._is_feature_available(FeatureName.TurnOff))
|
||||||
|
and (
|
||||||
|
not self._is_feature_available(FeatureName.PowerState)
|
||||||
|
or self.atv.power.power_state == PowerState.On
|
||||||
|
)
|
||||||
):
|
):
|
||||||
await self.atv.power.turn_off()
|
await self.atv.power.turn_off()
|
||||||
|
|
||||||
async def async_media_play_pause(self) -> None:
|
async def async_media_play_pause(self) -> None:
|
||||||
"""Pause media on media player."""
|
"""Pause media on media player."""
|
||||||
if self._playing:
|
if self.atv and self._playing:
|
||||||
await self.atv.remote_control.play_pause()
|
await self.atv.remote_control.play_pause()
|
||||||
|
|
||||||
async def async_media_play(self) -> None:
|
async def async_media_play(self) -> None:
|
||||||
@@ -519,5 +567,6 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
|||||||
|
|
||||||
async def async_select_source(self, source: str) -> None:
|
async def async_select_source(self, source: str) -> None:
|
||||||
"""Select input source."""
|
"""Select input source."""
|
||||||
if app_id := self._app_list.get(source):
|
if self.atv:
|
||||||
await self.atv.apps.launch_app(app_id)
|
if app_id := self._app_list.get(source):
|
||||||
|
await self.atv.apps.launch_app(app_id)
|
||||||
|
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_NAME
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import AppleTVEntity
|
from . import AppleTVEntity, AppleTVManager
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -38,8 +38,10 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load Apple TV remote based on a config entry."""
|
"""Load Apple TV remote based on a config entry."""
|
||||||
name = config_entry.data[CONF_NAME]
|
name: str = config_entry.data[CONF_NAME]
|
||||||
manager = hass.data[DOMAIN][config_entry.unique_id]
|
# apple_tv config entries always have a unique id
|
||||||
|
assert config_entry.unique_id is not None
|
||||||
|
manager: AppleTVManager = hass.data[DOMAIN][config_entry.unique_id]
|
||||||
async_add_entities([AppleTVRemote(name, config_entry.unique_id, manager)])
|
async_add_entities([AppleTVRemote(name, config_entry.unique_id, manager)])
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
|||||||
"""Device that sends commands to an Apple TV."""
|
"""Device that sends commands to an Apple TV."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self) -> bool:
|
||||||
"""Return true if device is on."""
|
"""Return true if device is on."""
|
||||||
return self.atv is not None
|
return self.atv is not None
|
||||||
|
|
||||||
@@ -64,13 +66,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
|||||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||||
delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
|
delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
|
||||||
|
|
||||||
if not self.is_on:
|
if not self.atv:
|
||||||
_LOGGER.error("Unable to send commands, not connected to %s", self.name)
|
_LOGGER.error("Unable to send commands, not connected to %s", self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
for _ in range(num_repeats):
|
for _ in range(num_repeats):
|
||||||
for single_command in command:
|
for single_command in command:
|
||||||
attr_value = None
|
attr_value: Any = None
|
||||||
if attributes := COMMAND_TO_ATTRIBUTE.get(single_command):
|
if attributes := COMMAND_TO_ATTRIBUTE.get(single_command):
|
||||||
attr_value = self.atv
|
attr_value = self.atv
|
||||||
for attr_name in attributes:
|
for attr_name in attributes:
|
||||||
@@ -81,5 +83,5 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
|||||||
raise ValueError("Command not found. Exiting sequence")
|
raise ValueError("Command not found. Exiting sequence")
|
||||||
|
|
||||||
_LOGGER.info("Sending command %s", single_command)
|
_LOGGER.info("Sending command %s", single_command)
|
||||||
await attr_value() # type: ignore[operator]
|
await attr_value()
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
69
homeassistant/components/aprilaire/__init__.py
Normal file
69
homeassistant/components/aprilaire/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""The Aprilaire integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pyaprilaire.const import Attribute
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
|
from homeassistant.core import Event, HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AprilaireCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up a config entry for Aprilaire."""
|
||||||
|
|
||||||
|
host = entry.data[CONF_HOST]
|
||||||
|
port = entry.data[CONF_PORT]
|
||||||
|
|
||||||
|
coordinator = AprilaireCoordinator(hass, entry.unique_id, host, port)
|
||||||
|
await coordinator.start_listen()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.unique_id] = coordinator
|
||||||
|
|
||||||
|
async def ready_callback(ready: bool):
|
||||||
|
if ready:
|
||||||
|
mac_address = format_mac(coordinator.data[Attribute.MAC_ADDRESS])
|
||||||
|
|
||||||
|
if mac_address != entry.unique_id:
|
||||||
|
raise ConfigEntryAuthFailed("Invalid MAC address")
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
async def _async_close(_: Event) -> None:
|
||||||
|
coordinator.stop_listen()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Failed to wait for ready")
|
||||||
|
|
||||||
|
coordinator.stop_listen()
|
||||||
|
|
||||||
|
raise ConfigEntryNotReady()
|
||||||
|
|
||||||
|
await coordinator.wait_for_ready(ready_callback)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
coordinator: AprilaireCoordinator = hass.data[DOMAIN].pop(entry.unique_id)
|
||||||
|
coordinator.stop_listen()
|
||||||
|
|
||||||
|
return unload_ok
|
302
homeassistant/components/aprilaire/climate.py
Normal file
302
homeassistant/components/aprilaire/climate.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""The Aprilaire climate component."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pyaprilaire.const import Attribute
|
||||||
|
|
||||||
|
from homeassistant.components.climate import (
|
||||||
|
FAN_AUTO,
|
||||||
|
FAN_ON,
|
||||||
|
PRESET_AWAY,
|
||||||
|
PRESET_NONE,
|
||||||
|
ClimateEntity,
|
||||||
|
ClimateEntityFeature,
|
||||||
|
HVACAction,
|
||||||
|
HVACMode,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
FAN_CIRCULATE,
|
||||||
|
PRESET_PERMANENT_HOLD,
|
||||||
|
PRESET_TEMPORARY_HOLD,
|
||||||
|
PRESET_VACATION,
|
||||||
|
)
|
||||||
|
from .coordinator import AprilaireCoordinator
|
||||||
|
from .entity import BaseAprilaireEntity
|
||||||
|
|
||||||
|
HVAC_MODE_MAP = {
|
||||||
|
1: HVACMode.OFF,
|
||||||
|
2: HVACMode.HEAT,
|
||||||
|
3: HVACMode.COOL,
|
||||||
|
4: HVACMode.HEAT,
|
||||||
|
5: HVACMode.AUTO,
|
||||||
|
}
|
||||||
|
|
||||||
|
HVAC_MODES_MAP = {
|
||||||
|
1: [HVACMode.OFF, HVACMode.HEAT],
|
||||||
|
2: [HVACMode.OFF, HVACMode.COOL],
|
||||||
|
3: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL],
|
||||||
|
4: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL],
|
||||||
|
5: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO],
|
||||||
|
6: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO],
|
||||||
|
}
|
||||||
|
|
||||||
|
PRESET_MODE_MAP = {
|
||||||
|
1: PRESET_TEMPORARY_HOLD,
|
||||||
|
2: PRESET_PERMANENT_HOLD,
|
||||||
|
3: PRESET_AWAY,
|
||||||
|
4: PRESET_VACATION,
|
||||||
|
}
|
||||||
|
|
||||||
|
FAN_MODE_MAP = {
|
||||||
|
1: FAN_ON,
|
||||||
|
2: FAN_AUTO,
|
||||||
|
3: FAN_CIRCULATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Add climates for passed config_entry in HA."""
|
||||||
|
|
||||||
|
coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.unique_id]
|
||||||
|
|
||||||
|
async_add_entities([AprilaireClimate(coordinator, config_entry.unique_id)])
|
||||||
|
|
||||||
|
|
||||||
|
class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
|
||||||
|
"""Climate entity for Aprilaire."""
|
||||||
|
|
||||||
|
_attr_fan_modes = [FAN_AUTO, FAN_ON, FAN_CIRCULATE]
|
||||||
|
_attr_min_humidity = 10
|
||||||
|
_attr_max_humidity = 50
|
||||||
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
_attr_translation_key = "thermostat"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def precision(self) -> float:
|
||||||
|
"""Get the precision based on the unit."""
|
||||||
|
return (
|
||||||
|
PRECISION_HALVES
|
||||||
|
if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS
|
||||||
|
else PRECISION_WHOLE
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> ClimateEntityFeature:
|
||||||
|
"""Get supported features."""
|
||||||
|
features = 0
|
||||||
|
|
||||||
|
if self.coordinator.data.get(Attribute.MODE) == 5:
|
||||||
|
features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||||
|
else:
|
||||||
|
features = features | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
|
||||||
|
if self.coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2:
|
||||||
|
features = features | ClimateEntityFeature.TARGET_HUMIDITY
|
||||||
|
|
||||||
|
features = features | ClimateEntityFeature.PRESET_MODE
|
||||||
|
|
||||||
|
features = features | ClimateEntityFeature.FAN_MODE
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_humidity(self) -> int | None:
|
||||||
|
"""Get current humidity."""
|
||||||
|
return self.coordinator.data.get(
|
||||||
|
Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_humidity(self) -> int | None:
|
||||||
|
"""Get current target humidity."""
|
||||||
|
return self.coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_mode(self) -> HVACMode | None:
|
||||||
|
"""Get HVAC mode."""
|
||||||
|
|
||||||
|
if mode := self.coordinator.data.get(Attribute.MODE):
|
||||||
|
if hvac_mode := HVAC_MODE_MAP.get(mode):
|
||||||
|
return hvac_mode
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_modes(self) -> list[HVACMode]:
|
||||||
|
"""Get supported HVAC modes."""
|
||||||
|
|
||||||
|
if modes := self.coordinator.data.get(Attribute.THERMOSTAT_MODES):
|
||||||
|
if thermostat_modes := HVAC_MODES_MAP.get(modes):
|
||||||
|
return thermostat_modes
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_action(self) -> HVACAction | None:
|
||||||
|
"""Get the current HVAC action."""
|
||||||
|
|
||||||
|
if self.coordinator.data.get(Attribute.HEATING_EQUIPMENT_STATUS, 0):
|
||||||
|
return HVACAction.HEATING
|
||||||
|
|
||||||
|
if self.coordinator.data.get(Attribute.COOLING_EQUIPMENT_STATUS, 0):
|
||||||
|
return HVACAction.COOLING
|
||||||
|
|
||||||
|
return HVACAction.IDLE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_temperature(self) -> float | None:
|
||||||
|
"""Get current temperature."""
|
||||||
|
return self.coordinator.data.get(
|
||||||
|
Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature(self) -> float | None:
|
||||||
|
"""Get the target temperature."""
|
||||||
|
|
||||||
|
hvac_mode = self.hvac_mode
|
||||||
|
|
||||||
|
if hvac_mode == HVACMode.COOL:
|
||||||
|
return self.target_temperature_high
|
||||||
|
if hvac_mode == HVACMode.HEAT:
|
||||||
|
return self.target_temperature_low
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_step(self) -> float | None:
|
||||||
|
"""Get the step for the target temperature based on the unit."""
|
||||||
|
return (
|
||||||
|
0.5
|
||||||
|
if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS
|
||||||
|
else 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_high(self) -> float | None:
|
||||||
|
"""Get cool setpoint."""
|
||||||
|
return self.coordinator.data.get(Attribute.COOL_SETPOINT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_low(self) -> float | None:
|
||||||
|
"""Get heat setpoint."""
|
||||||
|
return self.coordinator.data.get(Attribute.HEAT_SETPOINT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_mode(self) -> str | None:
|
||||||
|
"""Get the current preset mode."""
|
||||||
|
if hold := self.coordinator.data.get(Attribute.HOLD):
|
||||||
|
if preset_mode := PRESET_MODE_MAP.get(hold):
|
||||||
|
return preset_mode
|
||||||
|
|
||||||
|
return PRESET_NONE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_modes(self) -> list[str] | None:
|
||||||
|
"""Get the supported preset modes."""
|
||||||
|
presets = [PRESET_NONE, PRESET_VACATION]
|
||||||
|
|
||||||
|
if self.coordinator.data.get(Attribute.AWAY_AVAILABLE) == 1:
|
||||||
|
presets.append(PRESET_AWAY)
|
||||||
|
|
||||||
|
hold = self.coordinator.data.get(Attribute.HOLD, 0)
|
||||||
|
|
||||||
|
if hold == 1:
|
||||||
|
presets.append(PRESET_TEMPORARY_HOLD)
|
||||||
|
elif hold == 2:
|
||||||
|
presets.append(PRESET_PERMANENT_HOLD)
|
||||||
|
|
||||||
|
return presets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fan_mode(self) -> str | None:
|
||||||
|
"""Get fan mode."""
|
||||||
|
|
||||||
|
if mode := self.coordinator.data.get(Attribute.FAN_MODE):
|
||||||
|
if fan_mode := FAN_MODE_MAP.get(mode):
|
||||||
|
return fan_mode
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
|
"""Set new target temperature."""
|
||||||
|
|
||||||
|
cool_setpoint = 0
|
||||||
|
heat_setpoint = 0
|
||||||
|
|
||||||
|
if temperature := kwargs.get("temperature"):
|
||||||
|
if self.coordinator.data.get(Attribute.MODE) == 3:
|
||||||
|
cool_setpoint = temperature
|
||||||
|
else:
|
||||||
|
heat_setpoint = temperature
|
||||||
|
else:
|
||||||
|
if target_temp_low := kwargs.get("target_temp_low"):
|
||||||
|
heat_setpoint = target_temp_low
|
||||||
|
if target_temp_high := kwargs.get("target_temp_high"):
|
||||||
|
cool_setpoint = target_temp_high
|
||||||
|
|
||||||
|
if cool_setpoint == 0 and heat_setpoint == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.coordinator.client.update_setpoint(cool_setpoint, heat_setpoint)
|
||||||
|
|
||||||
|
await self.coordinator.client.read_control()
|
||||||
|
|
||||||
|
async def async_set_humidity(self, humidity: int) -> None:
|
||||||
|
"""Set the target humidification setpoint."""
|
||||||
|
|
||||||
|
await self.coordinator.client.set_humidification_setpoint(humidity)
|
||||||
|
|
||||||
|
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||||
|
"""Set the fan mode."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
fan_mode_value_index = list(FAN_MODE_MAP.values()).index(fan_mode)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Unsupported fan mode {fan_mode}") from exc
|
||||||
|
|
||||||
|
fan_mode_value = list(FAN_MODE_MAP.keys())[fan_mode_value_index]
|
||||||
|
|
||||||
|
await self.coordinator.client.update_fan_mode(fan_mode_value)
|
||||||
|
|
||||||
|
await self.coordinator.client.read_control()
|
||||||
|
|
||||||
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
|
"""Set the HVAC mode."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode_value_index = list(HVAC_MODE_MAP.values()).index(hvac_mode)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Unsupported HVAC mode {hvac_mode}") from exc
|
||||||
|
|
||||||
|
mode_value = list(HVAC_MODE_MAP.keys())[mode_value_index]
|
||||||
|
|
||||||
|
await self.coordinator.client.update_mode(mode_value)
|
||||||
|
|
||||||
|
await self.coordinator.client.read_control()
|
||||||
|
|
||||||
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
|
"""Set the preset mode."""
|
||||||
|
|
||||||
|
if preset_mode == PRESET_AWAY:
|
||||||
|
await self.coordinator.client.set_hold(3)
|
||||||
|
elif preset_mode == PRESET_VACATION:
|
||||||
|
await self.coordinator.client.set_hold(4)
|
||||||
|
elif preset_mode == PRESET_NONE:
|
||||||
|
await self.coordinator.client.set_hold(0)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported preset mode {preset_mode}")
|
||||||
|
|
||||||
|
await self.coordinator.client.read_scheduling()
|
72
homeassistant/components/aprilaire/config_flow.py
Normal file
72
homeassistant/components/aprilaire/config_flow.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Config flow for the Aprilaire integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pyaprilaire.const import Attribute
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AprilaireCoordinator
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Required(CONF_PORT, default=7000): cv.port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Aprilaire."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator = AprilaireCoordinator(
|
||||||
|
self.hass, None, user_input[CONF_HOST], user_input[CONF_PORT]
|
||||||
|
)
|
||||||
|
await coordinator.start_listen()
|
||||||
|
|
||||||
|
async def ready_callback(ready: bool):
|
||||||
|
if not ready:
|
||||||
|
_LOGGER.error("Failed to wait for ready")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ready = await coordinator.wait_for_ready(ready_callback)
|
||||||
|
finally:
|
||||||
|
coordinator.stop_listen()
|
||||||
|
|
||||||
|
mac_address = coordinator.data.get(Attribute.MAC_ADDRESS)
|
||||||
|
|
||||||
|
if ready and mac_address is not None:
|
||||||
|
await self.async_set_unique_id(format_mac(mac_address))
|
||||||
|
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(title="Aprilaire", data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors={"base": "connection_failed"},
|
||||||
|
)
|
11
homeassistant/components/aprilaire/const.py
Normal file
11
homeassistant/components/aprilaire/const.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Constants for the Aprilaire integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DOMAIN = "aprilaire"
|
||||||
|
|
||||||
|
FAN_CIRCULATE = "Circulate"
|
||||||
|
|
||||||
|
PRESET_TEMPORARY_HOLD = "Temporary"
|
||||||
|
PRESET_PERMANENT_HOLD = "Permanent"
|
||||||
|
PRESET_VACATION = "Vacation"
|
209
homeassistant/components/aprilaire/coordinator.py
Normal file
209
homeassistant/components/aprilaire/coordinator.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""The Aprilaire coordinator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import pyaprilaire.client
|
||||||
|
from pyaprilaire.const import MODELS, Attribute, FunctionalDomain
|
||||||
|
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
import homeassistant.helpers.device_registry as dr
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
RECONNECT_INTERVAL = 60 * 60
|
||||||
|
RETRY_CONNECTION_INTERVAL = 10
|
||||||
|
WAIT_TIMEOUT = 30
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||||
|
"""Coordinator for interacting with the thermostat."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
unique_id: str | None,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
|
||||||
|
self.hass = hass
|
||||||
|
self.unique_id = unique_id
|
||||||
|
self.data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||||
|
|
||||||
|
self.client = pyaprilaire.client.AprilaireClient(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
self.async_set_updated_data,
|
||||||
|
_LOGGER,
|
||||||
|
RECONNECT_INTERVAL,
|
||||||
|
RETRY_CONNECTION_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(self.client, "data") and self.client.data:
|
||||||
|
self.data = self.client.data
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_listener(
|
||||||
|
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Listen for data updates."""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def remove_listener() -> None:
|
||||||
|
"""Remove update listener."""
|
||||||
|
self._listeners.pop(remove_listener)
|
||||||
|
|
||||||
|
self._listeners[remove_listener] = (update_callback, context)
|
||||||
|
|
||||||
|
return remove_listener
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_listeners(self) -> None:
|
||||||
|
"""Update all registered listeners."""
|
||||||
|
for update_callback, _ in list(self._listeners.values()):
|
||||||
|
update_callback()
|
||||||
|
|
||||||
|
def async_set_updated_data(self, data: Any) -> None:
|
||||||
|
"""Manually update data, notify listeners and reset refresh interval."""
|
||||||
|
|
||||||
|
old_device_info = self.create_device_info(self.data)
|
||||||
|
|
||||||
|
self.data = self.data | data
|
||||||
|
|
||||||
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
new_device_info = self.create_device_info(data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
old_device_info is not None
|
||||||
|
and new_device_info is not None
|
||||||
|
and old_device_info != new_device_info
|
||||||
|
):
|
||||||
|
device_registry = dr.async_get(self.hass)
|
||||||
|
|
||||||
|
device = device_registry.async_get_device(old_device_info["identifiers"])
|
||||||
|
|
||||||
|
if device is not None:
|
||||||
|
new_device_info.pop("identifiers", None)
|
||||||
|
new_device_info.pop("connections", None)
|
||||||
|
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device_id=device.id,
|
||||||
|
**new_device_info, # type: ignore[misc]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start_listen(self):
|
||||||
|
"""Start listening for data."""
|
||||||
|
await self.client.start_listen()
|
||||||
|
|
||||||
|
def stop_listen(self):
|
||||||
|
"""Stop listening for data."""
|
||||||
|
self.client.stop_listen()
|
||||||
|
|
||||||
|
async def wait_for_ready(
|
||||||
|
self, ready_callback: Callable[[bool], Awaitable[bool]]
|
||||||
|
) -> bool:
|
||||||
|
"""Wait for the client to be ready."""
|
||||||
|
|
||||||
|
if not self.data or Attribute.MAC_ADDRESS not in self.data:
|
||||||
|
data = await self.client.wait_for_response(
|
||||||
|
FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data or Attribute.MAC_ADDRESS not in data:
|
||||||
|
_LOGGER.error("Missing MAC address")
|
||||||
|
await ready_callback(False)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.data or Attribute.NAME not in self.data:
|
||||||
|
await self.client.wait_for_response(
|
||||||
|
FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
|
||||||
|
await self.client.wait_for_response(
|
||||||
|
FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not self.data
|
||||||
|
or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data
|
||||||
|
):
|
||||||
|
await self.client.wait_for_response(
|
||||||
|
FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
await ready_callback(True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_name(self) -> str:
|
||||||
|
"""Get the name of the thermostat."""
|
||||||
|
|
||||||
|
return self.create_device_name(self.data)
|
||||||
|
|
||||||
|
def create_device_name(self, data: Optional[dict[str, Any]]) -> str:
|
||||||
|
"""Create the name of the thermostat."""
|
||||||
|
|
||||||
|
name = data.get(Attribute.NAME) if data else None
|
||||||
|
|
||||||
|
return name if name else "Aprilaire"
|
||||||
|
|
||||||
|
def get_hw_version(self, data: dict[str, Any]) -> str:
|
||||||
|
"""Get the hardware version."""
|
||||||
|
|
||||||
|
if hardware_revision := data.get(Attribute.HARDWARE_REVISION):
|
||||||
|
return (
|
||||||
|
f"Rev. {chr(hardware_revision)}"
|
||||||
|
if hardware_revision > ord("A")
|
||||||
|
else str(hardware_revision)
|
||||||
|
)
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo | None:
|
||||||
|
"""Get the device info for the thermostat."""
|
||||||
|
return self.create_device_info(self.data)
|
||||||
|
|
||||||
|
def create_device_info(self, data: dict[str, Any]) -> DeviceInfo | None:
|
||||||
|
"""Create the device info for the thermostat."""
|
||||||
|
|
||||||
|
if data is None or Attribute.MAC_ADDRESS not in data or self.unique_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self.unique_id)},
|
||||||
|
name=self.create_device_name(data),
|
||||||
|
manufacturer="Aprilaire",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_number = data.get(Attribute.MODEL_NUMBER)
|
||||||
|
if model_number is not None:
|
||||||
|
device_info["model"] = MODELS.get(model_number, f"Unknown ({model_number})")
|
||||||
|
|
||||||
|
device_info["hw_version"] = self.get_hw_version(data)
|
||||||
|
|
||||||
|
firmware_major_revision = data.get(Attribute.FIRMWARE_MAJOR_REVISION)
|
||||||
|
firmware_minor_revision = data.get(Attribute.FIRMWARE_MINOR_REVISION)
|
||||||
|
if firmware_major_revision is not None:
|
||||||
|
device_info["sw_version"] = (
|
||||||
|
str(firmware_major_revision)
|
||||||
|
if firmware_minor_revision is None
|
||||||
|
else f"{firmware_major_revision}.{firmware_minor_revision:02}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return device_info
|
46
homeassistant/components/aprilaire/entity.py
Normal file
46
homeassistant/components/aprilaire/entity.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Base functionality for Aprilaire entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pyaprilaire.const import Attribute
|
||||||
|
|
||||||
|
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
|
||||||
|
|
||||||
|
from .coordinator import AprilaireCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAprilaireEntity(BaseCoordinatorEntity[AprilaireCoordinator]):
|
||||||
|
"""Base for Aprilaire entities."""
|
||||||
|
|
||||||
|
_attr_available = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, coordinator: AprilaireCoordinator, unique_id: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
self._attr_device_info = coordinator.device_info
|
||||||
|
self._attr_unique_id = f"{unique_id}_{self.translation_key}"
|
||||||
|
|
||||||
|
self._update_available()
|
||||||
|
|
||||||
|
def _update_available(self):
|
||||||
|
"""Update the entity availability."""
|
||||||
|
|
||||||
|
connected: bool = self.coordinator.data.get(
|
||||||
|
Attribute.CONNECTED, None
|
||||||
|
) or self.coordinator.data.get(Attribute.RECONNECTING, None)
|
||||||
|
|
||||||
|
stopped: bool = self.coordinator.data.get(Attribute.STOPPED, None)
|
||||||
|
|
||||||
|
self._attr_available = connected and not stopped
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""Implement abstract base method."""
|
11
homeassistant/components/aprilaire/manifest.json
Normal file
11
homeassistant/components/aprilaire/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"domain": "aprilaire",
|
||||||
|
"name": "Aprilaire",
|
||||||
|
"codeowners": ["@chamberlain2007"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/aprilaire",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"loggers": ["pyaprilaire"],
|
||||||
|
"requirements": ["pyaprilaire==0.7.0"]
|
||||||
|
}
|
28
homeassistant/components/aprilaire/strings.json
Normal file
28
homeassistant/components/aprilaire/strings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"port": "Usually 7000 or 8000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"connection_failed": "Connection failed. Please check that the host and port is correct."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"climate": {
|
||||||
|
"thermostat": {
|
||||||
|
"name": "Thermostat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -83,7 +83,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N
|
|||||||
|
|
||||||
except ConnectionFailed:
|
except ConnectionFailed:
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
continue
|
continue
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||||
|
@@ -241,7 +241,7 @@ async def websocket_run(
|
|||||||
# Task contains a timeout
|
# Task contains a timeout
|
||||||
async with asyncio.timeout(timeout):
|
async with asyncio.timeout(timeout):
|
||||||
await run_task
|
await run_task
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
pipeline_input.run.process_event(
|
pipeline_input.run.process_event(
|
||||||
PipelineEvent(
|
PipelineEvent(
|
||||||
PipelineEventType.ERROR,
|
PipelineEventType.ERROR,
|
||||||
@@ -487,7 +487,7 @@ async def websocket_device_capture(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
async with asyncio.timeout(timeout_seconds):
|
async with asyncio.timeout(timeout_seconds):
|
||||||
while True:
|
while True:
|
||||||
# Send audio chunks encoded as base64
|
# Send audio chunks encoded as base64
|
||||||
|
@@ -11,6 +11,7 @@ from typing import Any, TypeVar, cast
|
|||||||
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
|
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from pyasuswrt import AsusWrtError, AsusWrtHttp
|
from pyasuswrt import AsusWrtError, AsusWrtHttp
|
||||||
|
from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@@ -354,13 +355,14 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
|||||||
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
|
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Return a dictionary of available sensors for this bridge."""
|
"""Return a dictionary of available sensors for this bridge."""
|
||||||
sensors_temperatures = await self._get_available_temperature_sensors()
|
sensors_temperatures = await self._get_available_temperature_sensors()
|
||||||
|
sensors_loadavg = await self._get_loadavg_sensors_availability()
|
||||||
sensors_types = {
|
sensors_types = {
|
||||||
SENSORS_TYPE_BYTES: {
|
SENSORS_TYPE_BYTES: {
|
||||||
KEY_SENSORS: SENSORS_BYTES,
|
KEY_SENSORS: SENSORS_BYTES,
|
||||||
KEY_METHOD: self._get_bytes,
|
KEY_METHOD: self._get_bytes,
|
||||||
},
|
},
|
||||||
SENSORS_TYPE_LOAD_AVG: {
|
SENSORS_TYPE_LOAD_AVG: {
|
||||||
KEY_SENSORS: SENSORS_LOAD_AVG,
|
KEY_SENSORS: sensors_loadavg,
|
||||||
KEY_METHOD: self._get_load_avg,
|
KEY_METHOD: self._get_load_avg,
|
||||||
},
|
},
|
||||||
SENSORS_TYPE_RATES: {
|
SENSORS_TYPE_RATES: {
|
||||||
@@ -393,6 +395,16 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
|||||||
return []
|
return []
|
||||||
return available_sensors
|
return available_sensors
|
||||||
|
|
||||||
|
async def _get_loadavg_sensors_availability(self) -> list[str]:
|
||||||
|
"""Check if load avg is available on the router."""
|
||||||
|
try:
|
||||||
|
await self._api.async_get_loadavg()
|
||||||
|
except AsusWrtNotAvailableInfoError:
|
||||||
|
return []
|
||||||
|
except AsusWrtError:
|
||||||
|
pass
|
||||||
|
return SENSORS_LOAD_AVG
|
||||||
|
|
||||||
@handle_errors_and_zip(AsusWrtError, SENSORS_BYTES)
|
@handle_errors_and_zip(AsusWrtError, SENSORS_BYTES)
|
||||||
async def _get_bytes(self) -> Any:
|
async def _get_bytes(self) -> Any:
|
||||||
"""Fetch byte information from the router."""
|
"""Fetch byte information from the router."""
|
||||||
|
@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return await async_setup_august(hass, entry, august_gateway)
|
return await async_setup_august(hass, entry, august_gateway)
|
||||||
except (RequireValidation, InvalidAuth) as err:
|
except (RequireValidation, InvalidAuth) as err:
|
||||||
raise ConfigEntryAuthFailed from err
|
raise ConfigEntryAuthFailed from err
|
||||||
except asyncio.TimeoutError as err:
|
except TimeoutError as err:
|
||||||
raise ConfigEntryNotReady("Timed out connecting to august api") from err
|
raise ConfigEntryNotReady("Timed out connecting to august api") from err
|
||||||
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
|
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
@@ -233,7 +233,7 @@ class AugustData(AugustSubscriberMixin):
|
|||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
):
|
):
|
||||||
if isinstance(result, Exception) and not isinstance(
|
if isinstance(result, Exception) and not isinstance(
|
||||||
result, (asyncio.TimeoutError, ClientResponseError, CannotConnect)
|
result, (TimeoutError, ClientResponseError, CannotConnect)
|
||||||
):
|
):
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Unexpected exception during initial sync: %s",
|
"Unexpected exception during initial sync: %s",
|
||||||
@@ -249,10 +249,11 @@ class AugustData(AugustSubscriberMixin):
|
|||||||
device = self.get_device_detail(device_id)
|
device = self.get_device_detail(device_id)
|
||||||
activities = activities_from_pubnub_message(device, date_time, message)
|
activities = activities_from_pubnub_message(device, date_time, message)
|
||||||
activity_stream = self.activity_stream
|
activity_stream = self.activity_stream
|
||||||
if activities:
|
if activities and activity_stream.async_process_newer_device_activities(
|
||||||
activity_stream.async_process_newer_device_activities(activities)
|
activities
|
||||||
|
):
|
||||||
self.async_signal_device_id_update(device.device_id)
|
self.async_signal_device_id_update(device.device_id)
|
||||||
activity_stream.async_schedule_house_id_refresh(device.house_id)
|
activity_stream.async_schedule_house_id_refresh(device.house_id)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_stop(self) -> None:
|
def async_stop(self) -> None:
|
||||||
@@ -292,7 +293,7 @@ class AugustData(AugustSubscriberMixin):
|
|||||||
for device_id in device_ids_list:
|
for device_id in device_ids_list:
|
||||||
try:
|
try:
|
||||||
await self._async_refresh_device_detail_by_id(device_id)
|
await self._async_refresh_device_detail_by_id(device_id)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Timed out calling august api during refresh of device: %s",
|
"Timed out calling august api during refresh of device: %s",
|
||||||
device_id,
|
device_id,
|
||||||
|
@@ -28,5 +28,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pubnub", "yalexs"],
|
"loggers": ["pubnub", "yalexs"],
|
||||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"]
|
"requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"]
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Helpers to resolve client ID/secret."""
|
"""Helpers to resolve client ID/secret."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
@@ -102,7 +101,7 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
|
|||||||
if chunks == 10:
|
if chunks == 10:
|
||||||
break
|
break
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.error("Timeout while looking up redirect_uri %s", url)
|
_LOGGER.error("Timeout while looking up redirect_uri %s", url)
|
||||||
except aiohttp.client_exceptions.ClientSSLError:
|
except aiohttp.client_exceptions.ClientSSLError:
|
||||||
_LOGGER.error("SSL error while looking up redirect_uri %s", url)
|
_LOGGER.error("SSL error while looking up redirect_uri %s", url)
|
||||||
|
@@ -50,7 +50,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||||||
|
|
||||||
if config_entry.version != 3:
|
if config_entry.version != 3:
|
||||||
# Home Assistant 2023.2
|
# Home Assistant 2023.2
|
||||||
config_entry.version = 3
|
hass.config_entries.async_update_entry(config_entry, version=3)
|
||||||
|
|
||||||
_LOGGER.info("Migration to version %s successful", config_entry.version)
|
_LOGGER.info("Migration to version %s successful", config_entry.version)
|
||||||
|
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
"""Axis network device abstraction."""
|
"""Axis network device abstraction."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -270,7 +269,7 @@ async def get_axis_device(
|
|||||||
)
|
)
|
||||||
raise AuthenticationRequired from err
|
raise AuthenticationRequired from err
|
||||||
|
|
||||||
except (asyncio.TimeoutError, axis.RequestError) as err:
|
except (TimeoutError, axis.RequestError) as err:
|
||||||
LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST])
|
LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST])
|
||||||
raise CannotConnect from err
|
raise CannotConnect from err
|
||||||
|
|
||||||
|
@@ -4,11 +4,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tarfile
|
import tarfile
|
||||||
from tarfile import TarError
|
from tarfile import TarError
|
||||||
from tempfile import TemporaryDirectory
|
import time
|
||||||
from typing import Any, Protocol, cast
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
from securetar import SecureTarFile, atomic_contents_add
|
from securetar import SecureTarFile, atomic_contents_add
|
||||||
@@ -17,7 +18,7 @@ from homeassistant.const import __version__ as HAVERSION
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import integration_platform
|
from homeassistant.helpers import integration_platform
|
||||||
from homeassistant.helpers.json import save_json
|
from homeassistant.helpers.json import json_bytes
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.json import json_loads_object
|
from homeassistant.util.json import json_loads_object
|
||||||
|
|
||||||
@@ -81,6 +82,38 @@ class BackupManager:
|
|||||||
return
|
return
|
||||||
self.platforms[integration_domain] = platform
|
self.platforms[integration_domain] = platform
|
||||||
|
|
||||||
|
async def pre_backup_actions(self) -> None:
|
||||||
|
"""Perform pre backup actions."""
|
||||||
|
if not self.loaded_platforms:
|
||||||
|
await self.load_platforms()
|
||||||
|
|
||||||
|
pre_backup_results = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
platform.async_pre_backup(self.hass)
|
||||||
|
for platform in self.platforms.values()
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for result in pre_backup_results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
|
||||||
|
async def post_backup_actions(self) -> None:
|
||||||
|
"""Perform post backup actions."""
|
||||||
|
if not self.loaded_platforms:
|
||||||
|
await self.load_platforms()
|
||||||
|
|
||||||
|
post_backup_results = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
platform.async_post_backup(self.hass)
|
||||||
|
for platform in self.platforms.values()
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for result in post_backup_results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
|
||||||
async def load_backups(self) -> None:
|
async def load_backups(self) -> None:
|
||||||
"""Load data of stored backup files."""
|
"""Load data of stored backup files."""
|
||||||
backups = await self.hass.async_add_executor_job(self._read_backups)
|
backups = await self.hass.async_add_executor_job(self._read_backups)
|
||||||
@@ -159,22 +192,9 @@ class BackupManager:
|
|||||||
if self.backing_up:
|
if self.backing_up:
|
||||||
raise HomeAssistantError("Backup already in progress")
|
raise HomeAssistantError("Backup already in progress")
|
||||||
|
|
||||||
if not self.loaded_platforms:
|
|
||||||
await self.load_platforms()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.backing_up = True
|
self.backing_up = True
|
||||||
pre_backup_results = await asyncio.gather(
|
await self.pre_backup_actions()
|
||||||
*(
|
|
||||||
platform.async_pre_backup(self.hass)
|
|
||||||
for platform in self.platforms.values()
|
|
||||||
),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
for result in pre_backup_results:
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
raise result
|
|
||||||
|
|
||||||
backup_name = f"Core {HAVERSION}"
|
backup_name = f"Core {HAVERSION}"
|
||||||
date_str = dt_util.now().isoformat()
|
date_str = dt_util.now().isoformat()
|
||||||
slug = _generate_slug(date_str, backup_name)
|
slug = _generate_slug(date_str, backup_name)
|
||||||
@@ -207,16 +227,7 @@ class BackupManager:
|
|||||||
return backup
|
return backup
|
||||||
finally:
|
finally:
|
||||||
self.backing_up = False
|
self.backing_up = False
|
||||||
post_backup_results = await asyncio.gather(
|
await self.post_backup_actions()
|
||||||
*(
|
|
||||||
platform.async_post_backup(self.hass)
|
|
||||||
for platform in self.platforms.values()
|
|
||||||
),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
for result in post_backup_results:
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
raise result
|
|
||||||
|
|
||||||
def _mkdir_and_generate_backup_contents(
|
def _mkdir_and_generate_backup_contents(
|
||||||
self,
|
self,
|
||||||
@@ -228,18 +239,18 @@ class BackupManager:
|
|||||||
LOGGER.debug("Creating backup directory")
|
LOGGER.debug("Creating backup directory")
|
||||||
self.backup_dir.mkdir()
|
self.backup_dir.mkdir()
|
||||||
|
|
||||||
with TemporaryDirectory() as tmp_dir, SecureTarFile(
|
outer_secure_tarfile = SecureTarFile(
|
||||||
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
|
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
|
||||||
) as tar_file:
|
)
|
||||||
tmp_dir_path = Path(tmp_dir)
|
with outer_secure_tarfile as outer_secure_tarfile_tarfile:
|
||||||
save_json(
|
raw_bytes = json_bytes(backup_data)
|
||||||
tmp_dir_path.joinpath("./backup.json").as_posix(),
|
fileobj = io.BytesIO(raw_bytes)
|
||||||
backup_data,
|
tar_info = tarfile.TarInfo(name="./backup.json")
|
||||||
)
|
tar_info.size = len(raw_bytes)
|
||||||
with SecureTarFile(
|
tar_info.mtime = int(time.time())
|
||||||
tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(),
|
outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
|
||||||
"w",
|
with outer_secure_tarfile.create_inner_tar(
|
||||||
bufsize=BUF_SIZE,
|
"./homeassistant.tar.gz", gzip=True
|
||||||
) as core_tar:
|
) as core_tar:
|
||||||
atomic_contents_add(
|
atomic_contents_add(
|
||||||
tar_file=core_tar,
|
tar_file=core_tar,
|
||||||
@@ -247,7 +258,7 @@ class BackupManager:
|
|||||||
excludes=EXCLUDE_FROM_BACKUP,
|
excludes=EXCLUDE_FROM_BACKUP,
|
||||||
arcname="data",
|
arcname="data",
|
||||||
)
|
)
|
||||||
tar_file.add(tmp_dir_path, arcname=".")
|
|
||||||
return tar_file_path.stat().st_size
|
return tar_file_path.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["securetar==2023.3.0"]
|
"requirements": ["securetar==2024.2.0"]
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""The Big Ass Fans integration."""
|
"""The Big Ass Fans integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
|
|
||||||
from aiobafi6 import Device, Service
|
from aiobafi6 import Device, Service
|
||||||
@@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}"
|
f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}"
|
||||||
) from ex
|
) from ex
|
||||||
except asyncio.TimeoutError as ex:
|
except TimeoutError as ex:
|
||||||
run_future.cancel()
|
run_future.cancel()
|
||||||
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex
|
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex
|
||||||
|
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for baf."""
|
"""Config flow for baf."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -28,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device:
|
|||||||
try:
|
try:
|
||||||
async with timeout(RUN_TIMEOUT):
|
async with timeout(RUN_TIMEOUT):
|
||||||
await device.async_wait_available()
|
await device.async_wait_available()
|
||||||
except asyncio.TimeoutError as ex:
|
except TimeoutError as ex:
|
||||||
raise CannotConnect from ex
|
raise CannotConnect from ex
|
||||||
finally:
|
finally:
|
||||||
run_future.cancel()
|
run_future.cancel()
|
||||||
|
@@ -2,7 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
|
||||||
|
|
||||||
from pyblackbird import get_blackbird
|
from pyblackbird import get_blackbird
|
||||||
from serial import SerialException
|
from serial import SerialException
|
||||||
@@ -93,7 +92,7 @@ def setup_platform(
|
|||||||
try:
|
try:
|
||||||
blackbird = get_blackbird(host, False)
|
blackbird = get_blackbird(host, False)
|
||||||
connection = host
|
connection = host
|
||||||
except socket.timeout:
|
except TimeoutError:
|
||||||
_LOGGER.error("Error connecting to the Blackbird controller")
|
_LOGGER.error("Error connecting to the Blackbird controller")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@@ -62,7 +62,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
|||||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||||
"""Initialize a BleBox light."""
|
"""Initialize a BleBox light."""
|
||||||
super().__init__(feature)
|
super().__init__(feature)
|
||||||
self._attr_supported_color_modes = {self.color_mode}
|
|
||||||
if feature.effect_list:
|
if feature.effect_list:
|
||||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||||
|
|
||||||
@@ -94,6 +93,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
|||||||
|
|
||||||
return color_mode_tmp
|
return color_mode_tmp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_color_modes(self):
|
||||||
|
"""Return supported color modes."""
|
||||||
|
return {self.color_mode}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effect_list(self) -> list[str]:
|
def effect_list(self) -> list[str]:
|
||||||
"""Return the list of supported effects."""
|
"""Return the list of supported effects."""
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
"""Support for Blink Home Camera System."""
|
"""Support for Blink Home Camera System."""
|
||||||
import asyncio
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await blink.start()
|
await blink.start()
|
||||||
except (ClientError, asyncio.TimeoutError) as ex:
|
except (ClientError, TimeoutError) as ex:
|
||||||
raise ConfigEntryNotReady("Can not connect to host") from ex
|
raise ConfigEntryNotReady("Can not connect to host") from ex
|
||||||
|
|
||||||
if blink.auth.check_key_required():
|
if blink.auth.check_key_required():
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Support for Blink Alarm Control Panel."""
|
"""Support for Blink Alarm Control Panel."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from blinkpy.blinkpy import Blink, BlinkSyncModule
|
from blinkpy.blinkpy import Blink, BlinkSyncModule
|
||||||
@@ -91,7 +90,7 @@ class BlinkSyncModuleHA(
|
|||||||
try:
|
try:
|
||||||
await self.sync.async_arm(False)
|
await self.sync.async_arm(False)
|
||||||
|
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError("Blink failed to disarm camera") from er
|
raise HomeAssistantError("Blink failed to disarm camera") from er
|
||||||
|
|
||||||
await self.coordinator.async_refresh()
|
await self.coordinator.async_refresh()
|
||||||
@@ -101,7 +100,7 @@ class BlinkSyncModuleHA(
|
|||||||
try:
|
try:
|
||||||
await self.sync.async_arm(True)
|
await self.sync.async_arm(True)
|
||||||
|
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError("Blink failed to arm camera away") from er
|
raise HomeAssistantError("Blink failed to arm camera away") from er
|
||||||
|
|
||||||
await self.coordinator.async_refresh()
|
await self.coordinator.async_refresh()
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Support for Blink system camera."""
|
"""Support for Blink system camera."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
@@ -96,7 +95,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
|||||||
try:
|
try:
|
||||||
await self._camera.async_arm(True)
|
await self._camera.async_arm(True)
|
||||||
|
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError("Blink failed to arm camera") from er
|
raise HomeAssistantError("Blink failed to arm camera") from er
|
||||||
|
|
||||||
self._camera.motion_enabled = True
|
self._camera.motion_enabled = True
|
||||||
@@ -106,7 +105,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
|||||||
"""Disable motion detection for the camera."""
|
"""Disable motion detection for the camera."""
|
||||||
try:
|
try:
|
||||||
await self._camera.async_arm(False)
|
await self._camera.async_arm(False)
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError("Blink failed to disarm camera") from er
|
raise HomeAssistantError("Blink failed to disarm camera") from er
|
||||||
|
|
||||||
self._camera.motion_enabled = False
|
self._camera.motion_enabled = False
|
||||||
@@ -124,7 +123,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
|
|||||||
|
|
||||||
async def trigger_camera(self) -> None:
|
async def trigger_camera(self) -> None:
|
||||||
"""Trigger camera to take a snapshot."""
|
"""Trigger camera to take a snapshot."""
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
await self._camera.snap_picture()
|
await self._camera.snap_picture()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Support for Blink Motion detection switches."""
|
"""Support for Blink Motion detection switches."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.switch import (
|
from homeassistant.components.switch import (
|
||||||
@@ -74,7 +73,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
|
|||||||
try:
|
try:
|
||||||
await self._camera.async_arm(True)
|
await self._camera.async_arm(True)
|
||||||
|
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Blink failed to arm camera motion detection"
|
"Blink failed to arm camera motion detection"
|
||||||
) from er
|
) from er
|
||||||
@@ -86,7 +85,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
|
|||||||
try:
|
try:
|
||||||
await self._camera.async_arm(False)
|
await self._camera.async_arm(False)
|
||||||
|
|
||||||
except asyncio.TimeoutError as er:
|
except TimeoutError as er:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Blink failed to dis-arm camera motion detection"
|
"Blink failed to dis-arm camera motion detection"
|
||||||
) from er
|
) from er
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
"""The Blue Current integration."""
|
"""The Blue Current integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -14,8 +15,13 @@ from bluecurrent_api.exceptions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform
|
from homeassistant.const import (
|
||||||
from homeassistant.core import HomeAssistant
|
ATTR_NAME,
|
||||||
|
CONF_API_TOKEN,
|
||||||
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
|
from homeassistant.core import Event, HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.event import async_call_later
|
from homeassistant.helpers.event import async_call_later
|
||||||
@@ -47,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
except BlueCurrentException as err:
|
except BlueCurrentException as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
hass.async_create_task(connector.start_loop())
|
hass.async_create_background_task(connector.start_loop(), "blue_current-websocket")
|
||||||
await client.get_charge_points()
|
await client.get_charge_points()
|
||||||
|
|
||||||
await client.wait_for_response()
|
await client.wait_for_response()
|
||||||
@@ -56,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
|
|
||||||
config_entry.async_on_unload(connector.disconnect)
|
config_entry.async_on_unload(connector.disconnect)
|
||||||
|
|
||||||
|
async def _async_disconnect_websocket(_: Event) -> None:
|
||||||
|
await connector.disconnect()
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -78,9 +89,9 @@ class Connector:
|
|||||||
self, hass: HomeAssistant, config: ConfigEntry, client: Client
|
self, hass: HomeAssistant, config: ConfigEntry, client: Client
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
self.config: ConfigEntry = config
|
self.config = config
|
||||||
self.hass: HomeAssistant = hass
|
self.hass = hass
|
||||||
self.client: Client = client
|
self.client = client
|
||||||
self.charge_points: dict[str, dict] = {}
|
self.charge_points: dict[str, dict] = {}
|
||||||
self.grid: dict[str, Any] = {}
|
self.grid: dict[str, Any] = {}
|
||||||
self.available = False
|
self.available = False
|
||||||
@@ -93,22 +104,12 @@ class Connector:
|
|||||||
async def on_data(self, message: dict) -> None:
|
async def on_data(self, message: dict) -> None:
|
||||||
"""Handle received data."""
|
"""Handle received data."""
|
||||||
|
|
||||||
async def handle_charge_points(data: list) -> None:
|
|
||||||
"""Loop over the charge points and get their data."""
|
|
||||||
for entry in data:
|
|
||||||
evse_id = entry[EVSE_ID]
|
|
||||||
model = entry[MODEL_TYPE]
|
|
||||||
name = entry[ATTR_NAME]
|
|
||||||
self.add_charge_point(evse_id, model, name)
|
|
||||||
await self.get_charge_point_data(evse_id)
|
|
||||||
await self.client.get_grid_status(data[0][EVSE_ID])
|
|
||||||
|
|
||||||
object_name: str = message[OBJECT]
|
object_name: str = message[OBJECT]
|
||||||
|
|
||||||
# gets charge point ids
|
# gets charge point ids
|
||||||
if object_name == CHARGE_POINTS:
|
if object_name == CHARGE_POINTS:
|
||||||
charge_points_data: list = message[DATA]
|
charge_points_data: list = message[DATA]
|
||||||
await handle_charge_points(charge_points_data)
|
await self.handle_charge_point_data(charge_points_data)
|
||||||
|
|
||||||
# gets charge point key / values
|
# gets charge point key / values
|
||||||
elif object_name in VALUE_TYPES:
|
elif object_name in VALUE_TYPES:
|
||||||
@@ -122,8 +123,21 @@ class Connector:
|
|||||||
self.grid = data
|
self.grid = data
|
||||||
self.dispatch_grid_update_signal()
|
self.dispatch_grid_update_signal()
|
||||||
|
|
||||||
async def get_charge_point_data(self, evse_id: str) -> None:
|
async def handle_charge_point_data(self, charge_points_data: list) -> None:
|
||||||
"""Get all the data of a charge point."""
|
"""Handle incoming chargepoint data."""
|
||||||
|
await asyncio.gather(
|
||||||
|
*(
|
||||||
|
self.handle_charge_point(
|
||||||
|
entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME]
|
||||||
|
)
|
||||||
|
for entry in charge_points_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.client.get_grid_status(charge_points_data[0][EVSE_ID])
|
||||||
|
|
||||||
|
async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None:
|
||||||
|
"""Add the chargepoint and request their data."""
|
||||||
|
self.add_charge_point(evse_id, model, name)
|
||||||
await self.client.get_status(evse_id)
|
await self.client.get_status(evse_id)
|
||||||
|
|
||||||
def add_charge_point(self, evse_id: str, model: str, name: str) -> None:
|
def add_charge_point(self, evse_id: str, model: str, name: str) -> None:
|
||||||
@@ -159,9 +173,8 @@ class Connector:
|
|||||||
"""Keep trying to reconnect to the websocket."""
|
"""Keep trying to reconnect to the websocket."""
|
||||||
try:
|
try:
|
||||||
await self.connect(self.config.data[CONF_API_TOKEN])
|
await self.connect(self.config.data[CONF_API_TOKEN])
|
||||||
LOGGER.info("Reconnected to the Blue Current websocket")
|
LOGGER.debug("Reconnected to the Blue Current websocket")
|
||||||
self.hass.async_create_task(self.start_loop())
|
self.hass.async_create_task(self.start_loop())
|
||||||
await self.client.get_charge_points()
|
|
||||||
except RequestLimitReached:
|
except RequestLimitReached:
|
||||||
self.available = False
|
self.available = False
|
||||||
async_call_later(
|
async_call_later(
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
"""Entity representing a Blue Current charge point."""
|
"""Entity representing a Blue Current charge point."""
|
||||||
|
from abc import abstractmethod
|
||||||
|
|
||||||
from homeassistant.const import ATTR_NAME
|
from homeassistant.const import ATTR_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
@@ -17,9 +19,9 @@ class BlueCurrentEntity(Entity):
|
|||||||
|
|
||||||
def __init__(self, connector: Connector, signal: str) -> None:
|
def __init__(self, connector: Connector, signal: str) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
self.connector: Connector = connector
|
self.connector = connector
|
||||||
self.signal: str = signal
|
self.signal = signal
|
||||||
self.has_value: bool = False
|
self.has_value = False
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
@@ -40,9 +42,9 @@ class BlueCurrentEntity(Entity):
|
|||||||
return self.connector.available and self.has_value
|
return self.connector.available and self.has_value
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
@abstractmethod
|
||||||
def update_from_latest_data(self) -> None:
|
def update_from_latest_data(self) -> None:
|
||||||
"""Update the entity from the latest data."""
|
"""Update the entity from the latest data."""
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class ChargepointEntity(BlueCurrentEntity):
|
class ChargepointEntity(BlueCurrentEntity):
|
||||||
@@ -50,6 +52,8 @@ class ChargepointEntity(BlueCurrentEntity):
|
|||||||
|
|
||||||
def __init__(self, connector: Connector, evse_id: str) -> None:
|
def __init__(self, connector: Connector, evse_id: str) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
|
super().__init__(connector, f"{DOMAIN}_value_update_{evse_id}")
|
||||||
|
|
||||||
chargepoint_name = connector.charge_points[evse_id][ATTR_NAME]
|
chargepoint_name = connector.charge_points[evse_id][ATTR_NAME]
|
||||||
|
|
||||||
self.evse_id = evse_id
|
self.evse_id = evse_id
|
||||||
@@ -59,5 +63,3 @@ class ChargepointEntity(BlueCurrentEntity):
|
|||||||
manufacturer="Blue Current",
|
manufacturer="Blue Current",
|
||||||
model=connector.charge_points[evse_id][MODEL_TYPE],
|
model=connector.charge_points[evse_id][MODEL_TYPE],
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}")
|
|
||||||
|
@@ -13,7 +13,6 @@
|
|||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"limit_reached": "Request limit reached",
|
"limit_reached": "Request limit reached",
|
||||||
"invalid_token": "Invalid token",
|
"invalid_token": "Invalid token",
|
||||||
"no_cards_found": "No charge cards found",
|
|
||||||
"already_connected": "Already connected",
|
"already_connected": "Already connected",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
|
@@ -290,7 +290,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
while True:
|
while True:
|
||||||
await self.async_update_status()
|
await self.async_update_status()
|
||||||
|
|
||||||
except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException):
|
except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException):
|
||||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
|
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||||
self.start_polling()
|
self.start_polling()
|
||||||
@@ -317,7 +317,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
self._retry_remove = None
|
self._retry_remove = None
|
||||||
|
|
||||||
await self.force_update_sync_status(self._init_callback, True)
|
await self.force_update_sync_status(self._init_callback, True)
|
||||||
except (asyncio.TimeoutError, ClientError):
|
except (TimeoutError, ClientError):
|
||||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
|
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
|
||||||
self._retry_remove = async_track_time_interval(
|
self._retry_remove = async_track_time_interval(
|
||||||
self._hass, self.async_init, NODE_RETRY_INITIATION
|
self._hass, self.async_init, NODE_RETRY_INITIATION
|
||||||
@@ -370,7 +370,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
_LOGGER.error("Error %s on %s", response.status, url)
|
_LOGGER.error("Error %s on %s", response.status, url)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
if raise_timeout:
|
if raise_timeout:
|
||||||
_LOGGER.info("Timeout: %s:%s", self.host, self.port)
|
_LOGGER.info("Timeout: %s:%s", self.host, self.port)
|
||||||
raise
|
raise
|
||||||
@@ -437,7 +437,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
"Error %s on %s. Trying one more time", response.status, url
|
"Error %s on %s. Trying one more time", response.status, url
|
||||||
)
|
)
|
||||||
|
|
||||||
except (asyncio.TimeoutError, ClientError):
|
except (TimeoutError, ClientError):
|
||||||
self._is_online = False
|
self._is_online = False
|
||||||
self._last_status_update = None
|
self._last_status_update = None
|
||||||
self._status = None
|
self._status = None
|
||||||
|
@@ -90,6 +90,8 @@ def seen_all_fields(
|
|||||||
class IntegrationMatcher:
|
class IntegrationMatcher:
|
||||||
"""Integration matcher for the bluetooth integration."""
|
"""Integration matcher for the bluetooth integration."""
|
||||||
|
|
||||||
|
__slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index")
|
||||||
|
|
||||||
def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None:
|
def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None:
|
||||||
"""Initialize the matcher."""
|
"""Initialize the matcher."""
|
||||||
self._integration_matchers = integration_matchers
|
self._integration_matchers = integration_matchers
|
||||||
@@ -159,6 +161,16 @@ class BluetoothMatcherIndexBase(Generic[_T]):
|
|||||||
any bucket and we can quickly reject the service info as not matching.
|
any bucket and we can quickly reject the service info as not matching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"local_name",
|
||||||
|
"service_uuid",
|
||||||
|
"service_data_uuid",
|
||||||
|
"manufacturer_id",
|
||||||
|
"service_uuid_set",
|
||||||
|
"service_data_uuid_set",
|
||||||
|
"manufacturer_id_set",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize the matcher index."""
|
"""Initialize the matcher index."""
|
||||||
self.local_name: dict[str, list[_T]] = {}
|
self.local_name: dict[str, list[_T]] = {}
|
||||||
@@ -285,6 +297,8 @@ class BluetoothCallbackMatcherIndex(
|
|||||||
Supports matching on addresses.
|
Supports matching on addresses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("address", "connectable")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize the matcher index."""
|
"""Initialize the matcher index."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@@ -649,7 +649,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce
|
|||||||
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
|
||||||
if device_id is None:
|
if device_id is None:
|
||||||
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)}
|
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)}
|
||||||
self._attr_name = processor.entity_names.get(entity_key)
|
if (name := processor.entity_names.get(entity_key)) is not None:
|
||||||
|
self._attr_name = name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Tracking for bluetooth low energy devices."""
|
"""Tracking for bluetooth low energy devices."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -155,7 +154,7 @@ async def async_setup_scanner( # noqa: C901
|
|||||||
async with BleakClient(device) as client:
|
async with BleakClient(device) as client:
|
||||||
bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
|
bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
|
||||||
battery = ord(bat_char)
|
battery = ord(bat_char)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Timeout when trying to get battery status for %s", service_info.name
|
"Timeout when trying to get battery status for %s", service_info.name
|
||||||
)
|
)
|
||||||
|
@@ -10,7 +10,11 @@ import voluptuous as vol
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
|
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import discovery, entity_registry as er
|
from homeassistant.helpers import (
|
||||||
|
device_registry as dr,
|
||||||
|
discovery,
|
||||||
|
entity_registry as er,
|
||||||
|
)
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@@ -146,6 +150,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up vehicles which are not assigned to the account anymore
|
||||||
|
account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles}
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
device_entries = dr.async_entries_for_config_entry(
|
||||||
|
device_registry, config_entry_id=entry.entry_id
|
||||||
|
)
|
||||||
|
for device in device_entries:
|
||||||
|
if not device.identifiers.intersection(account_vehicles):
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device.id, remove_config_entry_id=entry.entry_id
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
"""The Bond integration."""
|
"""The Bond integration."""
|
||||||
from asyncio import TimeoutError as AsyncIOTimeoutError
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -56,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.error("Bond token no longer valid: %s", ex)
|
_LOGGER.error("Bond token no longer valid: %s", ex)
|
||||||
return False
|
return False
|
||||||
raise ConfigEntryNotReady from ex
|
raise ConfigEntryNotReady from ex
|
||||||
except (ClientError, AsyncIOTimeoutError, OSError) as error:
|
except (ClientError, TimeoutError, OSError) as error:
|
||||||
raise ConfigEntryNotReady from error
|
raise ConfigEntryNotReady from error
|
||||||
|
|
||||||
bpup_subs = BPUPSubscriptions()
|
bpup_subs = BPUPSubscriptions()
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for Bond integration."""
|
"""Config flow for Bond integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
@@ -87,7 +86,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
try:
|
try:
|
||||||
if not (token := await async_get_token(self.hass, host)):
|
if not (token := await async_get_token(self.hass, host)):
|
||||||
return
|
return
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._discovered[CONF_ACCESS_TOKEN] = token
|
self._discovered[CONF_ACCESS_TOKEN] = token
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from asyncio import Lock, TimeoutError as AsyncIOTimeoutError
|
from asyncio import Lock
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ class BondEntity(Entity):
|
|||||||
"""Fetch via the API."""
|
"""Fetch via the API."""
|
||||||
try:
|
try:
|
||||||
state: dict = await self._hub.bond.device_state(self._device_id)
|
state: dict = await self._hub.bond.device_state(self._device_id)
|
||||||
except (ClientError, AsyncIOTimeoutError, OSError) as error:
|
except (ClientError, TimeoutError, OSError) as error:
|
||||||
if self.available:
|
if self.available:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Entity %s has become unavailable", self.entity_id, exc_info=error
|
"Entity %s has become unavailable", self.entity_id, exc_info=error
|
||||||
|
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from python_bring_api.bring import Bring
|
from bring_api.bring import Bring
|
||||||
from python_bring_api.exceptions import (
|
from bring_api.exceptions import (
|
||||||
BringAuthException,
|
BringAuthException,
|
||||||
BringParseException,
|
BringParseException,
|
||||||
BringRequestException,
|
BringRequestException,
|
||||||
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import BringDataUpdateCoordinator
|
from .coordinator import BringDataUpdateCoordinator
|
||||||
@@ -29,14 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
email = entry.data[CONF_EMAIL]
|
email = entry.data[CONF_EMAIL]
|
||||||
password = entry.data[CONF_PASSWORD]
|
password = entry.data[CONF_PASSWORD]
|
||||||
|
|
||||||
bring = Bring(email, password)
|
session = async_get_clientsession(hass)
|
||||||
|
bring = Bring(session, email, password)
|
||||||
def login_and_load_lists() -> None:
|
|
||||||
bring.login()
|
|
||||||
bring.loadLists()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await hass.async_add_executor_job(login_and_load_lists)
|
await bring.login()
|
||||||
|
await bring.loadLists()
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"Timeout while connecting for email '{email}'"
|
f"Timeout while connecting for email '{email}'"
|
||||||
|
@@ -4,13 +4,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from python_bring_api.bring import Bring
|
from bring_api.bring import Bring
|
||||||
from python_bring_api.exceptions import BringAuthException, BringRequestException
|
from bring_api.exceptions import BringAuthException, BringRequestException
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
TextSelector,
|
TextSelector,
|
||||||
TextSelectorConfig,
|
TextSelectorConfig,
|
||||||
@@ -48,14 +49,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
|
session = async_get_clientsession(self.hass)
|
||||||
|
bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
|
||||||
def login_and_load_lists() -> None:
|
|
||||||
bring.login()
|
|
||||||
bring.loadLists()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(login_and_load_lists)
|
await bring.login()
|
||||||
|
await bring.loadLists()
|
||||||
except BringRequestException:
|
except BringRequestException:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except BringAuthException:
|
except BringAuthException:
|
||||||
|
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from python_bring_api.bring import Bring
|
from bring_api.bring import Bring
|
||||||
from python_bring_api.exceptions import BringParseException, BringRequestException
|
from bring_api.exceptions import BringParseException, BringRequestException
|
||||||
from python_bring_api.types import BringItemsResponse, BringList
|
from bring_api.types import BringItemsResponse, BringList
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -40,9 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, BringData]:
|
async def _async_update_data(self) -> dict[str, BringData]:
|
||||||
try:
|
try:
|
||||||
lists_response = await self.hass.async_add_executor_job(
|
lists_response = await self.bring.loadLists()
|
||||||
self.bring.loadLists
|
|
||||||
)
|
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
|
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
|
||||||
except BringParseException as e:
|
except BringParseException as e:
|
||||||
@@ -51,9 +49,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
|||||||
list_dict = {}
|
list_dict = {}
|
||||||
for lst in lists_response["lists"]:
|
for lst in lists_response["lists"]:
|
||||||
try:
|
try:
|
||||||
items = await self.hass.async_add_executor_job(
|
items = await self.bring.getItems(lst["listUuid"])
|
||||||
self.bring.getItems, lst["listUuid"]
|
|
||||||
)
|
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
"Unable to connect and retrieve data from bring"
|
"Unable to connect and retrieve data from bring"
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/bring",
|
"documentation": "https://www.home-assistant.io/integrations/bring",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["python-bring-api==2.0.0"]
|
"requirements": ["bring-api==0.1.1"]
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_bring_api.exceptions import BringRequestException
|
from bring_api.exceptions import BringRequestException
|
||||||
|
|
||||||
from homeassistant.components.todo import (
|
from homeassistant.components.todo import (
|
||||||
TodoItem,
|
TodoItem,
|
||||||
@@ -75,8 +75,8 @@ class BringTodoListEntity(
|
|||||||
"""Return the todo items."""
|
"""Return the todo items."""
|
||||||
return [
|
return [
|
||||||
TodoItem(
|
TodoItem(
|
||||||
uid=item["name"],
|
uid=item["itemId"],
|
||||||
summary=item["name"],
|
summary=item["itemId"],
|
||||||
description=item["specification"] or "",
|
description=item["specification"] or "",
|
||||||
status=TodoItemStatus.NEEDS_ACTION,
|
status=TodoItemStatus.NEEDS_ACTION,
|
||||||
)
|
)
|
||||||
@@ -91,11 +91,8 @@ class BringTodoListEntity(
|
|||||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||||
"""Add an item to the To-do list."""
|
"""Add an item to the To-do list."""
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.saveItem(
|
||||||
self.coordinator.bring.saveItem,
|
self.bring_list["listUuid"], item.summary, item.description or ""
|
||||||
self.bring_list["listUuid"],
|
|
||||||
item.summary,
|
|
||||||
item.description or "",
|
|
||||||
)
|
)
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise HomeAssistantError("Unable to save todo item for bring") from e
|
raise HomeAssistantError("Unable to save todo item for bring") from e
|
||||||
@@ -126,16 +123,14 @@ class BringTodoListEntity(
|
|||||||
assert item.uid
|
assert item.uid
|
||||||
|
|
||||||
if item.status == TodoItemStatus.COMPLETED:
|
if item.status == TodoItemStatus.COMPLETED:
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.removeItem(
|
||||||
self.coordinator.bring.removeItem,
|
|
||||||
bring_list["listUuid"],
|
bring_list["listUuid"],
|
||||||
item.uid,
|
item.uid,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif item.summary == item.uid:
|
elif item.summary == item.uid:
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.updateItem(
|
||||||
self.coordinator.bring.updateItem,
|
|
||||||
bring_list["listUuid"],
|
bring_list["listUuid"],
|
||||||
item.uid,
|
item.uid,
|
||||||
item.description or "",
|
item.description or "",
|
||||||
@@ -144,13 +139,11 @@ class BringTodoListEntity(
|
|||||||
raise HomeAssistantError("Unable to update todo item for bring") from e
|
raise HomeAssistantError("Unable to update todo item for bring") from e
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.removeItem(
|
||||||
self.coordinator.bring.removeItem,
|
|
||||||
bring_list["listUuid"],
|
bring_list["listUuid"],
|
||||||
item.uid,
|
item.uid,
|
||||||
)
|
)
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.saveItem(
|
||||||
self.coordinator.bring.saveItem,
|
|
||||||
bring_list["listUuid"],
|
bring_list["listUuid"],
|
||||||
item.summary,
|
item.summary,
|
||||||
item.description or "",
|
item.description or "",
|
||||||
@@ -164,8 +157,8 @@ class BringTodoListEntity(
|
|||||||
"""Delete an item from the To-do list."""
|
"""Delete an item from the To-do list."""
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(
|
await self.coordinator.bring.removeItem(
|
||||||
self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid
|
self.bring_list["listUuid"], uid
|
||||||
)
|
)
|
||||||
except BringRequestException as e:
|
except BringRequestException as e:
|
||||||
raise HomeAssistantError("Unable to delete todo item for bring") from e
|
raise HomeAssistantError("Unable to delete todo item for bring") from e
|
||||||
|
105
homeassistant/components/brother/icons.json
Normal file
105
homeassistant/components/brother/icons.json
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"belt_unit_remaining_life": {
|
||||||
|
"default": "mdi:current-ac"
|
||||||
|
},
|
||||||
|
"black_drum_page_counter": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"black_drum_remaining_life": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"black_drum_remaining_pages": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"black_toner_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"black_ink_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"bw_pages": {
|
||||||
|
"default": "mdi:file-document-outline"
|
||||||
|
},
|
||||||
|
"color_pages": {
|
||||||
|
"default": "mdi:file-document-outline"
|
||||||
|
},
|
||||||
|
"cyan_drum_page_counter": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"cyan_drum_remaining_life": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"cyan_drum_remaining_pages": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"cyan_ink_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"cyan_toner_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"drum_page_counter": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"drum_remaining_life": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"drum_remaining_pages": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"duplex_unit_page_counter": {
|
||||||
|
"default": "mdi:file-document-outline"
|
||||||
|
},
|
||||||
|
"fuser_remaining_life": {
|
||||||
|
"default": "mdi:water-outline"
|
||||||
|
},
|
||||||
|
"laser_remaining_life": {
|
||||||
|
"default": "mdi:spotlight-beam"
|
||||||
|
},
|
||||||
|
"magenta_drum_page_counter": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"magenta_drum_remaining_life": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"magenta_drum_remaining_pages": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"magenta_ink_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"magenta_toner_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"default": "mdi:printer"
|
||||||
|
},
|
||||||
|
"page_counter": {
|
||||||
|
"default": "mdi:file-document-outline"
|
||||||
|
},
|
||||||
|
"pf_kit_1_remaining_life": {
|
||||||
|
"default": "mdi:printer-3d"
|
||||||
|
},
|
||||||
|
"pf_kit_mp_remaining_life": {
|
||||||
|
"default": "mdi:printer-3d"
|
||||||
|
},
|
||||||
|
"yellow_drum_page_counter": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"yellow_drum_remaining_life": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"yellow_drum_remaining_pages": {
|
||||||
|
"default": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
"yellow_ink_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
"yellow_toner_remaining": {
|
||||||
|
"default": "mdi:printer-3d-nozzle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -52,14 +52,12 @@ class BrotherSensorEntityDescription(
|
|||||||
SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="status",
|
key="status",
|
||||||
icon="mdi:printer",
|
|
||||||
translation_key="status",
|
translation_key="status",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
value=lambda data: data.status,
|
value=lambda data: data.status,
|
||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="page_counter",
|
key="page_counter",
|
||||||
icon="mdi:file-document-outline",
|
|
||||||
translation_key="page_counter",
|
translation_key="page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -68,7 +66,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="bw_counter",
|
key="bw_counter",
|
||||||
icon="mdi:file-document-outline",
|
|
||||||
translation_key="bw_pages",
|
translation_key="bw_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -77,7 +74,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="color_counter",
|
key="color_counter",
|
||||||
icon="mdi:file-document-outline",
|
|
||||||
translation_key="color_pages",
|
translation_key="color_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -86,7 +82,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="duplex_unit_pages_counter",
|
key="duplex_unit_pages_counter",
|
||||||
icon="mdi:file-document-outline",
|
|
||||||
translation_key="duplex_unit_page_counter",
|
translation_key="duplex_unit_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -95,7 +90,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="drum_remaining_life",
|
key="drum_remaining_life",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="drum_remaining_life",
|
translation_key="drum_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -104,7 +98,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="drum_remaining_pages",
|
key="drum_remaining_pages",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="drum_remaining_pages",
|
translation_key="drum_remaining_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -113,7 +106,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="drum_counter",
|
key="drum_counter",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="drum_page_counter",
|
translation_key="drum_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -122,7 +114,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="black_drum_remaining_life",
|
key="black_drum_remaining_life",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="black_drum_remaining_life",
|
translation_key="black_drum_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -131,7 +122,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="black_drum_remaining_pages",
|
key="black_drum_remaining_pages",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="black_drum_remaining_pages",
|
translation_key="black_drum_remaining_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -140,7 +130,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="black_drum_counter",
|
key="black_drum_counter",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="black_drum_page_counter",
|
translation_key="black_drum_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -149,7 +138,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="cyan_drum_remaining_life",
|
key="cyan_drum_remaining_life",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="cyan_drum_remaining_life",
|
translation_key="cyan_drum_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -158,7 +146,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="cyan_drum_remaining_pages",
|
key="cyan_drum_remaining_pages",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="cyan_drum_remaining_pages",
|
translation_key="cyan_drum_remaining_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -167,7 +154,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="cyan_drum_counter",
|
key="cyan_drum_counter",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="cyan_drum_page_counter",
|
translation_key="cyan_drum_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -176,7 +162,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="magenta_drum_remaining_life",
|
key="magenta_drum_remaining_life",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="magenta_drum_remaining_life",
|
translation_key="magenta_drum_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -185,7 +170,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="magenta_drum_remaining_pages",
|
key="magenta_drum_remaining_pages",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="magenta_drum_remaining_pages",
|
translation_key="magenta_drum_remaining_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -194,7 +178,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="magenta_drum_counter",
|
key="magenta_drum_counter",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="magenta_drum_page_counter",
|
translation_key="magenta_drum_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -203,7 +186,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="yellow_drum_remaining_life",
|
key="yellow_drum_remaining_life",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="yellow_drum_remaining_life",
|
translation_key="yellow_drum_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -212,7 +194,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="yellow_drum_remaining_pages",
|
key="yellow_drum_remaining_pages",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="yellow_drum_remaining_pages",
|
translation_key="yellow_drum_remaining_pages",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -221,7 +202,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="yellow_drum_counter",
|
key="yellow_drum_counter",
|
||||||
icon="mdi:chart-donut",
|
|
||||||
translation_key="yellow_drum_page_counter",
|
translation_key="yellow_drum_page_counter",
|
||||||
native_unit_of_measurement=UNIT_PAGES,
|
native_unit_of_measurement=UNIT_PAGES,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -230,7 +210,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="belt_unit_remaining_life",
|
key="belt_unit_remaining_life",
|
||||||
icon="mdi:current-ac",
|
|
||||||
translation_key="belt_unit_remaining_life",
|
translation_key="belt_unit_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -239,7 +218,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="fuser_remaining_life",
|
key="fuser_remaining_life",
|
||||||
icon="mdi:water-outline",
|
|
||||||
translation_key="fuser_remaining_life",
|
translation_key="fuser_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -248,7 +226,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="laser_remaining_life",
|
key="laser_remaining_life",
|
||||||
icon="mdi:spotlight-beam",
|
|
||||||
translation_key="laser_remaining_life",
|
translation_key="laser_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -257,7 +234,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="pf_kit_1_remaining_life",
|
key="pf_kit_1_remaining_life",
|
||||||
icon="mdi:printer-3d",
|
|
||||||
translation_key="pf_kit_1_remaining_life",
|
translation_key="pf_kit_1_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -266,7 +242,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="pf_kit_mp_remaining_life",
|
key="pf_kit_mp_remaining_life",
|
||||||
icon="mdi:printer-3d",
|
|
||||||
translation_key="pf_kit_mp_remaining_life",
|
translation_key="pf_kit_mp_remaining_life",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -275,7 +250,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="black_toner_remaining",
|
key="black_toner_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="black_toner_remaining",
|
translation_key="black_toner_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -284,7 +258,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="cyan_toner_remaining",
|
key="cyan_toner_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="cyan_toner_remaining",
|
translation_key="cyan_toner_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -293,7 +266,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="magenta_toner_remaining",
|
key="magenta_toner_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="magenta_toner_remaining",
|
translation_key="magenta_toner_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -302,7 +274,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="yellow_toner_remaining",
|
key="yellow_toner_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="yellow_toner_remaining",
|
translation_key="yellow_toner_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -311,7 +282,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="black_ink_remaining",
|
key="black_ink_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="black_ink_remaining",
|
translation_key="black_ink_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -320,7 +290,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="cyan_ink_remaining",
|
key="cyan_ink_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="cyan_ink_remaining",
|
translation_key="cyan_ink_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -329,7 +298,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="magenta_ink_remaining",
|
key="magenta_ink_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="magenta_ink_remaining",
|
translation_key="magenta_ink_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
@@ -338,7 +306,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
|
|||||||
),
|
),
|
||||||
BrotherSensorEntityDescription(
|
BrotherSensorEntityDescription(
|
||||||
key="yellow_ink_remaining",
|
key="yellow_ink_remaining",
|
||||||
icon="mdi:printer-3d-nozzle",
|
|
||||||
translation_key="yellow_ink_remaining",
|
translation_key="yellow_ink_remaining",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
@@ -128,7 +128,7 @@ class BuienradarCam(Camera):
|
|||||||
_LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified)
|
_LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
except (TimeoutError, aiohttp.ClientError) as err:
|
||||||
_LOGGER.error("Failed to fetch image, %s", type(err))
|
_LOGGER.error("Failed to fetch image, %s", type(err))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
"""Shared utilities for different supported platforms."""
|
"""Shared utilities for different supported platforms."""
|
||||||
import asyncio
|
|
||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -104,7 +103,7 @@ class BrData:
|
|||||||
result[MESSAGE] = "Got http statuscode: %d" % (resp.status)
|
result[MESSAGE] = "Got http statuscode: %d" % (resp.status)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
except (TimeoutError, aiohttp.ClientError) as err:
|
||||||
result[MESSAGE] = str(err)
|
result[MESSAGE] = str(err)
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
|
@@ -181,7 +181,7 @@ async def _async_get_image(
|
|||||||
that we can scale, however the majority of cases
|
that we can scale, however the majority of cases
|
||||||
are handled.
|
are handled.
|
||||||
"""
|
"""
|
||||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
with suppress(asyncio.CancelledError, TimeoutError):
|
||||||
async with asyncio.timeout(timeout):
|
async with asyncio.timeout(timeout):
|
||||||
image_bytes = (
|
image_bytes = (
|
||||||
await _async_get_stream_image(
|
await _async_get_stream_image(
|
||||||
@@ -891,7 +891,7 @@ async def ws_camera_stream(
|
|||||||
except HomeAssistantError as ex:
|
except HomeAssistantError as ex:
|
||||||
_LOGGER.error("Error requesting stream: %s", ex)
|
_LOGGER.error("Error requesting stream: %s", ex)
|
||||||
connection.send_error(msg["id"], "start_stream_failed", str(ex))
|
connection.send_error(msg["id"], "start_stream_failed", str(ex))
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.error("Timeout getting stream source")
|
_LOGGER.error("Timeout getting stream source")
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"], "start_stream_failed", "Timeout getting stream source"
|
msg["id"], "start_stream_failed", "Timeout getting stream source"
|
||||||
@@ -936,7 +936,7 @@ async def ws_camera_web_rtc_offer(
|
|||||||
except (HomeAssistantError, ValueError) as ex:
|
except (HomeAssistantError, ValueError) as ex:
|
||||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||||
connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex))
|
connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex))
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.error("Timeout handling WebRTC offer")
|
_LOGGER.error("Timeout handling WebRTC offer")
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer"
|
msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer"
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
"""Consts for Cast integration."""
|
"""Consts for Cast integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, TypedDict
|
||||||
|
|
||||||
from pychromecast.controllers.homeassistant import HomeAssistantController
|
|
||||||
|
|
||||||
from homeassistant.helpers.dispatcher import SignalType
|
from homeassistant.helpers.dispatcher import SignalType
|
||||||
|
|
||||||
@@ -33,8 +31,17 @@ SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed")
|
|||||||
|
|
||||||
# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view.
|
# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view.
|
||||||
SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[
|
SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[
|
||||||
HomeAssistantController, str, str, str | None
|
HomeAssistantControllerData, str, str, str | None
|
||||||
] = SignalType("cast_show_view")
|
] = SignalType("cast_show_view")
|
||||||
|
|
||||||
CONF_IGNORE_CEC = "ignore_cec"
|
CONF_IGNORE_CEC = "ignore_cec"
|
||||||
CONF_KNOWN_HOSTS = "known_hosts"
|
CONF_KNOWN_HOSTS = "known_hosts"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantControllerData(TypedDict):
|
||||||
|
"""Data for creating a HomeAssistantController."""
|
||||||
|
|
||||||
|
hass_url: str
|
||||||
|
hass_uuid: str
|
||||||
|
client_id: str | None
|
||||||
|
refresh_token: str
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Helpers to deal with Cast devices."""
|
"""Helpers to deal with Cast devices."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import configparser
|
import configparser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
@@ -183,10 +182,10 @@ class CastStatusListener(
|
|||||||
if self._valid:
|
if self._valid:
|
||||||
self._cast_device.new_media_status(status)
|
self._cast_device.new_media_status(status)
|
||||||
|
|
||||||
def load_media_failed(self, item, error_code):
|
def load_media_failed(self, queue_item_id, error_code):
|
||||||
"""Handle reception of a new MediaStatus."""
|
"""Handle reception of a new MediaStatus."""
|
||||||
if self._valid:
|
if self._valid:
|
||||||
self._cast_device.load_media_failed(item, error_code)
|
self._cast_device.load_media_failed(queue_item_id, error_code)
|
||||||
|
|
||||||
def new_connection_status(self, status):
|
def new_connection_status(self, status):
|
||||||
"""Handle reception of a new ConnectionStatus."""
|
"""Handle reception of a new ConnectionStatus."""
|
||||||
@@ -257,7 +256,7 @@ async def _fetch_playlist(hass, url, supported_content_types):
|
|||||||
playlist_data = (await resp.content.read(64 * 1024)).decode(charset)
|
playlist_data = (await resp.content.read(64 * 1024)).decode(charset)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
raise PlaylistError(f"Could not decode playlist {url}") from err
|
raise PlaylistError(f"Could not decode playlist {url}") from err
|
||||||
except asyncio.TimeoutError as err:
|
except TimeoutError as err:
|
||||||
raise PlaylistError(f"Timeout while fetching playlist {url}") from err
|
raise PlaylistError(f"Timeout while fetching playlist {url}") from err
|
||||||
except aiohttp.client_exceptions.ClientError as err:
|
except aiohttp.client_exceptions.ClientError as err:
|
||||||
raise PlaylistError(f"Error while fetching playlist {url}") from err
|
raise PlaylistError(f"Error while fetching playlist {url}") from err
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Home Assistant Cast integration for Cast."""
|
"""Home Assistant Cast integration for Cast."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pychromecast.controllers.homeassistant import HomeAssistantController
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import auth, config_entries, core
|
from homeassistant import auth, config_entries, core
|
||||||
@@ -11,7 +10,7 @@ from homeassistant.helpers import config_validation as cv, dispatcher, instance_
|
|||||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||||
from homeassistant.helpers.service import async_register_admin_service
|
from homeassistant.helpers.service import async_register_admin_service
|
||||||
|
|
||||||
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW
|
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
|
||||||
|
|
||||||
SERVICE_SHOW_VIEW = "show_lovelace_view"
|
SERVICE_SHOW_VIEW = "show_lovelace_view"
|
||||||
ATTR_VIEW_PATH = "view_path"
|
ATTR_VIEW_PATH = "view_path"
|
||||||
@@ -55,7 +54,7 @@ async def async_setup_ha_cast(
|
|||||||
|
|
||||||
hass_uuid = await instance_id.async_get(hass)
|
hass_uuid = await instance_id.async_get(hass)
|
||||||
|
|
||||||
controller = HomeAssistantController(
|
controller_data = HomeAssistantControllerData(
|
||||||
# If you are developing Home Assistant Cast, uncomment and set to
|
# If you are developing Home Assistant Cast, uncomment and set to
|
||||||
# your dev app id.
|
# your dev app id.
|
||||||
# app_id="5FE44367",
|
# app_id="5FE44367",
|
||||||
@@ -68,7 +67,7 @@ async def async_setup_ha_cast(
|
|||||||
dispatcher.async_dispatcher_send(
|
dispatcher.async_dispatcher_send(
|
||||||
hass,
|
hass,
|
||||||
SIGNAL_HASS_CAST_SHOW_VIEW,
|
SIGNAL_HASS_CAST_SHOW_VIEW,
|
||||||
controller,
|
controller_data,
|
||||||
call.data[ATTR_ENTITY_ID],
|
call.data[ATTR_ENTITY_ID],
|
||||||
call.data[ATTR_VIEW_PATH],
|
call.data[ATTR_VIEW_PATH],
|
||||||
call.data.get(ATTR_URL_PATH),
|
call.data.get(ATTR_URL_PATH),
|
||||||
|
@@ -14,6 +14,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["casttube", "pychromecast"],
|
"loggers": ["casttube", "pychromecast"],
|
||||||
"requirements": ["PyChromecast==13.1.0"],
|
"requirements": ["PyChromecast==14.0.0"],
|
||||||
"zeroconf": ["_googlecast._tcp.local."]
|
"zeroconf": ["_googlecast._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -61,6 +61,7 @@ from .const import (
|
|||||||
SIGNAL_CAST_DISCOVERED,
|
SIGNAL_CAST_DISCOVERED,
|
||||||
SIGNAL_CAST_REMOVED,
|
SIGNAL_CAST_REMOVED,
|
||||||
SIGNAL_HASS_CAST_SHOW_VIEW,
|
SIGNAL_HASS_CAST_SHOW_VIEW,
|
||||||
|
HomeAssistantControllerData,
|
||||||
)
|
)
|
||||||
from .discovery import setup_internal_discovery
|
from .discovery import setup_internal_discovery
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
@@ -389,15 +390,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
|||||||
self.media_status_received = dt_util.utcnow()
|
self.media_status_received = dt_util.utcnow()
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
def load_media_failed(self, item, error_code):
|
def load_media_failed(self, queue_item_id, error_code):
|
||||||
"""Handle load media failed."""
|
"""Handle load media failed."""
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"[%s %s] Load media failed with code %s(%s) for item %s",
|
"[%s %s] Load media failed with code %s(%s) for queue_item_id %s",
|
||||||
self.entity_id,
|
self.entity_id,
|
||||||
self._cast_info.friendly_name,
|
self._cast_info.friendly_name,
|
||||||
error_code,
|
error_code,
|
||||||
MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"),
|
MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"),
|
||||||
item,
|
queue_item_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_connection_status(self, connection_status):
|
def new_connection_status(self, connection_status):
|
||||||
@@ -951,7 +952,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
|||||||
|
|
||||||
def _handle_signal_show_view(
|
def _handle_signal_show_view(
|
||||||
self,
|
self,
|
||||||
controller: HomeAssistantController,
|
controller_data: HomeAssistantControllerData,
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
view_path: str,
|
view_path: str,
|
||||||
url_path: str | None,
|
url_path: str | None,
|
||||||
@@ -961,6 +962,23 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._hass_cast_controller is None:
|
if self._hass_cast_controller is None:
|
||||||
|
|
||||||
|
def unregister() -> None:
|
||||||
|
"""Handle request to unregister the handler."""
|
||||||
|
if not self._hass_cast_controller or not self._chromecast:
|
||||||
|
return
|
||||||
|
_LOGGER.debug(
|
||||||
|
"[%s %s] Unregistering HomeAssistantController",
|
||||||
|
self.entity_id,
|
||||||
|
self._cast_info.friendly_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._chromecast.unregister_handler(self._hass_cast_controller)
|
||||||
|
self._hass_cast_controller = None
|
||||||
|
|
||||||
|
controller = HomeAssistantController(
|
||||||
|
**controller_data, unregister=unregister
|
||||||
|
)
|
||||||
self._hass_cast_controller = controller
|
self._hass_cast_controller = controller
|
||||||
self._chromecast.register_handler(controller)
|
self._chromecast.register_handler(controller)
|
||||||
|
|
||||||
|
@@ -64,8 +64,11 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
|||||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
| ClimateEntityFeature.FAN_MODE
|
| ClimateEntityFeature.FAN_MODE
|
||||||
| ClimateEntityFeature.SWING_MODE
|
| ClimateEntityFeature.SWING_MODE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
| ClimateEntityFeature.TURN_ON
|
||||||
)
|
)
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator
|
self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator
|
||||||
|
@@ -55,7 +55,7 @@ async def get_cert_expiry_timestamp(
|
|||||||
cert = await async_get_cert(hass, hostname, port)
|
cert = await async_get_cert(hass, hostname, port)
|
||||||
except socket.gaierror as err:
|
except socket.gaierror as err:
|
||||||
raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err
|
raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err
|
||||||
except asyncio.TimeoutError as err:
|
except TimeoutError as err:
|
||||||
raise ConnectionTimeout(
|
raise ConnectionTimeout(
|
||||||
f"Connection timeout with server: {hostname}:{port}"
|
f"Connection timeout with server: {hostname}:{port}"
|
||||||
) from err
|
) from err
|
||||||
|
@@ -144,7 +144,7 @@ async def async_citybikes_request(hass, uri, schema):
|
|||||||
|
|
||||||
json_response = await req.json()
|
json_response = await req.json()
|
||||||
return schema(json_response)
|
return schema(json_response)
|
||||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
_LOGGER.error("Could not connect to CityBikes API endpoint")
|
_LOGGER.error("Could not connect to CityBikes API endpoint")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.error("Received non-JSON data from CityBikes API endpoint")
|
_LOGGER.error("Received non-JSON data from CityBikes API endpoint")
|
||||||
|
@@ -339,9 +339,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
|
|
||||||
def _report_turn_on_off(feature: str, method: str) -> None:
|
def _report_turn_on_off(feature: str, method: str) -> None:
|
||||||
"""Log warning not implemented turn on/off feature."""
|
"""Log warning not implemented turn on/off feature."""
|
||||||
module = type(self).__module__
|
|
||||||
if module and "custom_components" not in module:
|
|
||||||
return
|
|
||||||
report_issue = self._suggest_report_issue()
|
report_issue = self._suggest_report_issue()
|
||||||
if feature.startswith("TURN"):
|
if feature.startswith("TURN"):
|
||||||
message = (
|
message = (
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
"""Intents for the client integration."""
|
"""Intents for the client integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -36,24 +37,34 @@ class GetTemperatureIntent(intent.IntentHandler):
|
|||||||
if not entities:
|
if not entities:
|
||||||
raise intent.IntentHandleError("No climate entities")
|
raise intent.IntentHandleError("No climate entities")
|
||||||
|
|
||||||
if "area" in slots:
|
name_slot = slots.get("name", {})
|
||||||
# Filter by area
|
entity_name: str | None = name_slot.get("value")
|
||||||
area_name = slots["area"]["value"]
|
entity_text: str | None = name_slot.get("text")
|
||||||
|
|
||||||
|
area_slot = slots.get("area", {})
|
||||||
|
area_id = area_slot.get("value")
|
||||||
|
|
||||||
|
if area_id:
|
||||||
|
# Filter by area and optionally name
|
||||||
|
area_name = area_slot.get("text")
|
||||||
|
|
||||||
for maybe_climate in intent.async_match_states(
|
for maybe_climate in intent.async_match_states(
|
||||||
hass, area_name=area_name, domains=[DOMAIN]
|
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
|
||||||
):
|
):
|
||||||
climate_state = maybe_climate
|
climate_state = maybe_climate
|
||||||
break
|
break
|
||||||
|
|
||||||
if climate_state is None:
|
if climate_state is None:
|
||||||
raise intent.IntentHandleError(f"No climate entity in area {area_name}")
|
raise intent.NoStatesMatchedError(
|
||||||
|
name=entity_text or entity_name,
|
||||||
|
area=area_name or area_id,
|
||||||
|
domains={DOMAIN},
|
||||||
|
device_classes=None,
|
||||||
|
)
|
||||||
|
|
||||||
climate_entity = component.get_entity(climate_state.entity_id)
|
climate_entity = component.get_entity(climate_state.entity_id)
|
||||||
elif "name" in slots:
|
elif entity_name:
|
||||||
# Filter by name
|
# Filter by name
|
||||||
entity_name = slots["name"]["value"]
|
|
||||||
|
|
||||||
for maybe_climate in intent.async_match_states(
|
for maybe_climate in intent.async_match_states(
|
||||||
hass, name=entity_name, domains=[DOMAIN]
|
hass, name=entity_name, domains=[DOMAIN]
|
||||||
):
|
):
|
||||||
@@ -61,7 +72,12 @@ class GetTemperatureIntent(intent.IntentHandler):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if climate_state is None:
|
if climate_state is None:
|
||||||
raise intent.IntentHandleError(f"No climate entity named {entity_name}")
|
raise intent.NoStatesMatchedError(
|
||||||
|
name=entity_name,
|
||||||
|
area=None,
|
||||||
|
domains={DOMAIN},
|
||||||
|
device_classes=None,
|
||||||
|
)
|
||||||
|
|
||||||
climate_entity = component.get_entity(climate_state.entity_id)
|
climate_entity = component.get_entity(climate_state.entity_id)
|
||||||
else:
|
else:
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Account linking via the cloud."""
|
"""Account linking via the cloud."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -69,7 +68,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
services = await account_link.async_fetch_available_services(hass.data[DOMAIN])
|
services = await account_link.async_fetch_available_services(hass.data[DOMAIN])
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
except (aiohttp.ClientError, TimeoutError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
hass.data[DATA_SERVICES] = services
|
hass.data[DATA_SERVICES] = services
|
||||||
@@ -114,7 +113,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
|
|||||||
try:
|
try:
|
||||||
tokens = await helper.async_get_tokens()
|
tokens = await helper.async_get_tokens()
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.info("Timeout fetching tokens for flow %s", flow_id)
|
_LOGGER.info("Timeout fetching tokens for flow %s", flow_id)
|
||||||
except account_link.AccountLinkException as err:
|
except account_link.AccountLinkException as err:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
|
@@ -246,21 +246,27 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
|||||||
await self._prefs.async_update(
|
await self._prefs.async_update(
|
||||||
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
||||||
)
|
)
|
||||||
async_listen_entity_updates(
|
self._on_deinitialize.append(
|
||||||
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
|
async_listen_entity_updates(
|
||||||
|
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_hass_start(hass: HomeAssistant) -> None:
|
async def on_hass_start(hass: HomeAssistant) -> None:
|
||||||
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
|
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
|
||||||
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
|
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
|
||||||
|
|
||||||
start.async_at_start(self.hass, on_hass_start)
|
self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start))
|
||||||
start.async_at_started(self.hass, on_hass_started)
|
self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started))
|
||||||
|
|
||||||
self._prefs.async_listen_updates(self._async_prefs_updated)
|
self._on_deinitialize.append(
|
||||||
self.hass.bus.async_listen(
|
self._prefs.async_listen_updates(self._async_prefs_updated)
|
||||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
)
|
||||||
self._handle_entity_registry_updated,
|
self._on_deinitialize.append(
|
||||||
|
self.hass.bus.async_listen(
|
||||||
|
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||||
|
self._handle_entity_registry_updated,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||||
@@ -505,7 +511,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning("Timeout trying to sync entities to Alexa")
|
_LOGGER.warning("Timeout trying to sync entities to Alexa")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from hass_nabucasa.client import CloudClient as Interface
|
from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed
|
||||||
|
|
||||||
from homeassistant.components import google_assistant, persistent_notification, webhook
|
from homeassistant.components import google_assistant, persistent_notification, webhook
|
||||||
from homeassistant.components.alexa import (
|
from homeassistant.components.alexa import (
|
||||||
@@ -213,6 +213,10 @@ class CloudClient(Interface):
|
|||||||
"""Cleanup some stuff after logout."""
|
"""Cleanup some stuff after logout."""
|
||||||
await self.prefs.async_set_username(None)
|
await self.prefs.async_set_username(None)
|
||||||
|
|
||||||
|
if self._alexa_config:
|
||||||
|
self._alexa_config.async_deinitialize()
|
||||||
|
self._alexa_config = None
|
||||||
|
|
||||||
if self._google_config:
|
if self._google_config:
|
||||||
self._google_config.async_deinitialize()
|
self._google_config.async_deinitialize()
|
||||||
self._google_config = None
|
self._google_config = None
|
||||||
@@ -230,6 +234,8 @@ class CloudClient(Interface):
|
|||||||
|
|
||||||
async def async_cloud_connect_update(self, connect: bool) -> None:
|
async def async_cloud_connect_update(self, connect: bool) -> None:
|
||||||
"""Process cloud remote message to client."""
|
"""Process cloud remote message to client."""
|
||||||
|
if not self._prefs.remote_allow_remote_enable:
|
||||||
|
raise RemoteActivationNotAllowed
|
||||||
await self._prefs.async_update(remote_enabled=connect)
|
await self._prefs.async_update(remote_enabled=connect)
|
||||||
|
|
||||||
async def async_cloud_connection_info(
|
async def async_cloud_connection_info(
|
||||||
@@ -238,6 +244,7 @@ class CloudClient(Interface):
|
|||||||
"""Process cloud connection info message to client."""
|
"""Process cloud connection info message to client."""
|
||||||
return {
|
return {
|
||||||
"remote": {
|
"remote": {
|
||||||
|
"can_enable": self._prefs.remote_allow_remote_enable,
|
||||||
"connected": self.cloud.remote.is_connected,
|
"connected": self.cloud.remote.is_connected,
|
||||||
"enabled": self._prefs.remote_enabled,
|
"enabled": self._prefs.remote_enabled,
|
||||||
"instance_domain": self.cloud.remote.instance_domain,
|
"instance_domain": self.cloud.remote.instance_domain,
|
||||||
|
@@ -30,6 +30,8 @@ PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
|
|||||||
PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
|
PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
|
||||||
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
|
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
|
||||||
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
||||||
|
PREF_GOOGLE_CONNECTED = "google_connected"
|
||||||
|
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
|
||||||
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
|
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
|
||||||
DEFAULT_DISABLE_2FA = False
|
DEFAULT_DISABLE_2FA = False
|
||||||
DEFAULT_ALEXA_REPORT_STATE = True
|
DEFAULT_ALEXA_REPORT_STATE = True
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user