Merge branch 'dev' into pglab

This commit is contained in:
pglab-electronics
2024-04-18 23:25:13 +02:00
committed by GitHub
329 changed files with 20077 additions and 3171 deletions

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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).

View File

@@ -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.*

View File

@@ -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

View File

@@ -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}

View File

@@ -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)

View File

@@ -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)

View 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}/"
),
)

View File

@@ -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,
}

View File

@@ -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()
def _get_sensor_data(
sensors: dict[str, Any],
@staticmethod
def _get_sensor_data(
sensors: list[dict[str, Any]],
kind: str,
forecast_day: int | None = None,
) -> Any:
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]

View File

@@ -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",

View File

@@ -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
]

View File

@@ -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:

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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,
]

View File

@@ -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:

View File

@@ -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

View File

@@ -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%]",

View File

@@ -16,6 +16,7 @@ from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SELECT,
Platform.SENSOR,
Platform.WATER_HEATER,
]

View 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)

View File

@@ -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"
}
}
}
}
}

View File

@@ -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 = {

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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,

View 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

View 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,
)

View 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__)

View 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)

View 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()

View 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}"

View 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"
}
}
}
}

View 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"]
}

View 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

View 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"
}
}
}
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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%]"
},

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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%]",

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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"
]
}

View File

@@ -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": {

View File

@@ -28,3 +28,10 @@ SCAN_INTERVALS = {
"north_america": 600,
"rest_of_world": 300,
}
CLIMATE_ACTIVITY_STATE: list[str] = [
"cooling",
"heating",
"inactive",
"standby",
]

View File

@@ -85,6 +85,9 @@
},
"remaining_fuel_percent": {
"default": "mdi:gas-station"
},
"climate_status": {
"default": "mdi:fan"
}
},
"switch": {

View File

@@ -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,
),
]

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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%]",

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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%]",

View 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,
}

View File

@@ -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"
}

View File

@@ -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"]
}

View File

@@ -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

View 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
)

View File

@@ -16,3 +16,4 @@ DEFAULT_PASSWORD = "dreambox"
DEFAULT_DEEP_STANDBY = False
DEFAULT_SOURCE_BOUQUET = ""
DEFAULT_MAC_ADDRESS = ""
DEFAULT_VERIFY_SSL = False

View File

@@ -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"]

View File

@@ -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:

View 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."
}
}
}

View File

@@ -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,
}

View File

@@ -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"
],

View 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)

View File

@@ -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%]",

View File

@@ -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%]",

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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}."
}
}
}

View File

@@ -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]

View File

@@ -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."""

View File

@@ -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."
},

View File

@@ -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)

View File

@@ -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={

View File

@@ -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"
}

View File

@@ -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."""

View File

@@ -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%]",

View File

@@ -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%]",

View File

@@ -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"
)

View File

@@ -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,

View 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])

View File

@@ -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])

View File

@@ -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
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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": [
{

View File

@@ -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