mirror of
https://github.com/home-assistant/core.git
synced 2025-10-22 01:59:35 +00:00
Compare commits
207 Commits
chat-log-s
...
drop-ignor
Author | SHA1 | Date | |
---|---|---|---|
![]() |
642ffa45c3 | ||
![]() |
aa8198d852 | ||
![]() |
b7f30ec17f | ||
![]() |
2da1878f60 | ||
![]() |
872b33a088 | ||
![]() |
e0faa36157 | ||
![]() |
14b270a2db | ||
![]() |
8402bead4f | ||
![]() |
6bf7a4278e | ||
![]() |
3de62b2b4c | ||
![]() |
0d2558c030 | ||
![]() |
9efbcb2f82 | ||
![]() |
f210bb35ed | ||
![]() |
0581ceb771 | ||
![]() |
7ba2e60af3 | ||
![]() |
75fa0ffd04 | ||
![]() |
01effb7ca6 | ||
![]() |
88d383962c | ||
![]() |
3c001bd6ed | ||
![]() |
ec5c4843d1 | ||
![]() |
e2c281549e | ||
![]() |
051e472537 | ||
![]() |
1e5910215d | ||
![]() |
645089edba | ||
![]() |
7abe289681 | ||
![]() |
7829c2d03e | ||
![]() |
148a13361f | ||
![]() |
57dccd1474 | ||
![]() |
a3b0132299 | ||
![]() |
fbd8443745 | ||
![]() |
cd7015c6b7 | ||
![]() |
1012c7bdf9 | ||
![]() |
ca912906f5 | ||
![]() |
d0cad43a6c | ||
![]() |
751540e606 | ||
![]() |
3d2ec712f1 | ||
![]() |
e3a6c06997 | ||
![]() |
08b94e29e6 | ||
![]() |
79323189fb | ||
![]() |
7508828518 | ||
![]() |
f257e89b2a | ||
![]() |
a2e469eb28 | ||
![]() |
7c80491325 | ||
![]() |
adedf2037a | ||
![]() |
188459e3ff | ||
![]() |
7324a12ada | ||
![]() |
fe07e9c840 | ||
![]() |
afeaf2409f | ||
![]() |
69f9c0a6cc | ||
![]() |
46f52db87c | ||
![]() |
d877761dbb | ||
![]() |
95da65f552 | ||
![]() |
6ec82d0b21 | ||
![]() |
f6a16f63a4 | ||
![]() |
9ff2dab468 | ||
![]() |
9422703288 | ||
![]() |
d91eccb209 | ||
![]() |
939cbc8644 | ||
![]() |
0f1d2a77cb | ||
![]() |
385fc5b3d0 | ||
![]() |
18c63e3b8f | ||
![]() |
cf477186aa | ||
![]() |
0eef44be91 | ||
![]() |
e7ac56c59f | ||
![]() |
3cc4091f31 | ||
![]() |
00025c8f42 | ||
![]() |
db48f8cb28 | ||
![]() |
4fdbe82df2 | ||
![]() |
742f1b2157 | ||
![]() |
681eb6b594 | ||
![]() |
1d6c6628f4 | ||
![]() |
b6337c07d6 | ||
![]() |
8b6fb05ee4 | ||
![]() |
28405e2b04 | ||
![]() |
31857a03d6 | ||
![]() |
97a0a4ea17 | ||
![]() |
b494074ee0 | ||
![]() |
6aff1287dd | ||
![]() |
655de3dfd2 | ||
![]() |
11ee7d63be | ||
![]() |
080a7dcfa7 | ||
![]() |
3e20c506f4 | ||
![]() |
2abc197dcd | ||
![]() |
a3dec46d59 | ||
![]() |
7a3630e647 | ||
![]() |
2812d7c712 | ||
![]() |
c0fc7b66f0 | ||
![]() |
c6e334ca60 | ||
![]() |
416f6b922c | ||
![]() |
d2af875d63 | ||
![]() |
1237010b4a | ||
![]() |
26fec2fdcc | ||
![]() |
13e828038d | ||
![]() |
b517774be0 | ||
![]() |
6e515d4829 | ||
![]() |
7f5128eb15 | ||
![]() |
7ddfcd350b | ||
![]() |
a92e73ff17 | ||
![]() |
ae3d32073c | ||
![]() |
38d0299951 | ||
![]() |
8dba1edbe5 | ||
![]() |
f3c4288026 | ||
![]() |
8db6505a97 | ||
![]() |
61a9094d5f | ||
![]() |
d140eb4c76 | ||
![]() |
21f24c2f6a | ||
![]() |
85b26479de | ||
![]() |
bddbf9c73c | ||
![]() |
64f48564ff | ||
![]() |
06e4922021 | ||
![]() |
cdc6c44a49 | ||
![]() |
106a74c954 | ||
![]() |
8464dad8e0 | ||
![]() |
c3e2f0e19b | ||
![]() |
fbf875b5af | ||
![]() |
fcea5e0da6 | ||
![]() |
81fd9e1c5a | ||
![]() |
d108d5f106 | ||
![]() |
487940872e | ||
![]() |
aaf58075c6 | ||
![]() |
a23bed6f4d | ||
![]() |
02e05643f1 | ||
![]() |
5f9b098c19 | ||
![]() |
143f7df7fd | ||
![]() |
9a28ee5378 | ||
![]() |
82f33fbc39 | ||
![]() |
6a632a71b6 | ||
![]() |
ae8678b2af | ||
![]() |
b52ee6915a | ||
![]() |
b0e1b00598 | ||
![]() |
fd902af23b | ||
![]() |
07d6ebef4c | ||
![]() |
c9b9f05f4b | ||
![]() |
90a0262217 | ||
![]() |
324aa09ebe | ||
![]() |
663431fc80 | ||
![]() |
610183c11b | ||
![]() |
b7718f6f0f | ||
![]() |
5708f61964 | ||
![]() |
4fb3c9fed2 | ||
![]() |
1e5f5f4ad3 | ||
![]() |
82c536a4e9 | ||
![]() |
97afec1912 | ||
![]() |
0bfdd70730 | ||
![]() |
01dee6507b | ||
![]() |
04f83bc067 | ||
![]() |
f0756af52d | ||
![]() |
dd6bc715d8 | ||
![]() |
1452aec47f | ||
![]() |
6f8439de5b | ||
![]() |
f649717372 | ||
![]() |
bf273ef407 | ||
![]() |
94d015e00a | ||
![]() |
f185ffddf1 | ||
![]() |
2d0b4dd7e9 | ||
![]() |
eab1205823 | ||
![]() |
a991dcbe6a | ||
![]() |
6f79a65762 | ||
![]() |
ce1fdc6b75 | ||
![]() |
d7aa0834c7 | ||
![]() |
3151384867 | ||
![]() |
8aa5e7de91 | ||
![]() |
cca5c807ad | ||
![]() |
89433219dd | ||
![]() |
694b169c79 | ||
![]() |
f1e0954c61 | ||
![]() |
3c3b4ef14a | ||
![]() |
54ff49115c | ||
![]() |
2512dad843 | ||
![]() |
a3b67d5f28 | ||
![]() |
76a0b2d616 | ||
![]() |
1182082c1f | ||
![]() |
e0811558cb | ||
![]() |
d389405218 | ||
![]() |
3a71087c9c | ||
![]() |
c7d7cfa7ad | ||
![]() |
e4ea79866d | ||
![]() |
ddfa6f33d2 | ||
![]() |
15e99650aa | ||
![]() |
58bacbb84e | ||
![]() |
82758f7671 | ||
![]() |
7739cdc626 | ||
![]() |
4ca1ae61aa | ||
![]() |
3d130a9bdf | ||
![]() |
2b38f33d50 | ||
![]() |
19dedb038e | ||
![]() |
59781422f7 | ||
![]() |
083277d1ff | ||
![]() |
9b9c55b37b | ||
![]() |
c9d67d596b | ||
![]() |
7948b35265 | ||
![]() |
be843970fd | ||
![]() |
53b65b2fb4 | ||
![]() |
ac7be97245 | ||
![]() |
09e539bf0e | ||
![]() |
6ef1b3bad3 | ||
![]() |
38e46f7a53 | ||
![]() |
ef60d16659 | ||
![]() |
bf4f8b48a3 | ||
![]() |
3c1496d2bb | ||
![]() |
d457787639 | ||
![]() |
de4bfd6f05 | ||
![]() |
34c5748132 | ||
![]() |
5bfd9620db | ||
![]() |
6f8766e4bd | ||
![]() |
d3b519846b | ||
![]() |
1bfac54e56 |
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -42,7 +42,7 @@ env:
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.11"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
# 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
|
||||
@@ -625,7 +625,7 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -689,14 +689,14 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant
|
||||
pylint homeassistant
|
||||
- name: Run pylint (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
pylint homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
|
||||
pylint-tests:
|
||||
name: Check pylint on tests
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -79,7 +79,6 @@ junit.xml
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
.python-version
|
||||
.tool-versions
|
||||
|
||||
# emacs auto backups
|
||||
|
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -46,6 +46,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/accuweather/ @bieniu
|
||||
/homeassistant/components/acmeda/ @atmurray
|
||||
/tests/components/acmeda/ @atmurray
|
||||
/homeassistant/components/actron_air/ @kclif9 @JagadishDhanamjayam
|
||||
/tests/components/actron_air/ @kclif9 @JagadishDhanamjayam
|
||||
/homeassistant/components/adax/ @danielhiversen @lazytarget
|
||||
/tests/components/adax/ @danielhiversen @lazytarget
|
||||
/homeassistant/components/adguard/ @frenck
|
||||
@@ -1135,6 +1137,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/opengarage/ @danielhiversen
|
||||
/homeassistant/components/openhome/ @bazwilliams
|
||||
/tests/components/openhome/ @bazwilliams
|
||||
/homeassistant/components/openrgb/ @felipecrs
|
||||
/tests/components/openrgb/ @felipecrs
|
||||
/homeassistant/components/opensky/ @joostlek
|
||||
/tests/components/opensky/ @joostlek
|
||||
/homeassistant/components/opentherm_gw/ @mvn23
|
||||
|
@@ -36,7 +36,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
USER vscode
|
||||
|
||||
ENV UV_PYTHON=3.13.2
|
||||
COPY .python-version ./
|
||||
RUN uv python install
|
||||
|
||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||
|
57
homeassistant/components/actron_air/__init__.py
Normal file
57
homeassistant/components/actron_air/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""The Actron Air integration."""
|
||||
|
||||
from actron_neo_api import (
|
||||
ActronAirNeoACSystem,
|
||||
ActronNeoAPI,
|
||||
ActronNeoAPIError,
|
||||
ActronNeoAuthError,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import (
|
||||
ActronAirConfigEntry,
|
||||
ActronAirRuntimeData,
|
||||
ActronAirSystemCoordinator,
|
||||
)
|
||||
|
||||
PLATFORM = [Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||
"""Set up Actron Air integration from a config entry."""
|
||||
|
||||
api = ActronNeoAPI(refresh_token=entry.data[CONF_API_TOKEN])
|
||||
systems: list[ActronAirNeoACSystem] = []
|
||||
|
||||
try:
|
||||
systems = await api.get_ac_systems()
|
||||
await api.update_status()
|
||||
except ActronNeoAuthError:
|
||||
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
||||
raise
|
||||
except ActronNeoAPIError as err:
|
||||
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
||||
raise
|
||||
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||
for system in systems:
|
||||
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
|
||||
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
system_coordinators[system["serial"]] = coordinator
|
||||
|
||||
entry.runtime_data = ActronAirRuntimeData(
|
||||
api=api,
|
||||
system_coordinators=system_coordinators,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
|
259
homeassistant/components/actron_air/climate.py
Normal file
259
homeassistant/components/actron_air/climate.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Climate platform for Actron Air integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from actron_neo_api import ActronAirNeoStatus, ActronAirNeoZone
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
FAN_MODE_MAPPING_ACTRONAIR_TO_HA = {
|
||||
"AUTO": FAN_AUTO,
|
||||
"LOW": FAN_LOW,
|
||||
"MED": FAN_MEDIUM,
|
||||
"HIGH": FAN_HIGH,
|
||||
}
|
||||
FAN_MODE_MAPPING_HA_TO_ACTRONAIR = {
|
||||
v: k for k, v in FAN_MODE_MAPPING_ACTRONAIR_TO_HA.items()
|
||||
}
|
||||
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
|
||||
"COOL": HVACMode.COOL,
|
||||
"HEAT": HVACMode.HEAT,
|
||||
"FAN": HVACMode.FAN_ONLY,
|
||||
"AUTO": HVACMode.AUTO,
|
||||
"OFF": HVACMode.OFF,
|
||||
}
|
||||
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
|
||||
v: k for k, v in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.items()
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ActronAirConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Actron Air climate entities."""
|
||||
system_coordinators = entry.runtime_data.system_coordinators
|
||||
entities: list[ClimateEntity] = []
|
||||
|
||||
for coordinator in system_coordinators.values():
|
||||
status = coordinator.data
|
||||
name = status.ac_system.system_name
|
||||
entities.append(ActronSystemClimate(coordinator, name))
|
||||
|
||||
entities.extend(
|
||||
ActronZoneClimate(coordinator, zone)
|
||||
for zone in status.remote_zone_info
|
||||
if zone.exists
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
|
||||
"""Base class for Actron Air climate entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
_attr_name = None
|
||||
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_number = coordinator.serial_number
|
||||
|
||||
|
||||
class ActronSystemClimate(BaseClimateEntity):
|
||||
"""Representation of the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator, name)
|
||||
serial_number = coordinator.serial_number
|
||||
self._attr_unique_id = serial_number
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=self._status.ac_system.system_name,
|
||||
manufacturer="Actron Air",
|
||||
model_id=self._status.ac_system.master_wc_model,
|
||||
sw_version=self._status.ac_system.master_wc_firmware_version,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
return self._status.min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature that can be set."""
|
||||
return self._status.max_temp
|
||||
|
||||
@property
|
||||
def _status(self) -> ActronAirNeoStatus:
|
||||
"""Get the current status from the coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
if not self._status.user_aircon_settings.is_on:
|
||||
return HVACMode.OFF
|
||||
|
||||
mode = self._status.user_aircon_settings.mode
|
||||
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self._status.user_aircon_settings.fan_mode
|
||||
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float:
|
||||
"""Return the current humidity."""
|
||||
return self._status.master_info.live_humidity_pc
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
"""Return the current temperature."""
|
||||
return self._status.master_info.live_temp_c
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the target temperature."""
|
||||
return self._status.user_aircon_settings.temperature_setpoint_cool_c
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set a new fan mode."""
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
|
||||
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
|
||||
await self._status.ac_system.set_system_mode(ac_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
await self._status.user_aircon_settings.set_temperature(temperature=temp)
|
||||
|
||||
|
||||
class ActronZoneClimate(BaseClimateEntity):
|
||||
"""Representation of a zone within the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
zone: ActronAirNeoZone,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator, zone.title)
|
||||
serial_number = coordinator.serial_number
|
||||
self._zone_id: int = zone.zone_id
|
||||
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=zone.title,
|
||||
manufacturer="Actron Air",
|
||||
model="Zone",
|
||||
suggested_area=zone.title,
|
||||
via_device=(DOMAIN, serial_number),
|
||||
)
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
return self._zone.min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature that can be set."""
|
||||
return self._zone.max_temp
|
||||
|
||||
@property
|
||||
def _zone(self) -> ActronAirNeoZone:
|
||||
"""Get the current zone data from the coordinator."""
|
||||
status = self.coordinator.data
|
||||
return status.zones[self._zone_id]
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
if self._zone.is_active:
|
||||
mode = self._zone.hvac_mode
|
||||
return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode)
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
return self._zone.humidity
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._zone.live_temp_c
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self._zone.temperature_setpoint_cool_c
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
is_enabled = hvac_mode != HVACMode.OFF
|
||||
await self._zone.enable(is_enabled)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
await self._zone.set_temperature(temperature=kwargs["temperature"])
|
132
homeassistant/components/actron_air/config_flow.py
Normal file
132
homeassistant/components/actron_air/config_flow.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Setup config flow for Actron Air integration."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from actron_neo_api import ActronNeoAPI, ActronNeoAuthError
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
|
||||
|
||||
class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Actron Air."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._api: ActronNeoAPI | None = None
|
||||
self._device_code: str | None = None
|
||||
self._user_code: str = ""
|
||||
self._verification_uri: str = ""
|
||||
self._expires_minutes: str = "30"
|
||||
self.login_task: asyncio.Task | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if self._api is None:
|
||||
_LOGGER.debug("Initiating device authorization")
|
||||
self._api = ActronNeoAPI()
|
||||
try:
|
||||
device_code_response = await self._api.request_device_code()
|
||||
except ActronNeoAuthError as err:
|
||||
_LOGGER.error("OAuth2 flow failed: %s", err)
|
||||
return self.async_abort(reason="oauth2_error")
|
||||
|
||||
self._device_code = device_code_response["device_code"]
|
||||
self._user_code = device_code_response["user_code"]
|
||||
self._verification_uri = device_code_response["verification_uri_complete"]
|
||||
self._expires_minutes = str(device_code_response["expires_in"] // 60)
|
||||
|
||||
async def _wait_for_authorization() -> None:
|
||||
"""Wait for the user to authorize the device."""
|
||||
assert self._api is not None
|
||||
assert self._device_code is not None
|
||||
_LOGGER.debug("Waiting for device authorization")
|
||||
try:
|
||||
await self._api.poll_for_token(self._device_code)
|
||||
_LOGGER.debug("Authorization successful")
|
||||
except ActronNeoAuthError as ex:
|
||||
_LOGGER.exception("Error while waiting for device authorization")
|
||||
raise CannotConnect from ex
|
||||
|
||||
_LOGGER.debug("Checking login task")
|
||||
if self.login_task is None:
|
||||
_LOGGER.debug("Creating task for device authorization")
|
||||
self.login_task = self.hass.async_create_task(_wait_for_authorization())
|
||||
|
||||
if self.login_task.done():
|
||||
_LOGGER.debug("Login task is done, checking results")
|
||||
if exception := self.login_task.exception():
|
||||
if isinstance(exception, CannotConnect):
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="connection_error"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="timeout")
|
||||
return self.async_show_progress_done(next_step_id="finish_login")
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id="user",
|
||||
progress_action="wait_for_authorization",
|
||||
description_placeholders={
|
||||
"user_code": self._user_code,
|
||||
"verification_uri": self._verification_uri,
|
||||
"expires_minutes": self._expires_minutes,
|
||||
},
|
||||
progress_task=self.login_task,
|
||||
)
|
||||
|
||||
async def async_step_finish_login(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the finalization of login."""
|
||||
_LOGGER.debug("Finalizing authorization")
|
||||
assert self._api is not None
|
||||
|
||||
try:
|
||||
user_data = await self._api.get_user_info()
|
||||
except ActronNeoAuthError as err:
|
||||
_LOGGER.error("Error getting user info: %s", err)
|
||||
return self.async_abort(reason="oauth2_error")
|
||||
|
||||
unique_id = str(user_data["id"])
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_data["email"],
|
||||
data={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||
)
|
||||
|
||||
async def async_step_timeout(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle issues that need transition await from progress step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="timeout",
|
||||
)
|
||||
del self.login_task
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_connection_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle connection error from progress step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="connection_error")
|
||||
|
||||
# Reset state and try again
|
||||
self._api = None
|
||||
self._device_code = None
|
||||
self.login_task = None
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
6
homeassistant/components/actron_air/const.py
Normal file
6
homeassistant/components/actron_air/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants used by Actron Air integration."""
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "actron_air"
|
69
homeassistant/components/actron_air/coordinator.py
Normal file
69
homeassistant/components/actron_air/coordinator.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Coordinator for Actron Air integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from actron_neo_api import ActronAirNeoACSystem, ActronAirNeoStatus, ActronNeoAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER
|
||||
|
||||
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
|
||||
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActronAirRuntimeData:
|
||||
"""Runtime data for the Actron Air integration."""
|
||||
|
||||
api: ActronNeoAPI
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator]
|
||||
|
||||
|
||||
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
||||
|
||||
AUTH_ERROR_THRESHOLD = 3
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirNeoACSystem]):
|
||||
"""System coordinator for Actron Air integration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ActronAirConfigEntry,
|
||||
api: ActronNeoAPI,
|
||||
system: ActronAirNeoACSystem,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Actron Air Status",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
self.system = system
|
||||
self.serial_number = system["serial"]
|
||||
self.api = api
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
|
||||
async def _async_update_data(self) -> ActronAirNeoStatus:
|
||||
"""Fetch updates and merge incremental changes into the full state."""
|
||||
await self.api.update_status()
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
return self.status
|
||||
|
||||
def is_device_stale(self) -> bool:
|
||||
"""Check if a device is stale (not seen for a while)."""
|
||||
return (dt_util.utcnow() - self.last_seen) > STALE_DEVICE_TIMEOUT
|
16
homeassistant/components/actron_air/manifest.json
Normal file
16
homeassistant/components/actron_air/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"domain": "actron_air",
|
||||
"name": "Actron Air",
|
||||
"codeowners": ["@kclif9", "@JagadishDhanamjayam"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "neo-*",
|
||||
"macaddress": "FC0FE7*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["actron-neo-api==0.1.84"]
|
||||
}
|
78
homeassistant/components/actron_air/quality_scale.yaml
Normal file
78
homeassistant/components/actron_air/quality_scale.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not have custom service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not have custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not subscribe to external 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: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options flow
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not use entity categories.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not use entity device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Not required for this integration at this stage.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration does not have any known issues that require repair.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
29
homeassistant/components/actron_air/strings.json
Normal file
29
homeassistant/components/actron_air/strings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Actron Air OAuth2 Authorization"
|
||||
},
|
||||
"timeout": {
|
||||
"title": "Authorization timeout",
|
||||
"description": "The authorization process timed out. Please try again.",
|
||||
"data": {}
|
||||
},
|
||||
"connection_error": {
|
||||
"title": "Connection error",
|
||||
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
|
||||
},
|
||||
"error": {
|
||||
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
|
||||
},
|
||||
"abort": {
|
||||
"oauth2_error": "Failed to start OAuth2 flow",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.airos8 import AirOS8
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -12,10 +14,11 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
@@ -23,6 +26,8 @@ _PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Set up Ubiquiti airOS from a config entry."""
|
||||
@@ -54,11 +59,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
# This means the user has downgraded from a future version
|
||||
if entry.version > 2:
|
||||
return False
|
||||
|
||||
# 1.1 Migrate config_entry to add advanced ssl settings
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_minor_version = 2
|
||||
new_data = {**entry.data}
|
||||
advanced_data = {
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
@@ -69,7 +76,52 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_data,
|
||||
minor_version=2,
|
||||
minor_version=new_minor_version,
|
||||
)
|
||||
|
||||
# 2.1 Migrate binary_sensor entity unique_id from device_id to mac_address
|
||||
# Step 1 - migrate binary_sensor entity unique_id
|
||||
# Step 2 - migrate device entity identifier
|
||||
if entry.version == 1:
|
||||
new_version = 2
|
||||
new_minor_version = 1
|
||||
|
||||
mac_adress = dr.format_mac(entry.unique_id)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
if device_entry := device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, mac_adress)}
|
||||
):
|
||||
old_device_id = next(
|
||||
(
|
||||
device_id
|
||||
for domain, device_id in device_entry.identifiers
|
||||
if domain == DOMAIN
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def update_unique_id(
|
||||
entity_entry: er.RegistryEntry,
|
||||
) -> dict[str, str] | None:
|
||||
"""Update unique id from device_id to mac address."""
|
||||
if old_device_id and entity_entry.unique_id.startswith(old_device_id):
|
||||
suffix = entity_entry.unique_id.removeprefix(old_device_id)
|
||||
new_unique_id = f"{mac_adress}{suffix}"
|
||||
return {"new_unique_id": new_unique_id}
|
||||
return None
|
||||
|
||||
await er.async_migrate_entries(hass, entry.entry_id, update_unique_id)
|
||||
|
||||
new_identifiers = device_entry.identifiers.copy()
|
||||
new_identifiers.discard((DOMAIN, old_device_id))
|
||||
new_identifiers.add((DOMAIN, mac_adress))
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, new_identifiers=new_identifiers
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, version=new_version, minor_version=new_minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
@@ -98,7 +98,7 @@ class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}"
|
||||
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
@@ -15,7 +15,12 @@ from airos.exceptions import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -57,8 +62,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ubiquiti airOS."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
@@ -119,7 +124,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
await self.async_set_unique_id(airos_data.derived.mac)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -164,3 +169,54 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=self.errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self,
|
||||
user_input: Mapping[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of airOS."""
|
||||
self.errors = {}
|
||||
entry = self._get_reconfigure_entry()
|
||||
current_data = entry.data
|
||||
|
||||
if user_input is not None:
|
||||
validate_data = {**current_data, **user_input}
|
||||
if await self._validate_and_get_device_info(config_data=validate_data):
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=validate_data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_SSL,
|
||||
default=current_data[SECTION_ADVANCED_SETTINGS][
|
||||
CONF_SSL
|
||||
],
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_VERIFY_SSL,
|
||||
default=current_data[SECTION_ADVANCED_SETTINGS][
|
||||
CONF_VERIFY_SSL
|
||||
],
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=self.errors,
|
||||
)
|
||||
|
@@ -33,9 +33,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)},
|
||||
configuration_url=configuration_url,
|
||||
identifiers={(DOMAIN, str(airos_data.host.device_id))},
|
||||
identifiers={(DOMAIN, airos_data.derived.mac)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=airos_data.host.devmodel,
|
||||
model_id=(
|
||||
sku
|
||||
if (sku := airos_data.derived.sku) not in ["UNKNOWN", "AMBIGUOUS"]
|
||||
else None
|
||||
),
|
||||
name=airos_data.host.hostname,
|
||||
sw_version=airos_data.host.fwversion,
|
||||
)
|
||||
|
@@ -4,7 +4,8 @@
|
||||
"codeowners": ["@CoMPaTech"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airos==0.5.5"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.5.6"]
|
||||
}
|
||||
|
@@ -32,11 +32,11 @@ rules:
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
@@ -48,9 +48,9 @@ rules:
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
@@ -60,7 +60,7 @@ rules:
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: no (custom) icons used or envisioned
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
|
@@ -10,6 +10,27 @@
|
||||
"password": "[%key:component::airos::config::step::user::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airos::config::step::user::data_description::password%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]",
|
||||
"data": {
|
||||
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]",
|
||||
"verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -23,6 +44,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"name": "Advanced settings",
|
||||
"data": {
|
||||
"ssl": "Use HTTPS",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
@@ -44,6 +66,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
|
||||
}
|
||||
},
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.6"]
|
||||
"requirements": ["aioairq==0.4.7"]
|
||||
}
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.4.1"]
|
||||
"requirements": ["aioamazondevices==6.4.4"]
|
||||
}
|
||||
|
@@ -5,14 +5,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from random import randrange
|
||||
import sys
|
||||
from typing import Any, cast
|
||||
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -29,7 +24,11 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -43,6 +42,18 @@ from .const import (
|
||||
SIGNAL_DISCONNECTED,
|
||||
)
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
else:
|
||||
|
||||
class DeviceListener:
|
||||
"""Dummy class."""
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME_TV = "Apple TV"
|
||||
@@ -53,31 +64,41 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
if sys.version_info < (3, 14):
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
else:
|
||||
AUTH_EXCEPTIONS = ()
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = ()
|
||||
DEVICE_EXCEPTIONS = ()
|
||||
|
||||
|
||||
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
|
||||
"""Set up a config entry for Apple TV."""
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
manager = AppleTVManager(hass, entry)
|
||||
|
||||
if manager.is_on:
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
@@ -3,17 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import namedtuple
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
|
||||
from aiohttp import ClientSession
|
||||
from asusrouter import AsusRouter, AsusRouterError
|
||||
from asusrouter.config import ARConfigKey
|
||||
from asusrouter.modules.client import AsusClient
|
||||
from asusrouter.modules.connection import ConnectionState
|
||||
from asusrouter.modules.data import AsusData
|
||||
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
|
||||
from asusrouter.tools.connection import get_cookie_jar
|
||||
@@ -61,7 +61,14 @@ SENSORS_TYPE_RATES = "sensors_rates"
|
||||
SENSORS_TYPE_TEMPERATURES = "sensors_temperatures"
|
||||
SENSORS_TYPE_UPTIME = "sensors_uptime"
|
||||
|
||||
WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) # noqa: PYI024
|
||||
|
||||
class WrtDevice(NamedTuple):
|
||||
"""WrtDevice structure."""
|
||||
|
||||
ip: str | None
|
||||
name: str | None
|
||||
conneted_to: str | None
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,7 +87,7 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
||||
"""Run library methods and zip results or manage exceptions."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, Any]:
|
||||
async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, str]:
|
||||
try:
|
||||
data = await func(self)
|
||||
except exceptions as exc:
|
||||
@@ -219,7 +226,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Get connected status."""
|
||||
return cast(bool, self._api.is_connected)
|
||||
return self._api.is_connected
|
||||
|
||||
async def async_connect(self) -> None:
|
||||
"""Connect to the device."""
|
||||
@@ -235,8 +242,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
"""Disconnect to the device."""
|
||||
if self._api is not None and self._protocol == PROTOCOL_TELNET:
|
||||
self._api.connection.disconnect()
|
||||
await self._api.async_disconnect()
|
||||
|
||||
async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
|
||||
"""Get list of connected devices."""
|
||||
@@ -437,6 +443,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
if dev.connection is not None
|
||||
and dev.description is not None
|
||||
and dev.connection.ip_address is not None
|
||||
and dev.state is ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
|
||||
|
@@ -10,8 +10,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import AsusWrtConfigEntry
|
||||
from .router import AsusWrtDevInfo, AsusWrtRouter
|
||||
|
||||
ATTR_LAST_TIME_REACHABLE = "last_time_reachable"
|
||||
|
||||
DEFAULT_DEVICE_NAME = "Unknown device"
|
||||
|
||||
|
||||
@@ -58,8 +56,6 @@ def add_entities(
|
||||
class AsusWrtDevice(ScannerEntity):
|
||||
"""Representation of a AsusWrt device."""
|
||||
|
||||
_unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE})
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None:
|
||||
@@ -97,11 +93,6 @@ class AsusWrtDevice(ScannerEntity):
|
||||
def async_on_demand_update(self) -> None:
|
||||
"""Update state."""
|
||||
self._device = self._router.devices[self._device.mac]
|
||||
self._attr_extra_state_attributes = {}
|
||||
if self._device.last_activity:
|
||||
self._attr_extra_state_attributes[ATTR_LAST_TIME_REACHABLE] = (
|
||||
self._device.last_activity.isoformat(timespec="seconds")
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
|
||||
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"]
|
||||
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.0"]
|
||||
}
|
||||
|
@@ -36,11 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
|
||||
raise ConfigEntryAuthFailed("Migration to OAuth required")
|
||||
|
||||
session = async_create_august_clientsession(hass)
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ConfigEntryNotReady("OAuth implementation not available") from err
|
||||
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
|
||||
try:
|
||||
|
@@ -136,17 +136,22 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
url_prefix = get_url(hass, require_current_request=True)
|
||||
except NoURLAvailableError:
|
||||
url_prefix = ""
|
||||
return self.json(
|
||||
{
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
),
|
||||
}
|
||||
|
||||
# Add issuer only when we have a valid base URL (RFC 8414 compliance)
|
||||
if url_prefix:
|
||||
metadata["issuer"] = url_prefix
|
||||
|
||||
return self.json(metadata)
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
|
@@ -57,6 +57,7 @@ from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_current_scanners,
|
||||
async_discovered_service_info,
|
||||
async_get_advertisement_callback,
|
||||
@@ -115,6 +116,7 @@ __all__ = [
|
||||
"HomeAssistantRemoteScanner",
|
||||
"async_address_present",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
"async_current_scanners",
|
||||
"async_discovered_service_info",
|
||||
"async_get_advertisement_callback",
|
||||
|
@@ -193,6 +193,20 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None:
|
||||
_get_manager(hass).async_rediscover_address(address)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_clear_address_from_match_history(hass: HomeAssistant, address: str) -> None:
|
||||
"""Clear an address from the integration matcher history.
|
||||
|
||||
This allows future advertisements from this address to trigger discovery
|
||||
even if the advertisement content has changed but the service data UUIDs
|
||||
remain the same.
|
||||
|
||||
Unlike async_rediscover_address, this does not immediately re-trigger
|
||||
discovery with the current advertisement in history.
|
||||
"""
|
||||
_get_manager(hass).async_clear_address_from_match_history(address)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_register_scanner(
|
||||
hass: HomeAssistant,
|
||||
|
@@ -120,6 +120,19 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
if service_info := self._all_history.get(address):
|
||||
self._async_trigger_matching_discovery(service_info)
|
||||
|
||||
@hass_callback
|
||||
def async_clear_address_from_match_history(self, address: str) -> None:
|
||||
"""Clear an address from the integration matcher history.
|
||||
|
||||
This allows future advertisements from this address to trigger discovery
|
||||
even if the advertisement content has changed but the service data UUIDs
|
||||
remain the same.
|
||||
|
||||
Unlike async_rediscover_address, this does not immediately re-trigger
|
||||
discovery with the current advertisement in history.
|
||||
"""
|
||||
self._integration_matcher.async_clear_address(address)
|
||||
|
||||
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
matched_domains = self._integration_matcher.match_domains(service_info)
|
||||
if self._debug:
|
||||
|
@@ -68,12 +68,17 @@ class IntegrationMatchHistory:
|
||||
manufacturer_data: bool
|
||||
service_data: set[str]
|
||||
service_uuids: set[str]
|
||||
name: str
|
||||
|
||||
|
||||
def seen_all_fields(
|
||||
previous_match: IntegrationMatchHistory, advertisement_data: AdvertisementData
|
||||
previous_match: IntegrationMatchHistory,
|
||||
advertisement_data: AdvertisementData,
|
||||
name: str,
|
||||
) -> bool:
|
||||
"""Return if we have seen all fields."""
|
||||
if previous_match.name != name:
|
||||
return False
|
||||
if not previous_match.manufacturer_data and advertisement_data.manufacturer_data:
|
||||
return False
|
||||
if advertisement_data.service_data and (
|
||||
@@ -122,10 +127,11 @@ class IntegrationMatcher:
|
||||
device = service_info.device
|
||||
advertisement_data = service_info.advertisement
|
||||
connectable = service_info.connectable
|
||||
name = service_info.name
|
||||
matched = self._matched_connectable if connectable else self._matched
|
||||
matched_domains: set[str] = set()
|
||||
if (previous_match := matched.get(device.address)) and seen_all_fields(
|
||||
previous_match, advertisement_data
|
||||
previous_match, advertisement_data, name
|
||||
):
|
||||
# We have seen all fields so we can skip the rest of the matchers
|
||||
return matched_domains
|
||||
@@ -140,11 +146,13 @@ class IntegrationMatcher:
|
||||
)
|
||||
previous_match.service_data |= set(advertisement_data.service_data)
|
||||
previous_match.service_uuids |= set(advertisement_data.service_uuids)
|
||||
previous_match.name = name
|
||||
else:
|
||||
matched[device.address] = IntegrationMatchHistory(
|
||||
manufacturer_data=bool(advertisement_data.manufacturer_data),
|
||||
service_data=set(advertisement_data.service_data),
|
||||
service_uuids=set(advertisement_data.service_uuids),
|
||||
name=name,
|
||||
)
|
||||
return matched_domains
|
||||
|
||||
|
@@ -3,15 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
ENTITY_ID_FORMAT,
|
||||
PLATFORM_SCHEMA as CALENDAR_PLATFORM_SCHEMA,
|
||||
CalendarEntity,
|
||||
CalendarEntityFeature,
|
||||
CalendarEvent,
|
||||
is_offset_reached,
|
||||
)
|
||||
@@ -23,6 +28,7 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
@@ -175,6 +181,8 @@ async def async_setup_entry(
|
||||
class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity):
|
||||
"""A device for getting the next Task from a WebDav Calendar."""
|
||||
|
||||
_attr_supported_features = CalendarEntityFeature.CREATE_EVENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None,
|
||||
@@ -203,6 +211,31 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
||||
"""Get all events in a specific time frame."""
|
||||
return await self.coordinator.async_get_events(hass, start_date, end_date)
|
||||
|
||||
async def async_create_event(self, **kwargs: Any) -> None:
|
||||
"""Create a new event in the calendar."""
|
||||
_LOGGER.debug("Event: %s", kwargs)
|
||||
|
||||
item_data: dict[str, Any] = {
|
||||
"summary": kwargs["summary"],
|
||||
"dtstart": kwargs["dtstart"],
|
||||
"dtend": kwargs["dtend"],
|
||||
}
|
||||
if description := kwargs.get("description"):
|
||||
item_data["description"] = description
|
||||
if location := kwargs.get("location"):
|
||||
item_data["location"] = location
|
||||
if rrule := kwargs.get("rrule"):
|
||||
item_data["rrule"] = rrule
|
||||
|
||||
_LOGGER.debug("ICS data %s", item_data)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.coordinator.calendar.add_event, **item_data),
|
||||
)
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update event data."""
|
||||
|
@@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||
for location_id, location in coordinator.data["locations"].items()
|
||||
]
|
||||
|
||||
async_add_entities(alarms, True)
|
||||
async_add_entities(alarms)
|
||||
|
||||
|
||||
class CanaryAlarm(
|
||||
|
@@ -68,8 +68,7 @@ async def async_setup_entry(
|
||||
for location_id, location in coordinator.data["locations"].items()
|
||||
for device in location.devices
|
||||
if device.is_online
|
||||
),
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
@@ -80,7 +80,7 @@ async def async_setup_entry(
|
||||
if device_type.get("name") in sensor_type[4]
|
||||
)
|
||||
|
||||
async_add_entities(sensors, True)
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class CanarySensor(CoordinatorEntity[CanaryDataUpdateCoordinator], SensorEntity):
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.2.0"],
|
||||
"requirements": ["hass-nabucasa==1.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -38,6 +38,10 @@ TYPE_SPECIFY_COUNTRY = "specify_country_code"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION_PLACEHOLDER = {
|
||||
"register_link": "https://electricitymaps.com/free-tier",
|
||||
}
|
||||
|
||||
|
||||
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Co2signal."""
|
||||
@@ -70,6 +74,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
description_placeholders=DESCRIPTION_PLACEHOLDER,
|
||||
)
|
||||
|
||||
data = {CONF_API_KEY: user_input[CONF_API_KEY]}
|
||||
@@ -179,4 +184,5 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id=step_id,
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders=DESCRIPTION_PLACEHOLDER,
|
||||
)
|
||||
|
@@ -18,7 +18,6 @@ rules:
|
||||
status: todo
|
||||
comment: |
|
||||
The config flow misses data descriptions.
|
||||
Remove URLs from data descriptions, they should be replaced with placeholders.
|
||||
Make use of Electricity Maps zone keys in country code as dropdown.
|
||||
Make use of location selector for coordinates.
|
||||
dependency-transparency: done
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"api_key": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"description": "Visit https://electricitymaps.com/free-tier to request a token."
|
||||
"description": "Visit the [Electricity Maps page]({register_link}) to request a token."
|
||||
},
|
||||
"coordinates": {
|
||||
"data": {
|
||||
|
@@ -166,6 +166,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders={
|
||||
"account_name": self.reauth_entry.title,
|
||||
"developer_url": "https://www.coinbase.com/developer-platform",
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
@@ -195,6 +196,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders={
|
||||
"account_name": self.reauth_entry.title,
|
||||
"developer_url": "https://www.coinbase.com/developer-platform",
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Update Coinbase API credentials",
|
||||
"description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.",
|
||||
"description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit the [Developer Platform]({developer_url}) to create new credentials for {account_name}.",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_token": "API secret"
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==1.1.1"]
|
||||
"requirements": ["aiocomelit==1.1.2"]
|
||||
}
|
||||
|
@@ -138,7 +138,7 @@ def new_device_listener(
|
||||
data_type: str,
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to coordinator updates to check for new devices."""
|
||||
known_devices: set[int] = set()
|
||||
known_devices: dict[str, list[int]] = {}
|
||||
|
||||
def _check_devices() -> None:
|
||||
"""Check for new devices and call callback with any new monitors."""
|
||||
@@ -147,8 +147,8 @@ def new_device_listener(
|
||||
|
||||
new_devices: list[DeviceType] = []
|
||||
for _id in coordinator.data[data_type]:
|
||||
if _id not in known_devices:
|
||||
known_devices.add(_id)
|
||||
if _id not in (id_list := known_devices.get(data_type, [])):
|
||||
known_devices.update({data_type: [*id_list, _id]})
|
||||
new_devices.append(coordinator.data[data_type][_id])
|
||||
|
||||
if new_devices:
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/control4",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyControl4"],
|
||||
"requirements": ["pyControl4==1.2.0"],
|
||||
"requirements": ["pyControl4==1.5.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "c4:director"
|
||||
|
@@ -148,6 +148,15 @@ async def async_setup_entry(
|
||||
source_type={dev_type}, idx=dev_id, name=name
|
||||
)
|
||||
|
||||
# Skip rooms with no audio/video sources
|
||||
if not sources:
|
||||
_LOGGER.debug(
|
||||
"Skipping room '%s' (ID: %s) - no audio/video sources found",
|
||||
room.get("name"),
|
||||
room_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
hidden = room["roomHidden"]
|
||||
entity_list.append(
|
||||
|
@@ -20,13 +20,10 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
SUBSCRIPTIONS: HassKey[list[Callable[[ChatLogEventType, dict[str, Any]], None]]] = (
|
||||
HassKey("conversation_chat_log_subscriptions")
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -34,37 +31,6 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant, event_type: ChatLogEventType, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -97,8 +63,6 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -107,12 +71,6 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.CREATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -126,26 +84,14 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
return
|
||||
|
||||
if is_new_log:
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -154,13 +100,6 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.UPDATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -191,10 +130,6 @@ class SystemContent:
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContent:
|
||||
@@ -204,15 +139,6 @@ class UserContent:
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "content": self.content}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attachment:
|
||||
@@ -227,14 +153,6 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -247,17 +165,6 @@ class AssistantContent:
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "agent_id": self.agent_id}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResultContent:
|
||||
@@ -269,16 +176,6 @@ class ToolResultContent:
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
|
||||
@@ -314,13 +211,6 @@ class ChatLog:
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
"""Return whether the conversation should continue."""
|
||||
@@ -351,11 +241,6 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -374,11 +259,6 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -437,14 +317,6 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"conversation_id": self.conversation_id,
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -718,11 +590,6 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"conversation_id": self.conversation_id, "chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
@@ -26,19 +26,7 @@ SERVICE_RELOAD = "reload"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
from homeassistant.const import StrEnum
|
||||
|
||||
|
||||
class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
CONTENT_ADDED = "content_added"
|
||||
|
@@ -20,7 +20,6 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .chat_log import async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
@@ -36,7 +35,6 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_logs)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -267,28 +265,3 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to all chat logs."""
|
||||
|
||||
@callback
|
||||
def forward_events(event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
connection.send_message(
|
||||
{"type": "event", "event_type": event_type, "data": data}
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -9,7 +10,7 @@ from pycync import Auth
|
||||
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -39,7 +40,7 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
cync_auth: Auth
|
||||
cync_auth: Auth = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -47,29 +48,14 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Attempt login with user credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
if user_input:
|
||||
try:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login()
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
@@ -81,12 +67,65 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Attempt login with the two factor auth code sent to the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
if user_input:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required and prompts for their Cync credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_EMAIL: reauth_entry.title},
|
||||
)
|
||||
|
||||
async def _validate_credentials(self, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Attempt to log in with user email and password, and return the error dict."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if not self.cync_auth:
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
|
||||
await self.cync_auth.login(user_input.get(CONF_TWO_FACTOR_CODE))
|
||||
except TwoFactorRequiredError:
|
||||
raise
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CyncError:
|
||||
@@ -94,25 +133,29 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
return errors
|
||||
|
||||
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
|
||||
"""Create the Cync config entry using input user data."""
|
||||
|
||||
cync_user = self.cync_auth.user
|
||||
await self.async_set_unique_id(str(cync_user.user_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config = {
|
||||
config_data = {
|
||||
CONF_USER_ID: cync_user.user_id,
|
||||
CONF_AUTHORIZE_STRING: cync_user.authorize,
|
||||
CONF_EXPIRES_AT: cync_user.expires_at,
|
||||
CONF_ACCESS_TOKEN: cync_user.access_token,
|
||||
CONF_REFRESH_TOKEN: cync_user.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=user_email, data=config)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
entry=self._get_reauth_entry(), title=user_email, data=config_data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=user_email, data=config_data)
|
||||
|
@@ -37,7 +37,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
|
@@ -18,6 +18,18 @@
|
||||
"data_description": {
|
||||
"two_factor_code": "The two-factor code sent to your Cync account's email"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Cync integration needs to re-authenticate for {email}",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::cync::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::cync::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -26,7 +38,9 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "An incorrect user was provided by Cync for your email address, please consult your Cync app"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -196,7 +196,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
self._attr_name = name if name is not None else f"{source_entity} derivative"
|
||||
self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
|
||||
|
||||
self._unit_template: str | None = None
|
||||
if unit_of_measurement is None:
|
||||
final_unit_prefix = "" if unit_prefix is None else unit_prefix
|
||||
self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}"
|
||||
@@ -217,6 +217,23 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
lambda *args: None
|
||||
)
|
||||
|
||||
def _derive_and_set_attributes_from_state(self, source_state: State | None) -> None:
|
||||
if self._unit_template and source_state:
|
||||
original_unit = self._attr_native_unit_of_measurement
|
||||
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
self._attr_native_unit_of_measurement = self._unit_template.format(
|
||||
"" if source_unit is None else source_unit
|
||||
)
|
||||
if original_unit != self._attr_native_unit_of_measurement:
|
||||
_LOGGER.debug(
|
||||
"%s: Derivative sensor switched UoM from %s to %s, resetting state to 0",
|
||||
self.entity_id,
|
||||
original_unit,
|
||||
self._attr_native_unit_of_measurement,
|
||||
)
|
||||
self._state_list = []
|
||||
self._attr_native_value = round(Decimal(0), self._round_digits)
|
||||
|
||||
def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal:
|
||||
def calculate_weight(start: datetime, end: datetime, now: datetime) -> float:
|
||||
window_start = now - timedelta(seconds=self._time_window)
|
||||
@@ -285,6 +302,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
except (InvalidOperation, TypeError):
|
||||
self._attr_native_value = None
|
||||
|
||||
source_state = self.hass.states.get(self._sensor_source_id)
|
||||
self._derive_and_set_attributes_from_state(source_state)
|
||||
|
||||
def schedule_max_sub_interval_exceeded(source_state: State | None) -> None:
|
||||
"""Schedule calculation using the source state and max_sub_interval.
|
||||
|
||||
@@ -358,10 +378,18 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
return
|
||||
|
||||
assert new_state
|
||||
|
||||
original_unit = self._attr_native_unit_of_measurement
|
||||
self._derive_and_set_attributes_from_state(new_state)
|
||||
if original_unit != self._attr_native_unit_of_measurement:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
schedule_max_sub_interval_exceeded(new_state)
|
||||
old_state = event.data["old_state"]
|
||||
if old_state is not None:
|
||||
@@ -391,12 +419,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if self.native_unit_of_measurement is None:
|
||||
unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
self._attr_native_unit_of_measurement = self._unit_template.format(
|
||||
"" if unit is None else unit
|
||||
)
|
||||
|
||||
self._prune_state_list(new_timestamp)
|
||||
|
||||
try:
|
||||
|
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from .config_entry import ( # noqa: F401
|
||||
from .config_entry import (
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
@@ -15,7 +15,7 @@ from .config_entry import ( # noqa: F401
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
)
|
||||
from .const import ( # noqa: F401
|
||||
from .const import (
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BATTERY,
|
||||
ATTR_DEV_ID,
|
||||
@@ -37,7 +37,7 @@ from .const import ( # noqa: F401
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
from .legacy import ( # noqa: F401
|
||||
from .legacy import (
|
||||
PLATFORM_SCHEMA,
|
||||
PLATFORM_SCHEMA_BASE,
|
||||
SERVICE_SEE,
|
||||
@@ -61,3 +61,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the device tracker."""
|
||||
async_setup_legacy_integration(hass, config)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ATTR_ATTRIBUTES",
|
||||
"ATTR_BATTERY",
|
||||
"ATTR_DEV_ID",
|
||||
"ATTR_GPS",
|
||||
"ATTR_HOST_NAME",
|
||||
"ATTR_IP",
|
||||
"ATTR_LOCATION_NAME",
|
||||
"ATTR_MAC",
|
||||
"ATTR_SOURCE_TYPE",
|
||||
"CONF_CONSIDER_HOME",
|
||||
"CONF_NEW_DEVICE_DEFAULTS",
|
||||
"CONF_SCAN_INTERVAL",
|
||||
"CONF_TRACK_NEW",
|
||||
"CONNECTED_DEVICE_REGISTERED",
|
||||
"DEFAULT_CONSIDER_HOME",
|
||||
"DEFAULT_TRACK_NEW",
|
||||
"DOMAIN",
|
||||
"ENTITY_ID_FORMAT",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"SCAN_INTERVAL",
|
||||
"SERVICE_SEE",
|
||||
"SERVICE_SEE_PAYLOAD_SCHEMA",
|
||||
"SOURCE_TYPES",
|
||||
"AsyncSeeCallback",
|
||||
"DeviceScanner",
|
||||
"ScannerEntity",
|
||||
"ScannerEntityDescription",
|
||||
"SeeCallback",
|
||||
"SourceType",
|
||||
"TrackerEntity",
|
||||
"TrackerEntityDescription",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
"is_on",
|
||||
"see",
|
||||
)
|
||||
|
@@ -61,5 +61,8 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="authorize",
|
||||
errors=errors,
|
||||
description_placeholders={"pin": self._ecobee.pin},
|
||||
description_placeholders={
|
||||
"pin": self._ecobee.pin,
|
||||
"auth_url": "https://www.ecobee.com/consumerportal/index.html",
|
||||
},
|
||||
)
|
||||
|
@@ -8,7 +8,7 @@
|
||||
}
|
||||
},
|
||||
"authorize": {
|
||||
"description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select **Submit**."
|
||||
"description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -38,6 +38,25 @@
|
||||
},
|
||||
"available_energy": {
|
||||
"default": "mdi:battery-50"
|
||||
},
|
||||
"grid_status": {
|
||||
"default": "mdi:transmission-tower",
|
||||
"state": {
|
||||
"off_grid": "mdi:transmission-tower-off",
|
||||
"synchronizing": "mdi:sync-alert"
|
||||
}
|
||||
},
|
||||
"mid_state": {
|
||||
"default": "mdi:electric-switch-closed",
|
||||
"state": {
|
||||
"open": "mdi:electric-switch"
|
||||
}
|
||||
},
|
||||
"admin_state": {
|
||||
"default": "mdi:transmission-tower",
|
||||
"state": {
|
||||
"off_grid": "mdi:transmission-tower-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -824,6 +824,12 @@ class EnvoyCollarSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str]
|
||||
|
||||
|
||||
# translations don't accept uppercase
|
||||
ADMIN_STATE_MAP = {
|
||||
"ENCMN_MDE_ON_GRID": "on_grid",
|
||||
"ENCMN_MDE_OFF_GRID": "off_grid",
|
||||
}
|
||||
|
||||
COLLAR_SENSORS = (
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="temperature",
|
||||
@@ -838,11 +844,21 @@ COLLAR_SENSORS = (
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date),
|
||||
),
|
||||
# grid_state does not seem to change when off-grid, but rather admin_state_str
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="grid_state",
|
||||
translation_key="grid_status",
|
||||
value_fn=lambda collar: collar.grid_state,
|
||||
),
|
||||
# grid_status off-grid shows in admin_state rather than in grid_state
|
||||
# map values as translations don't accept uppercase which these are
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="admin_state_str",
|
||||
translation_key="admin_state",
|
||||
value_fn=lambda collar: ADMIN_STATE_MAP.get(
|
||||
collar.admin_state_str, collar.admin_state_str
|
||||
),
|
||||
),
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="mid_state",
|
||||
translation_key="mid_state",
|
||||
|
@@ -409,10 +409,26 @@
|
||||
"name": "Last report duration"
|
||||
},
|
||||
"grid_status": {
|
||||
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]"
|
||||
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]",
|
||||
"state": {
|
||||
"on_grid": "On grid",
|
||||
"off_grid": "Off grid",
|
||||
"synchronizing": "Synchronizing to grid"
|
||||
}
|
||||
},
|
||||
"mid_state": {
|
||||
"name": "MID state"
|
||||
"name": "MID state",
|
||||
"state": {
|
||||
"open": "[%key:common::state::open%]",
|
||||
"close": "[%key:common::state::closed%]"
|
||||
}
|
||||
},
|
||||
"admin_state": {
|
||||
"name": "Admin state",
|
||||
"state": {
|
||||
"on_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::on_grid%]",
|
||||
"off_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::off_grid%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -47,11 +47,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
try:
|
||||
await radar_coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada radar")
|
||||
# Skip initial refresh for radar since the camera entity is disabled by default.
|
||||
# The coordinator will fetch data when the entity is enabled.
|
||||
|
||||
aqhi_data = ECAirQuality(coordinates=(lat, lon))
|
||||
aqhi_coordinator = ECDataUpdateCoordinator(
|
||||
@@ -63,7 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada AQHI")
|
||||
|
||||
if errors == 3:
|
||||
# Require at least one coordinator to succeed (weather or AQHI)
|
||||
# Radar is optional since the camera entity is disabled by default
|
||||
if errors >= 2:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
config_entry.runtime_data = ECRuntimeData(
|
||||
|
@@ -59,6 +59,14 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
|
||||
self.content_type = "image/gif"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
# Trigger coordinator refresh when entity is enabled
|
||||
# since radar coordinator skips initial refresh during setup
|
||||
if not self.coordinator.last_update_success:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
|
@@ -6,11 +6,18 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
import aiohttp
|
||||
from env_canada import ECWeather, ec_exc
|
||||
from env_canada.ec_weather import get_ec_sites_list
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_STATION, CONF_TITLE, DOMAIN
|
||||
|
||||
@@ -25,14 +32,16 @@ async def validate_input(data):
|
||||
lang = data.get(CONF_LANGUAGE).lower()
|
||||
|
||||
if station:
|
||||
# When station is provided, use it and get the coordinates from ECWeather
|
||||
weather_data = ECWeather(station_id=station, language=lang)
|
||||
else:
|
||||
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
|
||||
await weather_data.update()
|
||||
|
||||
if lat is None or lon is None:
|
||||
await weather_data.update()
|
||||
# Always use the station's coordinates, not the user-provided ones
|
||||
lat = weather_data.lat
|
||||
lon = weather_data.lon
|
||||
else:
|
||||
# When no station is provided, use coordinates to find nearest station
|
||||
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
|
||||
await weather_data.update()
|
||||
|
||||
return {
|
||||
CONF_TITLE: weather_data.metadata.location,
|
||||
@@ -46,6 +55,13 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Environment Canada weather."""
|
||||
|
||||
VERSION = 1
|
||||
_station_codes: list[dict[str, str]] | None = None
|
||||
|
||||
async def _get_station_codes(self) -> list[dict[str, str]]:
|
||||
"""Get station codes, cached after first call."""
|
||||
if self._station_codes is None:
|
||||
self._station_codes = await get_ec_sites_list()
|
||||
return self._station_codes
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -80,9 +96,21 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=info[CONF_TITLE], data=user_input)
|
||||
|
||||
station_codes = await self._get_station_codes()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_STATION): str,
|
||||
vol.Optional(CONF_STATION): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=station["value"], label=station["label"]
|
||||
)
|
||||
for station in station_codes
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
): cv.latitude,
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env-canada==0.11.3"]
|
||||
"requirements": ["env-canada==0.12.1"]
|
||||
}
|
||||
|
@@ -3,11 +3,11 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Environment Canada: weather location and language",
|
||||
"description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.",
|
||||
"description": "Select a weather station from the dropdown, or specify coordinates to use the closest station. The default coordinates are from your Home Assistant installation. Weather information can be retrieved in English or French.",
|
||||
"data": {
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"station": "Weather station ID",
|
||||
"station": "Weather station",
|
||||
"language": "Weather information language"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_station_id": "Station ID is invalid, missing, or not found in the station ID database",
|
||||
"bad_station_id": "Station code is invalid, missing, or not found in the station code database",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"error_response": "Response from Environment Canada in error",
|
||||
"too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds",
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/epson",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["epson_projector"],
|
||||
"requirements": ["epson-projector==0.5.1"]
|
||||
"requirements": ["epson-projector==0.6.0"]
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ from aioesphomeapi import (
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
ResolveAPIError,
|
||||
wifi_mac_to_bluetooth_mac,
|
||||
)
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
@@ -37,6 +38,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowResultType
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.importlib import async_import_module
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
@@ -317,6 +319,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check if already configured
|
||||
await self.async_set_unique_id(mac_address)
|
||||
|
||||
# Convert WiFi MAC to Bluetooth MAC and notify Improv BLE if waiting
|
||||
# ESPHome devices use WiFi MAC + 1 for Bluetooth MAC
|
||||
# Late import to avoid circular dependency
|
||||
# NOTE: Do not change to hass.config.components check - improv_ble is
|
||||
# config_flow only and may not be in the components registry
|
||||
if improv_ble := await async_import_module(
|
||||
self.hass, "homeassistant.components.improv_ble"
|
||||
):
|
||||
ble_mac = wifi_mac_to_bluetooth_mac(mac_address)
|
||||
improv_ble.async_register_next_flow(self.hass, ble_mac, self.flow_id)
|
||||
_LOGGER.debug(
|
||||
"Notified Improv BLE of flow %s for BLE MAC %s (derived from WiFi MAC %s)",
|
||||
self.flow_id,
|
||||
ble_mac,
|
||||
mac_address,
|
||||
)
|
||||
|
||||
await self._async_validate_mac_abort_configured(
|
||||
mac_address, self._host, self._port
|
||||
)
|
||||
@@ -500,6 +520,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle creating a new entry by removing the old one and creating new."""
|
||||
assert self._entry_with_name_conflict is not None
|
||||
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
|
||||
return self.async_update_reload_and_abort(
|
||||
self._entry_with_name_conflict,
|
||||
title=self._name,
|
||||
unique_id=self.unique_id,
|
||||
data=self._async_make_config_data(),
|
||||
options={
|
||||
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
},
|
||||
)
|
||||
await self.hass.config_entries.async_remove(
|
||||
self._entry_with_name_conflict.entry_id
|
||||
)
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.13.0",
|
||||
"aioesphomeapi==42.0.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
@@ -2,14 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Category
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .const import DOMAIN, MANUFACTURER, NAME
|
||||
from .coordinator import FireflyDataUpdateCoordinator
|
||||
|
||||
|
||||
@@ -21,20 +21,65 @@ class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Firefly entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=NAME,
|
||||
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
|
||||
identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_service")},
|
||||
)
|
||||
|
||||
|
||||
class FireflyAccountBaseEntity(FireflyBaseEntity):
|
||||
"""Base class for Firefly III account entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize a Firefly account entity."""
|
||||
super().__init__(coordinator)
|
||||
self._account = account
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=account.attributes.name,
|
||||
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/accounts/show/{account.id}",
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
|
||||
)
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_account_{account.id}")
|
||||
},
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_account_{account.id}_{key}"
|
||||
)
|
||||
|
||||
|
||||
class FireflyCategoryBaseEntity(FireflyBaseEntity):
|
||||
"""Base class for Firefly III category entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
category: Category,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize a Firefly category entity."""
|
||||
super().__init__(coordinator)
|
||||
self._category = category
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=category.attributes.name,
|
||||
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/categories/show/{category.id}",
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_category_{category.id}")
|
||||
},
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_category_{category.id}_{key}"
|
||||
)
|
||||
|
@@ -2,13 +2,13 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"account_type": {
|
||||
"default": "mdi:bank",
|
||||
"state": {
|
||||
"expense": "mdi:cash-minus",
|
||||
"revenue": "mdi:cash-plus",
|
||||
"asset": "mdi:account-cash",
|
||||
"liability": "mdi:hand-coin"
|
||||
}
|
||||
"default": "mdi:bank"
|
||||
},
|
||||
"account_balance": {
|
||||
"default": "mdi:currency-usd"
|
||||
},
|
||||
"account_role": {
|
||||
"default": "mdi:account-circle"
|
||||
},
|
||||
"category": {
|
||||
"default": "mdi:label"
|
||||
|
@@ -4,35 +4,33 @@ from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Category
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass, StateType
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
||||
from .entity import FireflyBaseEntity
|
||||
from .entity import FireflyAccountBaseEntity, FireflyCategoryBaseEntity
|
||||
|
||||
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="account_type",
|
||||
translation_key="account",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
ACCOUNT_ROLE_MAPPING = {
|
||||
"defaultAsset": "default_asset",
|
||||
"sharedAsset": "shared_asset",
|
||||
"savingAsset": "saving_asset",
|
||||
"ccAsset": "cc_asset",
|
||||
"cashWalletAsset": "cash_wallet_asset",
|
||||
}
|
||||
ACCOUNT_TYPE_ICONS = {
|
||||
"expense": "mdi:cash-minus",
|
||||
"asset": "mdi:account-cash",
|
||||
"revenue": "mdi:cash-plus",
|
||||
"liability": "mdi:hand-coin",
|
||||
}
|
||||
|
||||
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="category",
|
||||
translation_key="category",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
ACCOUNT_BALANCE = "account_balance"
|
||||
ACCOUNT_ROLE = "account_role"
|
||||
ACCOUNT_TYPE = "account_type"
|
||||
CATEGORY = "category"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -40,94 +38,137 @@ async def async_setup_entry(
|
||||
entry: FireflyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Firefly III sensor platform."""
|
||||
"""Set up Firefly III sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[SensorEntity] = [
|
||||
FireflyAccountEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
account=account,
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
for account in coordinator.data.accounts:
|
||||
entities.append(
|
||||
FireflyAccountBalanceSensor(coordinator, account, ACCOUNT_BALANCE)
|
||||
)
|
||||
for account in coordinator.data.accounts
|
||||
for description in ACCOUNT_SENSORS
|
||||
]
|
||||
entities.append(FireflyAccountRoleSensor(coordinator, account, ACCOUNT_ROLE))
|
||||
entities.append(FireflyAccountTypeSensor(coordinator, account, ACCOUNT_TYPE))
|
||||
|
||||
entities.extend(
|
||||
FireflyCategoryEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
category=category,
|
||||
)
|
||||
for category in coordinator.data.category_details
|
||||
for description in CATEGORY_SENSORS
|
||||
[
|
||||
FireflyCategorySensor(coordinator, category, CATEGORY)
|
||||
for category in coordinator.data.category_details
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III account."""
|
||||
class FireflyAccountBalanceSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account balance sensor."""
|
||||
|
||||
_attr_translation_key = "account_balance"
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize Firefly account entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
"""Initialize the account balance sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
self._account = account
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
|
||||
self._attr_name = account.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
# Account type state doesn't go well with the icons.json. Need to fix it.
|
||||
if account.attributes.type == "expense":
|
||||
self._attr_icon = "mdi:cash-minus"
|
||||
elif account.attributes.type == "asset":
|
||||
self._attr_icon = "mdi:account-cash"
|
||||
elif account.attributes.type == "revenue":
|
||||
self._attr_icon = "mdi:cash-plus"
|
||||
elif account.attributes.type == "liability":
|
||||
self._attr_icon = "mdi:hand-coin"
|
||||
else:
|
||||
self._attr_icon = "mdi:bank"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
def native_value(self) -> StateType:
|
||||
"""Return current account balance."""
|
||||
return self._account.attributes.current_balance
|
||||
|
||||
|
||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III category."""
|
||||
class FireflyAccountRoleSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account role diagnostic sensor."""
|
||||
|
||||
_attr_translation_key = "account_role"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
category: Category,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize Firefly category entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
"""Initialize the account role sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
self._account = account
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return account role."""
|
||||
|
||||
# An account can be empty and then should resort to Unknown
|
||||
account_role: str | None = self._account.attributes.account_role
|
||||
if account_role is None:
|
||||
return None
|
||||
|
||||
return ACCOUNT_ROLE_MAPPING.get(account_role, account_role)
|
||||
|
||||
|
||||
class FireflyAccountTypeSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account type diagnostic sensor."""
|
||||
|
||||
_attr_translation_key = "account_type"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the account type sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
acc_type = account.attributes.type
|
||||
self._attr_icon = (
|
||||
ACCOUNT_TYPE_ICONS.get(acc_type, "mdi:bank")
|
||||
if acc_type is not None
|
||||
else "mdi:bank"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return account type."""
|
||||
return self._account.attributes.type
|
||||
|
||||
|
||||
class FireflyCategorySensor(FireflyCategoryBaseEntity, SensorEntity):
|
||||
"""Category sensor."""
|
||||
|
||||
_attr_translation_key = "category"
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
category: Category,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the category sensor."""
|
||||
super().__init__(coordinator, category, key)
|
||||
self._category = category
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
|
||||
self._attr_name = category.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
def native_value(self) -> StateType:
|
||||
"""Return net spent+earned value for this category in the period."""
|
||||
spent_items = self._category.attributes.spent or []
|
||||
earned_items = self._category.attributes.earned or []
|
||||
|
||||
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
|
||||
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
|
||||
|
||||
if spent == 0 and earned == 0:
|
||||
return None
|
||||
return spent + earned
|
||||
|
@@ -45,5 +45,34 @@
|
||||
"timeout_connect": {
|
||||
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"account_balance": {
|
||||
"name": "Account Balance"
|
||||
},
|
||||
"account_role": {
|
||||
"name": "Account Role",
|
||||
"state": {
|
||||
"default_asset": "Default asset",
|
||||
"shared_asset": "Shared asset",
|
||||
"saving_asset": "Saving asset",
|
||||
"cc_asset": "Credit card asset",
|
||||
"cash_wallet_asset": "Cash wallet asset"
|
||||
}
|
||||
},
|
||||
"account_type": {
|
||||
"name": "Account Type",
|
||||
"state": {
|
||||
"asset": "Asset",
|
||||
"expense": "Expense",
|
||||
"revenue": "Revenue",
|
||||
"liability": "Liability"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"name": "Earned/Spent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -35,9 +35,16 @@ class FoscamDeviceInfo:
|
||||
is_turn_off_volume: bool
|
||||
is_turn_off_light: bool
|
||||
supports_speak_volume_adjustment: bool
|
||||
supports_pet_adjustment: bool
|
||||
supports_car_adjustment: bool
|
||||
supports_wdr_adjustment: bool
|
||||
supports_hdr_adjustment: bool
|
||||
|
||||
is_open_wdr: bool | None = None
|
||||
is_open_hdr: bool | None = None
|
||||
is_pet_detection_on: bool | None = None
|
||||
is_car_detection_on: bool | None = None
|
||||
is_human_detection_on: bool | None = None
|
||||
|
||||
|
||||
class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
@@ -107,14 +114,15 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
|
||||
is_open_wdr = None
|
||||
is_open_hdr = None
|
||||
reserve3 = product_info.get("reserve3")
|
||||
reserve3 = product_info.get("reserve4")
|
||||
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||
|
||||
if (reserve3_int & (1 << 8)) != 0:
|
||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||
if supports_wdr_adjustment_val:
|
||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
||||
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||
is_open_wdr = bool(int(mode))
|
||||
else:
|
||||
elif supports_hdr_adjustment_val:
|
||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
||||
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
||||
is_open_hdr = bool(int(mode))
|
||||
@@ -126,6 +134,34 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
if ret_sw == 0
|
||||
else False
|
||||
)
|
||||
pet_adjustment_val = (
|
||||
bool(int(software_capabilities.get("swCapabilities2")) & 512)
|
||||
if ret_sw == 0
|
||||
else False
|
||||
)
|
||||
car_adjustment_val = (
|
||||
bool(int(software_capabilities.get("swCapabilities2")) & 256)
|
||||
if ret_sw == 0
|
||||
else False
|
||||
)
|
||||
ret_md, mothion_config_val = self.session.get_motion_detect_config()
|
||||
if pet_adjustment_val:
|
||||
is_pet_detection_on_val = (
|
||||
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False
|
||||
)
|
||||
else:
|
||||
is_pet_detection_on_val = False
|
||||
|
||||
if car_adjustment_val:
|
||||
is_car_detection_on_val = (
|
||||
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False
|
||||
)
|
||||
else:
|
||||
is_car_detection_on_val = False
|
||||
|
||||
is_human_detection_on_val = (
|
||||
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
|
||||
)
|
||||
|
||||
return FoscamDeviceInfo(
|
||||
dev_info=dev_info,
|
||||
@@ -141,8 +177,15 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
is_turn_off_volume=is_turn_off_volume_val,
|
||||
is_turn_off_light=is_turn_off_light_val,
|
||||
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
||||
supports_pet_adjustment=pet_adjustment_val,
|
||||
supports_car_adjustment=car_adjustment_val,
|
||||
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
||||
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
||||
is_open_wdr=is_open_wdr,
|
||||
is_open_hdr=is_open_hdr,
|
||||
is_pet_detection_on=is_pet_detection_on_val,
|
||||
is_car_detection_on=is_car_detection_on_val,
|
||||
is_human_detection_on=is_human_detection_on_val,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> FoscamDeviceInfo:
|
||||
|
@@ -38,6 +38,15 @@
|
||||
},
|
||||
"wdr_switch": {
|
||||
"default": "mdi:alpha-w-box"
|
||||
},
|
||||
"pet_detection": {
|
||||
"default": "mdi:paw"
|
||||
},
|
||||
"car_detection": {
|
||||
"default": "mdi:car-hatchback"
|
||||
},
|
||||
"human_detection": {
|
||||
"default": "mdi:human"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["libpyfoscamcgi"],
|
||||
"requirements": ["libpyfoscamcgi==0.0.7"]
|
||||
"requirements": ["libpyfoscamcgi==0.0.8"]
|
||||
}
|
||||
|
@@ -22,7 +22,7 @@ class FoscamNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
native_value_fn: Callable[[FoscamCoordinator], int]
|
||||
set_value_fn: Callable[[FoscamCamera, float], Any]
|
||||
exists_fn: Callable[[FoscamCoordinator], bool]
|
||||
exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
|
||||
@@ -34,7 +34,6 @@ NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
|
||||
native_step=1,
|
||||
native_value_fn=lambda coordinator: coordinator.data.device_volume,
|
||||
set_value_fn=lambda session, value: session.setAudioVolume(value),
|
||||
exists_fn=lambda _: True,
|
||||
),
|
||||
FoscamNumberEntityDescription(
|
||||
key="speak_volume",
|
||||
|
@@ -61,6 +61,15 @@
|
||||
},
|
||||
"wdr_switch": {
|
||||
"name": "WDR"
|
||||
},
|
||||
"pet_detection": {
|
||||
"name": "Pet detection"
|
||||
},
|
||||
"car_detection": {
|
||||
"name": "Car detection"
|
||||
},
|
||||
"human_detection": {
|
||||
"name": "Human detection"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
@@ -30,6 +30,14 @@ def handle_ir_turn_off(session: FoscamCamera) -> None:
|
||||
session.close_infra_led()
|
||||
|
||||
|
||||
def set_motion_detection(session: FoscamCamera, field: str, enabled: bool) -> None:
|
||||
"""Turns on pet detection."""
|
||||
ret, config = session.get_motion_detect_config()
|
||||
if not ret:
|
||||
config[field] = int(enabled)
|
||||
session.set_motion_detect_config(config)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FoscamSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""A custom entity description that supports a turn_off function."""
|
||||
@@ -37,6 +45,7 @@ class FoscamSwitchEntityDescription(SwitchEntityDescription):
|
||||
native_value_fn: Callable[..., bool]
|
||||
turn_off_fn: Callable[[FoscamCamera], None]
|
||||
turn_on_fn: Callable[[FoscamCamera], None]
|
||||
exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True
|
||||
|
||||
|
||||
SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
||||
@@ -102,6 +111,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
||||
native_value_fn=lambda data: data.is_open_hdr,
|
||||
turn_off_fn=lambda session: session.setHdrMode(0),
|
||||
turn_on_fn=lambda session: session.setHdrMode(1),
|
||||
exists_fn=lambda coordinator: coordinator.data.supports_hdr_adjustment,
|
||||
),
|
||||
FoscamSwitchEntityDescription(
|
||||
key="is_open_wdr",
|
||||
@@ -109,6 +119,30 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
||||
native_value_fn=lambda data: data.is_open_wdr,
|
||||
turn_off_fn=lambda session: session.setWdrMode(0),
|
||||
turn_on_fn=lambda session: session.setWdrMode(1),
|
||||
exists_fn=lambda coordinator: coordinator.data.supports_wdr_adjustment,
|
||||
),
|
||||
FoscamSwitchEntityDescription(
|
||||
key="pet_detection",
|
||||
translation_key="pet_detection",
|
||||
native_value_fn=lambda data: data.is_pet_detection_on,
|
||||
turn_off_fn=lambda session: set_motion_detection(session, "petEnable", False),
|
||||
turn_on_fn=lambda session: set_motion_detection(session, "petEnable", True),
|
||||
exists_fn=lambda coordinator: coordinator.data.supports_pet_adjustment,
|
||||
),
|
||||
FoscamSwitchEntityDescription(
|
||||
key="car_detection",
|
||||
translation_key="car_detection",
|
||||
native_value_fn=lambda data: data.is_car_detection_on,
|
||||
turn_off_fn=lambda session: set_motion_detection(session, "carEnable", False),
|
||||
turn_on_fn=lambda session: set_motion_detection(session, "carEnable", True),
|
||||
exists_fn=lambda coordinator: coordinator.data.supports_car_adjustment,
|
||||
),
|
||||
FoscamSwitchEntityDescription(
|
||||
key="human_detection",
|
||||
translation_key="human_detection",
|
||||
native_value_fn=lambda data: data.is_human_detection_on,
|
||||
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
||||
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -122,24 +156,11 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities = []
|
||||
|
||||
product_info = coordinator.data.product_info
|
||||
reserve3 = product_info.get("reserve3", "0")
|
||||
|
||||
for description in SWITCH_DESCRIPTIONS:
|
||||
if description.key == "is_asleep":
|
||||
if not coordinator.data.is_asleep["supported"]:
|
||||
continue
|
||||
elif description.key == "is_open_hdr":
|
||||
if ((1 << 8) & int(reserve3)) != 0 or ((1 << 7) & int(reserve3)) == 0:
|
||||
continue
|
||||
elif description.key == "is_open_wdr":
|
||||
if ((1 << 8) & int(reserve3)) == 0:
|
||||
continue
|
||||
|
||||
entities.append(FoscamGenericSwitch(coordinator, description))
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
FoscamGenericSwitch(coordinator, description)
|
||||
for description in SWITCH_DESCRIPTIONS
|
||||
if description.exists_fn(coordinator)
|
||||
)
|
||||
|
||||
|
||||
class FoscamGenericSwitch(FoscamEntity, SwitchEntity):
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -55,7 +55,7 @@ HostAttributes = TypedDict(
|
||||
"X_AVM-DE_Guest": bool,
|
||||
"X_AVM-DE_RequestClient": str,
|
||||
"X_AVM-DE_VPN": bool,
|
||||
"X_AVM-DE_WANAccess": str,
|
||||
"X_AVM-DE_WANAccess": NotRequired[str],
|
||||
"X_AVM-DE_Disallow": bool,
|
||||
"X_AVM-DE_IsMeshable": str,
|
||||
"X_AVM-DE_Priority": str,
|
||||
|
@@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
||||
|
||||
async_register_built_in_panel(hass, "light")
|
||||
async_register_built_in_panel(hass, "security")
|
||||
async_register_built_in_panel(hass, "safety")
|
||||
async_register_built_in_panel(hass, "climate")
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
@@ -191,7 +191,9 @@ async def async_test_still(
|
||||
try:
|
||||
async_client = get_async_client(hass, verify_ssl=verify_ssl)
|
||||
async with asyncio.timeout(GET_IMAGE_TIMEOUT):
|
||||
response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT)
|
||||
response = await async_client.get(
|
||||
url, auth=auth, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True
|
||||
)
|
||||
response.raise_for_status()
|
||||
image = response.content
|
||||
except (
|
||||
|
@@ -282,6 +282,7 @@ class CoverGroup(GroupEntity, CoverEntity):
|
||||
self._attr_is_closed = True
|
||||
self._attr_is_closing = False
|
||||
self._attr_is_opening = False
|
||||
self._update_assumed_state_from_members()
|
||||
for entity_id in self._entity_ids:
|
||||
if not (state := self.hass.states.get(entity_id)):
|
||||
continue
|
||||
|
@@ -115,6 +115,17 @@ class GroupEntity(Entity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Abstract method to update the entity."""
|
||||
|
||||
@callback
|
||||
def _update_assumed_state_from_members(self) -> None:
|
||||
"""Update assumed_state based on member entities."""
|
||||
self._attr_assumed_state = False
|
||||
for entity_id in self._entity_ids:
|
||||
if (state := self.hass.states.get(entity_id)) is None:
|
||||
continue
|
||||
if state.attributes.get(ATTR_ASSUMED_STATE):
|
||||
self._attr_assumed_state = True
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_update_supported_features(
|
||||
self,
|
||||
|
@@ -252,6 +252,7 @@ class FanGroup(GroupEntity, FanEntity):
|
||||
@callback
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Update state and attributes."""
|
||||
self._update_assumed_state_from_members()
|
||||
|
||||
states = [
|
||||
state
|
||||
|
@@ -205,6 +205,8 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
@callback
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the light group state."""
|
||||
self._update_assumed_state_from_members()
|
||||
|
||||
states = [
|
||||
state
|
||||
for entity_id in self._entity_ids
|
||||
|
@@ -156,6 +156,8 @@ class SwitchGroup(GroupEntity, SwitchEntity):
|
||||
@callback
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the switch group state."""
|
||||
self._update_assumed_state_from_members()
|
||||
|
||||
states = [
|
||||
state.state
|
||||
for entity_id in self._entity_ids
|
||||
|
@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
|
||||
|
||||
DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
|
@@ -210,6 +210,15 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_solar_generation_today",
|
||||
translation_key="tlx_solar_generation_today",
|
||||
api_key="epvToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_solar_generation_total",
|
||||
translation_key="tlx_solar_generation_total",
|
||||
@@ -430,4 +439,120 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_pac_to_local_load",
|
||||
translation_key="tlx_pac_to_local_load",
|
||||
api_key="pacToLocalLoad",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_pac_to_user_total",
|
||||
translation_key="tlx_pac_to_user_total",
|
||||
api_key="pacToUserTotal",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_pac_to_grid_total",
|
||||
translation_key="tlx_pac_to_grid_total",
|
||||
api_key="pacToGridTotal",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_system_production_today",
|
||||
translation_key="tlx_system_production_today",
|
||||
api_key="esystemToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_system_production_total",
|
||||
translation_key="tlx_system_production_total",
|
||||
api_key="esystemTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_self_consumption_today",
|
||||
translation_key="tlx_self_consumption_today",
|
||||
api_key="eselfToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_self_consumption_total",
|
||||
translation_key="tlx_self_consumption_total",
|
||||
api_key="eselfTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_import_from_grid_today",
|
||||
translation_key="tlx_import_from_grid_today",
|
||||
api_key="etoUserToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_import_from_grid_total",
|
||||
translation_key="tlx_import_from_grid_total",
|
||||
api_key="etoUserTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_batteries_charged_from_grid_today",
|
||||
translation_key="tlx_batteries_charged_from_grid_today",
|
||||
api_key="eacChargeToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_batteries_charged_from_grid_total",
|
||||
translation_key="tlx_batteries_charged_from_grid_total",
|
||||
api_key="eacChargeTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_p_system",
|
||||
translation_key="tlx_p_system",
|
||||
api_key="psystem",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_p_self",
|
||||
translation_key="tlx_p_self",
|
||||
api_key="pself",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
precision=1,
|
||||
),
|
||||
)
|
||||
|
@@ -362,6 +362,9 @@
|
||||
"tlx_wattage_input_4": {
|
||||
"name": "Input 4 wattage"
|
||||
},
|
||||
"tlx_solar_generation_today": {
|
||||
"name": "Solar energy today"
|
||||
},
|
||||
"tlx_solar_generation_total": {
|
||||
"name": "Lifetime total solar energy"
|
||||
},
|
||||
@@ -443,6 +446,45 @@
|
||||
"tlx_statement_of_charge": {
|
||||
"name": "State of charge (SoC)"
|
||||
},
|
||||
"tlx_pac_to_local_load": {
|
||||
"name": "Local load power"
|
||||
},
|
||||
"tlx_pac_to_user_total": {
|
||||
"name": "Import power"
|
||||
},
|
||||
"tlx_pac_to_grid_total": {
|
||||
"name": "Export power"
|
||||
},
|
||||
"tlx_system_production_today": {
|
||||
"name": "System production today"
|
||||
},
|
||||
"tlx_system_production_total": {
|
||||
"name": "Lifetime system production"
|
||||
},
|
||||
"tlx_self_consumption_today": {
|
||||
"name": "Self consumption today"
|
||||
},
|
||||
"tlx_self_consumption_total": {
|
||||
"name": "Lifetime self consumption"
|
||||
},
|
||||
"tlx_import_from_grid_today": {
|
||||
"name": "Import from grid today"
|
||||
},
|
||||
"tlx_import_from_grid_total": {
|
||||
"name": "Lifetime import from grid"
|
||||
},
|
||||
"tlx_batteries_charged_from_grid_today": {
|
||||
"name": "Batteries charged from grid today"
|
||||
},
|
||||
"tlx_batteries_charged_from_grid_total": {
|
||||
"name": "Lifetime batteries charged from grid"
|
||||
},
|
||||
"tlx_p_system": {
|
||||
"name": "System power"
|
||||
},
|
||||
"tlx_p_self": {
|
||||
"name": "Self power"
|
||||
},
|
||||
"total_money_today": {
|
||||
"name": "Total money today"
|
||||
},
|
||||
@@ -461,6 +503,11 @@
|
||||
"total_maximum_output": {
|
||||
"name": "Maximum power"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ac_charge": {
|
||||
"name": "Charge from grid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
138
homeassistant/components/growatt_server/switch.py
Normal file
138
homeassistant/components/growatt_server/switch.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Switch platform for Growatt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from growattServer import GrowattV1ApiError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = (
|
||||
1 # Serialize updates as inverter does not handle concurrent requests
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin):
|
||||
"""Describes Growatt switch entity."""
|
||||
|
||||
write_key: str | None = None # Parameter ID for writing (if different from api_key)
|
||||
|
||||
|
||||
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
|
||||
# Reading values returns camelCase keys, while writing requires snake_case keys.
|
||||
|
||||
MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = (
|
||||
GrowattSwitchEntityDescription(
|
||||
key="ac_charge",
|
||||
translation_key="ac_charge",
|
||||
api_key="acChargeEnable", # Key returned by V1 API
|
||||
write_key="ac_charge", # Key used to write parameter
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GrowattConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Growatt switch entities."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
# Add switch entities for each MIN device (only supported with V1 API)
|
||||
async_add_entities(
|
||||
GrowattSwitch(device_coordinator, description)
|
||||
for device_coordinator in runtime_data.devices.values()
|
||||
if (
|
||||
device_coordinator.device_type == "min"
|
||||
and device_coordinator.api_version == "v1"
|
||||
)
|
||||
for description in MIN_SWITCH_TYPES
|
||||
)
|
||||
|
||||
|
||||
class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
|
||||
"""Representation of a Growatt switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: GrowattSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GrowattCoordinator,
|
||||
description: GrowattSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the switch is on."""
|
||||
value = self.coordinator.data.get(self.entity_description.api_key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# API returns integer 1 for enabled, 0 for disabled
|
||||
return bool(value)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._async_set_state(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._async_set_state(False)
|
||||
|
||||
async def _async_set_state(self, state: bool) -> None:
|
||||
"""Set the switch state."""
|
||||
# Use write_key if specified, otherwise fall back to api_key
|
||||
parameter_id = (
|
||||
self.entity_description.write_key or self.entity_description.api_key
|
||||
)
|
||||
api_value = int(state)
|
||||
|
||||
try:
|
||||
# Use V1 API to write parameter
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.min_write_parameter,
|
||||
self.coordinator.device_id,
|
||||
parameter_id,
|
||||
api_value,
|
||||
)
|
||||
except GrowattV1ApiError as e:
|
||||
raise HomeAssistantError(f"Error while setting switch state: {e}") from e
|
||||
|
||||
# If no exception was raised, the write was successful
|
||||
_LOGGER.debug(
|
||||
"Set switch %s to %s",
|
||||
parameter_id,
|
||||
api_value,
|
||||
)
|
||||
|
||||
# Update the value in coordinator data (keep as integer like API returns)
|
||||
self.coordinator.data[self.entity_description.api_key] = api_value
|
||||
self.async_write_ha_state()
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
@@ -281,7 +282,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
math.inf
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
|
||||
else tasks_order.index(uid)
|
||||
@@ -367,7 +368,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
math.inf
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
|
||||
else tasks_order.index(uid)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.hardware.models import BoardInfo, HardwareInfo
|
||||
from homeassistant.components.hardware import BoardInfo, HardwareInfo
|
||||
from homeassistant.components.hassio import get_os_info
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
@@ -11,7 +11,13 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from . import websocket_api
|
||||
from .const import DATA_HARDWARE, DOMAIN
|
||||
from .hardware import async_process_hardware_platforms
|
||||
from .models import HardwareData, SystemStatus
|
||||
from .models import BoardInfo, HardwareData, HardwareInfo, SystemStatus, USBInfo
|
||||
|
||||
__all__ = [
|
||||
"BoardInfo",
|
||||
"HardwareInfo",
|
||||
"USBInfo",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
@@ -1,15 +1,20 @@
|
||||
"""The Logitech Harmony Hub integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
if sys.version_info < (3, 14):
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +25,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HarmonyConfigEntry) -> b
|
||||
# when setting up a config entry, we fallback to adding
|
||||
# the options to the config entry and pull them out here if
|
||||
# they are missing from the options
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Logitech Harmony Hub is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
_async_import_options_from_data_if_missing(hass, entry)
|
||||
|
||||
address = entry.data[CONF_HOST]
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioharmony", "slixmpp"],
|
||||
"requirements": ["aioharmony==0.5.3"],
|
||||
"requirements": ["aioharmony==0.5.3;python_version<'3.14'"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Logitech",
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyheos==1.0.5"],
|
||||
"requirements": ["pyheos==1.0.6"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||
|
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.20.0"],
|
||||
"requirements": ["aiohomeconnect==0.22.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
DOMAIN = ha.DOMAIN
|
||||
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
|
||||
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP: Final = "stop"
|
||||
|
@@ -7,11 +7,18 @@ from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.homeassistant_hardware import firmware_config_flow
|
||||
from homeassistant.components.homeassistant_hardware.helpers import (
|
||||
HardwareFirmwareDiscoveryInfo,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.usb import (
|
||||
usb_service_info_from_device,
|
||||
usb_unique_id_from_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
@@ -123,22 +130,16 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
|
||||
"""Handle usb discovery."""
|
||||
device = discovery_info.device
|
||||
vid = discovery_info.vid
|
||||
pid = discovery_info.pid
|
||||
serial_number = discovery_info.serial_number
|
||||
manufacturer = discovery_info.manufacturer
|
||||
description = discovery_info.description
|
||||
unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
|
||||
unique_id = usb_unique_id_from_service_info(discovery_info)
|
||||
|
||||
device = discovery_info.device = await self.hass.async_add_executor_job(
|
||||
discovery_info.device = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, discovery_info.device
|
||||
)
|
||||
|
||||
try:
|
||||
await self.async_set_unique_id(unique_id)
|
||||
finally:
|
||||
self._abort_if_unique_id_configured(updates={DEVICE: device})
|
||||
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device})
|
||||
|
||||
self._usb_info = discovery_info
|
||||
|
||||
@@ -148,6 +149,24 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_import(
|
||||
self, fw_discovery_info: HardwareFirmwareDiscoveryInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle import from ZHA/OTBR firmware notification."""
|
||||
assert fw_discovery_info["usb_device"] is not None
|
||||
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
|
||||
unique_id = usb_unique_id_from_service_info(usb_info)
|
||||
|
||||
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
|
||||
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
|
||||
|
||||
self._usb_info = usb_info
|
||||
self._device = usb_info.device
|
||||
self._hardware_name = HARDWARE_NAME
|
||||
self._probed_firmware_info = fw_discovery_info["firmware_info"]
|
||||
|
||||
return self._async_flow_finished()
|
||||
|
||||
def _async_flow_finished(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
assert self._usb_info is not None
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user