mirror of
https://github.com/home-assistant/core.git
synced 2025-08-11 08:35:15 +02:00
Merge branch 'dev' into pglab
This commit is contained in:
9
.github/workflows/builder.yml
vendored
9
.github/workflows/builder.yml
vendored
@@ -174,15 +174,6 @@ jobs:
|
||||
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Adjustments for 64-bit
|
||||
if: matrix.arch == 'amd64' || matrix.arch == 'aarch64'
|
||||
run: |
|
||||
# Some speedups are only available on 64-bit, and since
|
||||
# we build 32bit images on 64bit hosts, we only enable
|
||||
# the speed ups on 64bit since the wheels for 32bit
|
||||
# are not available.
|
||||
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@v4.1.4
|
||||
with:
|
||||
|
14
.github/workflows/ci.yaml
vendored
14
.github/workflows/ci.yaml
vendored
@@ -33,7 +33,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 5
|
||||
CACHE_VERSION: 7
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 8
|
||||
HA_SHORT_VERSION: "2024.5"
|
||||
@@ -95,6 +95,7 @@ jobs:
|
||||
run: >-
|
||||
echo "key=venv-${{ env.CACHE_VERSION }}-${{
|
||||
hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{
|
||||
hashFiles('requirements.txt') }}-${{
|
||||
hashFiles('requirements_all.txt') }}-${{
|
||||
hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial pre-commit restore key
|
||||
@@ -484,6 +485,7 @@ jobs:
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libgammu-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
@@ -496,6 +498,7 @@ jobs:
|
||||
pip install "$(grep '^uv' < requirements_test.txt)"
|
||||
uv pip install -U "pip>=21.3.1" setuptools wheel
|
||||
uv pip install -r requirements_all.txt
|
||||
uv pip install "$(grep 'python-gammu' < requirements_all.txt | sed -e 's|# python-gammu|python-gammu|g')"
|
||||
uv pip install -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
|
||||
@@ -688,7 +691,8 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg
|
||||
ffmpeg \
|
||||
libgammu-dev
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -747,7 +751,8 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg
|
||||
ffmpeg \
|
||||
libgammu-dev
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1124,7 +1129,8 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg
|
||||
ffmpeg \
|
||||
libgammu-dev
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.24.10
|
||||
uses: github/codeql-action/init@v3.25.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.24.10
|
||||
uses: github/codeql-action/analyze@v3.25.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
9
.github/workflows/wheels.yml
vendored
9
.github/workflows/wheels.yml
vendored
@@ -142,11 +142,9 @@ jobs:
|
||||
run: |
|
||||
requirement_files="requirements_all.txt requirements_diff.txt"
|
||||
for requirement_file in ${requirement_files}; do
|
||||
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
|
||||
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
|
||||
sed -i "s|# evdev|evdev|g" ${requirement_file}
|
||||
sed -i "s|# pycups|pycups|g" ${requirement_file}
|
||||
sed -i "s|# homekit|homekit|g" ${requirement_file}
|
||||
sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file}
|
||||
sed -i "s|# python-gammu|python-gammu|g" ${requirement_file}
|
||||
|
||||
@@ -163,16 +161,11 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file}
|
||||
fi
|
||||
|
||||
# Some speedups are only for 64-bit
|
||||
if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then
|
||||
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file}
|
||||
fi
|
||||
|
||||
done
|
||||
|
||||
- name: Split requirements all
|
||||
run: |
|
||||
# We split requirements all into two different files.
|
||||
# We split requirements all into multiple files.
|
||||
# This is to prevent the build from running out of memory when
|
||||
# resolving packages on 32-bits systems (like armhf, armv7).
|
||||
|
||||
|
@@ -66,6 +66,7 @@ homeassistant.components.alpha_vantage.*
|
||||
homeassistant.components.amazon_polly.*
|
||||
homeassistant.components.amberelectric.*
|
||||
homeassistant.components.ambiclimate.*
|
||||
homeassistant.components.ambient_network.*
|
||||
homeassistant.components.ambient_station.*
|
||||
homeassistant.components.amcrest.*
|
||||
homeassistant.components.ampio.*
|
||||
|
17
CODEOWNERS
17
CODEOWNERS
@@ -90,6 +90,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/amberelectric/ @madpilot
|
||||
/homeassistant/components/ambiclimate/ @danielhiversen
|
||||
/tests/components/ambiclimate/ @danielhiversen
|
||||
/homeassistant/components/ambient_network/ @thomaskistler
|
||||
/tests/components/ambient_network/ @thomaskistler
|
||||
/homeassistant/components/ambient_station/ @bachya
|
||||
/tests/components/ambient_station/ @bachya
|
||||
/homeassistant/components/amcrest/ @flacjacket
|
||||
@@ -387,6 +389,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/energyzero/ @klaasnicolaas
|
||||
/tests/components/energyzero/ @klaasnicolaas
|
||||
/homeassistant/components/enigma2/ @autinerd
|
||||
/tests/components/enigma2/ @autinerd
|
||||
/homeassistant/components/enocean/ @bdurrer
|
||||
/tests/components/enocean/ @bdurrer
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac
|
||||
@@ -683,8 +686,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/iqvia/ @bachya
|
||||
/tests/components/iqvia/ @bachya
|
||||
/homeassistant/components/irish_rail_transport/ @ttroy50
|
||||
/homeassistant/components/islamic_prayer_times/ @engrbm87
|
||||
/tests/components/islamic_prayer_times/ @engrbm87
|
||||
/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
|
||||
/tests/components/islamic_prayer_times/ @engrbm87 @cpfair
|
||||
/homeassistant/components/iss/ @DurgNomis-drol
|
||||
/tests/components/iss/ @DurgNomis-drol
|
||||
/homeassistant/components/isy994/ @bdraco @shbatm
|
||||
@@ -753,7 +756,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/leaone/ @bdraco
|
||||
/homeassistant/components/led_ble/ @bdraco
|
||||
/tests/components/led_ble/ @bdraco
|
||||
/homeassistant/components/lg_netcast/ @Drafteed
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/light/ @home-assistant/core
|
||||
@@ -1025,8 +1029,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/pglab/ @pierluigi
|
||||
/homeassistant/components/philips_js/ @elupus
|
||||
/tests/components/philips_js/ @elupus
|
||||
/homeassistant/components/pi_hole/ @johnluetke @shenxn
|
||||
/tests/components/pi_hole/ @johnluetke @shenxn
|
||||
/homeassistant/components/pi_hole/ @shenxn
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl
|
||||
/tests/components/picnic/ @corneyl
|
||||
/homeassistant/components/pilight/ @trekky12
|
||||
@@ -1185,6 +1189,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
@@ -1271,6 +1277,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/smhi/ @gjohansson-ST
|
||||
/tests/components/smhi/ @gjohansson-ST
|
||||
/homeassistant/components/sms/ @ocalvo
|
||||
/tests/components/sms/ @ocalvo
|
||||
/homeassistant/components/snapcast/ @luar123
|
||||
/tests/components/snapcast/ @luar123
|
||||
/homeassistant/components/snmp/ @nmaggioni
|
||||
|
@@ -2,14 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
from accuweather import AccuWeather
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -17,43 +13,70 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER
|
||||
from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION
|
||||
from .coordinator import (
|
||||
AccuWeatherDailyForecastDataUpdateCoordinator,
|
||||
AccuWeatherObservationDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccuWeatherData:
|
||||
"""Data for AccuWeather integration."""
|
||||
|
||||
coordinator_observation: AccuWeatherObservationDataUpdateCoordinator
|
||||
coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up AccuWeather as config entry."""
|
||||
api_key: str = entry.data[CONF_API_KEY]
|
||||
name: str = entry.data[CONF_NAME]
|
||||
assert entry.unique_id is not None
|
||||
location_key = entry.unique_id
|
||||
forecast: bool = entry.options.get(CONF_FORECAST, False)
|
||||
|
||||
_LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast)
|
||||
location_key = entry.unique_id
|
||||
|
||||
_LOGGER.debug("Using location_key: %s", location_key)
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
accuweather = AccuWeather(api_key, websession, location_key=location_key)
|
||||
|
||||
coordinator = AccuWeatherDataUpdateCoordinator(
|
||||
hass, websession, api_key, location_key, forecast, name
|
||||
coordinator_observation = AccuWeatherObservationDataUpdateCoordinator(
|
||||
hass,
|
||||
accuweather,
|
||||
name,
|
||||
"observation",
|
||||
UPDATE_INTERVAL_OBSERVATION,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator(
|
||||
hass,
|
||||
accuweather,
|
||||
name,
|
||||
"daily forecast",
|
||||
UPDATE_INTERVAL_DAILY_FORECAST,
|
||||
)
|
||||
|
||||
await coordinator_observation.async_config_entry_first_refresh()
|
||||
await coordinator_daily_forecast.async_config_entry_first_refresh()
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AccuWeatherData(
|
||||
coordinator_observation=coordinator_observation,
|
||||
coordinator_daily_forecast=coordinator_daily_forecast,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Remove ozone sensors from registry if they exist
|
||||
ent_reg = er.async_get(hass)
|
||||
for day in range(5):
|
||||
unique_id = f"{coordinator.location_key}-ozone-{day}"
|
||||
unique_id = f"{location_key}-ozone-{day}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Removing ozone sensor entity %s", entity_id)
|
||||
ent_reg.async_remove(entity_id)
|
||||
@@ -74,65 +97,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update listener."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
session: ClientSession,
|
||||
api_key: str,
|
||||
location_key: str,
|
||||
forecast: bool,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.location_key = location_key
|
||||
self.forecast = forecast
|
||||
self.accuweather = AccuWeather(api_key, session, location_key=location_key)
|
||||
self.device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, location_key)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=name,
|
||||
# You don't need to provide specific details for the URL,
|
||||
# so passing in _ characters is fine if the location key
|
||||
# is correct
|
||||
configuration_url=(
|
||||
"http://accuweather.com/en/"
|
||||
f"_/_/{location_key}/"
|
||||
f"weather-forecast/{location_key}/"
|
||||
),
|
||||
)
|
||||
|
||||
# Enabling the forecast download increases the number of requests per data
|
||||
# update, we use 40 minutes for current condition only and 80 minutes for
|
||||
# current condition and forecast as update interval to not exceed allowed number
|
||||
# of requests. We have 50 requests allowed per day, so we use 36 and leave 14 as
|
||||
# a reserve for restarting HA.
|
||||
update_interval = timedelta(minutes=40)
|
||||
if self.forecast:
|
||||
update_interval *= 2
|
||||
_LOGGER.debug("Data will be update every %s", update_interval)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library."""
|
||||
forecast: list[dict[str, Any]] = []
|
||||
try:
|
||||
async with timeout(10):
|
||||
current = await self.accuweather.async_get_current_conditions()
|
||||
if self.forecast:
|
||||
forecast = await self.accuweather.async_get_daily_forecast()
|
||||
except (
|
||||
ApiError,
|
||||
ClientConnectorError,
|
||||
InvalidApiKeyError,
|
||||
RequestsExceededError,
|
||||
) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
return {**current, ATTR_FORECAST: forecast}
|
||||
|
@@ -10,26 +10,12 @@ from aiohttp import ClientError
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaFlowFormStep,
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
|
||||
from .const import CONF_FORECAST, DOMAIN
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_FORECAST, default=False): bool,
|
||||
}
|
||||
)
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
|
||||
}
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -87,9 +73,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler:
|
||||
"""Options callback for AccuWeather."""
|
||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
@@ -27,10 +28,8 @@ ATTR_CATEGORY: Final = "Category"
|
||||
ATTR_DIRECTION: Final = "Direction"
|
||||
ATTR_ENGLISH: Final = "English"
|
||||
ATTR_LEVEL: Final = "level"
|
||||
ATTR_FORECAST: Final = "forecast"
|
||||
ATTR_SPEED: Final = "Speed"
|
||||
ATTR_VALUE: Final = "Value"
|
||||
CONF_FORECAST: Final = "forecast"
|
||||
DOMAIN: Final = "accuweather"
|
||||
MANUFACTURER: Final = "AccuWeather, Inc."
|
||||
MAX_FORECAST_DAYS: Final = 4
|
||||
@@ -56,3 +55,5 @@ CONDITION_MAP = {
|
||||
for cond_ha, cond_codes in CONDITION_CLASSES.items()
|
||||
for cond_code in cond_codes
|
||||
}
|
||||
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
|
||||
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
|
||||
|
124
homeassistant/components/accuweather/coordinator.py
Normal file
124
homeassistant/components/accuweather/coordinator.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""The AccuWeather coordinator."""
|
||||
|
||||
from asyncio import timeout
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
|
||||
from aiohttp.client_exceptions import ClientConnectorError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
TimestampDataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccuWeatherObservationDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[str, Any]]
|
||||
):
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
accuweather: AccuWeather,
|
||||
name: str,
|
||||
coordinator_type: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.accuweather = accuweather
|
||||
self.location_key = accuweather.location_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self.location_key is not None
|
||||
|
||||
self.device_info = _get_device_info(self.location_key, name)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{name} ({coordinator_type})",
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library."""
|
||||
try:
|
||||
async with timeout(10):
|
||||
result = await self.accuweather.async_get_current_conditions()
|
||||
except EXCEPTIONS as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class AccuWeatherDailyForecastDataUpdateCoordinator(
|
||||
TimestampDataUpdateCoordinator[list[dict[str, Any]]]
|
||||
):
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
accuweather: AccuWeather,
|
||||
name: str,
|
||||
coordinator_type: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.accuweather = accuweather
|
||||
self.location_key = accuweather.location_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self.location_key is not None
|
||||
|
||||
self.device_info = _get_device_info(self.location_key, name)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{name} ({coordinator_type})",
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> list[dict[str, Any]]:
|
||||
"""Update data via library."""
|
||||
try:
|
||||
async with timeout(10):
|
||||
result = await self.accuweather.async_get_daily_forecast()
|
||||
except EXCEPTIONS as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_device_info(location_key: str, name: str) -> DeviceInfo:
|
||||
"""Get device info."""
|
||||
return DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, location_key)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=name,
|
||||
# You don't need to provide specific details for the URL,
|
||||
# so passing in _ characters is fine if the location key
|
||||
# is correct
|
||||
configuration_url=(
|
||||
"http://accuweather.com/en/"
|
||||
f"_/_/{location_key}/weather-forecast/{location_key}/"
|
||||
),
|
||||
)
|
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AccuWeatherDataUpdateCoordinator
|
||||
from . import AccuWeatherData
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE}
|
||||
@@ -19,11 +19,9 @@ async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
accuweather_data: AccuWeatherData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
return {
|
||||
"config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT),
|
||||
"coordinator_data": coordinator.data,
|
||||
"observation_data": accuweather_data.coordinator_observation.data,
|
||||
}
|
||||
|
@@ -28,13 +28,12 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import AccuWeatherDataUpdateCoordinator
|
||||
from . import AccuWeatherData
|
||||
from .const import (
|
||||
API_METRIC,
|
||||
ATTR_CATEGORY,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_ENGLISH,
|
||||
ATTR_FORECAST,
|
||||
ATTR_LEVEL,
|
||||
ATTR_SPEED,
|
||||
ATTR_VALUE,
|
||||
@@ -42,6 +41,10 @@ from .const import (
|
||||
DOMAIN,
|
||||
MAX_FORECAST_DAYS,
|
||||
)
|
||||
from .coordinator import (
|
||||
AccuWeatherDailyForecastDataUpdateCoordinator,
|
||||
AccuWeatherObservationDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -52,12 +55,18 @@ class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], str | int | float | None]
|
||||
attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {}
|
||||
day: int | None = None
|
||||
|
||||
|
||||
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherForecastSensorDescription(AccuWeatherSensorDescription):
|
||||
"""Class describing AccuWeather sensor entities."""
|
||||
|
||||
day: int
|
||||
|
||||
|
||||
FORECAST_SENSOR_TYPES: tuple[AccuWeatherForecastSensorDescription, ...] = (
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="AirQuality",
|
||||
icon="mdi:air-filter",
|
||||
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
|
||||
@@ -69,7 +78,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="CloudCoverDay",
|
||||
icon="mdi:weather-cloudy",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -81,7 +90,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="CloudCoverNight",
|
||||
icon="mdi:weather-cloudy",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -93,7 +102,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="Grass",
|
||||
icon="mdi:grass",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -106,7 +115,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="HoursOfSun",
|
||||
icon="mdi:weather-partly-cloudy",
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
@@ -117,7 +126,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="LongPhraseDay",
|
||||
value_fn=lambda data: cast(str, data),
|
||||
translation_key=f"condition_day_{day}d",
|
||||
@@ -126,7 +135,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="LongPhraseNight",
|
||||
value_fn=lambda data: cast(str, data),
|
||||
translation_key=f"condition_night_{day}d",
|
||||
@@ -135,7 +144,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="Mold",
|
||||
icon="mdi:blur",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -148,7 +157,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="Ragweed",
|
||||
icon="mdi:sprout",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
@@ -161,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="RealFeelTemperatureMax",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
@@ -172,7 +181,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="RealFeelTemperatureMin",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
@@ -183,7 +192,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="RealFeelTemperatureShadeMax",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -195,7 +204,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="RealFeelTemperatureShadeMin",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -207,7 +216,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="SolarIrradianceDay",
|
||||
icon="mdi:weather-sunny",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -219,7 +228,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="SolarIrradianceNight",
|
||||
icon="mdi:weather-sunny",
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -231,7 +240,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="ThunderstormProbabilityDay",
|
||||
icon="mdi:weather-lightning",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -242,7 +251,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="ThunderstormProbabilityNight",
|
||||
icon="mdi:weather-lightning",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -253,7 +262,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="Tree",
|
||||
icon="mdi:tree-outline",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
@@ -266,7 +275,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="UVIndex",
|
||||
icon="mdi:weather-sunny",
|
||||
native_unit_of_measurement=UV_INDEX,
|
||||
@@ -278,7 +287,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="WindGustDay",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -291,7 +300,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="WindGustNight",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -304,7 +313,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="WindDay",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
@@ -316,7 +325,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
for day in range(MAX_FORECAST_DAYS + 1)
|
||||
),
|
||||
*(
|
||||
AccuWeatherSensorDescription(
|
||||
AccuWeatherForecastSensorDescription(
|
||||
key="WindNight",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
@@ -453,25 +462,33 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add AccuWeather entities from a config_entry."""
|
||||
|
||||
coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
sensors = [
|
||||
AccuWeatherSensor(coordinator, description) for description in SENSOR_TYPES
|
||||
observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = (
|
||||
accuweather_data.coordinator_observation
|
||||
)
|
||||
forecast_daily_coordinator: AccuWeatherDailyForecastDataUpdateCoordinator = (
|
||||
accuweather_data.coordinator_daily_forecast
|
||||
)
|
||||
|
||||
sensors: list[AccuWeatherSensor | AccuWeatherForecastSensor] = [
|
||||
AccuWeatherSensor(observation_coordinator, description)
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
|
||||
if coordinator.forecast:
|
||||
for description in FORECAST_SENSOR_TYPES:
|
||||
# Some air quality/allergy sensors are only available for certain
|
||||
# locations.
|
||||
if description.key not in coordinator.data[ATTR_FORECAST][description.day]:
|
||||
continue
|
||||
sensors.append(AccuWeatherSensor(coordinator, description))
|
||||
sensors.extend(
|
||||
[
|
||||
AccuWeatherForecastSensor(forecast_daily_coordinator, description)
|
||||
for description in FORECAST_SENSOR_TYPES
|
||||
if description.key in forecast_daily_coordinator.data[description.day]
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class AccuWeatherSensor(
|
||||
CoordinatorEntity[AccuWeatherDataUpdateCoordinator], SensorEntity
|
||||
CoordinatorEntity[AccuWeatherObservationDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Define an AccuWeather entity."""
|
||||
|
||||
@@ -481,21 +498,71 @@ class AccuWeatherSensor(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AccuWeatherDataUpdateCoordinator,
|
||||
coordinator: AccuWeatherObservationDataUpdateCoordinator,
|
||||
description: AccuWeatherSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
self._sensor_data = self._get_sensor_data(coordinator.data, description.key)
|
||||
self._attr_unique_id = f"{coordinator.location_key}-{description.key}".lower()
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self._sensor_data)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
return self.entity_description.attr_fn(self.coordinator.data)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle data update."""
|
||||
self._sensor_data = self._get_sensor_data(
|
||||
self.coordinator.data, self.entity_description.key
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@staticmethod
|
||||
def _get_sensor_data(
|
||||
sensors: dict[str, Any],
|
||||
kind: str,
|
||||
) -> Any:
|
||||
"""Get sensor data."""
|
||||
if kind == "Precipitation":
|
||||
return sensors["PrecipitationSummary"]["PastHour"]
|
||||
|
||||
return sensors[kind]
|
||||
|
||||
|
||||
class AccuWeatherForecastSensor(
|
||||
CoordinatorEntity[AccuWeatherDailyForecastDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Define an AccuWeather entity."""
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_has_entity_name = True
|
||||
entity_description: AccuWeatherForecastSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AccuWeatherDailyForecastDataUpdateCoordinator,
|
||||
description: AccuWeatherForecastSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.forecast_day = description.day
|
||||
self.entity_description = description
|
||||
self._sensor_data = _get_sensor_data(
|
||||
self._sensor_data = self._get_sensor_data(
|
||||
coordinator.data, description.key, self.forecast_day
|
||||
)
|
||||
if self.forecast_day is not None:
|
||||
self._attr_unique_id = f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower()
|
||||
else:
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.location_key}-{description.key}".lower()
|
||||
f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower()
|
||||
)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@@ -507,30 +574,21 @@ class AccuWeatherSensor(
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
if self.forecast_day is not None:
|
||||
return self.entity_description.attr_fn(self._sensor_data)
|
||||
|
||||
return self.entity_description.attr_fn(self.coordinator.data)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle data update."""
|
||||
self._sensor_data = _get_sensor_data(
|
||||
self._sensor_data = self._get_sensor_data(
|
||||
self.coordinator.data, self.entity_description.key, self.forecast_day
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_sensor_data(
|
||||
sensors: dict[str, Any],
|
||||
sensors: list[dict[str, Any]],
|
||||
kind: str,
|
||||
forecast_day: int | None = None,
|
||||
forecast_day: int,
|
||||
) -> Any:
|
||||
"""Get sensor data."""
|
||||
if forecast_day is not None:
|
||||
return sensors[ATTR_FORECAST][forecast_day][kind]
|
||||
|
||||
if kind == "Precipitation":
|
||||
return sensors["PrecipitationSummary"]["PastHour"]
|
||||
|
||||
return sensors[kind]
|
||||
return sensors[forecast_day][kind]
|
||||
|
@@ -11,7 +11,7 @@
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options."
|
||||
"default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -790,16 +790,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 80 minutes instead of every 40 minutes.",
|
||||
"data": {
|
||||
"forecast": "Weather forecast"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"can_reach_server": "Reach AccuWeather server",
|
||||
|
@@ -17,8 +17,8 @@ from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_UV_INDEX,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
CoordinatorWeatherEntity,
|
||||
Forecast,
|
||||
SingleCoordinatorWeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -31,19 +31,23 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
|
||||
from homeassistant.util.dt import utc_from_timestamp
|
||||
|
||||
from . import AccuWeatherDataUpdateCoordinator
|
||||
from . import AccuWeatherData
|
||||
from .const import (
|
||||
API_METRIC,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_FORECAST,
|
||||
ATTR_SPEED,
|
||||
ATTR_VALUE,
|
||||
ATTRIBUTION,
|
||||
CONDITION_MAP,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import (
|
||||
AccuWeatherDailyForecastDataUpdateCoordinator,
|
||||
AccuWeatherObservationDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -52,106 +56,134 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Add a AccuWeather weather entity from a config_entry."""
|
||||
accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities([AccuWeatherEntity(coordinator)])
|
||||
async_add_entities([AccuWeatherEntity(accuweather_data)])
|
||||
|
||||
|
||||
class AccuWeatherEntity(
|
||||
SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator]
|
||||
CoordinatorWeatherEntity[
|
||||
AccuWeatherObservationDataUpdateCoordinator,
|
||||
AccuWeatherDailyForecastDataUpdateCoordinator,
|
||||
TimestampDataUpdateCoordinator,
|
||||
TimestampDataUpdateCoordinator,
|
||||
]
|
||||
):
|
||||
"""Define an AccuWeather entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None:
|
||||
def __init__(self, accuweather_data: AccuWeatherData) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(
|
||||
observation_coordinator=accuweather_data.coordinator_observation,
|
||||
daily_coordinator=accuweather_data.coordinator_daily_forecast,
|
||||
)
|
||||
|
||||
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
self._attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
||||
self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
self._attr_unique_id = coordinator.location_key
|
||||
self._attr_unique_id = accuweather_data.coordinator_observation.location_key
|
||||
self._attr_attribution = ATTRIBUTION
|
||||
self._attr_device_info = coordinator.device_info
|
||||
if self.coordinator.forecast:
|
||||
self._attr_device_info = accuweather_data.coordinator_observation.device_info
|
||||
self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
|
||||
|
||||
self.observation_coordinator = accuweather_data.coordinator_observation
|
||||
self.daily_coordinator = accuweather_data.coordinator_daily_forecast
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"])
|
||||
return CONDITION_MAP.get(self.observation_coordinator.data["WeatherIcon"])
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> float:
|
||||
"""Return the Cloud coverage in %."""
|
||||
return cast(float, self.coordinator.data["CloudCover"])
|
||||
return cast(float, self.observation_coordinator.data["CloudCover"])
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float:
|
||||
"""Return the apparent temperature."""
|
||||
return cast(
|
||||
float, self.coordinator.data["ApparentTemperature"][API_METRIC][ATTR_VALUE]
|
||||
float,
|
||||
self.observation_coordinator.data["ApparentTemperature"][API_METRIC][
|
||||
ATTR_VALUE
|
||||
],
|
||||
)
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return cast(float, self.coordinator.data["Temperature"][API_METRIC][ATTR_VALUE])
|
||||
return cast(
|
||||
float,
|
||||
self.observation_coordinator.data["Temperature"][API_METRIC][ATTR_VALUE],
|
||||
)
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float:
|
||||
"""Return the pressure."""
|
||||
return cast(float, self.coordinator.data["Pressure"][API_METRIC][ATTR_VALUE])
|
||||
return cast(
|
||||
float, self.observation_coordinator.data["Pressure"][API_METRIC][ATTR_VALUE]
|
||||
)
|
||||
|
||||
@property
|
||||
def native_dew_point(self) -> float:
|
||||
"""Return the dew point."""
|
||||
return cast(float, self.coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE])
|
||||
return cast(
|
||||
float, self.observation_coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE]
|
||||
)
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
"""Return the humidity."""
|
||||
return cast(int, self.coordinator.data["RelativeHumidity"])
|
||||
return cast(int, self.observation_coordinator.data["RelativeHumidity"])
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float:
|
||||
"""Return the wind gust speed."""
|
||||
return cast(
|
||||
float, self.coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ATTR_VALUE]
|
||||
float,
|
||||
self.observation_coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][
|
||||
ATTR_VALUE
|
||||
],
|
||||
)
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return cast(
|
||||
float, self.coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ATTR_VALUE]
|
||||
float,
|
||||
self.observation_coordinator.data["Wind"][ATTR_SPEED][API_METRIC][
|
||||
ATTR_VALUE
|
||||
],
|
||||
)
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> int:
|
||||
"""Return the wind bearing."""
|
||||
return cast(int, self.coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"])
|
||||
return cast(
|
||||
int, self.observation_coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"]
|
||||
)
|
||||
|
||||
@property
|
||||
def native_visibility(self) -> float:
|
||||
"""Return the visibility."""
|
||||
return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE])
|
||||
return cast(
|
||||
float,
|
||||
self.observation_coordinator.data["Visibility"][API_METRIC][ATTR_VALUE],
|
||||
)
|
||||
|
||||
@property
|
||||
def uv_index(self) -> float:
|
||||
"""Return the UV index."""
|
||||
return cast(float, self.coordinator.data["UVIndex"])
|
||||
return cast(float, self.observation_coordinator.data["UVIndex"])
|
||||
|
||||
@callback
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
if not self.coordinator.forecast:
|
||||
return None
|
||||
# remap keys from library to keys understood by the weather component
|
||||
return [
|
||||
{
|
||||
ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(),
|
||||
@@ -175,5 +207,5 @@ class AccuWeatherEntity(
|
||||
ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"],
|
||||
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]),
|
||||
}
|
||||
for item in self.coordinator.data[ATTR_FORECAST]
|
||||
for item in self.daily_coordinator.data
|
||||
]
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -24,7 +26,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_FORCE,
|
||||
DATA_ADGUARD_CLIENT,
|
||||
DOMAIN,
|
||||
SERVICE_ADD_URL,
|
||||
SERVICE_DISABLE_URL,
|
||||
@@ -44,6 +45,14 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdGuardData:
|
||||
"""Adguard data type."""
|
||||
|
||||
client: AdGuardHome
|
||||
version: str
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up AdGuard Home from a config entry."""
|
||||
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
|
||||
@@ -57,13 +66,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session=session,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_ADGUARD_CLIENT: adguard}
|
||||
|
||||
try:
|
||||
await adguard.version()
|
||||
version = await adguard.version()
|
||||
except AdGuardHomeConnectionError as exception:
|
||||
raise ConfigEntryNotReady from exception
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AdGuardData(adguard, version)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def add_url(call: ServiceCall) -> None:
|
||||
|
@@ -6,9 +6,6 @@ DOMAIN = "adguard"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DATA_ADGUARD_CLIENT = "adguard_client"
|
||||
DATA_ADGUARD_VERSION = "adguard_version"
|
||||
|
||||
CONF_FORCE = "force"
|
||||
|
||||
SERVICE_ADD_URL = "add_url"
|
||||
|
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeError
|
||||
from adguardhome import AdGuardHomeError
|
||||
|
||||
from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DATA_ADGUARD_VERSION, DOMAIN, LOGGER
|
||||
from . import AdGuardData
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
class AdGuardHomeEntity(Entity):
|
||||
@@ -19,12 +20,13 @@ class AdGuardHomeEntity(Entity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adguard: AdGuardHome,
|
||||
data: AdGuardData,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the AdGuard Home entity."""
|
||||
self._entry = entry
|
||||
self.adguard = adguard
|
||||
self.data = data
|
||||
self.adguard = data.client
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
@@ -68,8 +70,6 @@ class AdGuardHomeEntity(Entity):
|
||||
},
|
||||
manufacturer="AdGuard Team",
|
||||
name="AdGuard Home",
|
||||
sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get(
|
||||
DATA_ADGUARD_VERSION
|
||||
),
|
||||
sw_version=self.data.version,
|
||||
configuration_url=config_url,
|
||||
)
|
||||
|
@@ -7,16 +7,16 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
from adguardhome import AdGuardHome
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN
|
||||
from . import AdGuardData
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
@@ -89,17 +89,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AdGuard Home sensor based on a config entry."""
|
||||
adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT]
|
||||
|
||||
try:
|
||||
version = await adguard.version()
|
||||
except AdGuardHomeConnectionError as exception:
|
||||
raise PlatformNotReady from exception
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version
|
||||
data: AdGuardData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
[AdGuardHomeSensor(adguard, entry, description) for description in SENSORS],
|
||||
[AdGuardHomeSensor(data, entry, description) for description in SENSORS],
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -111,18 +104,18 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adguard: AdGuardHome,
|
||||
data: AdGuardData,
|
||||
entry: ConfigEntry,
|
||||
description: AdGuardHomeEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(adguard, entry)
|
||||
super().__init__(data, entry)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = "_".join(
|
||||
[
|
||||
DOMAIN,
|
||||
adguard.host,
|
||||
str(adguard.port),
|
||||
self.adguard.host,
|
||||
str(self.adguard.port),
|
||||
"sensor",
|
||||
description.key,
|
||||
]
|
||||
|
@@ -7,15 +7,15 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError
|
||||
from adguardhome import AdGuardHome, AdGuardHomeError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN, LOGGER
|
||||
from . import AdGuardData
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
@@ -83,17 +83,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AdGuard Home switch based on a config entry."""
|
||||
adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT]
|
||||
|
||||
try:
|
||||
version = await adguard.version()
|
||||
except AdGuardHomeConnectionError as exception:
|
||||
raise PlatformNotReady from exception
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version
|
||||
data: AdGuardData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
[AdGuardHomeSwitch(adguard, entry, description) for description in SWITCHES],
|
||||
[AdGuardHomeSwitch(data, entry, description) for description in SWITCHES],
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -105,15 +98,21 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adguard: AdGuardHome,
|
||||
data: AdGuardData,
|
||||
entry: ConfigEntry,
|
||||
description: AdGuardHomeSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(adguard, entry)
|
||||
super().__init__(data, entry)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = "_".join(
|
||||
[DOMAIN, adguard.host, str(adguard.port), "switch", description.key]
|
||||
[
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
str(self.adguard.port),
|
||||
"switch",
|
||||
description.key,
|
||||
]
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
|
@@ -44,10 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def _async_update_method() -> AirthingsDevice:
|
||||
"""Get data from Airthings BLE."""
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address)
|
||||
|
||||
try:
|
||||
data = await airthings.update_device(ble_device) # type: ignore[arg-type]
|
||||
data = await airthings.update_device(ble_device)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -16,6 +16,7 @@ from .coordinator import AirzoneUpdateCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
124
homeassistant/components/airzone_cloud/select.py
Normal file
124
homeassistant/components/airzone_cloud/select.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Support for the Airzone Cloud select."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioairzone_cloud.common import AirQualityMode
|
||||
from aioairzone_cloud.const import (
|
||||
API_AQ_MODE_CONF,
|
||||
API_VALUE,
|
||||
AZD_AQ_MODE_CONF,
|
||||
AZD_ZONES,
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirzoneUpdateCoordinator
|
||||
from .entity import AirzoneEntity, AirzoneZoneEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirzoneSelectDescription(SelectEntityDescription):
|
||||
"""Class to describe an Airzone select entity."""
|
||||
|
||||
api_param: str
|
||||
options_dict: dict[str, str]
|
||||
|
||||
|
||||
AIR_QUALITY_MAP: Final[dict[str, str]] = {
|
||||
"off": AirQualityMode.OFF,
|
||||
"on": AirQualityMode.ON,
|
||||
"auto": AirQualityMode.AUTO,
|
||||
}
|
||||
|
||||
|
||||
ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_AQ_MODE_CONF,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key=AZD_AQ_MODE_CONF,
|
||||
options=list(AIR_QUALITY_MAP),
|
||||
options_dict=AIR_QUALITY_MAP,
|
||||
translation_key="air_quality",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Add Airzone Cloud select from a config_entry."""
|
||||
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
# Zones
|
||||
async_add_entities(
|
||||
AirzoneZoneSelect(
|
||||
coordinator,
|
||||
description,
|
||||
zone_id,
|
||||
zone_data,
|
||||
)
|
||||
for description in ZONE_SELECT_TYPES
|
||||
for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items()
|
||||
if description.key in zone_data
|
||||
)
|
||||
|
||||
|
||||
class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
|
||||
"""Define an Airzone Cloud select."""
|
||||
|
||||
entity_description: AirzoneSelectDescription
|
||||
values_dict: dict[str, str]
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update attributes when the coordinator updates."""
|
||||
self._async_update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
def _get_current_option(self) -> str | None:
|
||||
"""Get current selected option."""
|
||||
value = self.get_airzone_value(self.entity_description.key)
|
||||
return self.values_dict.get(value)
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update select attributes."""
|
||||
self._attr_current_option = self._get_current_option()
|
||||
|
||||
|
||||
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
|
||||
"""Define an Airzone Cloud Zone select."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirzoneUpdateCoordinator,
|
||||
description: AirzoneSelectDescription,
|
||||
zone_id: str,
|
||||
zone_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator, zone_id, zone_data)
|
||||
|
||||
self._attr_unique_id = f"{zone_id}_{description.key}"
|
||||
self.entity_description = description
|
||||
self.values_dict = {v: k for k, v in description.options_dict.items()}
|
||||
|
||||
self._async_update_attrs()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
param = self.entity_description.api_param
|
||||
value = self.entity_description.options_dict[option]
|
||||
params: dict[str, Any] = {}
|
||||
params[param] = {
|
||||
API_VALUE: value,
|
||||
}
|
||||
await self._async_update_params(params)
|
@@ -21,6 +21,16 @@
|
||||
"air_quality_active": {
|
||||
"name": "Air Quality active"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"air_quality": {
|
||||
"name": "Air Quality mode",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"auto": "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -300,6 +300,10 @@ class Alexa(AlexaCapability):
|
||||
The API suggests you should explicitly include this interface.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-interface.html
|
||||
|
||||
To compare current supported locales in Home Assistant
|
||||
with Alexa supported locales, run the following script:
|
||||
python -m script.alexa_locales
|
||||
"""
|
||||
|
||||
supported_locales = {
|
||||
|
@@ -13,6 +13,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entities import TRANSLATION_TABLE
|
||||
from .state_report import async_enable_proactive_mode
|
||||
|
||||
STORE_AUTHORIZED = "authorized"
|
||||
@@ -101,6 +102,10 @@ class AbstractConfig(ABC):
|
||||
"""If an entity should be exposed."""
|
||||
return False
|
||||
|
||||
def generate_alexa_id(self, entity_id: str) -> str:
|
||||
"""Return the alexa ID for an entity ID."""
|
||||
return entity_id.replace(".", "#").translate(TRANSLATION_TABLE)
|
||||
|
||||
@callback
|
||||
def async_invalidate_access_token(self) -> None:
|
||||
"""Invalidate access token."""
|
||||
|
@@ -259,11 +259,6 @@ class DisplayCategory:
|
||||
WEARABLE = "WEARABLE"
|
||||
|
||||
|
||||
def generate_alexa_id(entity_id: str) -> str:
|
||||
"""Return the alexa ID for an entity ID."""
|
||||
return entity_id.replace(".", "#").translate(TRANSLATION_TABLE)
|
||||
|
||||
|
||||
class AlexaEntity:
|
||||
"""An adaptation of an entity, expressed in Alexa's terms.
|
||||
|
||||
@@ -298,7 +293,7 @@ class AlexaEntity:
|
||||
|
||||
def alexa_id(self) -> str:
|
||||
"""Return the Alexa API entity id."""
|
||||
return generate_alexa_id(self.entity.entity_id)
|
||||
return self.config.generate_alexa_id(self.entity.entity_id)
|
||||
|
||||
def display_categories(self) -> list[str] | None:
|
||||
"""Return a list of display categories."""
|
||||
|
@@ -41,7 +41,7 @@ from .const import (
|
||||
Cause,
|
||||
)
|
||||
from .diagnostics import async_redact_auth_data
|
||||
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
||||
from .entities import ENTITY_ADAPTERS, AlexaEntity
|
||||
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -492,7 +492,7 @@ async def async_send_delete_message(
|
||||
if domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
endpoints.append({"endpointId": generate_alexa_id(entity_id)})
|
||||
endpoints.append({"endpointId": config.generate_alexa_id(entity_id)})
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"endpoints": endpoints,
|
||||
|
35
homeassistant/components/ambient_network/__init__.py
Normal file
35
homeassistant/components/ambient_network/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""The Ambient Weather Network integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioambient.open_api import OpenAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AmbientNetworkDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Ambient Weather Network from a config entry."""
|
||||
|
||||
api = OpenAPI()
|
||||
coordinator = AmbientNetworkDataUpdateCoordinator(hass, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
152
homeassistant/components/ambient_network/config_flow.py
Normal file
152
homeassistant/components/ambient_network/config_flow.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Config flow for the Ambient Weather Network integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aioambient import OpenAPI
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
CONF_MAC,
|
||||
CONF_RADIUS,
|
||||
UnitOfLength,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
LocationSelector,
|
||||
LocationSelectorConfig,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import DistanceConverter
|
||||
|
||||
from .const import API_STATION_INDOOR, API_STATION_INFO, API_STATION_MAC_ADDRESS, DOMAIN
|
||||
from .helper import get_station_name
|
||||
|
||||
CONF_USER = "user"
|
||||
CONF_STATION = "station"
|
||||
|
||||
# One mile
|
||||
CONF_RADIUS_DEFAULT = 1609.34
|
||||
|
||||
|
||||
class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the config flow for the Ambient Weather Network integration."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Construct the config flow."""
|
||||
|
||||
self._longitude = 0.0
|
||||
self._latitude = 0.0
|
||||
self._radius = 0.0
|
||||
self._stations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step to select the location."""
|
||||
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input:
|
||||
self._latitude = user_input[CONF_LOCATION][CONF_LATITUDE]
|
||||
self._longitude = user_input[CONF_LOCATION][CONF_LONGITUDE]
|
||||
self._radius = user_input[CONF_LOCATION][CONF_RADIUS]
|
||||
|
||||
client: OpenAPI = OpenAPI()
|
||||
self._stations = {
|
||||
x[API_STATION_MAC_ADDRESS]: x
|
||||
for x in await client.get_devices_by_location(
|
||||
self._latitude,
|
||||
self._longitude,
|
||||
radius=DistanceConverter.convert(
|
||||
self._radius,
|
||||
UnitOfLength.METERS,
|
||||
UnitOfLength.MILES,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
# Filter out indoor stations
|
||||
self._stations = dict(
|
||||
filter(
|
||||
lambda item: not item[1]
|
||||
.get(API_STATION_INFO, {})
|
||||
.get(API_STATION_INDOOR, False),
|
||||
self._stations.items(),
|
||||
)
|
||||
)
|
||||
|
||||
if self._stations:
|
||||
return await self.async_step_station()
|
||||
|
||||
errors = {"base": "no_stations_found"}
|
||||
|
||||
schema: vol.Schema = self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_LOCATION,
|
||||
): LocationSelector(LocationSelectorConfig(radius=True)),
|
||||
}
|
||||
),
|
||||
{
|
||||
CONF_LOCATION: {
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
CONF_RADIUS: CONF_RADIUS_DEFAULT,
|
||||
}
|
||||
if not errors
|
||||
else {
|
||||
CONF_LATITUDE: self._latitude,
|
||||
CONF_LONGITUDE: self._longitude,
|
||||
CONF_RADIUS: self._radius,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=CONF_USER, data_schema=schema, errors=errors if errors else {}
|
||||
)
|
||||
|
||||
async def async_step_station(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the second step to select the station."""
|
||||
|
||||
if user_input:
|
||||
mac_address = user_input[CONF_STATION]
|
||||
await self.async_set_unique_id(mac_address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=get_station_name(self._stations[mac_address]),
|
||||
data={CONF_MAC: mac_address},
|
||||
)
|
||||
|
||||
options: list[SelectOptionDict] = [
|
||||
SelectOptionDict(
|
||||
label=get_station_name(station),
|
||||
value=mac_address,
|
||||
)
|
||||
for mac_address, station in self._stations.items()
|
||||
]
|
||||
|
||||
schema: vol.Schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STATION): SelectSelector(
|
||||
SelectSelectorConfig(options=options, multiple=False, sort=True),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=CONF_STATION,
|
||||
data_schema=schema,
|
||||
)
|
16
homeassistant/components/ambient_network/const.py
Normal file
16
homeassistant/components/ambient_network/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Ambient Weather Network integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "ambient_network"
|
||||
|
||||
API_LAST_DATA = "lastData"
|
||||
API_STATION_COORDS = "coords"
|
||||
API_STATION_INDOOR = "indoor"
|
||||
API_STATION_INFO = "info"
|
||||
API_STATION_LOCATION = "location"
|
||||
API_STATION_NAME = "name"
|
||||
API_STATION_MAC_ADDRESS = "macAddress"
|
||||
API_STATION_TYPE = "stationtype"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
65
homeassistant/components/ambient_network/coordinator.py
Normal file
65
homeassistant/components/ambient_network/coordinator.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""DataUpdateCoordinator for the Ambient Weather Network integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from aioambient import OpenAPI
|
||||
from aioambient.errors import RequestError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import API_LAST_DATA, DOMAIN, LOGGER
|
||||
from .helper import get_station_name
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The Ambient Network Data Update Coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
station_name: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch the latest data from the Ambient Network."""
|
||||
|
||||
try:
|
||||
response = await self.api.get_device_details(
|
||||
self.config_entry.data[CONF_MAC]
|
||||
)
|
||||
except RequestError as ex:
|
||||
raise UpdateFailed("Cannot connect to Ambient Network") from ex
|
||||
|
||||
self.station_name = get_station_name(response)
|
||||
|
||||
if (last_data := response.get(API_LAST_DATA)) is None:
|
||||
raise UpdateFailed(
|
||||
f"Station '{self.config_entry.title}' did not report any data"
|
||||
)
|
||||
|
||||
# Eliminate data if the station hasn't been updated for a while.
|
||||
if (created_at := last_data.get("created_at")) is None:
|
||||
raise UpdateFailed(
|
||||
f"Station '{self.config_entry.title}' did not report a time stamp"
|
||||
)
|
||||
|
||||
# Eliminate data that has been generated more than an hour ago. The station is
|
||||
# probably offline.
|
||||
if int(created_at / 1000) < int(
|
||||
(datetime.now() - timedelta(hours=1)).timestamp()
|
||||
):
|
||||
raise UpdateFailed(
|
||||
f"Station '{self.config_entry.title}' reported stale data"
|
||||
)
|
||||
|
||||
return cast(dict[str, Any], last_data)
|
50
homeassistant/components/ambient_network/entity.py
Normal file
50
homeassistant/components/ambient_network/entity.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Base entity class for the Ambient Weather Network integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AmbientNetworkDataUpdateCoordinator
|
||||
|
||||
|
||||
class AmbientNetworkEntity(CoordinatorEntity[AmbientNetworkDataUpdateCoordinator]):
|
||||
"""Entity class for Ambient network devices."""
|
||||
|
||||
_attr_attribution = "Data provided by ambientnetwork.net"
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmbientNetworkDataUpdateCoordinator,
|
||||
description: EntityDescription,
|
||||
mac_address: str,
|
||||
) -> None:
|
||||
"""Initialize the Ambient network entity."""
|
||||
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{mac_address}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=coordinator.station_name,
|
||||
identifiers={(DOMAIN, mac_address)},
|
||||
manufacturer="Ambient Weather",
|
||||
)
|
||||
self._update_attrs()
|
||||
|
||||
@abstractmethod
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update state attributes."""
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Get the latest data and updates the state."""
|
||||
|
||||
self._update_attrs()
|
||||
super()._handle_coordinator_update()
|
31
homeassistant/components/ambient_network/helper.py
Normal file
31
homeassistant/components/ambient_network/helper.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Helper class for the Ambient Weather Network integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
API_LAST_DATA,
|
||||
API_STATION_COORDS,
|
||||
API_STATION_INFO,
|
||||
API_STATION_LOCATION,
|
||||
API_STATION_NAME,
|
||||
API_STATION_TYPE,
|
||||
)
|
||||
|
||||
|
||||
def get_station_name(station: dict[str, Any]) -> str:
|
||||
"""Pick a station name.
|
||||
|
||||
Station names can be empty, in which case we construct the name from
|
||||
the location and device type.
|
||||
"""
|
||||
if name := station.get(API_STATION_INFO, {}).get(API_STATION_NAME):
|
||||
return str(name)
|
||||
location = (
|
||||
station.get(API_STATION_INFO, {})
|
||||
.get(API_STATION_COORDS, {})
|
||||
.get(API_STATION_LOCATION)
|
||||
)
|
||||
station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE)
|
||||
return f"{location}{'' if location is None or station_type is None else ' '}{station_type}"
|
21
homeassistant/components/ambient_network/icons.json
Normal file
21
homeassistant/components/ambient_network/icons.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_rain": {
|
||||
"default": "mdi:water"
|
||||
},
|
||||
"lightning_strikes_per_day": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"lightning_strikes_per_hour": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"lightning_distance": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"wind_direction": {
|
||||
"default": "mdi:compass-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
homeassistant/components/ambient_network/manifest.json
Normal file
11
homeassistant/components/ambient_network/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "ambient_network",
|
||||
"name": "Ambient Weather Network",
|
||||
"codeowners": ["@thomaskistler"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ambient_network",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioambient"],
|
||||
"requirements": ["aioambient==2024.01.0"]
|
||||
}
|
315
homeassistant/components/ambient_network/sensor.py
Normal file
315
homeassistant/components/ambient_network/sensor.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Support for Ambient Weather Network sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONF_MAC,
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
UnitOfIrradiance,
|
||||
UnitOfLength,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AmbientNetworkDataUpdateCoordinator
|
||||
from .entity import AmbientNetworkEntity
|
||||
|
||||
TYPE_AQI_PM25 = "aqi_pm25"
|
||||
TYPE_AQI_PM25_24H = "aqi_pm25_24h"
|
||||
TYPE_BAROMABSIN = "baromabsin"
|
||||
TYPE_BAROMRELIN = "baromrelin"
|
||||
TYPE_CO2 = "co2"
|
||||
TYPE_DAILYRAININ = "dailyrainin"
|
||||
TYPE_DEWPOINT = "dewPoint"
|
||||
TYPE_EVENTRAININ = "eventrainin"
|
||||
TYPE_FEELSLIKE = "feelsLike"
|
||||
TYPE_HOURLYRAININ = "hourlyrainin"
|
||||
TYPE_HUMIDITY = "humidity"
|
||||
TYPE_LASTRAIN = "lastRain"
|
||||
TYPE_LIGHTNING_DISTANCE = "lightning_distance"
|
||||
TYPE_LIGHTNING_PER_DAY = "lightning_day"
|
||||
TYPE_LIGHTNING_PER_HOUR = "lightning_hour"
|
||||
TYPE_MAXDAILYGUST = "maxdailygust"
|
||||
TYPE_MONTHLYRAININ = "monthlyrainin"
|
||||
TYPE_PM25 = "pm25"
|
||||
TYPE_PM25_24H = "pm25_24h"
|
||||
TYPE_SOLARRADIATION = "solarradiation"
|
||||
TYPE_TEMPF = "tempf"
|
||||
TYPE_UV = "uv"
|
||||
TYPE_WEEKLYRAININ = "weeklyrainin"
|
||||
TYPE_WINDDIR = "winddir"
|
||||
TYPE_WINDGUSTMPH = "windgustmph"
|
||||
TYPE_WINDSPEEDMPH = "windspeedmph"
|
||||
TYPE_YEARLYRAININ = "yearlyrainin"
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
SensorEntityDescription(
|
||||
key=TYPE_AQI_PM25,
|
||||
translation_key="pm25_aqi",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_AQI_PM25_24H,
|
||||
translation_key="pm25_aqi_24h_average",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_BAROMABSIN,
|
||||
translation_key="absolute_pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.INHG,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_BAROMRELIN,
|
||||
translation_key="relative_pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.INHG,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_DAILYRAININ,
|
||||
translation_key="daily_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_DEWPOINT,
|
||||
translation_key="dew_point",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_FEELSLIKE,
|
||||
translation_key="feels_like",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_HOURLYRAININ,
|
||||
translation_key="hourly_rain",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LASTRAIN,
|
||||
translation_key="last_rain",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_DAY,
|
||||
translation_key="lightning_strikes_per_day",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_HOUR,
|
||||
translation_key="lightning_strikes_per_hour",
|
||||
native_unit_of_measurement="strikes/hour",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_DISTANCE,
|
||||
translation_key="lightning_distance",
|
||||
native_unit_of_measurement=UnitOfLength.MILES,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_MAXDAILYGUST,
|
||||
translation_key="max_daily_gust",
|
||||
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_MONTHLYRAININ,
|
||||
translation_key="monthly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_PM25_24H,
|
||||
translation_key="pm25_24h_average",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_SOLARRADIATION,
|
||||
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
|
||||
device_class=SensorDeviceClass.IRRADIANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_TEMPF,
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_UV,
|
||||
translation_key="uv_index",
|
||||
native_unit_of_measurement="index",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WEEKLYRAININ,
|
||||
translation_key="weekly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR,
|
||||
translation_key="wind_direction",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTMPH,
|
||||
translation_key="wind_gust",
|
||||
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDSPEEDMPH,
|
||||
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_YEARLYRAININ,
|
||||
translation_key="yearly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Ambient Network sensor entities."""
|
||||
|
||||
coordinator: AmbientNetworkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
if coordinator.config_entry is not None:
|
||||
async_add_entities(
|
||||
AmbientNetworkSensor(
|
||||
coordinator,
|
||||
description,
|
||||
coordinator.config_entry.data[CONF_MAC],
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if coordinator.data.get(description.key) is not None
|
||||
)
|
||||
|
||||
|
||||
class AmbientNetworkSensor(AmbientNetworkEntity, SensorEntity):
|
||||
"""A sensor implementation for an Ambient Weather Network sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmbientNetworkDataUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
mac_address: str,
|
||||
) -> None:
|
||||
"""Initialize a sensor object."""
|
||||
|
||||
super().__init__(coordinator, description, mac_address)
|
||||
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update sensor attributes."""
|
||||
|
||||
value = self.coordinator.data.get(self.entity_description.key)
|
||||
|
||||
# Treatments for special units.
|
||||
if value is not None and self.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
value = datetime.fromtimestamp(value / 1000, tz=dt_util.DEFAULT_TIME_ZONE)
|
||||
|
||||
self._attr_available = value is not None
|
||||
self._attr_native_value = value
|
87
homeassistant/components/ambient_network/strings.json
Normal file
87
homeassistant/components/ambient_network/strings.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Select region",
|
||||
"description": "Choose the region you want to survey in order to locate Ambient personal weather stations."
|
||||
},
|
||||
"station": {
|
||||
"title": "Select station",
|
||||
"description": "Select the weather station you want to add to Home Assistant.",
|
||||
"data": {
|
||||
"station": "Station"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"no_stations_found": "Did not find any stations in the selected region."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"pm25_24h_average": {
|
||||
"name": "PM2.5 (24 hour average)"
|
||||
},
|
||||
"pm25_aqi": {
|
||||
"name": "PM2.5 AQI"
|
||||
},
|
||||
"pm25_aqi_24h_average": {
|
||||
"name": "PM2.5 AQI (24 hour average)"
|
||||
},
|
||||
"absolute_pressure": {
|
||||
"name": "Absolute pressure"
|
||||
},
|
||||
"relative_pressure": {
|
||||
"name": "Relative pressure"
|
||||
},
|
||||
"daily_rain": {
|
||||
"name": "Daily rain"
|
||||
},
|
||||
"dew_point": {
|
||||
"name": "Dew point"
|
||||
},
|
||||
"feels_like": {
|
||||
"name": "Feels like"
|
||||
},
|
||||
"hourly_rain": {
|
||||
"name": "Hourly rain"
|
||||
},
|
||||
"last_rain": {
|
||||
"name": "Last rain"
|
||||
},
|
||||
"lightning_strikes_per_day": {
|
||||
"name": "Lightning strikes per day"
|
||||
},
|
||||
"lightning_strikes_per_hour": {
|
||||
"name": "Lightning strikes per hour"
|
||||
},
|
||||
"lightning_distance": {
|
||||
"name": "Lightning distance"
|
||||
},
|
||||
"max_daily_gust": {
|
||||
"name": "Max daily gust"
|
||||
},
|
||||
"monthly_rain": {
|
||||
"name": "Monthly rain"
|
||||
},
|
||||
"uv_index": {
|
||||
"name": "UV index"
|
||||
},
|
||||
"weekly_rain": {
|
||||
"name": "Weekly rain"
|
||||
},
|
||||
"wind_direction": {
|
||||
"name": "Wind direction"
|
||||
},
|
||||
"wind_gust": {
|
||||
"name": "Wind gust"
|
||||
},
|
||||
"yearly_rain": {
|
||||
"name": "Yearly rain"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -49,7 +49,7 @@ class AmbientWeatherEntity(Entity):
|
||||
last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA]
|
||||
key = self.entity_description.key
|
||||
available_key = TYPE_SOLARRADIATION if key == TYPE_SOLARRADIATION_LX else key
|
||||
self._attr_available = last_data[available_key] is not None
|
||||
self._attr_available = last_data.get(available_key) is not None
|
||||
self.update_from_latest_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
@@ -5,10 +5,14 @@ from collections.abc import Iterable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyatv.const import InputAction
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_DELAY_SECS,
|
||||
ATTR_HOLD_SECS,
|
||||
ATTR_NUM_REPEATS,
|
||||
DEFAULT_DELAY_SECS,
|
||||
DEFAULT_HOLD_SECS,
|
||||
RemoteEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -29,7 +33,6 @@ COMMAND_TO_ATTRIBUTE = {
|
||||
"turn_off": ("power", "turn_off"),
|
||||
"volume_up": ("audio", "volume_up"),
|
||||
"volume_down": ("audio", "volume_down"),
|
||||
"home_hold": ("remote_control", "home"),
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +69,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
"""Send a command to one device."""
|
||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||
delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
|
||||
hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS)
|
||||
|
||||
if not self.atv:
|
||||
_LOGGER.error("Unable to send commands, not connected to %s", self.name)
|
||||
@@ -84,5 +88,10 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
raise ValueError("Command not found. Exiting sequence")
|
||||
|
||||
_LOGGER.info("Sending command %s", single_command)
|
||||
|
||||
if hold_secs >= 1:
|
||||
await attr_value(action=InputAction.Hold)
|
||||
else:
|
||||
await attr_value()
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
@@ -11,7 +11,7 @@
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
}
|
||||
},
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"error": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from arris_tg2492lg import ConnectBox, Device
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -12,6 +13,7 @@ from homeassistant.components.device_tracker import (
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -25,12 +27,21 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner:
|
||||
"""Return the Arris device scanner."""
|
||||
async def async_get_scanner(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ArrisDeviceScanner | None:
|
||||
"""Return the Arris device scanner if successful."""
|
||||
conf = config[DOMAIN]
|
||||
url = f"http://{conf[CONF_HOST]}"
|
||||
connect_box = ConnectBox(url, conf[CONF_PASSWORD])
|
||||
websession = async_get_clientsession(hass)
|
||||
connect_box = ConnectBox(websession, url, conf[CONF_PASSWORD])
|
||||
|
||||
try:
|
||||
await connect_box.async_login()
|
||||
|
||||
return ArrisDeviceScanner(connect_box)
|
||||
except ClientResponseError:
|
||||
return None
|
||||
|
||||
|
||||
class ArrisDeviceScanner(DeviceScanner):
|
||||
@@ -41,22 +52,22 @@ class ArrisDeviceScanner(DeviceScanner):
|
||||
self.connect_box = connect_box
|
||||
self.last_results: list[Device] = []
|
||||
|
||||
def scan_devices(self) -> list[str]:
|
||||
async def async_scan_devices(self) -> list[str]:
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
self._update_info()
|
||||
await self._async_update_info()
|
||||
|
||||
return [device.mac for device in self.last_results if device.mac]
|
||||
|
||||
def get_device_name(self, device: str) -> str | None:
|
||||
async def async_get_device_name(self, device: str) -> str | None:
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
return next(
|
||||
(result.hostname for result in self.last_results if result.mac == device),
|
||||
None,
|
||||
)
|
||||
|
||||
def _update_info(self) -> None:
|
||||
async def _async_update_info(self) -> None:
|
||||
"""Ensure the information from the Arris TG2492LG router is up to date."""
|
||||
result = self.connect_box.get_connected_devices()
|
||||
result = await self.connect_box.async_get_connected_devices()
|
||||
|
||||
last_results: list[Device] = []
|
||||
mac_addresses: set[str | None] = set()
|
||||
|
@@ -2,8 +2,10 @@
|
||||
"domain": "arris_tg2492lg",
|
||||
"name": "Arris TG2492LG",
|
||||
"codeowners": ["@vanbalken"],
|
||||
"dependencies": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arris_tg2492lg",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arris_tg2492lg"],
|
||||
"requirements": ["arris-tg2492lg==1.2.1"]
|
||||
"requirements": ["arris-tg2492lg==2.2.0"]
|
||||
}
|
||||
|
@@ -331,17 +331,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
await async_get_blueprints(hass).async_reset_cache()
|
||||
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
|
||||
return
|
||||
if automation_id := service_call.data.get(CONF_ID):
|
||||
await _async_process_single_config(hass, conf, component, automation_id)
|
||||
else:
|
||||
await _async_process_config(hass, conf, component)
|
||||
hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context)
|
||||
|
||||
reload_helper = ReloadServiceHelper(reload_service_handler)
|
||||
def reload_targets(service_call: ServiceCall) -> set[str | None]:
|
||||
if automation_id := service_call.data.get(CONF_ID):
|
||||
return {automation_id}
|
||||
return {automation.unique_id for automation in component.entities}
|
||||
|
||||
reload_helper = ReloadServiceHelper(reload_service_handler, reload_targets)
|
||||
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RELOAD,
|
||||
reload_helper.execute_service,
|
||||
schema=vol.Schema({}),
|
||||
schema=vol.Schema({vol.Optional(CONF_ID): str}),
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_config)
|
||||
@@ -859,6 +867,7 @@ class AutomationEntityConfig:
|
||||
async def _prepare_automation_config(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
wanted_automation_id: str | None,
|
||||
) -> list[AutomationEntityConfig]:
|
||||
"""Parse configuration and prepare automation entity configuration."""
|
||||
automation_configs: list[AutomationEntityConfig] = []
|
||||
@@ -866,6 +875,10 @@ async def _prepare_automation_config(
|
||||
conf: list[ConfigType] = config[DOMAIN]
|
||||
|
||||
for list_no, config_block in enumerate(conf):
|
||||
automation_id: str | None = config_block.get(CONF_ID)
|
||||
if wanted_automation_id is not None and automation_id != wanted_automation_id:
|
||||
continue
|
||||
|
||||
raw_config = cast(AutomationConfig, config_block).raw_config
|
||||
raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs
|
||||
validation_failed = cast(AutomationConfig, config_block).validation_failed
|
||||
@@ -1025,7 +1038,7 @@ async def _async_process_config(
|
||||
|
||||
return automation_matches, config_matches
|
||||
|
||||
automation_configs = await _prepare_automation_config(hass, config)
|
||||
automation_configs = await _prepare_automation_config(hass, config, None)
|
||||
automations: list[BaseAutomationEntity] = list(component.entities)
|
||||
|
||||
# Find automations and configurations which have matches
|
||||
@@ -1049,6 +1062,41 @@ async def _async_process_config(
|
||||
await component.async_add_entities(entities)
|
||||
|
||||
|
||||
def _automation_matches_config(
|
||||
automation: BaseAutomationEntity | None, config: AutomationEntityConfig | None
|
||||
) -> bool:
|
||||
"""Return False if an automation's config has been changed."""
|
||||
if not automation:
|
||||
return False
|
||||
if not config:
|
||||
return False
|
||||
name = _automation_name(config)
|
||||
return automation.name == name and automation.raw_config == config.raw_config
|
||||
|
||||
|
||||
async def _async_process_single_config(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
component: EntityComponent[BaseAutomationEntity],
|
||||
automation_id: str,
|
||||
) -> None:
|
||||
"""Process config and add a single automation."""
|
||||
|
||||
automation_configs = await _prepare_automation_config(hass, config, automation_id)
|
||||
automation = next(
|
||||
(x for x in component.entities if x.unique_id == automation_id), None
|
||||
)
|
||||
automation_config = automation_configs[0] if automation_configs else None
|
||||
|
||||
if _automation_matches_config(automation, automation_config):
|
||||
return
|
||||
|
||||
if automation:
|
||||
await automation.async_remove()
|
||||
entities = await _create_automation_entities(hass, automation_configs)
|
||||
await component.async_add_entities(entities)
|
||||
|
||||
|
||||
async def _async_process_if(
|
||||
hass: HomeAssistant, name: str, config: dict[str, Any]
|
||||
) -> IfAction | None:
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -53,7 +53,6 @@ from homeassistant.loader import async_get_bluetooth
|
||||
|
||||
from . import models, passive_update_processor
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_ble_device_from_address,
|
||||
async_discovered_service_info,
|
||||
@@ -130,13 +129,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def _async_get_adapter_from_address(
|
||||
hass: HomeAssistant, address: str
|
||||
) -> str | None:
|
||||
"""Get an adapter by the address."""
|
||||
return await _get_manager(hass).async_get_adapter_from_address(address)
|
||||
|
||||
|
||||
async def _async_start_adapter_discovery(
|
||||
hass: HomeAssistant,
|
||||
manager: HomeAssistantBluetoothManager,
|
||||
@@ -204,6 +196,17 @@ async def _async_start_adapter_discovery(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the bluetooth integration."""
|
||||
if platform.system() == "Linux":
|
||||
# Remove any config entries that are using the default address
|
||||
# that were created from discovering adapters in a crashed state
|
||||
#
|
||||
# DEFAULT_ADDRESS is perfectly valid on MacOS but on
|
||||
# Linux it means the adapter is not yet configured
|
||||
# or crashed
|
||||
for entry in list(hass.config_entries.async_entries(DOMAIN)):
|
||||
if entry.unique_id == DEFAULT_ADDRESS:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
|
||||
bluetooth_adapters = get_adapters()
|
||||
bluetooth_storage = BluetoothStorage(hass)
|
||||
slot_manager = BleakSlotManager()
|
||||
@@ -265,13 +268,19 @@ async def async_discover_adapters(
|
||||
adapters: dict[str, AdapterDetails],
|
||||
) -> None:
|
||||
"""Discover adapters and start flows."""
|
||||
if platform.system() == "Windows":
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# We currently do not have a good way to detect if a bluetooth device is
|
||||
# available on Windows. We will just assume that it is not unless they
|
||||
# actively add it.
|
||||
return
|
||||
|
||||
for adapter, details in adapters.items():
|
||||
if system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS:
|
||||
# DEFAULT_ADDRESS is perfectly valid on MacOS but on
|
||||
# Linux it means the adapter is not yet configured
|
||||
# or crashed so we should not try to start a flow for it.
|
||||
continue
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
DOMAIN,
|
||||
@@ -303,28 +312,24 @@ async def async_update_device(
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry for a bluetooth scanner."""
|
||||
manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER]
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
adapter = await _async_get_adapter_from_address(hass, address)
|
||||
adapter = await manager.async_get_adapter_from_address_or_recover(address)
|
||||
if adapter is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Bluetooth adapter {adapter} with address {address} not found"
|
||||
)
|
||||
|
||||
passive = entry.options.get(CONF_PASSIVE)
|
||||
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
||||
manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER]
|
||||
scanner = HaScanner(mode, adapter, address)
|
||||
try:
|
||||
scanner.async_setup()
|
||||
except RuntimeError as err:
|
||||
try:
|
||||
await scanner.async_start()
|
||||
except (RuntimeError, ScannerStartError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"{adapter_human_name(adapter, address)}: {err}"
|
||||
) from err
|
||||
try:
|
||||
await scanner.async_start()
|
||||
except ScannerStartError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
adapters = await manager.async_get_bluetooth_adapters()
|
||||
details = adapters[adapter]
|
||||
slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS
|
||||
@@ -332,6 +337,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await async_update_device(hass, entry, adapter, details)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_listener))
|
||||
entry.async_on_unload(scanner.async_stop)
|
||||
return True
|
||||
|
||||
|
||||
|
@@ -2,12 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from typing import Any, cast
|
||||
|
||||
from bluetooth_adapters import (
|
||||
ADAPTER_ADDRESS,
|
||||
ADAPTER_MANUFACTURER,
|
||||
DEFAULT_ADDRESS,
|
||||
AdapterDetails,
|
||||
adapter_human_name,
|
||||
adapter_model,
|
||||
adapter_unique_name,
|
||||
get_adapters,
|
||||
)
|
||||
@@ -35,6 +39,22 @@ OPTIONS_FLOW = {
|
||||
}
|
||||
|
||||
|
||||
def adapter_display_info(adapter: str, details: AdapterDetails) -> str:
|
||||
"""Return the adapter display info."""
|
||||
name = adapter_human_name(adapter, details[ADAPTER_ADDRESS])
|
||||
model = adapter_model(details)
|
||||
manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown"
|
||||
return f"{name} {manufacturer} {model}"
|
||||
|
||||
|
||||
def adapter_title(adapter: str, details: AdapterDetails) -> str:
|
||||
"""Return the adapter title."""
|
||||
unique_name = adapter_unique_name(adapter, details[ADAPTER_ADDRESS])
|
||||
model = adapter_model(details)
|
||||
manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown"
|
||||
return f"{manufacturer} {model} ({unique_name})"
|
||||
|
||||
|
||||
class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Bluetooth."""
|
||||
|
||||
@@ -45,6 +65,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._adapter: str | None = None
|
||||
self._details: AdapterDetails | None = None
|
||||
self._adapters: dict[str, AdapterDetails] = {}
|
||||
self._placeholders: dict[str, str] = {}
|
||||
|
||||
async def async_step_integration_discovery(
|
||||
self, discovery_info: DiscoveryInfoType
|
||||
@@ -54,11 +75,23 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS])
|
||||
await self.async_set_unique_id(self._details[ADAPTER_ADDRESS])
|
||||
self._abort_if_unique_id_configured()
|
||||
self.context["title_placeholders"] = {
|
||||
"name": adapter_human_name(self._adapter, self._details[ADAPTER_ADDRESS])
|
||||
}
|
||||
details = self._details
|
||||
self._async_set_adapter_info(self._adapter, details)
|
||||
return await self.async_step_single_adapter()
|
||||
|
||||
@callback
|
||||
def _async_set_adapter_info(self, adapter: str, details: AdapterDetails) -> None:
|
||||
"""Set the adapter info."""
|
||||
name = adapter_human_name(adapter, details[ADAPTER_ADDRESS])
|
||||
model = adapter_model(details)
|
||||
manufacturer = details[ADAPTER_MANUFACTURER]
|
||||
self._placeholders = {
|
||||
"name": name,
|
||||
"model": model,
|
||||
"manufacturer": manufacturer or "Unknown",
|
||||
}
|
||||
self.context["title_placeholders"] = self._placeholders
|
||||
|
||||
async def async_step_single_adapter(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -67,6 +100,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
details = self._details
|
||||
assert adapter is not None
|
||||
assert details is not None
|
||||
assert self._placeholders is not None
|
||||
|
||||
address = details[ADAPTER_ADDRESS]
|
||||
|
||||
@@ -74,12 +108,12 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=adapter_unique_name(adapter, address), data={}
|
||||
title=adapter_title(adapter, details), data={}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="single_adapter",
|
||||
description_placeholders={"name": adapter_human_name(adapter, address)},
|
||||
description_placeholders=self._placeholders,
|
||||
)
|
||||
|
||||
async def async_step_multiple_adapters(
|
||||
@@ -89,21 +123,27 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
assert self._adapters is not None
|
||||
adapter = user_input[CONF_ADAPTER]
|
||||
address = self._adapters[adapter][ADAPTER_ADDRESS]
|
||||
details = self._adapters[adapter]
|
||||
address = details[ADAPTER_ADDRESS]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=adapter_unique_name(adapter, address), data={}
|
||||
title=adapter_title(adapter, details), data={}
|
||||
)
|
||||
|
||||
configured_addresses = self._async_current_ids()
|
||||
bluetooth_adapters = get_adapters()
|
||||
await bluetooth_adapters.refresh()
|
||||
self._adapters = bluetooth_adapters.adapters
|
||||
system = platform.system()
|
||||
unconfigured_adapters = [
|
||||
adapter
|
||||
for adapter, details in self._adapters.items()
|
||||
if details[ADAPTER_ADDRESS] not in configured_addresses
|
||||
# DEFAULT_ADDRESS is perfectly valid on MacOS but on
|
||||
# Linux it means the adapter is not yet configured
|
||||
# or crashed
|
||||
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
|
||||
]
|
||||
if not unconfigured_adapters:
|
||||
ignored_adapters = len(
|
||||
@@ -116,6 +156,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if len(unconfigured_adapters) == 1:
|
||||
self._adapter = list(self._adapters)[0]
|
||||
self._details = self._adapters[self._adapter]
|
||||
self._async_set_adapter_info(self._adapter, self._details)
|
||||
return await self.async_step_single_adapter()
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -124,8 +165,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_ADAPTER): vol.In(
|
||||
{
|
||||
adapter: adapter_human_name(
|
||||
adapter, self._adapters[adapter][ADAPTER_ADDRESS]
|
||||
adapter: adapter_display_info(
|
||||
adapter, self._adapters[adapter]
|
||||
)
|
||||
for adapter in sorted(unconfigured_adapters)
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ from bluetooth_adapters import get_dbus_managed_objects
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import _get_manager
|
||||
from .api import _get_manager
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
@@ -17,9 +17,9 @@
|
||||
"bleak==0.21.1",
|
||||
"bleak-retry-connector==3.5.0",
|
||||
"bluetooth-adapters==0.18.0",
|
||||
"bluetooth-auto-recovery==1.4.0",
|
||||
"bluetooth-auto-recovery==1.4.1",
|
||||
"bluetooth-data-tools==1.19.0",
|
||||
"dbus-fast==2.21.1",
|
||||
"habluetooth==2.4.2"
|
||||
"habluetooth==2.8.0"
|
||||
]
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"flow_title": "{name} {manufacturer} {model}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Choose a device to set up",
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
},
|
||||
"single_adapter": {
|
||||
"description": "Do you want to set up the Bluetooth adapter {name}?"
|
||||
"description": "Do you want to set up the Bluetooth adapter {name} {manufacturer} {model}?"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -28,3 +28,10 @@ SCAN_INTERVALS = {
|
||||
"north_america": 600,
|
||||
"rest_of_world": 300,
|
||||
}
|
||||
|
||||
CLIMATE_ACTIVITY_STATE: list[str] = [
|
||||
"cooling",
|
||||
"heating",
|
||||
"inactive",
|
||||
"standby",
|
||||
]
|
||||
|
@@ -85,6 +85,9 @@
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"default": "mdi:gas-station"
|
||||
},
|
||||
"climate_status": {
|
||||
"default": "mdi:fan"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BMWBaseEntity
|
||||
from .const import DOMAIN, UNIT_MAP
|
||||
from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -153,6 +153,15 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="activity",
|
||||
translation_key="climate_status",
|
||||
key_class="climate",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=CLIMATE_ACTIVITY_STATE,
|
||||
value=lambda x, _: x.lower() if x != "UNKNOWN" else None,
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
@@ -122,6 +122,15 @@
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"name": "Remaining fuel percent"
|
||||
},
|
||||
"climate_status": {
|
||||
"name": "Climate status",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"heating": "Heating",
|
||||
"inactive": "Inactive",
|
||||
"standby": "Standby"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -9,6 +9,7 @@ DOMAINS_AND_TYPES = {
|
||||
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
|
||||
Platform.SENSOR: {
|
||||
"A1",
|
||||
"MP1S",
|
||||
"RM4MINI",
|
||||
"RM4PRO",
|
||||
"RMPRO",
|
||||
@@ -20,6 +21,7 @@ DOMAINS_AND_TYPES = {
|
||||
Platform.SWITCH: {
|
||||
"BG1",
|
||||
"MP1",
|
||||
"MP1S",
|
||||
"RM4MINI",
|
||||
"RM4PRO",
|
||||
"RMMINI",
|
||||
|
@@ -38,5 +38,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/broadlink",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["broadlink"],
|
||||
"requirements": ["broadlink==0.18.3"]
|
||||
"requirements": ["broadlink==0.19.0"]
|
||||
}
|
||||
|
@@ -373,7 +373,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity):
|
||||
start_time = dt_util.utcnow()
|
||||
while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
|
||||
await asyncio.sleep(1)
|
||||
found = await device.async_request(device.api.check_frequency)
|
||||
found = await device.async_request(device.api.check_frequency)[0]
|
||||
if found:
|
||||
break
|
||||
else:
|
||||
|
@@ -129,7 +129,7 @@ async def async_setup_entry(
|
||||
elif device.api.type == "BG1":
|
||||
switches.extend(BroadlinkBG1Slot(device, slot) for slot in range(1, 3))
|
||||
|
||||
elif device.api.type == "MP1":
|
||||
elif device.api.type in {"MP1", "MP1S"}:
|
||||
switches.extend(BroadlinkMP1Slot(device, slot) for slot in range(1, 5))
|
||||
|
||||
async_add_entities(switches)
|
||||
|
@@ -21,6 +21,7 @@ def get_update_manager(device):
|
||||
"LB1": BroadlinkLB1UpdateManager,
|
||||
"LB2": BroadlinkLB1UpdateManager,
|
||||
"MP1": BroadlinkMP1UpdateManager,
|
||||
"MP1S": BroadlinkMP1SUpdateManager,
|
||||
"RM4MINI": BroadlinkRMUpdateManager,
|
||||
"RM4PRO": BroadlinkRMUpdateManager,
|
||||
"RMMINI": BroadlinkRMUpdateManager,
|
||||
@@ -112,6 +113,16 @@ class BroadlinkMP1UpdateManager(BroadlinkUpdateManager):
|
||||
return await self.device.async_request(self.device.api.check_power)
|
||||
|
||||
|
||||
class BroadlinkMP1SUpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink MP1 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
power = await self.device.async_request(self.device.api.check_power)
|
||||
sensors = await self.device.async_request(self.device.api.get_state)
|
||||
return {**power, **sensors}
|
||||
|
||||
|
||||
class BroadlinkRMUpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink remotes."""
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -26,7 +26,9 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
async def hook(action: str, config_key: str) -> None:
|
||||
"""post_write_hook for Config View that reloads automations."""
|
||||
if action != ACTION_DELETE:
|
||||
await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_RELOAD, {CONF_ID: config_key}
|
||||
)
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
@@ -480,15 +480,30 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def _get_toggle_function(
|
||||
self, fns: dict[str, Callable[_P, _R]]
|
||||
) -> Callable[_P, _R]:
|
||||
# If we are opening or closing and we support stopping, then we should stop
|
||||
if self.supported_features & CoverEntityFeature.STOP and (
|
||||
self.is_closing or self.is_opening
|
||||
):
|
||||
return fns["stop"]
|
||||
if self.is_closed:
|
||||
|
||||
# If we are fully closed or in the process of closing, then we should open
|
||||
if self.is_closed or self.is_closing:
|
||||
return fns["open"]
|
||||
if self._cover_is_last_toggle_direction_open:
|
||||
|
||||
# If we are fully open or in the process of opening, then we should close
|
||||
if self.current_cover_position == 100 or self.is_opening:
|
||||
return fns["close"]
|
||||
return fns["open"]
|
||||
|
||||
# We are any of:
|
||||
# * fully open but do not report `current_cover_position`
|
||||
# * stopped partially open
|
||||
# * either opening or closing, but do not report them
|
||||
# If we previously reported opening/closing, we should move in the opposite direction.
|
||||
# Otherwise, we must assume we are (partially) open and should always close.
|
||||
# Note: _cover_is_last_toggle_direction_open will always remain True if we never report opening/closing.
|
||||
return (
|
||||
fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"]
|
||||
)
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Support to turn on lights based on the states."""
|
||||
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -27,11 +28,11 @@ from homeassistant.const import (
|
||||
SUN_EVENT_SUNRISE,
|
||||
SUN_EVENT_SUNSET,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_utc_time,
|
||||
async_track_state_change,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.sun import get_astral_event_next, is_up
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -195,8 +196,20 @@ async def activate_automation( # noqa: C901
|
||||
schedule_light_turn_on(None)
|
||||
|
||||
@callback
|
||||
def check_light_on_dev_state_change(entity, old_state, new_state):
|
||||
def check_light_on_dev_state_change(
|
||||
from_state: str, to_state: str, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle tracked device state changes."""
|
||||
event_data = event.data
|
||||
if (
|
||||
(old_state := event_data["old_state"]) is None
|
||||
or (new_state := event_data["new_state"]) is None
|
||||
or old_state.state != from_state
|
||||
or new_state.state != to_state
|
||||
):
|
||||
return
|
||||
|
||||
entity = event_data["entity_id"]
|
||||
lights_are_on = any_light_on()
|
||||
light_needed = not (lights_are_on or is_up(hass))
|
||||
|
||||
@@ -237,12 +250,10 @@ async def activate_automation( # noqa: C901
|
||||
# will all the following then, break.
|
||||
break
|
||||
|
||||
async_track_state_change(
|
||||
async_track_state_change_event(
|
||||
hass,
|
||||
device_entity_ids,
|
||||
check_light_on_dev_state_change,
|
||||
STATE_NOT_HOME,
|
||||
STATE_HOME,
|
||||
partial(check_light_on_dev_state_change, STATE_NOT_HOME, STATE_HOME),
|
||||
)
|
||||
|
||||
if disable_turn_off:
|
||||
@@ -266,12 +277,10 @@ async def activate_automation( # noqa: C901
|
||||
)
|
||||
)
|
||||
|
||||
async_track_state_change(
|
||||
async_track_state_change_event(
|
||||
hass,
|
||||
device_entity_ids,
|
||||
turn_off_lights_when_all_leave,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
partial(turn_off_lights_when_all_leave, STATE_HOME, STATE_NOT_HOME),
|
||||
)
|
||||
|
||||
return
|
||||
|
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"image": {
|
||||
"image_guest_wifi": {
|
||||
"name": "Guest Wifi credentials as QR code"
|
||||
"name": "Guest Wi-Fi credentials as QR code"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -58,10 +58,10 @@
|
||||
"name": "Connected PLC devices"
|
||||
},
|
||||
"connected_wifi_clients": {
|
||||
"name": "Connected Wifi clients"
|
||||
"name": "Connected Wi-Fi clients"
|
||||
},
|
||||
"neighboring_wifi_networks": {
|
||||
"name": "Neighboring Wifi networks"
|
||||
"name": "Neighboring Wi-Fi networks"
|
||||
},
|
||||
"plc_rx_rate": {
|
||||
"name": "PLC downlink PHY rate"
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
"switch": {
|
||||
"switch_guest_wifi": {
|
||||
"name": "Enable guest Wifi"
|
||||
"name": "Enable guest Wi-Fi"
|
||||
},
|
||||
"switch_leds": {
|
||||
"name": "Enable LEDs"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
27
homeassistant/components/dsmr_reader/diagnostics.py
Normal file
27
homeassistant/components/dsmr_reader/diagnostics.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Diagnostics support for DSMR Reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for the config entry."""
|
||||
ent_reg = er.async_get(hass)
|
||||
entities = [
|
||||
entity.entity_id
|
||||
for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
|
||||
]
|
||||
|
||||
entity_states = {entity: hass.states.get(entity) for entity in entities}
|
||||
|
||||
return {
|
||||
"entry": entry.as_dict(),
|
||||
"entities": entity_states,
|
||||
}
|
@@ -6,6 +6,5 @@
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/emulated_hue",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohttp_cors==0.7.0"]
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/emulated_roku",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["emulated_roku"],
|
||||
"requirements": ["emulated-roku==0.2.1"]
|
||||
"requirements": ["emulated-roku==0.3.0"]
|
||||
}
|
||||
|
@@ -1 +1,48 @@
|
||||
"""Support for Enigma2 devices."""
|
||||
|
||||
from openwebif.api import OpenWebIfDevice
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Enigma2 from a config entry."""
|
||||
base_url = URL.build(
|
||||
scheme="http" if not entry.data[CONF_SSL] else "https",
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
user=entry.data.get(CONF_USERNAME),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = OpenWebIfDevice(session)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
165
homeassistant/components/enigma2/config_flow.py
Normal file
165
homeassistant/components/enigma2/config_flow.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Config flow for Enigma2."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from openwebif.api import OpenWebIfDevice
|
||||
from openwebif.error import InvalidAuthError
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import (
|
||||
CONF_DEEP_STANDBY,
|
||||
CONF_SOURCE_BOUQUET,
|
||||
CONF_USE_CHANNEL_ICON,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): selector.TextSelector(),
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1, max=65535, mode=selector.NumberSelectorMode.BOX
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Optional(CONF_USERNAME): selector.TextSelector(),
|
||||
vol.Optional(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_SSL, default=DEFAULT_SSL): selector.BooleanSelector(),
|
||||
vol.Required(
|
||||
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
|
||||
): selector.BooleanSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Enigma2."""
|
||||
|
||||
DATA_KEYS = (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
OPTIONS_KEYS = (CONF_DEEP_STANDBY, CONF_SOURCE_BOUQUET, CONF_USE_CHANNEL_ICON)
|
||||
|
||||
async def validate_user_input(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> dict[str, str] | None:
|
||||
"""Validate user input."""
|
||||
|
||||
errors = None
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
base_url = URL.build(
|
||||
scheme="http" if not user_input[CONF_SSL] else "https",
|
||||
host=user_input[CONF_HOST],
|
||||
port=user_input[CONF_PORT],
|
||||
user=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
session = async_create_clientsession(
|
||||
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL], base_url=base_url
|
||||
)
|
||||
|
||||
try:
|
||||
about = await OpenWebIfDevice(session).get_about()
|
||||
except InvalidAuthError:
|
||||
errors = {"base": "invalid_auth"}
|
||||
except ClientError:
|
||||
errors = {"base": "cannot_connect"}
|
||||
except Exception: # pylint: disable=broad-except
|
||||
errors = {"base": "unknown"}
|
||||
else:
|
||||
await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA)
|
||||
|
||||
if errors := await self.validate_user_input(user_input):
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA, errors=errors
|
||||
)
|
||||
return self.async_create_entry(data=user_input, title=user_input[CONF_HOST])
|
||||
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle the import step."""
|
||||
if CONF_PORT not in user_input:
|
||||
user_input[CONF_PORT] = DEFAULT_PORT
|
||||
if CONF_SSL not in user_input:
|
||||
user_input[CONF_SSL] = DEFAULT_SSL
|
||||
user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL
|
||||
|
||||
data = {key: user_input[key] for key in user_input if key in self.DATA_KEYS}
|
||||
options = {
|
||||
key: user_input[key] for key in user_input if key in self.OPTIONS_KEYS
|
||||
}
|
||||
|
||||
if errors := await self.validate_user_input(user_input):
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}_import_issue_{errors["base"]}",
|
||||
breaks_in_ha_version="2024.11.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{errors["base"]}",
|
||||
translation_placeholders={
|
||||
"url": "/config/integrations/dashboard/add?domain=enigma2"
|
||||
},
|
||||
)
|
||||
return self.async_abort(reason=errors["base"])
|
||||
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2024.11.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Enigma2",
|
||||
},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
data=data, title=data[CONF_HOST], options=options
|
||||
)
|
@@ -16,3 +16,4 @@ DEFAULT_PASSWORD = "dreambox"
|
||||
DEFAULT_DEEP_STANDBY = False
|
||||
DEFAULT_SOURCE_BOUQUET = ""
|
||||
DEFAULT_MAC_ADDRESS = ""
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
|
@@ -2,7 +2,9 @@
|
||||
"domain": "enigma2",
|
||||
"name": "Enigma2 (OpenWebif)",
|
||||
"codeowners": ["@autinerd"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/enigma2",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openwebif"],
|
||||
"requirements": ["openwebifpy==4.2.4"]
|
||||
|
@@ -9,7 +9,6 @@ from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedEr
|
||||
from openwebif.api import OpenWebIfDevice
|
||||
from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
@@ -17,6 +16,7 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -26,10 +26,9 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
@@ -47,6 +46,7 @@ from .const import (
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_USE_CHANNEL_ICON,
|
||||
DEFAULT_USERNAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
|
||||
@@ -81,49 +81,44 @@ async def async_setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up of an enigma2 media player."""
|
||||
if discovery_info:
|
||||
# Discovery gives us the streaming service port (8001)
|
||||
# which is not useful as OpenWebif never runs on that port.
|
||||
# So use the default port instead.
|
||||
config[CONF_PORT] = DEFAULT_PORT
|
||||
config[CONF_NAME] = discovery_info["hostname"]
|
||||
config[CONF_HOST] = discovery_info["host"]
|
||||
config[CONF_USERNAME] = DEFAULT_USERNAME
|
||||
config[CONF_PASSWORD] = DEFAULT_PASSWORD
|
||||
config[CONF_SSL] = DEFAULT_SSL
|
||||
config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON
|
||||
config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS
|
||||
config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY
|
||||
config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET
|
||||
|
||||
base_url = URL.build(
|
||||
scheme="https" if config[CONF_SSL] else "http",
|
||||
host=config[CONF_HOST],
|
||||
port=config.get(CONF_PORT),
|
||||
user=config.get(CONF_USERNAME),
|
||||
password=config.get(CONF_PASSWORD),
|
||||
entry_data = {
|
||||
CONF_HOST: config[CONF_HOST],
|
||||
CONF_PORT: config[CONF_PORT],
|
||||
CONF_USERNAME: config[CONF_USERNAME],
|
||||
CONF_PASSWORD: config[CONF_PASSWORD],
|
||||
CONF_SSL: config[CONF_SSL],
|
||||
CONF_USE_CHANNEL_ICON: config[CONF_USE_CHANNEL_ICON],
|
||||
CONF_DEEP_STANDBY: config[CONF_DEEP_STANDBY],
|
||||
CONF_SOURCE_BOUQUET: config[CONF_SOURCE_BOUQUET],
|
||||
}
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data
|
||||
)
|
||||
)
|
||||
|
||||
session = async_create_clientsession(hass, verify_ssl=False, base_url=base_url)
|
||||
|
||||
device = OpenWebIfDevice(
|
||||
host=session,
|
||||
turn_off_to_deep=config.get(CONF_DEEP_STANDBY, False),
|
||||
source_bouquet=config.get(CONF_SOURCE_BOUQUET),
|
||||
)
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Enigma2 media player platform."""
|
||||
|
||||
try:
|
||||
device: OpenWebIfDevice = hass.data[DOMAIN][entry.entry_id]
|
||||
about = await device.get_about()
|
||||
except ClientConnectorError as err:
|
||||
raise PlatformNotReady from err
|
||||
|
||||
async_add_entities([Enigma2Device(config[CONF_NAME], device, about)])
|
||||
device.mac_address = about["info"]["ifaces"][0]["mac"]
|
||||
entity = Enigma2Device(entry, device, about)
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
class Enigma2Device(MediaPlayerEntity):
|
||||
"""Representation of an Enigma2 box."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
_attr_media_content_type = MediaType.TVSHOW
|
||||
_attr_supported_features = (
|
||||
@@ -139,14 +134,23 @@ class Enigma2Device(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None:
|
||||
def __init__(
|
||||
self, entry: ConfigEntry, device: OpenWebIfDevice, about: dict
|
||||
) -> None:
|
||||
"""Initialize the Enigma2 device."""
|
||||
self._device: OpenWebIfDevice = device
|
||||
self._device.mac_address = about["info"]["ifaces"][0]["mac"]
|
||||
self._entry = entry
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = device.mac_address
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.mac_address)},
|
||||
manufacturer=about["info"]["brand"],
|
||||
model=about["info"]["model"],
|
||||
configuration_url=device.base,
|
||||
name=entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off media player."""
|
||||
if self._device.turn_off_to_deep:
|
||||
|
43
homeassistant/components/enigma2/strings.json
Normal file
43
homeassistant/components/enigma2/strings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please enter the connection details of your device.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_unknown": {
|
||||
"title": "The Enigma2 YAML configuration import failed",
|
||||
"description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works, the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
|
||||
},
|
||||
"deprecated_yaml_import_issue_invalid_auth": {
|
||||
"title": "The Enigma2 YAML configuration import failed",
|
||||
"description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
|
||||
},
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"title": "The Enigma2 YAML configuration import failed",
|
||||
"description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
|
||||
}
|
||||
}
|
||||
}
|
@@ -36,6 +36,7 @@ from aioesphomeapi import (
|
||||
TextSensorInfo,
|
||||
TimeInfo,
|
||||
UserService,
|
||||
ValveInfo,
|
||||
build_unique_id,
|
||||
)
|
||||
from aioesphomeapi.model import ButtonInfo
|
||||
@@ -78,6 +79,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
TextInfo: Platform.TEXT,
|
||||
TextSensorInfo: Platform.SENSOR,
|
||||
TimeInfo: Platform.TIME,
|
||||
ValveInfo: Platform.VALVE,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -15,7 +15,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==24.0.0",
|
||||
"aioesphomeapi==24.1.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==1.0.0"
|
||||
],
|
||||
|
103
homeassistant/components/esphome/valve.py
Normal file
103
homeassistant/components/esphome/valve.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Support for ESPHome valves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import EntityInfo, ValveInfo, ValveOperation, ValveState
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ValveDeviceClass,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
esphome_state_property,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up ESPHome valves based on a config entry."""
|
||||
await platform_async_setup_entry(
|
||||
hass,
|
||||
entry,
|
||||
async_add_entities,
|
||||
info_type=ValveInfo,
|
||||
entity_type=EsphomeValve,
|
||||
state_type=ValveState,
|
||||
)
|
||||
|
||||
|
||||
class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity):
|
||||
"""A valve implementation for ESPHome."""
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
flags = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
if static_info.supports_stop:
|
||||
flags |= ValveEntityFeature.STOP
|
||||
if static_info.supports_position:
|
||||
flags |= ValveEntityFeature.SET_POSITION
|
||||
self._attr_supported_features = flags
|
||||
self._attr_device_class = try_parse_enum(
|
||||
ValveDeviceClass, static_info.device_class
|
||||
)
|
||||
self._attr_assumed_state = static_info.assumed_state
|
||||
self._attr_reports_position = static_info.supports_position
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the valve is closed or not."""
|
||||
return self._state.position == 0.0
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_opening(self) -> bool:
|
||||
"""Return if the valve is opening or not."""
|
||||
return self._state.current_operation is ValveOperation.IS_OPENING
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the valve is closing or not."""
|
||||
return self._state.current_operation is ValveOperation.IS_CLOSING
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def current_valve_position(self) -> int | None:
|
||||
"""Return current position of valve. 0 is closed, 100 is open."""
|
||||
return round(self._state.position * 100.0)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||
"""Open the valve."""
|
||||
self._client.valve_command(key=self._key, position=1.0)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_close_valve(self, **kwargs: Any) -> None:
|
||||
"""Close valve."""
|
||||
self._client.valve_command(key=self._key, position=0.0)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_stop_valve(self, **kwargs: Any) -> None:
|
||||
"""Stop the valve."""
|
||||
self._client.valve_command(key=self._key, stop=True)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_set_valve_position(self, position: float) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
self._client.valve_command(key=self._key, position=position / 100)
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "hassio",
|
||||
"name": "Home Assistant Supervisor",
|
||||
"codeowners": ["@home-assistant/supervisor"],
|
||||
"dependencies": ["http"],
|
||||
"dependencies": ["http", "repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal"
|
||||
|
@@ -52,14 +52,14 @@
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"fix_menu": {
|
||||
"description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the 'Rename' option to rename the filesystem to prevent this. Use the 'Adopt' option to make that your data disk and rename the existing one. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system.",
|
||||
"description": "At `{reference}`, we detected another active data disk (containing a file system `hassos-data` from another Home Assistant installation).\n\nYou need to decide what to do with it. Otherwise Home Assistant might choose the wrong data disk at system reboot.\n\nIf you don't want to use this data disk, unplug it from your system. If you leave it plugged in, choose one of the following options:",
|
||||
"menu_options": {
|
||||
"system_rename_data_disk": "Rename",
|
||||
"system_adopt_data_disk": "Adopt"
|
||||
"system_rename_data_disk": "Mark as inactive data disk (rename file system)",
|
||||
"system_adopt_data_disk": "Use the detected data disk instead of the current system"
|
||||
}
|
||||
},
|
||||
"system_adopt_data_disk": {
|
||||
"description": "This fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period. After the reboot `{reference}` will be the data disk of Home Assistant and your existing data disk will be renamed and ignored."
|
||||
"description": "Select submit to make `{reference}` the active data disk. The one and only.\n\nYou won't have access anymore to the current Home Assistant data (will be marked as inactive data disk). After reboot, your system will be in the state of the Home Assistant data on `{reference}`."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
@@ -187,6 +187,10 @@
|
||||
"unsupported_systemd_resolved": {
|
||||
"title": "Unsupported system - Systemd-Resolved issues",
|
||||
"description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_virtualization_image": {
|
||||
"title": "Unsupported system - Incorrect OS image for virtualization",
|
||||
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -191,6 +191,18 @@
|
||||
},
|
||||
"service_not_found": {
|
||||
"message": "Service {domain}.{service} not found."
|
||||
},
|
||||
"service_does_not_supports_reponse": {
|
||||
"message": "A service which does not return responses can't be called with {return_response}."
|
||||
},
|
||||
"service_lacks_response_request": {
|
||||
"message": "The service call requires responses and must be called with {return_response}."
|
||||
},
|
||||
"service_reponse_invalid": {
|
||||
"message": "Failed to process the returned service response data, expected a dictionary, but got {response_data_type}."
|
||||
},
|
||||
"service_should_be_blocking": {
|
||||
"message": "A non blocking service call with argument {non_blocking_argument} can't be used together with argument {return_response}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -93,7 +93,7 @@ BUTTON_EDIT = {
|
||||
}
|
||||
|
||||
|
||||
validate_addr = cv.matches_regex(r"\[\d\d:\d\d:\d\d:\d\d\]")
|
||||
validate_addr = cv.matches_regex(r"\[(?:\d\d:)?\d\d:\d\d:\d\d\]")
|
||||
|
||||
|
||||
async def validate_add_controller(
|
||||
@@ -565,15 +565,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_KEYPADS: [
|
||||
{
|
||||
CONF_ADDR: keypad[CONF_ADDR],
|
||||
CONF_BUTTONS: [
|
||||
{
|
||||
CONF_LED: button[CONF_LED],
|
||||
CONF_NAME: button[CONF_NAME],
|
||||
CONF_NUMBER: button[CONF_NUMBER],
|
||||
CONF_RELEASE_DELAY: button[CONF_RELEASE_DELAY],
|
||||
}
|
||||
for button in keypad[CONF_BUTTONS]
|
||||
],
|
||||
CONF_BUTTONS: [],
|
||||
CONF_NAME: keypad[CONF_NAME],
|
||||
}
|
||||
for keypad in config[CONF_KEYPADS]
|
||||
|
@@ -34,7 +34,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -361,15 +361,18 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if mode in ["heat", "emheat"]:
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
|
||||
except UnexpectedResponse as err:
|
||||
except (AscConnectionError, UnexpectedResponse) as err:
|
||||
raise HomeAssistantError(
|
||||
"Honeywell set temperature failed: Invalid Response"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_failed",
|
||||
) from err
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
raise ValueError(
|
||||
f"Honeywell set temperature failed: invalid temperature {temperature}."
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_failed_value",
|
||||
translation_placeholders={"temp": temperature},
|
||||
) from err
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -382,30 +385,41 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW):
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
|
||||
except UnexpectedResponse as err:
|
||||
except (AscConnectionError, UnexpectedResponse) as err:
|
||||
raise HomeAssistantError(
|
||||
"Honeywell set temperature failed: Invalid Response"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_failed",
|
||||
) from err
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
raise ValueError(
|
||||
f"Honeywell set temperature failed: invalid temperature: {temperature}."
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_failed_value",
|
||||
translation_placeholders={"temp": str(temperature)},
|
||||
) from err
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
try:
|
||||
await self._device.set_fan_mode(self._fan_mode_map[fan_mode])
|
||||
|
||||
except SomeComfortError as err:
|
||||
raise HomeAssistantError("Honeywell could not set fan mode.") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fan_mode_failed",
|
||||
) from err
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
try:
|
||||
await self._device.set_system_mode(self._hvac_mode_map[hvac_mode])
|
||||
|
||||
except SomeComfortError as err:
|
||||
raise HomeAssistantError("Honeywell could not set system mode.") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="sys_mode_failed",
|
||||
) from err
|
||||
|
||||
async def _turn_away_mode_on(self) -> None:
|
||||
"""Turn away on.
|
||||
@@ -425,6 +439,12 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if mode in HEATING_MODES:
|
||||
await self._device.set_hold_heat(True, self._heat_away_temp)
|
||||
|
||||
except (AscConnectionError, UnexpectedResponse) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="away_mode_failed",
|
||||
) from err
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error(
|
||||
"Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f",
|
||||
@@ -432,8 +452,14 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
self._heat_away_temp,
|
||||
self._cool_away_temp,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}."
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_failed_range",
|
||||
translation_placeholders={
|
||||
"heat": str(self._heat_away_temp),
|
||||
"cool": str(self._cool_away_temp),
|
||||
"mode": mode,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _turn_hold_mode_on(self) -> None:
|
||||
@@ -452,11 +478,16 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Couldn't set permanent hold")
|
||||
raise HomeAssistantError(
|
||||
"Honeywell couldn't set permanent hold."
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_hold_failed",
|
||||
) from err
|
||||
else:
|
||||
_LOGGER.error("Invalid system mode returned: %s", mode)
|
||||
raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_mode_failed",
|
||||
translation_placeholders={"mode": mode},
|
||||
)
|
||||
|
||||
async def _turn_away_mode_off(self) -> None:
|
||||
"""Turn away/hold off."""
|
||||
@@ -465,9 +496,13 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
# Disabling all hold modes
|
||||
await self._device.set_hold_cool(False)
|
||||
await self._device.set_hold_heat(False)
|
||||
|
||||
except SomeComfortError as err:
|
||||
_LOGGER.error("Can not stop hold mode")
|
||||
raise HomeAssistantError("Honeywell could not stop hold mode") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stop_hold_failed",
|
||||
) from err
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
@@ -493,9 +528,11 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
)
|
||||
try:
|
||||
await self._device.set_system_mode("emheat")
|
||||
|
||||
except SomeComfortError as err:
|
||||
raise HomeAssistantError(
|
||||
"Honeywell could not set system mode to aux heat."
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_aux_failed",
|
||||
) from err
|
||||
|
||||
async def async_turn_aux_heat_off(self) -> None:
|
||||
@@ -517,8 +554,12 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
await self.async_set_hvac_mode(HVACMode.HEAT)
|
||||
else:
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
except HomeAssistantError as err:
|
||||
raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="disable_aux_failed",
|
||||
) from err
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest state from the service."""
|
||||
|
@@ -61,6 +61,39 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"temp_failed": {
|
||||
"message": "Honeywell set temperature failed"
|
||||
},
|
||||
"sys_mode_failed": {
|
||||
"message": "Honeywell could not set system mode"
|
||||
},
|
||||
"fan_mode_failed": {
|
||||
"message": "Honeywell could not set fan mode"
|
||||
},
|
||||
"away_mode_failed": {
|
||||
"message": "Honeywell set away mode failed"
|
||||
},
|
||||
"temp_failed_value": {
|
||||
"message": "Honeywell set temperature failed: invalid temperature {temperature}"
|
||||
},
|
||||
"temp_failed_range": {
|
||||
"message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {heat}, Cool Temperature: {cool}"
|
||||
},
|
||||
"set_hold_failed": {
|
||||
"message": "Honeywell could not set permanent hold"
|
||||
},
|
||||
"set_mode_failed": {
|
||||
"message": "Honeywell invalid system mode returned {mode}"
|
||||
},
|
||||
"stop_hold_failed": {
|
||||
"message": "Honeywell could not stop hold mode"
|
||||
},
|
||||
"set_aux_failed": {
|
||||
"message": "Honeywell could not set system mode to aux heat"
|
||||
},
|
||||
"disable_aux_failed": {
|
||||
"message": "Honeywell could turn off aux heat mode"
|
||||
},
|
||||
"switch_failed_off": {
|
||||
"message": "Honeywell could turn off emergency heat mode."
|
||||
},
|
||||
|
@@ -21,7 +21,7 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL
|
||||
from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection
|
||||
from aiohttp.web_protocol import RequestHandler
|
||||
from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher
|
||||
from aiohttp_zlib_ng import enable_zlib_ng
|
||||
from aiohttp_isal import enable_isal
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
@@ -202,7 +202,7 @@ class ApiConfig:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HTTP API and debug interface."""
|
||||
enable_zlib_ng()
|
||||
enable_isal()
|
||||
|
||||
conf: ConfData | None = config.get(DOMAIN)
|
||||
|
||||
|
@@ -13,6 +13,7 @@ from aiohttp.web_urldispatcher import (
|
||||
ResourceRoute,
|
||||
StaticResource,
|
||||
)
|
||||
import aiohttp_cors
|
||||
|
||||
from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH
|
||||
from homeassistant.core import callback
|
||||
@@ -35,11 +36,6 @@ VALID_CORS_TYPES: Final = (Resource, ResourceRoute, StaticResource)
|
||||
@callback
|
||||
def setup_cors(app: Application, origins: list[str]) -> None:
|
||||
"""Set up CORS."""
|
||||
# This import should remain here. That way the HTTP integration can always
|
||||
# be imported by other integrations without it's requirements being installed.
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import aiohttp_cors
|
||||
|
||||
cors = aiohttp_cors.setup(
|
||||
app,
|
||||
defaults={
|
||||
|
@@ -5,10 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/http",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiohttp_cors==0.7.0",
|
||||
"aiohttp-fast-url-dispatcher==0.3.0",
|
||||
"aiohttp-zlib-ng==0.3.1"
|
||||
]
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
@@ -6,11 +6,13 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from iaqualink.device import AqualinkThermostat
|
||||
from iaqualink.systems.iaqua.device import AqualinkState
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -82,6 +84,16 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity):
|
||||
else:
|
||||
_LOGGER.warning("Unknown operation mode: %s", hvac_mode)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return the current HVAC action."""
|
||||
state = AqualinkState(self.dev._heater.state)
|
||||
if state == AqualinkState.ON:
|
||||
return HVACAction.HEATING
|
||||
if state == AqualinkState.ENABLED:
|
||||
return HVACAction.IDLE
|
||||
return HVACAction.OFF
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the current target temperature."""
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from . import api
|
||||
from .const import (
|
||||
CONF_CAT,
|
||||
CONF_DEV_PATH,
|
||||
CONF_DIM_STEPS,
|
||||
CONF_HOUSECODE,
|
||||
CONF_OVERRIDE,
|
||||
@@ -84,6 +85,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up an Insteon entry."""
|
||||
|
||||
if dev_path := entry.options.get(CONF_DEV_PATH):
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data[DOMAIN][CONF_DEV_PATH] = dev_path
|
||||
|
||||
api.async_load_api(hass)
|
||||
await api.async_register_insteon_frontend(hass)
|
||||
|
||||
if not devices.modem:
|
||||
try:
|
||||
await async_connect(**entry.data)
|
||||
@@ -149,9 +157,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
api.async_load_api(hass)
|
||||
await api.async_register_insteon_frontend(hass)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
|
||||
)
|
||||
|
@@ -16,10 +16,19 @@ from .aldb import (
|
||||
websocket_reset_aldb,
|
||||
websocket_write_aldb,
|
||||
)
|
||||
from .config import (
|
||||
websocket_add_device_override,
|
||||
websocket_get_config,
|
||||
websocket_get_modem_schema,
|
||||
websocket_remove_device_override,
|
||||
websocket_update_modem_config,
|
||||
)
|
||||
from .device import (
|
||||
websocket_add_device,
|
||||
websocket_add_x10_device,
|
||||
websocket_cancel_add_device,
|
||||
websocket_get_device,
|
||||
websocket_remove_device,
|
||||
)
|
||||
from .properties import (
|
||||
websocket_change_properties_record,
|
||||
@@ -58,6 +67,8 @@ def async_load_api(hass):
|
||||
websocket_api.async_register_command(hass, websocket_reset_aldb)
|
||||
websocket_api.async_register_command(hass, websocket_add_default_links)
|
||||
websocket_api.async_register_command(hass, websocket_notify_on_aldb_status)
|
||||
websocket_api.async_register_command(hass, websocket_add_x10_device)
|
||||
websocket_api.async_register_command(hass, websocket_remove_device)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_get_properties)
|
||||
websocket_api.async_register_command(hass, websocket_change_properties_record)
|
||||
@@ -65,6 +76,12 @@ def async_load_api(hass):
|
||||
websocket_api.async_register_command(hass, websocket_load_properties)
|
||||
websocket_api.async_register_command(hass, websocket_reset_properties)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_get_config)
|
||||
websocket_api.async_register_command(hass, websocket_get_modem_schema)
|
||||
websocket_api.async_register_command(hass, websocket_update_modem_config)
|
||||
websocket_api.async_register_command(hass, websocket_add_device_override)
|
||||
websocket_api.async_register_command(hass, websocket_remove_device_override)
|
||||
|
||||
|
||||
async def async_register_insteon_frontend(hass: HomeAssistant):
|
||||
"""Register the Insteon frontend configuration panel."""
|
||||
@@ -80,8 +97,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant):
|
||||
hass=hass,
|
||||
frontend_url_path=DOMAIN,
|
||||
webcomponent_name="insteon-frontend",
|
||||
sidebar_title=DOMAIN.capitalize(),
|
||||
sidebar_icon="mdi:power",
|
||||
config_panel_domain=DOMAIN,
|
||||
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
|
||||
embed_iframe=True,
|
||||
require_admin=True,
|
||||
|
272
homeassistant/components/insteon/api/config.py
Normal file
272
homeassistant/components/insteon/api/config.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""API calls to manage Insteon configuration changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from pyinsteon import async_close, async_connect, devices
|
||||
from pyinsteon.address import Address
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_DEVICE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from ..const import (
|
||||
CONF_HOUSECODE,
|
||||
CONF_OVERRIDE,
|
||||
CONF_UNITCODE,
|
||||
CONF_X10,
|
||||
DEVICE_ADDRESS,
|
||||
DOMAIN,
|
||||
ID,
|
||||
SIGNAL_ADD_DEVICE_OVERRIDE,
|
||||
SIGNAL_ADD_X10_DEVICE,
|
||||
SIGNAL_REMOVE_DEVICE_OVERRIDE,
|
||||
TYPE,
|
||||
)
|
||||
from ..schemas import (
|
||||
build_device_override_schema,
|
||||
build_hub_schema,
|
||||
build_plm_manual_schema,
|
||||
build_plm_schema,
|
||||
)
|
||||
from ..utils import async_get_usb_ports
|
||||
|
||||
HUB_V1_SCHEMA = build_hub_schema(hub_version=1)
|
||||
HUB_V2_SCHEMA = build_hub_schema(hub_version=2)
|
||||
PLM_SCHEMA = build_plm_manual_schema()
|
||||
DEVICE_OVERRIDE_SCHEMA = build_device_override_schema()
|
||||
OVERRIDE = "override"
|
||||
|
||||
|
||||
class X10DeviceConfig(TypedDict):
|
||||
"""X10 Device Configuration Definition."""
|
||||
|
||||
housecode: str
|
||||
unitcode: int
|
||||
platform: str
|
||||
dim_steps: int
|
||||
|
||||
|
||||
class DeviceOverride(TypedDict):
|
||||
"""X10 Device Configuration Definition."""
|
||||
|
||||
address: Address | str
|
||||
cat: int
|
||||
subcat: str
|
||||
|
||||
|
||||
def get_insteon_config_entry(hass: HomeAssistant) -> ConfigEntry:
|
||||
"""Return the Insteon configuration entry."""
|
||||
return hass.config_entries.async_entries(DOMAIN)[0]
|
||||
|
||||
|
||||
def add_x10_device(hass: HomeAssistant, x10_device: X10DeviceConfig):
|
||||
"""Add an X10 device to the Insteon integration."""
|
||||
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
x10_config = config_entry.options.get(CONF_X10, [])
|
||||
if any(
|
||||
device[CONF_HOUSECODE] == x10_device["housecode"]
|
||||
and device[CONF_UNITCODE] == x10_device["unitcode"]
|
||||
for device in x10_config
|
||||
):
|
||||
raise ValueError("Duplicate X10 device")
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry=config_entry,
|
||||
options=config_entry.options | {CONF_X10: [*x10_config, x10_device]},
|
||||
)
|
||||
async_dispatcher_send(hass, SIGNAL_ADD_X10_DEVICE, x10_device)
|
||||
|
||||
|
||||
def remove_x10_device(hass: HomeAssistant, housecode: str, unitcode: int):
|
||||
"""Remove an X10 device from the config."""
|
||||
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
new_options = {**config_entry.options}
|
||||
new_x10 = [
|
||||
existing_device
|
||||
for existing_device in config_entry.options.get(CONF_X10, [])
|
||||
if existing_device[CONF_HOUSECODE].lower() != housecode.lower()
|
||||
or existing_device[CONF_UNITCODE] != unitcode
|
||||
]
|
||||
|
||||
new_options[CONF_X10] = new_x10
|
||||
hass.config_entries.async_update_entry(entry=config_entry, options=new_options)
|
||||
|
||||
|
||||
def add_device_overide(hass: HomeAssistant, override: DeviceOverride):
|
||||
"""Add an Insteon device override."""
|
||||
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
override_config = config_entry.options.get(CONF_OVERRIDE, [])
|
||||
address = Address(override[CONF_ADDRESS])
|
||||
if any(
|
||||
Address(existing_override[CONF_ADDRESS]) == address
|
||||
for existing_override in override_config
|
||||
):
|
||||
raise ValueError("Duplicate override")
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry=config_entry,
|
||||
options=config_entry.options | {CONF_OVERRIDE: [*override_config, override]},
|
||||
)
|
||||
async_dispatcher_send(hass, SIGNAL_ADD_DEVICE_OVERRIDE, override)
|
||||
|
||||
|
||||
def remove_device_override(hass: HomeAssistant, address: Address):
|
||||
"""Remove a device override from config."""
|
||||
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
new_overrides = [
|
||||
existing_override
|
||||
for existing_override in config_entry.options.get(CONF_OVERRIDE, [])
|
||||
if Address(existing_override[CONF_ADDRESS]) != address
|
||||
]
|
||||
new_options[CONF_OVERRIDE] = new_overrides
|
||||
hass.config_entries.async_update_entry(entry=config_entry, options=new_options)
|
||||
|
||||
|
||||
async def _async_connect(**kwargs):
|
||||
"""Connect to the Insteon modem."""
|
||||
if devices.modem:
|
||||
await async_close()
|
||||
try:
|
||||
await async_connect(**kwargs)
|
||||
except ConnectionError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required(TYPE): "insteon/config/get"})
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_get_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get Insteon configuration."""
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
modem_config = config_entry.data
|
||||
options_config = config_entry.options
|
||||
x10_config = options_config.get(CONF_X10)
|
||||
override_config = options_config.get(CONF_OVERRIDE)
|
||||
connection.send_result(
|
||||
msg[ID],
|
||||
{
|
||||
"modem_config": {**modem_config},
|
||||
"x10_config": x10_config,
|
||||
"override_config": override_config,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/config/get_modem_schema",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_get_modem_schema(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the schema for the modem configuration."""
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
config_data = config_entry.data
|
||||
if device := config_data.get(CONF_DEVICE):
|
||||
ports = await async_get_usb_ports(hass=hass)
|
||||
plm_schema = voluptuous_serialize.convert(
|
||||
build_plm_schema(ports=ports, device=device)
|
||||
)
|
||||
connection.send_result(msg[ID], plm_schema)
|
||||
else:
|
||||
hub_schema = voluptuous_serialize.convert(build_hub_schema(**config_data))
|
||||
connection.send_result(msg[ID], hub_schema)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/config/update_modem_config",
|
||||
vol.Required("config"): vol.Any(PLM_SCHEMA, HUB_V2_SCHEMA, HUB_V1_SCHEMA),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_modem_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the schema for the modem configuration."""
|
||||
config = msg["config"]
|
||||
config_entry = get_insteon_config_entry(hass)
|
||||
is_connected = devices.modem.connected
|
||||
|
||||
if not await _async_connect(**config):
|
||||
connection.send_error(
|
||||
msg_id=msg[ID], code="connection_failed", message="Connection failed"
|
||||
)
|
||||
# Try to reconnect using old info
|
||||
if is_connected:
|
||||
await _async_connect(**config_entry.data)
|
||||
return
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry=config_entry,
|
||||
data=config,
|
||||
)
|
||||
connection.send_result(msg[ID], {"status": "success"})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/config/device_override/add",
|
||||
vol.Required(OVERRIDE): DEVICE_OVERRIDE_SCHEMA,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_add_device_override(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the schema for the modem configuration."""
|
||||
override = msg[OVERRIDE]
|
||||
try:
|
||||
add_device_overide(hass, override)
|
||||
except ValueError:
|
||||
connection.send_error(msg[ID], "duplicate", "Duplicate device address")
|
||||
|
||||
connection.send_result(msg[ID])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/config/device_override/remove",
|
||||
vol.Required(DEVICE_ADDRESS): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_remove_device_override(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the schema for the modem configuration."""
|
||||
address = Address(msg[DEVICE_ADDRESS])
|
||||
remove_device_override(hass, address)
|
||||
async_dispatcher_send(hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, address)
|
||||
connection.send_result(msg[ID])
|
@@ -3,12 +3,14 @@
|
||||
from typing import Any
|
||||
|
||||
from pyinsteon import devices
|
||||
from pyinsteon.address import Address
|
||||
from pyinsteon.constants import DeviceAction
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from ..const import (
|
||||
DEVICE_ADDRESS,
|
||||
@@ -18,8 +20,17 @@ from ..const import (
|
||||
ID,
|
||||
INSTEON_DEVICE_NOT_FOUND,
|
||||
MULTIPLE,
|
||||
SIGNAL_REMOVE_HA_DEVICE,
|
||||
SIGNAL_REMOVE_INSTEON_DEVICE,
|
||||
SIGNAL_REMOVE_X10_DEVICE,
|
||||
TYPE,
|
||||
)
|
||||
from ..schemas import build_x10_schema
|
||||
from .config import add_x10_device, remove_device_override, remove_x10_device
|
||||
|
||||
X10_DEVICE = "x10_device"
|
||||
X10_DEVICE_SCHEMA = build_x10_schema()
|
||||
REMOVE_ALL_REFS = "remove_all_refs"
|
||||
|
||||
|
||||
def compute_device_name(ha_device):
|
||||
@@ -139,3 +150,61 @@ async def websocket_cancel_add_device(
|
||||
"""Cancel the Insteon all-linking process."""
|
||||
await devices.async_cancel_all_linking()
|
||||
connection.send_result(msg[ID])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/device/remove",
|
||||
vol.Required(DEVICE_ADDRESS): str,
|
||||
vol.Required(REMOVE_ALL_REFS): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_remove_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove an Insteon device."""
|
||||
|
||||
address = msg[DEVICE_ADDRESS]
|
||||
remove_all_refs = msg[REMOVE_ALL_REFS]
|
||||
if address.startswith("X10"):
|
||||
_, housecode, unitcode = address.split(".")
|
||||
unitcode = int(unitcode)
|
||||
async_dispatcher_send(hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode)
|
||||
remove_x10_device(hass, housecode, unitcode)
|
||||
else:
|
||||
address = Address(address)
|
||||
remove_device_override(hass, address)
|
||||
async_dispatcher_send(hass, SIGNAL_REMOVE_HA_DEVICE, address)
|
||||
async_dispatcher_send(
|
||||
hass, SIGNAL_REMOVE_INSTEON_DEVICE, address, remove_all_refs
|
||||
)
|
||||
|
||||
connection.send_result(msg[ID])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "insteon/device/add_x10",
|
||||
vol.Required(X10_DEVICE): X10_DEVICE_SCHEMA,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_add_x10_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the schema for the X10 devices configuration."""
|
||||
x10_device = msg[X10_DEVICE]
|
||||
try:
|
||||
add_x10_device(hass, x10_device)
|
||||
except ValueError:
|
||||
connection.send_error(msg[ID], code="duplicate", message="Duplicate X10 device")
|
||||
return
|
||||
|
||||
connection.send_result(msg[ID])
|
||||
|
@@ -4,52 +4,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyinsteon import async_close, async_connect, devices
|
||||
from pyinsteon import async_connect
|
||||
|
||||
from homeassistant.components import dhcp, usb
|
||||
from homeassistant.config_entries import (
|
||||
DEFAULT_DISCOVERY_UNIQUE_ID,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import (
|
||||
CONF_HOUSECODE,
|
||||
CONF_HUB_VERSION,
|
||||
CONF_OVERRIDE,
|
||||
CONF_UNITCODE,
|
||||
CONF_X10,
|
||||
DOMAIN,
|
||||
SIGNAL_ADD_DEVICE_OVERRIDE,
|
||||
SIGNAL_ADD_X10_DEVICE,
|
||||
SIGNAL_REMOVE_DEVICE_OVERRIDE,
|
||||
SIGNAL_REMOVE_X10_DEVICE,
|
||||
)
|
||||
from .schemas import (
|
||||
add_device_override,
|
||||
add_x10_device,
|
||||
build_device_override_schema,
|
||||
build_hub_schema,
|
||||
build_plm_manual_schema,
|
||||
build_plm_schema,
|
||||
build_remove_override_schema,
|
||||
build_remove_x10_schema,
|
||||
build_x10_schema,
|
||||
)
|
||||
from .const import CONF_HUB_VERSION, DOMAIN
|
||||
from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema
|
||||
from .utils import async_get_usb_ports
|
||||
|
||||
STEP_PLM = "plm"
|
||||
@@ -80,41 +47,6 @@ async def _async_connect(**kwargs):
|
||||
return True
|
||||
|
||||
|
||||
def _remove_override(address, options):
|
||||
"""Remove a device override from config."""
|
||||
new_options = {}
|
||||
if options.get(CONF_X10):
|
||||
new_options[CONF_X10] = options.get(CONF_X10)
|
||||
new_overrides = [
|
||||
override
|
||||
for override in options[CONF_OVERRIDE]
|
||||
if override[CONF_ADDRESS] != address
|
||||
]
|
||||
if new_overrides:
|
||||
new_options[CONF_OVERRIDE] = new_overrides
|
||||
return new_options
|
||||
|
||||
|
||||
def _remove_x10(device, options):
|
||||
"""Remove an X10 device from the config."""
|
||||
housecode = device[11].lower()
|
||||
unitcode = int(device[24:])
|
||||
new_options = {}
|
||||
if options.get(CONF_OVERRIDE):
|
||||
new_options[CONF_OVERRIDE] = options.get(CONF_OVERRIDE)
|
||||
new_x10 = [
|
||||
existing_device
|
||||
for existing_device in options[CONF_X10]
|
||||
if (
|
||||
existing_device[CONF_HOUSECODE].lower() != housecode
|
||||
or existing_device[CONF_UNITCODE] != unitcode
|
||||
)
|
||||
]
|
||||
if new_x10:
|
||||
new_options[CONF_X10] = new_x10
|
||||
return new_options, housecode, unitcode
|
||||
|
||||
|
||||
class InsteonFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Insteon config flow handler."""
|
||||
|
||||
@@ -122,14 +54,6 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_device_name: str | None = None
|
||||
discovered_conf: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> InsteonOptionsFlowHandler:
|
||||
"""Define the config flow to handle options."""
|
||||
return InsteonOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Init the config flow."""
|
||||
if self._async_current_entries():
|
||||
@@ -237,140 +161,3 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
class InsteonOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle an Insteon options flow."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Init the InsteonOptionsFlowHandler class."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Init the options config flow."""
|
||||
menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10]
|
||||
|
||||
if self.config_entry.data.get(CONF_HOST):
|
||||
menu_options.append(STEP_CHANGE_HUB_CONFIG)
|
||||
else:
|
||||
menu_options.append(STEP_CHANGE_PLM_CONFIG)
|
||||
|
||||
options = {**self.config_entry.options}
|
||||
if options.get(CONF_OVERRIDE):
|
||||
menu_options.append(STEP_REMOVE_OVERRIDE)
|
||||
if options.get(CONF_X10):
|
||||
menu_options.append(STEP_REMOVE_X10)
|
||||
|
||||
return self.async_show_menu(step_id="init", menu_options=menu_options)
|
||||
|
||||
async def async_step_change_hub_config(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Change the Hub configuration."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
data = {
|
||||
**self.config_entry.data,
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
}
|
||||
if self.config_entry.data[CONF_HUB_VERSION] == 2:
|
||||
data[CONF_USERNAME] = user_input[CONF_USERNAME]
|
||||
data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
|
||||
if devices.modem:
|
||||
await async_close()
|
||||
|
||||
if await _async_connect(**data):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=data
|
||||
)
|
||||
return self.async_create_entry(data={**self.config_entry.options})
|
||||
errors["base"] = "cannot_connect"
|
||||
data_schema = build_hub_schema(**self.config_entry.data)
|
||||
return self.async_show_form(
|
||||
step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_change_plm_config(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Change the PLM configuration."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
data = {
|
||||
**self.config_entry.data,
|
||||
CONF_DEVICE: user_input[CONF_DEVICE],
|
||||
}
|
||||
if devices.modem:
|
||||
await async_close()
|
||||
if await _async_connect(**data):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=data
|
||||
)
|
||||
return self.async_create_entry(data={**self.config_entry.options})
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
ports = await async_get_usb_ports(self.hass)
|
||||
data_schema = build_plm_schema(ports, **self.config_entry.data)
|
||||
return self.async_show_form(
|
||||
step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_add_override(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Add a device override."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
data = add_device_override({**self.config_entry.options}, user_input)
|
||||
async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input)
|
||||
return self.async_create_entry(data=data)
|
||||
except ValueError:
|
||||
errors["base"] = "input_error"
|
||||
schema_defaults = user_input if user_input is not None else {}
|
||||
data_schema = build_device_override_schema(**schema_defaults)
|
||||
return self.async_show_form(
|
||||
step_id=STEP_ADD_OVERRIDE, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_add_x10(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Add an X10 device."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
options = add_x10_device({**self.config_entry.options}, user_input)
|
||||
async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input)
|
||||
return self.async_create_entry(data=options)
|
||||
schema_defaults: dict[str, str] = user_input if user_input is not None else {}
|
||||
data_schema = build_x10_schema(**schema_defaults)
|
||||
return self.async_show_form(
|
||||
step_id=STEP_ADD_X10, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_remove_override(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Remove a device override."""
|
||||
errors: dict[str, str] = {}
|
||||
options = self.config_entry.options
|
||||
if user_input is not None:
|
||||
options = _remove_override(user_input[CONF_ADDRESS], options)
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
SIGNAL_REMOVE_DEVICE_OVERRIDE,
|
||||
user_input[CONF_ADDRESS],
|
||||
)
|
||||
return self.async_create_entry(data=options)
|
||||
|
||||
data_schema = build_remove_override_schema(options[CONF_OVERRIDE])
|
||||
return self.async_show_form(
|
||||
step_id=STEP_REMOVE_OVERRIDE, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_remove_x10(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Remove an X10 device."""
|
||||
errors: dict[str, str] = {}
|
||||
options = self.config_entry.options
|
||||
if user_input is not None:
|
||||
options, housecode, unitcode = _remove_x10(user_input[CONF_DEVICE], options)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode
|
||||
)
|
||||
return self.async_create_entry(data=options)
|
||||
|
||||
data_schema = build_remove_x10_schema(options[CONF_X10])
|
||||
return self.async_show_form(
|
||||
step_id=STEP_REMOVE_X10, data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
@@ -101,6 +101,8 @@ SIGNAL_SAVE_DEVICES = "save_devices"
|
||||
SIGNAL_ADD_ENTITIES = "insteon_add_entities"
|
||||
SIGNAL_ADD_DEFAULT_LINKS = "add_default_links"
|
||||
SIGNAL_ADD_DEVICE_OVERRIDE = "add_device_override"
|
||||
SIGNAL_REMOVE_HA_DEVICE = "insteon_remove_ha_device"
|
||||
SIGNAL_REMOVE_INSTEON_DEVICE = "insteon_remove_insteon_device"
|
||||
SIGNAL_REMOVE_DEVICE_OVERRIDE = "insteon_remove_device_override"
|
||||
SIGNAL_REMOVE_ENTITY = "insteon_remove_entity"
|
||||
SIGNAL_ADD_X10_DEVICE = "insteon_add_x10_device"
|
||||
|
@@ -95,6 +95,7 @@ class InsteonEntity(Entity):
|
||||
f" {self._insteon_device.engine_version}"
|
||||
),
|
||||
via_device=(DOMAIN, str(devices.modem.address)),
|
||||
configuration_url=f"homeassistant://insteon/device/config/{self._insteon_device.id}",
|
||||
)
|
||||
|
||||
@callback
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.5.3",
|
||||
"insteon-frontend-home-assistant==0.4.0"
|
||||
"insteon-frontend-home-assistant==0.5.0"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
|
@@ -2,9 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from binascii import Error as HexError, unhexlify
|
||||
|
||||
from pyinsteon.address import Address
|
||||
from pyinsteon.constants import HC_LOOKUP
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -25,10 +22,8 @@ from .const import (
|
||||
CONF_CAT,
|
||||
CONF_DIM_STEPS,
|
||||
CONF_HOUSECODE,
|
||||
CONF_OVERRIDE,
|
||||
CONF_SUBCAT,
|
||||
CONF_UNITCODE,
|
||||
CONF_X10,
|
||||
HOUSECODES,
|
||||
PORT_HUB_V1,
|
||||
PORT_HUB_V2,
|
||||
@@ -76,76 +71,6 @@ TRIGGER_SCENE_SCHEMA = vol.Schema(
|
||||
ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id})
|
||||
|
||||
|
||||
def normalize_byte_entry_to_int(entry: int | bytes | str):
|
||||
"""Format a hex entry value."""
|
||||
if isinstance(entry, int):
|
||||
if entry in range(256):
|
||||
return entry
|
||||
raise ValueError("Must be single byte")
|
||||
if isinstance(entry, str):
|
||||
if entry[0:2].lower() == "0x":
|
||||
entry = entry[2:]
|
||||
if len(entry) != 2:
|
||||
raise ValueError("Not a valid hex code")
|
||||
try:
|
||||
entry = unhexlify(entry)
|
||||
except HexError as err:
|
||||
raise ValueError("Not a valid hex code") from err
|
||||
return int.from_bytes(entry, byteorder="big")
|
||||
|
||||
|
||||
def add_device_override(config_data, new_override):
|
||||
"""Add a new device override."""
|
||||
try:
|
||||
address = str(Address(new_override[CONF_ADDRESS]))
|
||||
cat = normalize_byte_entry_to_int(new_override[CONF_CAT])
|
||||
subcat = normalize_byte_entry_to_int(new_override[CONF_SUBCAT])
|
||||
except ValueError as err:
|
||||
raise ValueError("Incorrect values") from err
|
||||
|
||||
overrides = [
|
||||
override
|
||||
for override in config_data.get(CONF_OVERRIDE, [])
|
||||
if override[CONF_ADDRESS] != address
|
||||
]
|
||||
overrides.append(
|
||||
{
|
||||
CONF_ADDRESS: address,
|
||||
CONF_CAT: cat,
|
||||
CONF_SUBCAT: subcat,
|
||||
}
|
||||
)
|
||||
|
||||
new_config = {}
|
||||
if config_data.get(CONF_X10):
|
||||
new_config[CONF_X10] = config_data[CONF_X10]
|
||||
new_config[CONF_OVERRIDE] = overrides
|
||||
return new_config
|
||||
|
||||
|
||||
def add_x10_device(config_data, new_x10):
|
||||
"""Add a new X10 device to X10 device list."""
|
||||
x10_devices = [
|
||||
x10_device
|
||||
for x10_device in config_data.get(CONF_X10, [])
|
||||
if x10_device[CONF_HOUSECODE] != new_x10[CONF_HOUSECODE]
|
||||
or x10_device[CONF_UNITCODE] != new_x10[CONF_UNITCODE]
|
||||
]
|
||||
x10_devices.append(
|
||||
{
|
||||
CONF_HOUSECODE: new_x10[CONF_HOUSECODE],
|
||||
CONF_UNITCODE: new_x10[CONF_UNITCODE],
|
||||
CONF_PLATFORM: new_x10[CONF_PLATFORM],
|
||||
CONF_DIM_STEPS: new_x10[CONF_DIM_STEPS],
|
||||
}
|
||||
)
|
||||
new_config = {}
|
||||
if config_data.get(CONF_OVERRIDE):
|
||||
new_config[CONF_OVERRIDE] = config_data[CONF_OVERRIDE]
|
||||
new_config[CONF_X10] = x10_devices
|
||||
return new_config
|
||||
|
||||
|
||||
def build_device_override_schema(
|
||||
address=vol.UNDEFINED,
|
||||
cat=vol.UNDEFINED,
|
||||
@@ -169,12 +94,16 @@ def build_x10_schema(
|
||||
dim_steps=22,
|
||||
):
|
||||
"""Build the X10 schema for config flow."""
|
||||
if platform == "light":
|
||||
dim_steps_schema = vol.Required(CONF_DIM_STEPS, default=dim_steps)
|
||||
else:
|
||||
dim_steps_schema = vol.Optional(CONF_DIM_STEPS, default=dim_steps)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOUSECODE, default=housecode): vol.In(HC_LOOKUP.keys()),
|
||||
vol.Required(CONF_UNITCODE, default=unitcode): vol.In(range(1, 17)),
|
||||
vol.Required(CONF_PLATFORM, default=platform): vol.In(X10_PLATFORMS),
|
||||
vol.Optional(CONF_DIM_STEPS, default=dim_steps): vol.In(range(1, 255)),
|
||||
dim_steps_schema: vol.Range(min=0, max=255),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -219,18 +148,3 @@ def build_hub_schema(
|
||||
schema[vol.Required(CONF_USERNAME, default=username)] = str
|
||||
schema[vol.Required(CONF_PASSWORD, default=password)] = str
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
def build_remove_override_schema(data):
|
||||
"""Build the schema to remove device overrides in config flow options."""
|
||||
selection = [override[CONF_ADDRESS] for override in data]
|
||||
return vol.Schema({vol.Required(CONF_ADDRESS): vol.In(selection)})
|
||||
|
||||
|
||||
def build_remove_x10_schema(data):
|
||||
"""Build the schema to remove an X10 device in config flow options."""
|
||||
selection = [
|
||||
f"Housecode: {device[CONF_HOUSECODE].upper()}, Unitcode: {device[CONF_UNITCODE]}"
|
||||
for device in data
|
||||
]
|
||||
return vol.Schema({vol.Required(CONF_DEVICE): vol.In(selection)})
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user