mirror of
https://github.com/home-assistant/core.git
synced 2025-12-12 10:58:38 +00:00
Compare commits
109 Commits
knx-ui-sen
...
button_tri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
196a42a44d | ||
|
|
4cc46ecea4 | ||
|
|
1a0791b59a | ||
|
|
3d23f43461 | ||
|
|
2b3de7fa2b | ||
|
|
9b48e92940 | ||
|
|
c03b9d1f87 | ||
|
|
3f30df203c | ||
|
|
7fe0d96c88 | ||
|
|
cdc2192bba | ||
|
|
74b1c1f6fd | ||
|
|
69c7a7b0ab | ||
|
|
ef302215cc | ||
|
|
6378f5f02a | ||
|
|
79245195cd | ||
|
|
d0e33a6e04 | ||
|
|
f55fc788db | ||
|
|
6152e0fa27 | ||
|
|
f1a89741c0 | ||
|
|
7629c9f280 | ||
|
|
6b8650c6d9 | ||
|
|
48f186368a | ||
|
|
d65baac8d4 | ||
|
|
d57801407b | ||
|
|
4495a76557 | ||
|
|
99dfb93ac0 | ||
|
|
7c7c0aad25 | ||
|
|
5992898340 | ||
|
|
4f2ff9a4f4 | ||
|
|
a8a135c2ca | ||
|
|
43e241ee39 | ||
|
|
6af7052b9d | ||
|
|
c0aa35ff6d | ||
|
|
2c7763e350 | ||
|
|
95e344ea44 | ||
|
|
7ed8613411 | ||
|
|
4ac0567ccc | ||
|
|
bc031e7a81 | ||
|
|
ad1ba629c5 | ||
|
|
0c2cb460cb | ||
|
|
5388740c83 | ||
|
|
2a54d4c3a9 | ||
|
|
2008972215 | ||
|
|
39004bd0a2 | ||
|
|
bb847ce3ff | ||
|
|
05920a9c73 | ||
|
|
61499a5ad4 | ||
|
|
0076aafa6e | ||
|
|
c50f4d6d2d | ||
|
|
68036099a2 | ||
|
|
180053fe98 | ||
|
|
280c25cb85 | ||
|
|
4064b6d28c | ||
|
|
ff25809a3e | ||
|
|
245f47c7fb | ||
|
|
86135a19d1 | ||
|
|
2e038250a9 | ||
|
|
88c7c6fc8a | ||
|
|
d691862d0d | ||
|
|
cceaff7bc6 | ||
|
|
079c6daa63 | ||
|
|
b120ae827f | ||
|
|
c1227aaf1f | ||
|
|
c0365dfe99 | ||
|
|
02aa3fc906 | ||
|
|
42e55491cc | ||
|
|
33e09c4967 | ||
|
|
6f5507670f | ||
|
|
765be3f047 | ||
|
|
12bc9e9f68 | ||
|
|
2617c4a453 | ||
|
|
0e6d9ecbdc | ||
|
|
5cdbbe999d | ||
|
|
5ca61386f8 | ||
|
|
6d6ee866a6 | ||
|
|
eeb2b2febc | ||
|
|
a6c7bd76eb | ||
|
|
470f5a2396 | ||
|
|
d934fd974d | ||
|
|
edc81b706d | ||
|
|
03aaebe718 | ||
|
|
98d61aa5b2 | ||
|
|
fe5d411856 | ||
|
|
efa5a773eb | ||
|
|
32399de5f1 | ||
|
|
a1ad28c066 | ||
|
|
6faccf4327 | ||
|
|
2ac15ab67d | ||
|
|
d599bb9553 | ||
|
|
92ee37017d | ||
|
|
adf698d570 | ||
|
|
6ce9a13816 | ||
|
|
9cb9efeb88 | ||
|
|
ca31134caa | ||
|
|
769578dc51 | ||
|
|
9dcabfe804 | ||
|
|
dc6c23a58c | ||
|
|
6ec7efc2b8 | ||
|
|
97e5b7954e | ||
|
|
25505752b7 | ||
|
|
95a347dcf8 | ||
|
|
8c0f3014f7 | ||
|
|
bb3cd3ebd3 | ||
|
|
319d6711c4 | ||
|
|
ea3f76c315 | ||
|
|
b892cc1cad | ||
|
|
3046c7afd8 | ||
|
|
73dc81034e | ||
|
|
f306cde3b6 |
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -41,8 +41,8 @@ env:
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
DEFAULT_PYTHON: "3.13.9"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.9', '3.14.0']"
|
||||
DEFAULT_PYTHON: "3.13.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -1188,7 +1188,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1313,7 +1313,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -73,6 +73,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/airobot/ @mettolen
|
||||
/homeassistant/components/airos/ @CoMPaTech
|
||||
/tests/components/airos/ @CoMPaTech
|
||||
/homeassistant/components/airpatrol/ @antondalgren
|
||||
/tests/components/airpatrol/ @antondalgren
|
||||
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
||||
/tests/components/airq/ @Sibgatulin @dl2080
|
||||
/homeassistant/components/airthings/ @danielhiversen @LaStrada
|
||||
@@ -418,6 +420,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/egauge/ @neggert
|
||||
/tests/components/egauge/ @neggert
|
||||
/homeassistant/components/eheimdigital/ @autinerd
|
||||
/tests/components/eheimdigital/ @autinerd
|
||||
/homeassistant/components/ekeybionyx/ @richardpolzer
|
||||
@@ -460,7 +464,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/enigma2/ @autinerd
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
|
||||
38
homeassistant/components/airobot/diagnostics.py
Normal file
38
homeassistant/components/airobot/diagnostics.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Diagnostics support for Airobot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirobotConfigEntry
|
||||
|
||||
TO_REDACT_CONFIG = [CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AirobotConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Build device capabilities info
|
||||
device_capabilities = None
|
||||
if coordinator.data:
|
||||
device_capabilities = {
|
||||
"has_floor_sensor": coordinator.data.status.has_floor_sensor,
|
||||
"has_co2_sensor": coordinator.data.status.has_co2_sensor,
|
||||
"hw_version": coordinator.data.status.hw_version,
|
||||
"fw_version": coordinator.data.status.fw_version,
|
||||
}
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT_CONFIG),
|
||||
"device_capabilities": device_capabilities,
|
||||
"status": asdict(coordinator.data.status) if coordinator.data else None,
|
||||
"settings": asdict(coordinator.data.settings) if coordinator.data else None,
|
||||
}
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
@@ -55,7 +55,7 @@ rules:
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
|
||||
24
homeassistant/components/airpatrol/__init__.py
Normal file
24
homeassistant/components/airpatrol/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""The AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
|
||||
"""Set up AirPatrol from a config entry."""
|
||||
coordinator = AirPatrolDataUpdateCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
208
homeassistant/components/airpatrol/climate.py
Normal file
208
homeassistant/components/airpatrol/climate.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Climate platform for AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
SWING_OFF,
|
||||
SWING_ON,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AirPatrolConfigEntry
|
||||
from .coordinator import AirPatrolDataUpdateCoordinator
|
||||
from .entity import AirPatrolEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AP_TO_HA_HVAC_MODES = {
|
||||
"heat": HVACMode.HEAT,
|
||||
"cool": HVACMode.COOL,
|
||||
"off": HVACMode.OFF,
|
||||
}
|
||||
HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()}
|
||||
|
||||
AP_TO_HA_FAN_MODES = {
|
||||
"min": FAN_LOW,
|
||||
"max": FAN_HIGH,
|
||||
"auto": FAN_AUTO,
|
||||
}
|
||||
HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()}
|
||||
|
||||
AP_TO_HA_SWING_MODES = {
|
||||
"on": SWING_ON,
|
||||
"off": SWING_OFF,
|
||||
}
|
||||
HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirPatrolConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AirPatrol climate entities."""
|
||||
coordinator = config_entry.runtime_data
|
||||
units = coordinator.data
|
||||
|
||||
async_add_entities(
|
||||
AirPatrolClimate(coordinator, unit_id)
|
||||
for unit_id, unit in units.items()
|
||||
if "climate" in unit
|
||||
)
|
||||
|
||||
|
||||
class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
|
||||
"""AirPatrol climate entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.SWING_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
|
||||
_attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO]
|
||||
_attr_swing_modes = [SWING_ON, SWING_OFF]
|
||||
_attr_min_temp = 16.0
|
||||
_attr_max_temp = 30.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirPatrolDataUpdateCoordinator,
|
||||
unit_id: str,
|
||||
) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(coordinator, unit_id)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
|
||||
|
||||
@property
|
||||
def climate_data(self) -> dict[str, Any]:
|
||||
"""Return the climate data."""
|
||||
return self.device_data.get("climate") or {}
|
||||
|
||||
@property
|
||||
def params(self) -> dict[str, Any]:
|
||||
"""Return the current parameters for the climate entity."""
|
||||
return self.climate_data.get("ParametersData") or {}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and bool(self.climate_data)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
if humidity := self.climate_data.get("RoomHumidity"):
|
||||
return float(humidity)
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if temp := self.climate_data.get("RoomTemp"):
|
||||
return float(temp)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
if temp := self.params.get("PumpTemp"):
|
||||
return float(temp)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
pump_power = self.params.get("PumpPower")
|
||||
pump_mode = self.params.get("PumpMode")
|
||||
|
||||
if pump_power and pump_power == "on" and pump_mode:
|
||||
return AP_TO_HA_HVAC_MODES.get(pump_mode)
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_speed = self.params.get("FanSpeed")
|
||||
if fan_speed:
|
||||
return AP_TO_HA_FAN_MODES.get(fan_speed)
|
||||
return None
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the current swing mode."""
|
||||
swing = self.params.get("Swing")
|
||||
if swing:
|
||||
return AP_TO_HA_SWING_MODES.get(swing)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
params = self.params.copy()
|
||||
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
temp = kwargs[ATTR_TEMPERATURE]
|
||||
params["PumpTemp"] = f"{temp:.3f}"
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
params = self.params.copy()
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
params["PumpPower"] = "off"
|
||||
else:
|
||||
params["PumpPower"] = "on"
|
||||
params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
params = self.params.copy()
|
||||
params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing mode."""
|
||||
params = self.params.copy()
|
||||
params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode)
|
||||
|
||||
await self._async_set_params(params)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
params = self.params.copy()
|
||||
if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]):
|
||||
await self.async_set_hvac_mode(mode)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
async def _async_set_params(self, params: dict[str, Any]) -> None:
|
||||
"""Set the unit to dry mode."""
|
||||
new_climate_data = self.climate_data.copy()
|
||||
new_climate_data["ParametersData"] = params
|
||||
|
||||
await self.coordinator.api.set_unit_climate_data(
|
||||
self._unit_id, new_climate_data
|
||||
)
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
111
homeassistant/components/airpatrol/config_flow.py
Normal file
111
homeassistant/components/airpatrol/config_flow.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Config flow for the AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.EMAIL,
|
||||
autocomplete="email",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_api(
|
||||
hass: HomeAssistant, user_input: dict[str, str]
|
||||
) -> tuple[str | None, str | None, dict[str, str]]:
|
||||
"""Validate the API connection."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(hass)
|
||||
access_token = None
|
||||
unique_id = None
|
||||
try:
|
||||
api = await AirPatrolAPI.authenticate(
|
||||
session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except AirPatrolAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except AirPatrolError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
access_token = api.get_access_token()
|
||||
unique_id = api.get_unique_id()
|
||||
|
||||
return (access_token, unique_id, errors)
|
||||
|
||||
|
||||
class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for AirPatrol."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
access_token, unique_id, errors = await validate_api(self.hass, user_input)
|
||||
if access_token and unique_id:
|
||||
user_input[CONF_ACCESS_TOKEN] = access_token
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication with new credentials."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
access_token, unique_id, errors = await validate_api(self.hass, user_input)
|
||||
if access_token and unique_id:
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
user_input[CONF_ACCESS_TOKEN] = access_token
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
16
homeassistant/components/airpatrol/const.py
Normal file
16
homeassistant/components/airpatrol/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the AirPatrol integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "airpatrol"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
||||
100
homeassistant/components/airpatrol/coordinator.py
Normal file
100
homeassistant/components/airpatrol/coordinator.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Data update coordinator for AirPatrol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
type AirPatrolConfigEntry = ConfigEntry[AirPatrolDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirPatrolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||
"""Class to manage fetching AirPatrol data."""
|
||||
|
||||
config_entry: AirPatrolConfigEntry
|
||||
api: AirPatrolAPI
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: AirPatrolConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN.capitalize()} {config_entry.title}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
await self._setup_client()
|
||||
except AirPatrolError as api_err:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with AirPatrol API: {api_err}"
|
||||
) from api_err
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Update unit data from AirPatrol API."""
|
||||
return {unit_data["unit_id"]: unit_data for unit_data in await self._get_data()}
|
||||
|
||||
async def _get_data(self, retry: bool = False) -> list[dict[str, Any]]:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.api.get_data()
|
||||
except AirPatrolAuthenticationError as auth_err:
|
||||
if retry:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication with AirPatrol failed"
|
||||
) from auth_err
|
||||
await self._update_token()
|
||||
return await self._get_data(retry=True)
|
||||
except AirPatrolError as err:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with AirPatrol API: {err}"
|
||||
) from err
|
||||
|
||||
async def _update_token(self) -> None:
|
||||
"""Refresh the AirPatrol API client and update the access token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
self.api = await AirPatrolAPI.authenticate(
|
||||
session,
|
||||
self.config_entry.data[CONF_EMAIL],
|
||||
self.config_entry.data[CONF_PASSWORD],
|
||||
)
|
||||
except AirPatrolAuthenticationError as auth_err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication with AirPatrol failed"
|
||||
) from auth_err
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
CONF_ACCESS_TOKEN: self.api.get_access_token(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _setup_client(self) -> None:
|
||||
"""Set up the AirPatrol API client from stored access_token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = AirPatrolAPI(
|
||||
session,
|
||||
self.config_entry.data[CONF_ACCESS_TOKEN],
|
||||
self.config_entry.unique_id,
|
||||
)
|
||||
try:
|
||||
await api.get_data()
|
||||
except AirPatrolAuthenticationError:
|
||||
await self._update_token()
|
||||
self.api = api
|
||||
44
homeassistant/components/airpatrol/entity.py
Normal file
44
homeassistant/components/airpatrol/entity.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Base entity for AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirPatrolDataUpdateCoordinator
|
||||
|
||||
|
||||
class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
|
||||
"""Base entity for AirPatrol devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirPatrolDataUpdateCoordinator,
|
||||
unit_id: str,
|
||||
) -> None:
|
||||
"""Initialize the AirPatrol entity."""
|
||||
super().__init__(coordinator)
|
||||
self._unit_id = unit_id
|
||||
device = coordinator.data[unit_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unit_id)},
|
||||
name=device["name"],
|
||||
manufacturer=device["manufacturer"],
|
||||
model=device["model"],
|
||||
serial_number=device["hwid"],
|
||||
)
|
||||
|
||||
@property
|
||||
def device_data(self) -> dict[str, Any]:
|
||||
"""Return the device data."""
|
||||
return self.coordinator.data[self._unit_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._unit_id in self.coordinator.data
|
||||
11
homeassistant/components/airpatrol/manifest.json
Normal file
11
homeassistant/components/airpatrol/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "airpatrol",
|
||||
"name": "AirPatrol",
|
||||
"codeowners": ["@antondalgren"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airpatrol",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airpatrol==0.1.0"]
|
||||
}
|
||||
65
homeassistant/components/airpatrol/quality_scale.yaml
Normal file
65
homeassistant/components/airpatrol/quality_scale.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not provide custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities doesn't subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
38
homeassistant/components/airpatrol/strings.json
Normal file
38
homeassistant/components/airpatrol/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "Login credentials do not match the configured account"
|
||||
},
|
||||
"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%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::airpatrol::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::airpatrol::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Reauthenticate with AirPatrol"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Your AirPatrol email address",
|
||||
"password": "Your AirPatrol password"
|
||||
},
|
||||
"description": "Connect to AirPatrol"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.2"]
|
||||
"requirements": ["aioairzone==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ async def async_setup_entry(
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
refresh_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
account_number=entry.data[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
try:
|
||||
await auth.send_refresh_request()
|
||||
@@ -49,7 +48,7 @@ async def async_setup_entry(
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
|
||||
try:
|
||||
await _aw.validate_smart_meter()
|
||||
await _aw.validate_smart_meter(entry.data[CONF_ACCOUNT_NUMBER])
|
||||
except SmartMeterUnavailableError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="smart_meter_unavailable"
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.auth import BaseAuth, MSOB2CAuth
|
||||
from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
InvalidAccountIdError,
|
||||
SelfAssertedError,
|
||||
@@ -35,7 +35,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
|
||||
async def validate_credentials(
|
||||
auth: MSOB2CAuth, account_number: str
|
||||
) -> str | MSOB2CAuth:
|
||||
"""Validate the provided credentials."""
|
||||
try:
|
||||
await auth.send_login_request()
|
||||
@@ -46,7 +48,7 @@ async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
|
||||
return "unknown"
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
try:
|
||||
await _aw.validate_smart_meter()
|
||||
await _aw.validate_smart_meter(account_number)
|
||||
except (InvalidAccountIdError, SmartMeterUnavailableError):
|
||||
return "smart_meter_unavailable"
|
||||
return auth
|
||||
@@ -69,10 +71,12 @@ class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
account_number=user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
),
|
||||
user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
if isinstance(validation_response, BaseAuth):
|
||||
if isinstance(validation_response, str):
|
||||
errors["base"] = validation_response
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
@@ -82,7 +86,6 @@ class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_ACCESS_TOKEN: validation_response.refresh_token,
|
||||
},
|
||||
)
|
||||
errors["base"] = validation_response
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
|
||||
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
|
||||
|
||||
@@ -44,6 +44,6 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data from Anglian Water's API."""
|
||||
try:
|
||||
return await self.api.update()
|
||||
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
||||
raise UpdateFailed from err
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==2.1.0"]
|
||||
"requirements": ["pyanglianwater==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
|
||||
from pymicro_vad import MicroVad
|
||||
from pysilero_vad import SileroVoiceActivityDetector
|
||||
from pyspeex_noise import AudioProcessor
|
||||
|
||||
from .const import BYTES_PER_CHUNK
|
||||
@@ -42,8 +43,8 @@ class AudioEnhancer(ABC):
|
||||
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
|
||||
|
||||
class MicroVadSpeexEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that runs microVAD and speex."""
|
||||
class SileroVadSpeexEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that runs Silero VAD and speex."""
|
||||
|
||||
def __init__(
|
||||
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
|
||||
@@ -69,21 +70,49 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
|
||||
self.noise_suppression,
|
||||
)
|
||||
|
||||
self.vad: MicroVad | None = None
|
||||
self.vad: SileroVoiceActivityDetector | None = None
|
||||
|
||||
# We get 10ms chunks but Silero works on 32ms chunks, so we have to
|
||||
# buffer audio. The previous speech probability is used until enough
|
||||
# audio has been buffered.
|
||||
self._vad_buffer: bytearray | None = None
|
||||
self._vad_buffer_chunks = 0
|
||||
self._vad_buffer_chunk_idx = 0
|
||||
self._last_speech_probability: float | None = None
|
||||
|
||||
if self.is_vad_enabled:
|
||||
self.vad = MicroVad()
|
||||
_LOGGER.debug("Initialized microVAD")
|
||||
self.vad = SileroVoiceActivityDetector()
|
||||
|
||||
# VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
|
||||
self._vad_buffer_chunks = int(
|
||||
math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
|
||||
)
|
||||
self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
|
||||
self._vad_buffer = bytearray(self.vad.chunk_bytes())
|
||||
_LOGGER.debug("Initialized Silero VAD")
|
||||
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
speech_probability: float | None = None
|
||||
|
||||
assert len(audio) == BYTES_PER_CHUNK
|
||||
|
||||
if self.vad is not None:
|
||||
# Run VAD
|
||||
speech_probability = self.vad.Process10ms(audio)
|
||||
assert self._vad_buffer is not None
|
||||
start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
|
||||
self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
|
||||
|
||||
self._vad_buffer_chunk_idx += 1
|
||||
if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
|
||||
# We have enough data to run Silero VAD (32 ms)
|
||||
self._last_speech_probability = self.vad.process_chunk(
|
||||
self._vad_buffer[: self.vad.chunk_bytes()]
|
||||
)
|
||||
|
||||
# Copy leftover audio that wasn't processed to start
|
||||
self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
|
||||
-self._vad_leftover_bytes :
|
||||
]
|
||||
self._vad_buffer_chunk_idx = 0
|
||||
|
||||
if self.audio_processor is not None:
|
||||
# Run noise suppression and auto gain
|
||||
@@ -92,5 +121,5 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
|
||||
return EnhancedAudioChunk(
|
||||
audio=audio,
|
||||
timestamp_ms=timestamp_ms,
|
||||
speech_probability=speech_probability,
|
||||
speech_probability=self._last_speech_probability,
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
|
||||
"requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ from homeassistant.util import (
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
|
||||
from .const import (
|
||||
ACKNOWLEDGE_PATH,
|
||||
BYTES_PER_CHUNK,
|
||||
@@ -633,7 +633,7 @@ class PipelineRun:
|
||||
# Initialize with audio settings
|
||||
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
|
||||
# Default audio enhancer
|
||||
self.audio_enhancer = MicroVadSpeexEnhancer(
|
||||
self.audio_enhancer = SileroVadSpeexEnhancer(
|
||||
self.audio_settings.auto_gain_dbfs,
|
||||
self.audio_settings.noise_suppression_level,
|
||||
self.audio_settings.is_vad_enabled,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
|
||||
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.0"]
|
||||
"requirements": ["aioasuswrt==1.5.2", "asusrouter==1.21.3"]
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.1"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
|
||||
@@ -32,7 +32,7 @@ BEO_STATES: dict[str, MediaPlayerState] = {
|
||||
"buffering": MediaPlayerState.PLAYING,
|
||||
"idle": MediaPlayerState.IDLE,
|
||||
"paused": MediaPlayerState.PAUSED,
|
||||
"stopped": MediaPlayerState.PAUSED,
|
||||
"stopped": MediaPlayerState.IDLE,
|
||||
"ended": MediaPlayerState.PAUSED,
|
||||
"error": MediaPlayerState.IDLE,
|
||||
# A device's initial state is "unknown" and should be treated as "idle"
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/blink",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["blinkpy"],
|
||||
"requirements": ["blinkpy==0.24.1"]
|
||||
"requirements": ["blinkpy==0.25.1"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from bsblan import BSBLANError
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -39,6 +39,22 @@ PRESET_MODES = [
|
||||
PRESET_NONE,
|
||||
]
|
||||
|
||||
# Mapping from Home Assistant HVACMode to BSB-Lan integer values
|
||||
# BSB-Lan uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
|
||||
HA_TO_BSBLAN_HVAC_MODE: Final[dict[HVACMode, int]] = {
|
||||
HVACMode.OFF: 0,
|
||||
HVACMode.AUTO: 1,
|
||||
HVACMode.HEAT: 3,
|
||||
}
|
||||
|
||||
# Mapping from BSB-Lan integer values to Home Assistant HVACMode
|
||||
BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = {
|
||||
0: HVACMode.OFF,
|
||||
1: HVACMode.AUTO,
|
||||
2: HVACMode.AUTO, # eco/reduced maps to AUTO with preset
|
||||
3: HVACMode.HEAT,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -98,17 +114,20 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if self.coordinator.data.state.hvac_mode.value == PRESET_ECO:
|
||||
return HVACMode.AUTO
|
||||
return try_parse_enum(HVACMode, self.coordinator.data.state.hvac_mode.value)
|
||||
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
|
||||
if hvac_mode_value is None:
|
||||
return None
|
||||
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
|
||||
if isinstance(hvac_mode_value, int):
|
||||
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
|
||||
return try_parse_enum(HVACMode, hvac_mode_value)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
if (
|
||||
self.hvac_mode == HVACMode.AUTO
|
||||
and self.coordinator.data.state.hvac_mode.value == PRESET_ECO
|
||||
):
|
||||
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
|
||||
# BSB-Lan mode 2 is eco/reduced mode
|
||||
if hvac_mode_value == 2:
|
||||
return PRESET_ECO
|
||||
return PRESET_NONE
|
||||
|
||||
@@ -118,13 +137,6 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set preset mode."""
|
||||
if self.hvac_mode != HVACMode.AUTO and preset_mode != PRESET_NONE:
|
||||
raise ServiceValidationError(
|
||||
"Preset mode can only be set when HVAC mode is set to 'auto'",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_preset_mode_error",
|
||||
translation_placeholders={"preset_mode": preset_mode},
|
||||
)
|
||||
await self.async_set_data(preset_mode=preset_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -133,16 +145,17 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
|
||||
async def async_set_data(self, **kwargs: Any) -> None:
|
||||
"""Set device settings using BSBLAN."""
|
||||
data = {}
|
||||
data: dict[str, Any] = {}
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
data[ATTR_TARGET_TEMPERATURE] = kwargs[ATTR_TEMPERATURE]
|
||||
if ATTR_HVAC_MODE in kwargs:
|
||||
data[ATTR_HVAC_MODE] = kwargs[ATTR_HVAC_MODE]
|
||||
data[ATTR_HVAC_MODE] = HA_TO_BSBLAN_HVAC_MODE[kwargs[ATTR_HVAC_MODE]]
|
||||
if ATTR_PRESET_MODE in kwargs:
|
||||
# eco preset uses BSB-Lan mode 2, none preset uses mode 1 (auto)
|
||||
if kwargs[ATTR_PRESET_MODE] == PRESET_ECO:
|
||||
data[ATTR_HVAC_MODE] = PRESET_ECO
|
||||
data[ATTR_HVAC_MODE] = 2
|
||||
elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE:
|
||||
data[ATTR_HVAC_MODE] = PRESET_NONE
|
||||
data[ATTR_HVAC_MODE] = 1
|
||||
|
||||
try:
|
||||
await self.coordinator.client.thermostat(**data)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==3.1.1"],
|
||||
"requirements": ["python-bsblan==3.1.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bsblan import BSBLANError
|
||||
from bsblan import BSBLANError, SetHotWaterParam
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
@@ -131,7 +131,9 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
try:
|
||||
await self.coordinator.client.set_hot_water(nominal_setpoint=temperature)
|
||||
await self.coordinator.client.set_hot_water(
|
||||
SetHotWaterParam(nominal_setpoint=temperature)
|
||||
)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -144,7 +146,9 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
"""Set new operation mode."""
|
||||
bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode)
|
||||
try:
|
||||
await self.coordinator.client.set_hot_water(operating_mode=bsblan_mode)
|
||||
await self.coordinator.client.set_hot_water(
|
||||
SetHotWaterParam(operating_mode=bsblan_mode)
|
||||
)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -17,5 +17,10 @@
|
||||
"press": {
|
||||
"service": "mdi:gesture-tap-button"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"pressed": {
|
||||
"trigger": "mdi:gesture-tap-button"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,5 +27,11 @@
|
||||
"name": "Press"
|
||||
}
|
||||
},
|
||||
"title": "Button"
|
||||
"title": "Button",
|
||||
"triggers": {
|
||||
"pressed": {
|
||||
"description": "Triggers after one or several buttons were pressed.",
|
||||
"name": "Button pressed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
homeassistant/components/button/trigger.py
Normal file
42
homeassistant/components/button/trigger.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Provides triggers for buttons."""
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is not an expected target states."""
|
||||
|
||||
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
|
||||
# would not trigger
|
||||
if from_state.state == STATE_UNAVAILABLE:
|
||||
return False
|
||||
|
||||
return from_state.state != to_state.state
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state is not invalid."""
|
||||
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"pressed": ButtonPressedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for buttons."""
|
||||
return TRIGGERS
|
||||
4
homeassistant/components/button/triggers.yaml
Normal file
4
homeassistant/components/button/triggers.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
pressed:
|
||||
target:
|
||||
entity:
|
||||
domain: button
|
||||
@@ -1,157 +1,19 @@
|
||||
"""Module for color_extractor (RGB extraction from images) component."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from colorthief import ColorThief
|
||||
from PIL import UnidentifiedImageError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_RGB_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
LIGHT_TURN_ON_SCHEMA,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
# Extend the existing light.turn_on service schema
|
||||
SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
**LIGHT_TURN_ON_SCHEMA,
|
||||
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
|
||||
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_file(file_path):
|
||||
"""Get a PIL acceptable input file reference.
|
||||
|
||||
Allows us to mock patch during testing to make BytesIO stream.
|
||||
"""
|
||||
return file_path
|
||||
|
||||
|
||||
def _get_color(file_handler) -> tuple:
|
||||
"""Given an image file, extract the predominant color from it."""
|
||||
color_thief = ColorThief(file_handler)
|
||||
|
||||
# get_color returns a SINGLE RGB value for the given image
|
||||
color = color_thief.get_color(quality=1)
|
||||
|
||||
_LOGGER.debug("Extracted RGB color %s from image", color)
|
||||
|
||||
return color
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Color extractor component."""
|
||||
|
||||
async def async_handle_service(service_call: ServiceCall) -> None:
|
||||
"""Decide which color_extractor method to call based on service."""
|
||||
service_data = dict(service_call.data)
|
||||
|
||||
try:
|
||||
if ATTR_URL in service_data:
|
||||
image_type = "URL"
|
||||
image_reference = service_data.pop(ATTR_URL)
|
||||
color = await async_extract_color_from_url(image_reference)
|
||||
|
||||
elif ATTR_PATH in service_data:
|
||||
image_type = "file path"
|
||||
image_reference = service_data.pop(ATTR_PATH)
|
||||
color = await hass.async_add_executor_job(
|
||||
extract_color_from_path, image_reference
|
||||
)
|
||||
|
||||
except UnidentifiedImageError as ex:
|
||||
_LOGGER.error(
|
||||
"Bad image from %s '%s' provided, are you sure it's an image? %s",
|
||||
image_type,
|
||||
image_reference,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
|
||||
if color:
|
||||
service_data[ATTR_RGB_COLOR] = color
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
async_handle_service,
|
||||
schema=SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
async def async_extract_color_from_url(url):
|
||||
"""Handle call for URL based image."""
|
||||
if not hass.config.is_allowed_external_url(url):
|
||||
_LOGGER.error(
|
||||
(
|
||||
"External URL '%s' is not allowed, please add to"
|
||||
" 'allowlist_external_urls'"
|
||||
),
|
||||
url,
|
||||
)
|
||||
return None
|
||||
|
||||
_LOGGER.debug("Getting predominant RGB from image URL '%s'", url)
|
||||
|
||||
# Download the image into a buffer for ColorThief to check against
|
||||
try:
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
response = await session.get(url)
|
||||
|
||||
except (TimeoutError, aiohttp.ClientError) as err:
|
||||
_LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err)
|
||||
return None
|
||||
|
||||
content = await response.content.read()
|
||||
|
||||
with io.BytesIO(content) as _file:
|
||||
_file.name = "color_extractor.jpg"
|
||||
_file.seek(0)
|
||||
|
||||
return _get_color(_file)
|
||||
|
||||
def extract_color_from_path(file_path):
|
||||
"""Handle call for local file based image."""
|
||||
if not hass.config.is_allowed_path(file_path):
|
||||
_LOGGER.error(
|
||||
(
|
||||
"File path '%s' is not allowed, please add to"
|
||||
" 'allowlist_external_dirs'"
|
||||
),
|
||||
file_path,
|
||||
)
|
||||
return None
|
||||
|
||||
_LOGGER.debug("Getting predominant RGB from file path '%s'", file_path)
|
||||
|
||||
_file = _get_file(file_path)
|
||||
return _get_color(_file)
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
156
homeassistant/components/color_extractor/services.py
Normal file
156
homeassistant/components/color_extractor/services.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Module for color_extractor (RGB extraction from images) component."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from colorthief import ColorThief
|
||||
from PIL import UnidentifiedImageError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_RGB_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
LIGHT_TURN_ON_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Extend the existing light.turn_on service schema
|
||||
SERVICE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
**LIGHT_TURN_ON_SCHEMA,
|
||||
vol.Exclusive(ATTR_PATH, "color_extractor"): cv.isfile,
|
||||
vol.Exclusive(ATTR_URL, "color_extractor"): cv.url,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_file(file_path: str) -> str:
|
||||
"""Get a PIL acceptable input file reference.
|
||||
|
||||
Allows us to mock patch during testing to make BytesIO stream.
|
||||
"""
|
||||
return file_path
|
||||
|
||||
|
||||
def _get_color(file_handler: io.BytesIO | str) -> tuple[int, int, int]:
|
||||
"""Given an image file, extract the predominant color from it."""
|
||||
color_thief = ColorThief(file_handler)
|
||||
|
||||
# get_color returns a SINGLE RGB value for the given image
|
||||
color = color_thief.get_color(quality=1)
|
||||
|
||||
_LOGGER.debug("Extracted RGB color %s from image", color)
|
||||
|
||||
return color
|
||||
|
||||
|
||||
async def _async_extract_color_from_url(
|
||||
hass: HomeAssistant, url: str
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""Handle call for URL based image."""
|
||||
if not hass.config.is_allowed_external_url(url):
|
||||
_LOGGER.error(
|
||||
(
|
||||
"External URL '%s' is not allowed, please add to"
|
||||
" 'allowlist_external_urls'"
|
||||
),
|
||||
url,
|
||||
)
|
||||
return None
|
||||
|
||||
_LOGGER.debug("Getting predominant RGB from image URL '%s'", url)
|
||||
|
||||
# Download the image into a buffer for ColorThief to check against
|
||||
try:
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
response = await session.get(url)
|
||||
|
||||
except (TimeoutError, aiohttp.ClientError) as err:
|
||||
_LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err)
|
||||
return None
|
||||
|
||||
content = await response.content.read()
|
||||
|
||||
with io.BytesIO(content) as _file:
|
||||
_file.name = "color_extractor.jpg"
|
||||
_file.seek(0)
|
||||
|
||||
return _get_color(_file)
|
||||
|
||||
|
||||
def _extract_color_from_path(
|
||||
hass: HomeAssistant, file_path: str
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""Handle call for local file based image."""
|
||||
if not hass.config.is_allowed_path(file_path):
|
||||
_LOGGER.error(
|
||||
"File path '%s' is not allowed, please add to 'allowlist_external_dirs'",
|
||||
file_path,
|
||||
)
|
||||
return None
|
||||
|
||||
_LOGGER.debug("Getting predominant RGB from file path '%s'", file_path)
|
||||
|
||||
_file = _get_file(file_path)
|
||||
return _get_color(_file)
|
||||
|
||||
|
||||
async def async_handle_service(service_call: ServiceCall) -> None:
|
||||
"""Decide which color_extractor method to call based on service."""
|
||||
service_data = dict(service_call.data)
|
||||
|
||||
try:
|
||||
if ATTR_URL in service_data:
|
||||
image_type = "URL"
|
||||
image_reference = service_data.pop(ATTR_URL)
|
||||
color = await _async_extract_color_from_url(
|
||||
service_call.hass, image_reference
|
||||
)
|
||||
|
||||
elif ATTR_PATH in service_data:
|
||||
image_type = "file path"
|
||||
image_reference = service_data.pop(ATTR_PATH)
|
||||
color = await service_call.hass.async_add_executor_job(
|
||||
_extract_color_from_path, service_call.hass, image_reference
|
||||
)
|
||||
|
||||
except UnidentifiedImageError as ex:
|
||||
_LOGGER.error(
|
||||
"Bad image from %s '%s' provided, are you sure it's an image? %s",
|
||||
image_type,
|
||||
image_reference,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
|
||||
if color:
|
||||
service_data[ATTR_RGB_COLOR] = color
|
||||
|
||||
await service_call.hass.services.async_call(
|
||||
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the services."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
async_handle_service,
|
||||
schema=SERVICE_SCHEMA,
|
||||
)
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.3"]
|
||||
"requirements": ["pycync==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbird",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
"requirements": ["DoorBirdPy==3.0.8"],
|
||||
"requirements": ["DoorBirdPy==3.0.11"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
42
homeassistant/components/egauge/__init__.py
Normal file
42
homeassistant/components/egauge/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Integration for eGauge energy monitors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import EgaugeConfigEntry, EgaugeDataCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EgaugeConfigEntry) -> bool:
|
||||
"""Set up eGauge from a config entry."""
|
||||
|
||||
coordinator = EgaugeDataCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Store coordinator in runtime_data
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# Set up main device
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, coordinator.serial_number)},
|
||||
name=coordinator.hostname,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL,
|
||||
serial_number=coordinator.serial_number,
|
||||
)
|
||||
|
||||
# Setup sensor platform
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EgaugeConfigEntry) -> bool:
|
||||
"""Unload eGauge config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
77
homeassistant/components/egauge/config_flow.py
Normal file
77
homeassistant/components/egauge/config_flow.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Config flow to configure the eGauge integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from egauge_async.exceptions import EgaugeAuthenticationError, EgaugePermissionError
|
||||
from egauge_async.json.client import EgaugeJsonClient
|
||||
from httpx import ConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SSL, default=True): bool,
|
||||
vol.Required(CONF_VERIFY_SSL, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EgaugeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle an eGauge config flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
client = EgaugeJsonClient(
|
||||
host=user_input[CONF_HOST],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
client=get_async_client(
|
||||
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
|
||||
),
|
||||
use_ssl=user_input[CONF_SSL],
|
||||
)
|
||||
try:
|
||||
serial_number = await client.get_device_serial_number()
|
||||
hostname = await client.get_hostname()
|
||||
except EgaugeAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EgaugePermissionError:
|
||||
errors["base"] = "missing_permission"
|
||||
except ConnectError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=hostname, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
10
homeassistant/components/egauge/const.py
Normal file
10
homeassistant/components/egauge/const.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Constants for the eGauge integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "egauge"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
MANUFACTURER = "eGauge Systems"
|
||||
MODEL = "eGauge Energy Monitor"
|
||||
COORDINATOR_UPDATE_INTERVAL_SECONDS = 30
|
||||
105
homeassistant/components/egauge/coordinator.py
Normal file
105
homeassistant/components/egauge/coordinator.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Data update coordinator for eGauge energy monitors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from egauge_async.exceptions import (
|
||||
EgaugeAuthenticationError,
|
||||
EgaugeException,
|
||||
EgaugePermissionError,
|
||||
)
|
||||
from egauge_async.json.client import EgaugeJsonClient
|
||||
from egauge_async.json.models import RegisterInfo
|
||||
from httpx import ConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import COORDINATOR_UPDATE_INTERVAL_SECONDS, DOMAIN, LOGGER
|
||||
|
||||
type EgaugeConfigEntry = ConfigEntry[EgaugeDataCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EgaugeData:
|
||||
"""Data from eGauge device."""
|
||||
|
||||
measurements: dict[str, float] # Instantaneous values (W, V, A, etc.)
|
||||
counters: dict[str, float] # Cumulative values (Ws)
|
||||
register_info: dict[str, RegisterInfo] # Metadata for all registers
|
||||
|
||||
|
||||
class EgaugeDataCoordinator(DataUpdateCoordinator[EgaugeData]):
|
||||
"""Class to manage fetching eGauge data."""
|
||||
|
||||
serial_number: str
|
||||
hostname: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=COORDINATOR_UPDATE_INTERVAL_SECONDS),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = EgaugeJsonClient(
|
||||
host=config_entry.data[CONF_HOST],
|
||||
username=config_entry.data[CONF_USERNAME],
|
||||
password=config_entry.data[CONF_PASSWORD],
|
||||
client=get_async_client(
|
||||
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
|
||||
),
|
||||
use_ssl=config_entry.data[CONF_SSL],
|
||||
)
|
||||
# Populated in _async_setup
|
||||
self._register_info: dict[str, RegisterInfo] = {}
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
self.serial_number = await self.client.get_device_serial_number()
|
||||
self.hostname = await self.client.get_hostname()
|
||||
self._register_info = await self.client.get_register_info()
|
||||
except (
|
||||
EgaugeAuthenticationError,
|
||||
EgaugePermissionError,
|
||||
EgaugeException,
|
||||
) as err:
|
||||
# EgaugeAuthenticationError and EgaugePermissionError will raise ConfigEntryAuthFailed once reauth is implemented
|
||||
raise ConfigEntryError from err
|
||||
except ConnectError as err:
|
||||
raise UpdateFailed(f"Error fetching device info: {err}") from err
|
||||
|
||||
async def _async_update_data(self) -> EgaugeData:
|
||||
"""Fetch data from eGauge device."""
|
||||
try:
|
||||
measurements = await self.client.get_current_measurements()
|
||||
counters = await self.client.get_current_counters()
|
||||
except (
|
||||
EgaugeAuthenticationError,
|
||||
EgaugePermissionError,
|
||||
EgaugeException,
|
||||
) as err:
|
||||
# will raise ConfigEntryAuthFailed once reauth is implemented
|
||||
raise ConfigEntryError("Error fetching device info: {err}") from err
|
||||
except ConnectError as err:
|
||||
raise UpdateFailed(f"Error fetching device info: {err}") from err
|
||||
|
||||
return EgaugeData(
|
||||
measurements=measurements,
|
||||
counters=counters,
|
||||
register_info=self._register_info,
|
||||
)
|
||||
35
homeassistant/components/egauge/entity.py
Normal file
35
homeassistant/components/egauge/entity.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Base entity for the eGauge integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import EgaugeDataCoordinator
|
||||
|
||||
|
||||
class EgaugeEntity(CoordinatorEntity[EgaugeDataCoordinator]):
|
||||
"""Base entity for eGauge sensors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EgaugeDataCoordinator,
|
||||
register_name: str,
|
||||
) -> None:
|
||||
"""Initialize the eGauge entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
register_identifier = f"{coordinator.serial_number}_{register_name}"
|
||||
register_name = f"{coordinator.hostname} {register_name}"
|
||||
|
||||
# Device info using coordinator's cached data
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, register_identifier)},
|
||||
name=register_name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL,
|
||||
via_device=(DOMAIN, coordinator.serial_number),
|
||||
)
|
||||
11
homeassistant/components/egauge/manifest.json
Normal file
11
homeassistant/components/egauge/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "egauge",
|
||||
"name": "eGauge",
|
||||
"codeowners": ["@neggert"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/egauge",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["egauge-async==0.4.0"]
|
||||
}
|
||||
74
homeassistant/components/egauge/quality_scale.yaml
Normal file
74
homeassistant/components/egauge/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register actions
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not expose configuration options
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Integration only has essential entities
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Integration uses standard device class icons
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
99
homeassistant/components/egauge/sensor.py
Normal file
99
homeassistant/components/egauge/sensor.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Sensor platform for eGauge energy monitors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from egauge_async.json.models import RegisterType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EgaugeConfigEntry, EgaugeData, EgaugeDataCoordinator
|
||||
from .entity import EgaugeEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EgaugeSensorEntityDescription(SensorEntityDescription):
|
||||
"""Extended sensor description for eGauge sensors."""
|
||||
|
||||
native_value_fn: Callable[[EgaugeData, str], float]
|
||||
available_fn: Callable[[EgaugeData, str], bool]
|
||||
|
||||
|
||||
SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
|
||||
EgaugeSensorEntityDescription(
|
||||
key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_value_fn=lambda data, register: data.measurements[register],
|
||||
available_fn=lambda data, register: register in data.measurements,
|
||||
),
|
||||
EgaugeSensorEntityDescription(
|
||||
key="energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.JOULE,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
native_value_fn=lambda data, register: data.counters[register],
|
||||
available_fn=lambda data, register: register in data.counters,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EgaugeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up eGauge sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
EgaugeSensor(coordinator, register_name, sensor)
|
||||
for sensor in SENSORS
|
||||
for register_name, register_info in coordinator.data.register_info.items()
|
||||
if register_info.type == RegisterType.POWER
|
||||
)
|
||||
|
||||
|
||||
class EgaugeSensor(EgaugeEntity, SensorEntity):
|
||||
"""Generic sensor entity using entity description pattern."""
|
||||
|
||||
entity_description: EgaugeSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EgaugeDataCoordinator,
|
||||
register_name: str,
|
||||
description: EgaugeSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, register_name)
|
||||
self._register_name = register_name
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.serial_number}_{register_name}_{description.key}"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the sensor value using the description's value function."""
|
||||
return self.entity_description.native_value_fn(
|
||||
self.coordinator.data, self._register_name
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if the corresponding register is available."""
|
||||
return super().available and self.entity_description.available_fn(
|
||||
self.coordinator.data, self._register_name
|
||||
)
|
||||
32
homeassistant/components/egauge/strings.json
Normal file
32
homeassistant/components/egauge/strings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_permission": "The provided user does not have the necessary permissions",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the eGauge device",
|
||||
"password": "The password for the provided user.",
|
||||
"ssl": "Use SSL for a secure connection.",
|
||||
"username": "The username for the eGauge device. The user must have permission to read registers and settings.",
|
||||
"verify_ssl": "Verify SSL certificate. eGauge devices use a self-signed certificate by default, so leave this off unless a custom certificate has been installed on the device."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This device is already configured.",
|
||||
"reauth_successful": "Reauthentication successful."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"add_sensor_mapping_hint": "You can now add mappings from any sensor in Home Assistant to {integration_name} using the '+ add sensor mapping' button."
|
||||
|
||||
@@ -56,11 +56,11 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
|
||||
energy_tomorrow = None
|
||||
|
||||
try:
|
||||
energy_today = await self.energyzero.energy_prices(
|
||||
energy_today = await self.energyzero.get_electricity_prices_legacy(
|
||||
start_date=today, end_date=today
|
||||
)
|
||||
try:
|
||||
gas_today = await self.energyzero.gas_prices(
|
||||
gas_today = await self.energyzero.get_gas_prices_legacy(
|
||||
start_date=today, end_date=today
|
||||
)
|
||||
except EnergyZeroNoDataError:
|
||||
@@ -69,8 +69,10 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
|
||||
if dt_util.utcnow().hour >= THRESHOLD_HOUR:
|
||||
tomorrow = today + timedelta(days=1)
|
||||
try:
|
||||
energy_tomorrow = await self.energyzero.energy_prices(
|
||||
start_date=tomorrow, end_date=tomorrow
|
||||
energy_tomorrow = (
|
||||
await self.energyzero.get_electricity_prices_legacy(
|
||||
start_date=tomorrow, end_date=tomorrow
|
||||
)
|
||||
)
|
||||
except EnergyZeroNoDataError:
|
||||
LOGGER.debug("No data for tomorrow for EnergyZero integration")
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/energyzero",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["energyzero==2.1.1"],
|
||||
"requirements": ["energyzero==4.0.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -128,13 +128,13 @@ async def __get_prices(
|
||||
data: Electricity | Gas
|
||||
|
||||
if price_type == PriceType.GAS:
|
||||
data = await coordinator.energyzero.gas_prices(
|
||||
data = await coordinator.energyzero.get_gas_prices_legacy(
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
vat=vat,
|
||||
)
|
||||
else:
|
||||
data = await coordinator.energyzero.energy_prices(
|
||||
data = await coordinator.energyzero.get_electricity_prices_legacy(
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
vat=vat,
|
||||
|
||||
44
homeassistant/components/entur_public_transport/const.py
Normal file
44
homeassistant/components/entur_public_transport/const.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Constants for the Entur public transport integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "entur_public_transport"
|
||||
|
||||
API_CLIENT_NAME = "homeassistant-{}"
|
||||
|
||||
CONF_STOP_IDS = "stop_ids"
|
||||
CONF_EXPAND_PLATFORMS = "expand_platforms"
|
||||
CONF_WHITELIST_LINES = "line_whitelist"
|
||||
CONF_OMIT_NON_BOARDING = "omit_non_boarding"
|
||||
CONF_NUMBER_OF_DEPARTURES = "number_of_departures"
|
||||
|
||||
DEFAULT_NAME = "Entur"
|
||||
DEFAULT_ICON_KEY = "bus"
|
||||
|
||||
ICONS = {
|
||||
"air": "mdi:airplane",
|
||||
"bus": "mdi:bus",
|
||||
"metro": "mdi:subway",
|
||||
"rail": "mdi:train",
|
||||
"tram": "mdi:tram",
|
||||
"water": "mdi:ferry",
|
||||
}
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=45)
|
||||
|
||||
ATTR_STOP_ID = "stop_id"
|
||||
|
||||
ATTR_ROUTE = "route"
|
||||
ATTR_ROUTE_ID = "route_id"
|
||||
ATTR_EXPECTED_AT = "due_at"
|
||||
ATTR_DELAY = "delay"
|
||||
ATTR_REALTIME = "real_time"
|
||||
|
||||
ATTR_NEXT_UP_IN = "next_due_in"
|
||||
ATTR_NEXT_UP_ROUTE = "next_route"
|
||||
ATTR_NEXT_UP_ROUTE_ID = "next_route_id"
|
||||
ATTR_NEXT_UP_AT = "next_due_at"
|
||||
ATTR_NEXT_UP_DELAY = "next_delay"
|
||||
ATTR_NEXT_UP_REALTIME = "next_real_time"
|
||||
|
||||
ATTR_TRANSPORT_MODE = "transport_mode"
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"domain": "entur_public_transport",
|
||||
"name": "Entur",
|
||||
"codeowners": ["@hfurubotten"],
|
||||
"codeowners": ["@hfurubotten", "@SanderBlom"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/entur_public_transport",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["enturclient"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
@@ -26,27 +26,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
|
||||
API_CLIENT_NAME = "homeassistant-{}"
|
||||
|
||||
CONF_STOP_IDS = "stop_ids"
|
||||
CONF_EXPAND_PLATFORMS = "expand_platforms"
|
||||
CONF_WHITELIST_LINES = "line_whitelist"
|
||||
CONF_OMIT_NON_BOARDING = "omit_non_boarding"
|
||||
CONF_NUMBER_OF_DEPARTURES = "number_of_departures"
|
||||
|
||||
DEFAULT_NAME = "Entur"
|
||||
DEFAULT_ICON_KEY = "bus"
|
||||
|
||||
ICONS = {
|
||||
"air": "mdi:airplane",
|
||||
"bus": "mdi:bus",
|
||||
"metro": "mdi:subway",
|
||||
"rail": "mdi:train",
|
||||
"tram": "mdi:tram",
|
||||
"water": "mdi:ferry",
|
||||
}
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=45)
|
||||
from .const import (
|
||||
API_CLIENT_NAME,
|
||||
ATTR_DELAY,
|
||||
ATTR_EXPECTED_AT,
|
||||
ATTR_NEXT_UP_AT,
|
||||
ATTR_NEXT_UP_DELAY,
|
||||
ATTR_NEXT_UP_IN,
|
||||
ATTR_NEXT_UP_REALTIME,
|
||||
ATTR_NEXT_UP_ROUTE,
|
||||
ATTR_NEXT_UP_ROUTE_ID,
|
||||
ATTR_REALTIME,
|
||||
ATTR_ROUTE,
|
||||
ATTR_ROUTE_ID,
|
||||
ATTR_STOP_ID,
|
||||
CONF_EXPAND_PLATFORMS,
|
||||
CONF_NUMBER_OF_DEPARTURES,
|
||||
CONF_OMIT_NON_BOARDING,
|
||||
CONF_STOP_IDS,
|
||||
CONF_WHITELIST_LINES,
|
||||
DEFAULT_ICON_KEY,
|
||||
DEFAULT_NAME,
|
||||
ICONS,
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -63,24 +65,6 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
ATTR_STOP_ID = "stop_id"
|
||||
|
||||
ATTR_ROUTE = "route"
|
||||
ATTR_ROUTE_ID = "route_id"
|
||||
ATTR_EXPECTED_AT = "due_at"
|
||||
ATTR_DELAY = "delay"
|
||||
ATTR_REALTIME = "real_time"
|
||||
|
||||
ATTR_NEXT_UP_IN = "next_due_in"
|
||||
ATTR_NEXT_UP_ROUTE = "next_route"
|
||||
ATTR_NEXT_UP_ROUTE_ID = "next_route_id"
|
||||
ATTR_NEXT_UP_AT = "next_due_at"
|
||||
ATTR_NEXT_UP_DELAY = "next_delay"
|
||||
ATTR_NEXT_UP_REALTIME = "next_real_time"
|
||||
|
||||
ATTR_TRANSPORT_MODE = "transport_mode"
|
||||
|
||||
|
||||
def due_in_minutes(timestamp: datetime) -> int:
|
||||
"""Get the time in minutes from a timestamp."""
|
||||
if timestamp is None:
|
||||
|
||||
@@ -15,12 +15,14 @@ from aioesphomeapi import (
|
||||
APIVersion,
|
||||
DeviceInfo as EsphomeDeviceInfo,
|
||||
EncryptionPlaintextAPIError,
|
||||
ExecuteServiceResponse,
|
||||
HomeassistantServiceCall,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
ReconnectLogic,
|
||||
RequiresEncryptionAPIError,
|
||||
SupportsResponseType,
|
||||
UserService,
|
||||
UserServiceArgType,
|
||||
ZWaveProxyRequest,
|
||||
@@ -44,7 +46,9 @@ from homeassistant.core import (
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
State,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
@@ -58,7 +62,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
json,
|
||||
json as json_helper,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -70,6 +74,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
)
|
||||
from homeassistant.helpers.service import async_set_service_schema
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .bluetooth import async_connect_scanner
|
||||
from .const import (
|
||||
@@ -91,6 +96,7 @@ from .encryption_key_storage import async_get_encryption_key_storage
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
||||
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
|
||||
@@ -367,7 +373,7 @@ class ESPHomeManager:
|
||||
response_dict = {"response": action_response}
|
||||
|
||||
# JSON encode response data for ESPHome
|
||||
response_data = json.json_bytes(response_dict)
|
||||
response_data = json_helper.json_bytes(response_dict)
|
||||
|
||||
except (
|
||||
ServiceNotFound,
|
||||
@@ -1150,13 +1156,52 @@ ARG_TYPE_METADATA = {
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def execute_service(
|
||||
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
|
||||
) -> None:
|
||||
"""Execute a service on a node."""
|
||||
async def execute_service(
|
||||
entry_data: RuntimeEntryData,
|
||||
service: UserService,
|
||||
call: ServiceCall,
|
||||
*,
|
||||
supports_response: SupportsResponseType,
|
||||
) -> ServiceResponse:
|
||||
"""Execute a service on a node and optionally wait for response."""
|
||||
# Determine if we should wait for a response
|
||||
# NONE: fire and forget
|
||||
# OPTIONAL/ONLY/STATUS: always wait for success/error confirmation
|
||||
wait_for_response = supports_response != SupportsResponseType.NONE
|
||||
|
||||
if not wait_for_response:
|
||||
# Fire and forget - no response expected
|
||||
try:
|
||||
await entry_data.client.execute_service(service, call.data)
|
||||
except APIConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_call_failed",
|
||||
translation_placeholders={
|
||||
"call_name": service.name,
|
||||
"device_name": entry_data.name,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
else:
|
||||
return None
|
||||
|
||||
# Determine if we need response_data from ESPHome
|
||||
# ONLY: always need response_data
|
||||
# OPTIONAL: only if caller requested it
|
||||
# STATUS: never need response_data (just success/error)
|
||||
need_response_data = supports_response == SupportsResponseType.ONLY or (
|
||||
supports_response == SupportsResponseType.OPTIONAL and call.return_response
|
||||
)
|
||||
|
||||
try:
|
||||
entry_data.client.execute_service(service, call.data)
|
||||
response: (
|
||||
ExecuteServiceResponse | None
|
||||
) = await entry_data.client.execute_service(
|
||||
service,
|
||||
call.data,
|
||||
return_response=need_response_data,
|
||||
)
|
||||
except APIConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -1167,6 +1212,44 @@ def execute_service(
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_call_timeout",
|
||||
translation_placeholders={
|
||||
"call_name": service.name,
|
||||
"device_name": entry_data.name,
|
||||
},
|
||||
) from err
|
||||
|
||||
assert response is not None
|
||||
|
||||
if not response.success:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_call_failed",
|
||||
translation_placeholders={
|
||||
"call_name": service.name,
|
||||
"device_name": entry_data.name,
|
||||
"error": response.error_message,
|
||||
},
|
||||
)
|
||||
|
||||
# Parse and return response data as JSON if we requested it
|
||||
if need_response_data and response.response_data:
|
||||
try:
|
||||
return json_loads_object(response.response_data)
|
||||
except ValueError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_call_failed",
|
||||
translation_placeholders={
|
||||
"call_name": service.name,
|
||||
"device_name": entry_data.name,
|
||||
"error": f"Invalid JSON response: {err}",
|
||||
},
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
|
||||
@@ -1174,6 +1257,19 @@ def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) ->
|
||||
return f"{device_info.name.replace('-', '_')}_{service.name}"
|
||||
|
||||
|
||||
# Map ESPHome SupportsResponseType to Home Assistant SupportsResponse
|
||||
# STATUS (100) is ESPHome-specific: waits for success/error internally but
|
||||
# doesn't return data to HA, so it maps to NONE from HA's perspective
|
||||
_RESPONSE_TYPE_MAPPER = EsphomeEnumMapper[SupportsResponseType, SupportsResponse](
|
||||
{
|
||||
SupportsResponseType.NONE: SupportsResponse.NONE,
|
||||
SupportsResponseType.OPTIONAL: SupportsResponse.OPTIONAL,
|
||||
SupportsResponseType.ONLY: SupportsResponse.ONLY,
|
||||
SupportsResponseType.STATUS: SupportsResponse.NONE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_register_service(
|
||||
hass: HomeAssistant,
|
||||
@@ -1205,11 +1301,21 @@ def _async_register_service(
|
||||
"selector": metadata.selector,
|
||||
}
|
||||
|
||||
# Get the supports_response from the service, defaulting to NONE
|
||||
esphome_supports_response = service.supports_response or SupportsResponseType.NONE
|
||||
ha_supports_response = _RESPONSE_TYPE_MAPPER.from_esphome(esphome_supports_response)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_name,
|
||||
partial(execute_service, entry_data, service),
|
||||
partial(
|
||||
execute_service,
|
||||
entry_data,
|
||||
service,
|
||||
supports_response=esphome_supports_response,
|
||||
),
|
||||
vol.Schema(schema),
|
||||
supports_response=ha_supports_response,
|
||||
)
|
||||
async_set_service_schema(
|
||||
hass,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.10.0",
|
||||
"aioesphomeapi==43.0.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -128,6 +128,9 @@
|
||||
"action_call_failed": {
|
||||
"message": "Failed to execute the action call {call_name} on {device_name}: {error}"
|
||||
},
|
||||
"action_call_timeout": {
|
||||
"message": "Timeout waiting for response from action call {call_name} on {device_name}"
|
||||
},
|
||||
"error_communicating_with_device": {
|
||||
"message": "Error communicating with the device {device_name}: {error}"
|
||||
},
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251203.1"]
|
||||
"requirements": ["home-assistant-frontend==20251203.2"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"winter_mode": {
|
||||
"description": "Adds falling snowflakes on your screen. Get your home ready for winter! ❄️",
|
||||
"disable_confirmation": "Snowflakes will no longer fall on your screen. You can re-enable this at any time in labs settings.",
|
||||
"enable_confirmation": "Snowflakes will start falling on your screen. You can turn this off at any time in labs settings.",
|
||||
"description": "Adds falling snowflakes on your screen. Get your home ready for winter! ❄️\n\nIf you have animations disabled in your device accessibility settings, this feature will not work.",
|
||||
"disable_confirmation": "Snowflakes will no longer fall on your screen. You can re-enable this at any time in Labs settings.",
|
||||
"enable_confirmation": "Snowflakes will start falling on your screen. You can turn this off at any time in Labs settings.",
|
||||
"name": "Winter mode"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
from . import oauth2
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData
|
||||
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
@@ -44,9 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) ->
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = HomeLinkData(
|
||||
provider=provider, coordinator=coordinator, last_update_id=None
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -54,5 +52,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) ->
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.coordinator.async_on_unload(None)
|
||||
await entry.runtime_data.async_on_unload(None)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -7,7 +7,7 @@ import botocore.exceptions
|
||||
from homelink.auth.srp_auth import SRPAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
@@ -34,7 +34,7 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Ask for username and password."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
from homelink.model.device import Device
|
||||
from homelink.mqtt_provider import MQTTProvider
|
||||
@@ -15,24 +14,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .event import HomeLinkEventEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkData]
|
||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
|
||||
type EventCallback = Callable[[HomeLinkEventData], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeLinkData:
|
||||
"""Class for HomeLink integration runtime data."""
|
||||
|
||||
provider: MQTTProvider
|
||||
coordinator: HomeLinkCoordinator
|
||||
last_update_id: str | None
|
||||
|
||||
|
||||
class HomeLinkEventData(TypedDict):
|
||||
"""Data for a single event."""
|
||||
|
||||
@@ -61,7 +48,6 @@ class HomeLinkCoordinator:
|
||||
self.config_entry = config_entry
|
||||
self.provider = provider
|
||||
self.device_data: list[Device] = []
|
||||
self.buttons: list[HomeLinkEventEntity] = []
|
||||
self._listeners: dict[str, EventCallback] = {}
|
||||
|
||||
@callback
|
||||
@@ -72,11 +58,11 @@ class HomeLinkCoordinator:
|
||||
self._listeners[target_event_id] = update_callback
|
||||
return partial(self.__async_remove_listener_internal, target_event_id)
|
||||
|
||||
def __async_remove_listener_internal(self, listener_id: str):
|
||||
def __async_remove_listener_internal(self, listener_id: str) -> None:
|
||||
del self._listeners[listener_id]
|
||||
|
||||
@callback
|
||||
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]):
|
||||
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]) -> None:
|
||||
"""Notify listeners."""
|
||||
for button_id, event in data.items():
|
||||
if listener := self._listeners.get(button_id):
|
||||
@@ -86,7 +72,7 @@ class HomeLinkCoordinator:
|
||||
"""Refresh data for the first time when a config entry is setup."""
|
||||
await self._async_setup()
|
||||
|
||||
async def async_on_unload(self, _event):
|
||||
async def async_on_unload(self, _event) -> None:
|
||||
"""Disconnect and unregister when unloaded."""
|
||||
await self.provider.disable()
|
||||
|
||||
@@ -96,14 +82,12 @@ class HomeLinkCoordinator:
|
||||
await self.discover_devices()
|
||||
self.provider.listen(self.on_message)
|
||||
|
||||
async def discover_devices(self):
|
||||
async def discover_devices(self) -> None:
|
||||
"""Discover devices and build the Entities."""
|
||||
self.device_data = await self.provider.discover()
|
||||
|
||||
def on_message(
|
||||
self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage
|
||||
):
|
||||
"MQTT Callback function."
|
||||
def on_message(self, _topic: str, message: HomeLinkMQTTMessage) -> None:
|
||||
"""MQTT Callback function."""
|
||||
if message["type"] == "state":
|
||||
self.hass.add_job(self.async_handle_state_data, message["data"])
|
||||
if message["type"] == "requestSync":
|
||||
|
||||
@@ -3,30 +3,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, EVENT_PRESSED
|
||||
from .coordinator import HomeLinkCoordinator, HomeLinkEventData
|
||||
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkEventData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HomeLinkConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the entities for the binary sensor."""
|
||||
coordinator = config_entry.runtime_data.coordinator
|
||||
for device in coordinator.device_data:
|
||||
buttons = [
|
||||
HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator)
|
||||
for b in device.buttons
|
||||
]
|
||||
coordinator.buttons.extend(buttons)
|
||||
"""Add the entities for the event platform."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(coordinator.buttons)
|
||||
async_add_entities(
|
||||
HomeLinkEventEntity(coordinator, button.id, button.name, device.id, device.name)
|
||||
for device in coordinator.device_data
|
||||
for button in device.buttons
|
||||
)
|
||||
|
||||
|
||||
# Updates are centralized by the coordinator.
|
||||
@@ -42,17 +39,17 @@ class HomeLinkEventEntity(EventEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
coordinator: HomeLinkCoordinator,
|
||||
button_id: str,
|
||||
param_name: str,
|
||||
device_id: str,
|
||||
device_name: str,
|
||||
coordinator: HomeLinkCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the event entity."""
|
||||
|
||||
self.id: str = id
|
||||
self._attr_name: str = param_name
|
||||
self._attr_unique_id: str = id
|
||||
self.button_id = button_id
|
||||
self._attr_name = param_name
|
||||
self._attr_unique_id = button_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_name,
|
||||
@@ -65,7 +62,7 @@ class HomeLinkEventEntity(EventEntity):
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_event_listener(
|
||||
self._handle_event_data_update, self.id
|
||||
self._handle_event_data_update, self.button_id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -76,8 +73,4 @@ class HomeLinkEventEntity(EventEntity):
|
||||
if update_data["requestId"] != self.last_request_id:
|
||||
self._trigger_event(EVENT_PRESSED)
|
||||
self.last_request_id = update_data["requestId"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
"""Request early polling. Left intentionally blank because it's not possible in this implementation."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation):
|
||||
"""Base class to abstract OAuth2 authentication."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, domain) -> None:
|
||||
def __init__(self, hass: HomeAssistant, domain: str) -> None:
|
||||
"""Initialize the SRP Auth implementation."""
|
||||
|
||||
self.hass = hass
|
||||
@@ -45,16 +45,13 @@ class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementatio
|
||||
async def async_resolve_external_data(self, external_data) -> dict:
|
||||
"""Format the token from the source appropriately for HomeAssistant."""
|
||||
tokens = external_data["tokens"]
|
||||
new_token = {}
|
||||
new_token["access_token"] = tokens["AuthenticationResult"]["AccessToken"]
|
||||
new_token["refresh_token"] = tokens["AuthenticationResult"]["RefreshToken"]
|
||||
new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"]
|
||||
new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
new_token["expires_at"] = (
|
||||
time.time() + tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
)
|
||||
|
||||
return new_token
|
||||
return {
|
||||
"access_token": tokens["AuthenticationResult"]["AccessToken"],
|
||||
"refresh_token": tokens["AuthenticationResult"]["RefreshToken"],
|
||||
"token_type": tokens["AuthenticationResult"]["TokenType"],
|
||||
"expires_in": tokens["AuthenticationResult"]["ExpiresIn"],
|
||||
"expires_at": (time.time() + tokens["AuthenticationResult"]["ExpiresIn"]),
|
||||
}
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
"""Make a token request."""
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
|
||||
|
||||
@@ -38,7 +38,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(SECTION_API_KEY_OPTIONS): section(
|
||||
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
|
||||
vol.Schema({vol.Optional(CONF_REFERRER): str}),
|
||||
SectionConfig(collapsed=True),
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -51,9 +52,9 @@ async def _validate_input(
|
||||
description_placeholders: dict[str, str],
|
||||
) -> bool:
|
||||
try:
|
||||
await api.async_air_quality(
|
||||
await api.async_get_current_conditions(
|
||||
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
|
||||
long=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
lon=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
except GoogleAirQualityApiError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Final
|
||||
|
||||
from google_air_quality_api.api import GoogleAirQualityApi
|
||||
from google_air_quality_api.exceptions import GoogleAirQualityApiError
|
||||
from google_air_quality_api.model import AirQualityData
|
||||
from google_air_quality_api.model import AirQualityCurrentConditionsData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
@@ -23,7 +23,9 @@ UPDATE_INTERVAL: Final = timedelta(hours=1)
|
||||
type GoogleAirQualityConfigEntry = ConfigEntry[GoogleAirQualityRuntimeData]
|
||||
|
||||
|
||||
class GoogleAirQualityUpdateCoordinator(DataUpdateCoordinator[AirQualityData]):
|
||||
class GoogleAirQualityUpdateCoordinator(
|
||||
DataUpdateCoordinator[AirQualityCurrentConditionsData]
|
||||
):
|
||||
"""Coordinator for fetching Google AirQuality data."""
|
||||
|
||||
config_entry: GoogleAirQualityConfigEntry
|
||||
@@ -48,10 +50,10 @@ class GoogleAirQualityUpdateCoordinator(DataUpdateCoordinator[AirQualityData]):
|
||||
self.lat = subentry.data[CONF_LATITUDE]
|
||||
self.long = subentry.data[CONF_LONGITUDE]
|
||||
|
||||
async def _async_update_data(self) -> AirQualityData:
|
||||
async def _async_update_data(self) -> AirQualityCurrentConditionsData:
|
||||
"""Fetch air quality data for this coordinate."""
|
||||
try:
|
||||
return await self.client.async_air_quality(self.lat, self.long)
|
||||
return await self.client.async_get_current_conditions(self.lat, self.long)
|
||||
except GoogleAirQualityApiError as ex:
|
||||
_LOGGER.debug("Cannot fetch air quality data: %s", str(ex))
|
||||
raise UpdateFailed(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==1.1.3"]
|
||||
"requirements": ["google_air_quality_api==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from google_air_quality_api.model import AirQualityData
|
||||
from google_air_quality_api.model import AirQualityCurrentConditionsData
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -33,15 +33,17 @@ PARALLEL_UPDATES = 0
|
||||
class AirQualitySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Air Quality sensor entity."""
|
||||
|
||||
exists_fn: Callable[[AirQualityData], bool] = lambda _: True
|
||||
options_fn: Callable[[AirQualityData], list[str] | None] = lambda _: None
|
||||
value_fn: Callable[[AirQualityData], StateType]
|
||||
native_unit_of_measurement_fn: Callable[[AirQualityData], str | None] = (
|
||||
exists_fn: Callable[[AirQualityCurrentConditionsData], bool] = lambda _: True
|
||||
options_fn: Callable[[AirQualityCurrentConditionsData], list[str] | None] = (
|
||||
lambda _: None
|
||||
)
|
||||
translation_placeholders_fn: Callable[[AirQualityData], dict[str, str]] | None = (
|
||||
None
|
||||
)
|
||||
value_fn: Callable[[AirQualityCurrentConditionsData], StateType]
|
||||
native_unit_of_measurement_fn: Callable[
|
||||
[AirQualityCurrentConditionsData], str | None
|
||||
] = lambda _: None
|
||||
translation_placeholders_fn: (
|
||||
Callable[[AirQualityCurrentConditionsData], dict[str, str]] | None
|
||||
) = None
|
||||
|
||||
|
||||
AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
|
||||
@@ -59,8 +59,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
for service_name in hass.services.async_services_for_domain(DOMAIN):
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hanna",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hanna-cloud==0.0.6"]
|
||||
"requirements": ["hanna-cloud==0.0.7"]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ ATTR_ISSUES = "issues"
|
||||
ATTR_MESSAGE = "message"
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_PANELS = "panels"
|
||||
ATTR_PARAMS = "params"
|
||||
ATTR_PASSWORD = "password"
|
||||
ATTR_RESULT = "result"
|
||||
ATTR_STARTUP = "startup"
|
||||
|
||||
@@ -198,6 +198,7 @@ class HassIO:
|
||||
timeout: int | None = 10,
|
||||
return_text: bool = False,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
source: str = "core.handler",
|
||||
) -> Any:
|
||||
"""Send API command to Hass.io.
|
||||
@@ -218,6 +219,7 @@ class HassIO:
|
||||
response = await self.websession.request(
|
||||
method,
|
||||
joined_url,
|
||||
params=params,
|
||||
json=payload,
|
||||
headers={
|
||||
aiohttp.hdrs.AUTHORIZATION: (
|
||||
|
||||
@@ -24,6 +24,7 @@ from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_ENDPOINT,
|
||||
ATTR_METHOD,
|
||||
ATTR_PARAMS,
|
||||
ATTR_SESSION_DATA_USER_ID,
|
||||
ATTR_SLUG,
|
||||
ATTR_TIMEOUT,
|
||||
@@ -111,6 +112,7 @@ def websocket_supervisor_event(
|
||||
vol.Required(ATTR_ENDPOINT): cv.string,
|
||||
vol.Required(ATTR_METHOD): cv.string,
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
vol.Optional(ATTR_PARAMS): dict,
|
||||
vol.Optional(ATTR_TIMEOUT): vol.Any(Number, None),
|
||||
}
|
||||
)
|
||||
@@ -140,6 +142,7 @@ async def websocket_supervisor_api(
|
||||
timeout=msg.get(ATTR_TIMEOUT, 10),
|
||||
payload=payload,
|
||||
source="core.websocket_api",
|
||||
params=msg.get(ATTR_PARAMS),
|
||||
)
|
||||
except HassioAPIError as err:
|
||||
_LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["heatmiserV3"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["heatmiserV3==2.0.3"]
|
||||
"requirements": ["heatmiserV3==2.0.4"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from HueBLE import HueBleLight
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
async_ble_device_from_address,
|
||||
@@ -38,8 +38,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bo
|
||||
|
||||
light = HueBleLight(ble_device)
|
||||
|
||||
if not await light.connect() or not await light.poll_state():
|
||||
raise ConfigEntryNotReady("Device found but unable to connect.")
|
||||
try:
|
||||
await light.connect()
|
||||
await light.poll_state()
|
||||
except ConnectionError as e:
|
||||
raise ConfigEntryNotReady("Device found but unable to connect.") from e
|
||||
except HueBleError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
"Device found and connected but unable to poll values from it."
|
||||
) from e
|
||||
|
||||
entry.runtime_data = light
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from HueBLE import HueBleLight
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, URL_PAIRING_MODE
|
||||
from .const import DOMAIN, URL_FACTORY_RESET, URL_PAIRING_MODE
|
||||
from .light import get_available_color_modes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -41,32 +41,22 @@ async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
|
||||
|
||||
try:
|
||||
light = HueBleLight(ble_device)
|
||||
|
||||
await light.connect()
|
||||
get_available_color_modes(light)
|
||||
await light.poll_state()
|
||||
|
||||
if light.authenticated is None:
|
||||
_LOGGER.warning(
|
||||
"Unable to determine if light authenticated, proceeding anyway"
|
||||
)
|
||||
elif not light.authenticated:
|
||||
return Error.INVALID_AUTH
|
||||
|
||||
if not light.connected:
|
||||
return Error.CANNOT_CONNECT
|
||||
|
||||
try:
|
||||
get_available_color_modes(light)
|
||||
except HomeAssistantError:
|
||||
return Error.NOT_SUPPORTED
|
||||
|
||||
_, errors = await light.poll_state()
|
||||
if len(errors) != 0:
|
||||
_LOGGER.warning("Errors raised when connecting to light: %s", errors)
|
||||
return Error.CANNOT_CONNECT
|
||||
|
||||
except Exception:
|
||||
except ConnectionError as e:
|
||||
_LOGGER.exception("Error connecting to light")
|
||||
return (
|
||||
Error.INVALID_AUTH
|
||||
if type(e.__cause__) is PairingError
|
||||
else Error.CANNOT_CONNECT
|
||||
)
|
||||
except HueBleError:
|
||||
_LOGGER.exception("Unexpected error validating light connection")
|
||||
return Error.UNKNOWN
|
||||
except HomeAssistantError:
|
||||
return Error.NOT_SUPPORTED
|
||||
else:
|
||||
return None
|
||||
finally:
|
||||
@@ -129,6 +119,7 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_NAME: self._discovery_info.name,
|
||||
CONF_MAC: self._discovery_info.address,
|
||||
"url_pairing_mode": URL_PAIRING_MODE,
|
||||
"url_factory_reset": URL_FACTORY_RESET,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
DOMAIN = "hue_ble"
|
||||
URL_PAIRING_MODE = "https://www.home-assistant.io/integrations/hue_ble#initial-setup"
|
||||
URL_FACTORY_RESET = "https://www.philips-hue.com/en-gb/support/article/how-to-factory-reset-philips-hue-lights/000004"
|
||||
|
||||
@@ -113,7 +113,7 @@ class HueBLELight(LightEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch latest state from light and make available via properties."""
|
||||
await self._api.poll_state(run_callbacks=True)
|
||||
await self._api.poll_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Set properties then turn the light on."""
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["bleak", "HueBLE"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["HueBLE==1.0.8"]
|
||||
"requirements": ["HueBLE==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode})."
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ from .const import (
|
||||
DEFAULT_LANGUAGE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarData
|
||||
from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator
|
||||
from .entity import JewishCalendarConfigEntry
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -69,7 +70,7 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
config_entry.runtime_data = JewishCalendarData(
|
||||
data = JewishCalendarData(
|
||||
language,
|
||||
diaspora,
|
||||
location,
|
||||
@@ -77,8 +78,11 @@ async def async_setup_entry(
|
||||
havdalah_offset,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
config_entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -86,7 +90,12 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
coordinator = config_entry.runtime_data
|
||||
await coordinator.async_shutdown()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
|
||||
@@ -72,8 +72,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
zmanim = self.make_zmanim(dt.date.today())
|
||||
return self.entity_description.is_on(zmanim)(dt_util.now())
|
||||
return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now())
|
||||
|
||||
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||
"""Return a list of times to update the sensor."""
|
||||
|
||||
105
homeassistant/components/jewish_calendar/coordinator.py
Normal file
105
homeassistant/components/jewish_calendar/coordinator.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Data update coordinator for Jewish calendar."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from hdate import HDateInfo, Location, Zmanim
|
||||
from hdate.translator import Language, set_language
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JewishCalendarData:
|
||||
"""Jewish Calendar runtime dataclass."""
|
||||
|
||||
language: Language
|
||||
diaspora: bool
|
||||
location: Location
|
||||
candle_lighting_offset: int
|
||||
havdalah_offset: int
|
||||
dateinfo: HDateInfo | None = None
|
||||
zmanim: Zmanim | None = None
|
||||
|
||||
|
||||
class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]):
|
||||
"""Data update coordinator class for Jewish calendar."""
|
||||
|
||||
config_entry: JewishCalendarConfigEntry
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: JewishCalendarConfigEntry,
|
||||
data: JewishCalendarData,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry)
|
||||
self.data = data
|
||||
set_language(data.language)
|
||||
|
||||
async def _async_update_data(self) -> JewishCalendarData:
|
||||
"""Return HDate and Zmanim for today."""
|
||||
now = dt_util.now()
|
||||
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
||||
today = now.date()
|
||||
|
||||
# Create new data object with today's information
|
||||
new_data = JewishCalendarData(
|
||||
language=self.data.language,
|
||||
diaspora=self.data.diaspora,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
dateinfo=HDateInfo(today, self.data.diaspora),
|
||||
zmanim=self.make_zmanim(today),
|
||||
)
|
||||
|
||||
# Schedule next update at midnight
|
||||
next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
||||
_LOGGER.debug("Scheduling next update at %s", next_midnight)
|
||||
|
||||
# Schedule update at next midnight
|
||||
self._unsub_refresh = event.async_track_point_in_time(
|
||||
self.hass, self._handle_midnight_update, next_midnight
|
||||
)
|
||||
|
||||
return new_data
|
||||
|
||||
@callback
|
||||
def _handle_midnight_update(self, _now: dt.datetime) -> None:
|
||||
"""Handle midnight update callback."""
|
||||
self.hass.async_create_task(self.async_request_refresh())
|
||||
|
||||
def make_zmanim(self, date: dt.date) -> Zmanim:
|
||||
"""Create a Zmanim object."""
|
||||
return Zmanim(
|
||||
date=date,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
)
|
||||
|
||||
@property
|
||||
def zmanim(self) -> Zmanim:
|
||||
"""Return the current Zmanim."""
|
||||
assert self.data.zmanim is not None, "Zmanim data not available"
|
||||
return self.data.zmanim
|
||||
|
||||
@property
|
||||
def dateinfo(self) -> HDateInfo:
|
||||
"""Return the current HDateInfo."""
|
||||
assert self.data.dateinfo is not None, "HDateInfo data not available"
|
||||
return self.data.dateinfo
|
||||
@@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": async_redact_data(asdict(entry.runtime_data), TO_REDACT),
|
||||
"data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT),
|
||||
}
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
"""Entity representing a Jewish Calendar sensor."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from hdate import HDateInfo, Location, Zmanim
|
||||
from hdate.translator import Language, set_language
|
||||
from hdate import Zmanim
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
|
||||
from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class JewishCalendarDataResults:
|
||||
"""Jewish Calendar results dataclass."""
|
||||
|
||||
dateinfo: HDateInfo
|
||||
zmanim: Zmanim
|
||||
|
||||
|
||||
@dataclass
|
||||
class JewishCalendarData:
|
||||
"""Jewish Calendar runtime dataclass."""
|
||||
|
||||
language: Language
|
||||
diaspora: bool
|
||||
location: Location
|
||||
candle_lighting_offset: int
|
||||
havdalah_offset: int
|
||||
results: JewishCalendarDataResults | None = None
|
||||
|
||||
|
||||
class JewishCalendarEntity(Entity):
|
||||
class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
"""An HA implementation for Jewish Calendar entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -55,23 +29,13 @@ class JewishCalendarEntity(Entity):
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Jewish Calendar entity."""
|
||||
super().__init__(config_entry.runtime_data)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
)
|
||||
self.data = config_entry.runtime_data
|
||||
set_language(self.data.language)
|
||||
|
||||
def make_zmanim(self, date: dt.date) -> Zmanim:
|
||||
"""Create a Zmanim object."""
|
||||
return Zmanim(
|
||||
date=date,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
@@ -85,6 +49,14 @@ class JewishCalendarEntity(Entity):
|
||||
self._update_unsub = None
|
||||
return await super().async_will_remove_from_hass()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# When coordinator updates (e.g., from tests forcing refresh or midnight update),
|
||||
# reschedule our entity-specific updates
|
||||
self._schedule_update()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@abstractmethod
|
||||
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||
"""Return a list of times to update the sensor."""
|
||||
@@ -92,10 +64,9 @@ class JewishCalendarEntity(Entity):
|
||||
def _schedule_update(self) -> None:
|
||||
"""Schedule the next update of the sensor."""
|
||||
now = dt_util.now()
|
||||
zmanim = self.make_zmanim(now.date())
|
||||
update = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
||||
|
||||
for update_time in self._update_times(zmanim):
|
||||
for update_time in self._update_times(self.coordinator.zmanim):
|
||||
if update_time is not None and now < update_time < update:
|
||||
update = update_time
|
||||
|
||||
@@ -110,17 +81,4 @@ class JewishCalendarEntity(Entity):
|
||||
"""Update the sensor data."""
|
||||
self._update_unsub = None
|
||||
self._schedule_update()
|
||||
self.create_results(now)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def create_results(self, now: dt.datetime | None = None) -> None:
|
||||
"""Create the results for the sensor."""
|
||||
if now is None:
|
||||
now = dt_util.now()
|
||||
|
||||
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
||||
|
||||
today = now.date()
|
||||
zmanim = self.make_zmanim(today)
|
||||
dateinfo = HDateInfo(today, diaspora=self.data.diaspora)
|
||||
self.data.results = JewishCalendarDataResults(dateinfo, zmanim)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
|
||||
|
||||
@@ -238,25 +238,18 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
|
||||
return []
|
||||
return [self.entity_description.next_update_fn(zmanim)]
|
||||
|
||||
def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
|
||||
def get_dateinfo(self) -> HDateInfo:
|
||||
"""Get the next date info."""
|
||||
if self.data.results is None:
|
||||
self.create_results()
|
||||
assert self.data.results is not None, "Results should be available"
|
||||
|
||||
if now is None:
|
||||
now = dt_util.now()
|
||||
|
||||
today = now.date()
|
||||
zmanim = self.make_zmanim(today)
|
||||
now = dt_util.now()
|
||||
update = None
|
||||
if self.entity_description.next_update_fn:
|
||||
update = self.entity_description.next_update_fn(zmanim)
|
||||
|
||||
_LOGGER.debug("Today: %s, update: %s", today, update)
|
||||
if self.entity_description.next_update_fn:
|
||||
update = self.entity_description.next_update_fn(self.coordinator.zmanim)
|
||||
|
||||
_LOGGER.debug("Today: %s, update: %s", now.date(), update)
|
||||
if update is not None and now >= update:
|
||||
return self.data.results.dateinfo.next_day
|
||||
return self.data.results.dateinfo
|
||||
return self.coordinator.dateinfo.next_day
|
||||
return self.coordinator.dateinfo
|
||||
|
||||
|
||||
class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
@@ -273,7 +266,9 @@ class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
super().__init__(config_entry, description)
|
||||
# Set the options for enumeration sensors
|
||||
if self.entity_description.options_fn is not None:
|
||||
self._attr_options = self.entity_description.options_fn(self.data.diaspora)
|
||||
self._attr_options = self.entity_description.options_fn(
|
||||
self.coordinator.data.diaspora
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | dt.datetime | None:
|
||||
@@ -297,9 +292,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor):
|
||||
@property
|
||||
def native_value(self) -> dt.datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
if self.data.results is None:
|
||||
self.create_results()
|
||||
assert self.data.results is not None, "Results should be available"
|
||||
if self.entity_description.value_fn is None:
|
||||
return self.data.results.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim)
|
||||
return self.coordinator.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(
|
||||
self.get_dateinfo(), self.coordinator.make_zmanim
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pykaleidescape==1.0.1"],
|
||||
"requirements": ["pykaleidescape==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1",
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"telegram_count": {
|
||||
"default": "mdi:plus-network"
|
||||
},
|
||||
"telegrams_data_secure_undecodable": {
|
||||
"default": "mdi:lock-alert"
|
||||
},
|
||||
"telegrams_incoming": {
|
||||
"default": "mdi:upload-network"
|
||||
},
|
||||
|
||||
@@ -108,6 +108,12 @@ SYSTEM_ENTITY_DESCRIPTIONS = (
|
||||
+ knx.xknx.connection_manager.cemi_count_incoming
|
||||
+ knx.xknx.connection_manager.cemi_count_incoming_error,
|
||||
),
|
||||
KNXSystemEntityDescription(
|
||||
key="telegrams_data_secure_undecodable",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda knx: knx.xknx.connection_manager.undecoded_data_secure,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -639,6 +639,10 @@
|
||||
"name": "Telegrams",
|
||||
"unit_of_measurement": "telegrams"
|
||||
},
|
||||
"telegrams_data_secure_undecodable": {
|
||||
"name": "Undecodable Data Secure telegrams",
|
||||
"unit_of_measurement": "[%key:component::knx::entity::sensor::telegrams_incoming_error::unit_of_measurement%]"
|
||||
},
|
||||
"telegrams_incoming": {
|
||||
"name": "Incoming telegrams",
|
||||
"unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]"
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.2.2"]
|
||||
"requirements": ["pylamarzocco==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.5", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -98,7 +98,11 @@ class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
self._smartbridge.add_smart_away_subscriber(self._handle_bridge_update)
|
||||
self._smartbridge.add_smart_away_subscriber(self._handle_smart_away_update)
|
||||
|
||||
def _handle_smart_away_update(self, smart_away_state: str | None = None) -> None:
|
||||
"""Handle updated smart away state from the bridge."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn Smart Away on."""
|
||||
|
||||
@@ -183,10 +183,35 @@ PUMP_CONTROL_MODE_MAP = {
|
||||
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
MATTER_2000_TO_UNIX_EPOCH_OFFSET = (
|
||||
946684800 # Seconds from Matter 2000 epoch to Unix epoch
|
||||
)
|
||||
HUMIDITY_SCALING_FACTOR = 100
|
||||
TEMPERATURE_SCALING_FACTOR = 100
|
||||
|
||||
|
||||
def matter_epoch_seconds_to_utc(x: int | None) -> datetime | None:
|
||||
"""Convert Matter epoch seconds (since 2000-01-01) to UTC datetime.
|
||||
|
||||
Returns None for non-positive or None values (represents unknown/absent).
|
||||
"""
|
||||
if x is None or x <= 0:
|
||||
return None
|
||||
return dt_util.utc_from_timestamp(x + MATTER_2000_TO_UNIX_EPOCH_OFFSET)
|
||||
|
||||
|
||||
def matter_epoch_microseconds_to_utc(x: int | None) -> datetime | None:
|
||||
"""Convert Matter epoch microseconds (since 2000-01-01) to UTC datetime.
|
||||
|
||||
The value is in microseconds; convert to seconds before applying offset.
|
||||
Returns None for non-positive or None values.
|
||||
"""
|
||||
if x is None or x <= 0:
|
||||
return None
|
||||
seconds = x // 1_000_000
|
||||
return dt_util.utc_from_timestamp(seconds + MATTER_2000_TO_UNIX_EPOCH_OFFSET)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -1468,7 +1493,8 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="auto_close_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
state_class=None,
|
||||
device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
|
||||
# AutoCloseTime is defined as epoch-us in the spec
|
||||
device_to_ha=matter_epoch_microseconds_to_utc,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
featuremap_contains=clusters.ValveConfigurationAndControl.Bitmaps.Feature.kTimeSync,
|
||||
@@ -1483,7 +1509,8 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="estimated_end_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
state_class=None,
|
||||
device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
|
||||
# EstimatedEndTime is defined as epoch-s (Matter 2000 epoch) in the spec
|
||||
device_to_ha=matter_epoch_seconds_to_utc,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.11.12"],
|
||||
"requirements": ["yt-dlp[default]==2025.12.08"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
|
||||
drying = 280
|
||||
disinfecting = 285
|
||||
flex_load_active = 11047
|
||||
automatic_start = 11044
|
||||
|
||||
|
||||
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
@@ -451,19 +452,19 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for washing machines."""
|
||||
|
||||
no_program = 0, -1
|
||||
cottons = 1
|
||||
cottons = 1, 10001
|
||||
minimum_iron = 3
|
||||
delicates = 4
|
||||
woollens = 8
|
||||
silks = 9
|
||||
delicates = 4, 10022
|
||||
woollens = 8, 10040
|
||||
silks = 9, 10042
|
||||
starch = 17
|
||||
rinse = 18
|
||||
drain_spin = 21
|
||||
curtains = 22
|
||||
shirts = 23
|
||||
rinse = 18, 10058
|
||||
drain_spin = 21, 10036
|
||||
curtains = 22, 10055
|
||||
shirts = 23, 10038
|
||||
denim = 24, 123
|
||||
proofing = 27
|
||||
sportswear = 29
|
||||
proofing = 27, 10057
|
||||
sportswear = 29, 10052
|
||||
automatic_plus = 31
|
||||
outerwear = 37
|
||||
pillows = 39
|
||||
@@ -472,19 +473,29 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
|
||||
rinse_out_lint = 48 # washer-dryer
|
||||
dark_garments = 50
|
||||
separate_rinse_starch = 52
|
||||
first_wash = 53
|
||||
first_wash = 53, 10053
|
||||
cottons_hygiene = 69
|
||||
steam_care = 75 # washer-dryer
|
||||
freshen_up = 76 # washer-dryer
|
||||
trainers = 77
|
||||
clean_machine = 91
|
||||
down_duvets = 95
|
||||
express_20 = 122
|
||||
trainers = 77, 10056
|
||||
clean_machine = 91, 10067
|
||||
down_duvets = 95, 10050
|
||||
express_20 = 122, 10029
|
||||
down_filled_items = 129
|
||||
cottons_eco = 133
|
||||
quick_power_wash = 146, 10031
|
||||
eco_40_60 = 190, 10007
|
||||
normal = 10001
|
||||
bed_linen = 10047
|
||||
easy_care = 10016
|
||||
dark_jeans = 10048
|
||||
outdoor_garments = 10049
|
||||
game_pieces = 10070
|
||||
stuffed_toys = 10069
|
||||
pre_ironing = 10059
|
||||
trainers_refresh = 10066
|
||||
smartmatic = 10068
|
||||
cottonrepair = 10065
|
||||
powerfresh = 10075
|
||||
|
||||
|
||||
class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pymiele"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pymiele==0.6.0"],
|
||||
"requirements": ["pymiele==0.6.1"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_mieleathome._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -411,6 +411,7 @@
|
||||
"cook_bacon": "Cook bacon",
|
||||
"cool_air": "Cool air",
|
||||
"corn_on_the_cob": "Corn on the cob",
|
||||
"cottonrepair": "CottonRepair",
|
||||
"cottons": "Cottons",
|
||||
"cottons_eco": "Cottons ECO",
|
||||
"cottons_hygiene": "Cottons hygiene",
|
||||
@@ -440,6 +441,7 @@
|
||||
"custom_program_8": "Custom program 8",
|
||||
"custom_program_9": "Custom program 9",
|
||||
"dark_garments": "Dark garments",
|
||||
"dark_jeans": "Dark/jeans",
|
||||
"dark_mixed_grain_bread": "Dark mixed grain bread",
|
||||
"decrystallise_honey": "Decrystallize honey",
|
||||
"defrost": "Defrost",
|
||||
@@ -457,6 +459,7 @@
|
||||
"drop_cookies_2_trays": "Drop cookies (2 trays)",
|
||||
"duck": "Duck",
|
||||
"dutch_hash": "Dutch hash",
|
||||
"easy_care": "Easy care",
|
||||
"eco": "ECO",
|
||||
"eco_40_60": "ECO 40-60",
|
||||
"eco_fan_heat": "ECO fan heat",
|
||||
@@ -487,6 +490,7 @@
|
||||
"fruit_streusel_cake": "Fruit streusel cake",
|
||||
"fruit_tea": "Fruit tea",
|
||||
"full_grill": "Full grill",
|
||||
"game_pieces": "Game pieces",
|
||||
"gentle": "Gentle",
|
||||
"gentle_denim": "Gentle denim",
|
||||
"gentle_minimum_iron": "Gentle minimum iron",
|
||||
@@ -607,6 +611,7 @@
|
||||
"oats_cracked": "Oats (cracked)",
|
||||
"oats_whole": "Oats (whole)",
|
||||
"osso_buco": "Osso buco",
|
||||
"outdoor_garments": "Outdoor garments",
|
||||
"outerwear": "Outerwear",
|
||||
"oyster_mushroom_diced": "Oyster mushroom (diced)",
|
||||
"oyster_mushroom_strips": "Oyster mushroom (strips)",
|
||||
@@ -713,8 +718,10 @@
|
||||
"potatoes_waxy_whole_small": "Potatoes (waxy, whole, small)",
|
||||
"poularde_breast": "Poularde breast",
|
||||
"poularde_whole": "Poularde (whole)",
|
||||
"power_fresh": "PowerFresh",
|
||||
"power_wash": "PowerWash",
|
||||
"prawns": "Prawns",
|
||||
"pre_ironing": "Pre-ironing",
|
||||
"proofing": "Proofing",
|
||||
"prove_15_min": "Prove for 15 min",
|
||||
"prove_30_min": "Prove for 30 min",
|
||||
@@ -807,6 +814,7 @@
|
||||
"simiao_rapid_steam_cooking": "Simiao (rapid steam cooking)",
|
||||
"simiao_steam_cooking": "Simiao (steam cooking)",
|
||||
"small_shrimps": "Small shrimps",
|
||||
"smartmatic": "SmartMatic",
|
||||
"smoothing": "Smoothing",
|
||||
"snow_pea": "Snow pea",
|
||||
"soak": "Soak",
|
||||
@@ -833,6 +841,7 @@
|
||||
"sterilize_crockery": "Sterilize crockery",
|
||||
"stollen": "Stollen",
|
||||
"stuffed_cabbage": "Stuffed cabbage",
|
||||
"stuffed_toys": "Stuffed toys",
|
||||
"sweat_onions": "Sweat onions",
|
||||
"swede_cut_into_batons": "Swede (cut into batons)",
|
||||
"swede_diced": "Swede (diced)",
|
||||
@@ -855,6 +864,7 @@
|
||||
"top_heat": "Top heat",
|
||||
"tortellini_fresh": "Tortellini (fresh)",
|
||||
"trainers": "Trainers",
|
||||
"trainers_refresh": "Trainers refresh",
|
||||
"treacle_sponge_pudding_one_large": "Treacle sponge pudding (one large)",
|
||||
"treacle_sponge_pudding_several_small": "Treacle sponge pudding (several small)",
|
||||
"trout": "Trout",
|
||||
@@ -935,6 +945,7 @@
|
||||
"2nd_grinding": "2nd grinding",
|
||||
"2nd_pre_brewing": "2nd pre-brewing",
|
||||
"anti_crease": "Anti-crease",
|
||||
"automatic_start": "Automatic start",
|
||||
"blocked_brushes": "Brushes blocked",
|
||||
"blocked_drive_wheels": "Drive wheels blocked",
|
||||
"blocked_front_wheel": "Front wheel blocked",
|
||||
|
||||
@@ -163,9 +163,6 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.exception("Unexpected exception during add-on discovery")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
if not server_info.onboard_done:
|
||||
return self.async_abort(reason="server_not_ready")
|
||||
|
||||
# We trust the token from hassio discovery and validate it during setup
|
||||
self.token = discovery_info.config["auth_token"]
|
||||
|
||||
@@ -226,11 +223,6 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.debug("Ignoring add-on server in zeroconf discovery")
|
||||
return self.async_abort(reason="already_discovered_addon")
|
||||
|
||||
# Ignore servers that have not completed onboarding yet
|
||||
if not server_info.onboard_done:
|
||||
LOGGER.debug("Ignoring server that hasn't completed onboarding")
|
||||
return self.async_abort(reason="server_not_ready")
|
||||
|
||||
self.url = server_info.base_url
|
||||
self.server_info = server_info
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pynintendoparental import Authenticator
|
||||
from pynintendoparental.exceptions import (
|
||||
from pynintendoauth.exceptions import (
|
||||
InvalidOAuthConfigurationException,
|
||||
InvalidSessionTokenException,
|
||||
)
|
||||
from pynintendoparental import Authenticator
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -39,13 +39,12 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Nintendo Switch parental controls from a config entry."""
|
||||
nintendo_auth = Authenticator(
|
||||
session_token=entry.data[CONF_SESSION_TOKEN],
|
||||
client_session=async_get_clientsession(hass),
|
||||
)
|
||||
try:
|
||||
nintendo_auth = await Authenticator.complete_login(
|
||||
auth=None,
|
||||
response_token=entry.data[CONF_SESSION_TOKEN],
|
||||
is_session_token=True,
|
||||
client_session=async_get_clientsession(hass),
|
||||
)
|
||||
await nintendo_auth.async_complete_login(use_session_token=True)
|
||||
except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user