mirror of
https://github.com/home-assistant/core.git
synced 2025-09-23 20:09:35 +00:00
Compare commits
122 Commits
frontend-d
...
2023.3.2
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3dca4c2f23 | ||
![]() |
3f8f38f2df | ||
![]() |
0844a0b269 | ||
![]() |
b65180d20a | ||
![]() |
7f8a9697f0 | ||
![]() |
563bd4a0dd | ||
![]() |
29b5ef31c1 | ||
![]() |
863f8b727d | ||
![]() |
83ed8cf689 | ||
![]() |
52cd2f9429 | ||
![]() |
74d3b2374b | ||
![]() |
f982af2412 | ||
![]() |
0b5ddd9cbf | ||
![]() |
8d1aa0132e | ||
![]() |
d737b97c91 | ||
![]() |
0fac12866d | ||
![]() |
e3fe71f76e | ||
![]() |
eba1bfad51 | ||
![]() |
1a0a385e03 | ||
![]() |
c9999cd08c | ||
![]() |
8252aeead2 | ||
![]() |
c27a69ef85 | ||
![]() |
d4c28a1f4a | ||
![]() |
322eb4bd83 | ||
![]() |
f0f12fd14a | ||
![]() |
1836e35717 | ||
![]() |
4eb55146be | ||
![]() |
b1ee6e304e | ||
![]() |
d0b195516b | ||
![]() |
a867f1d3c8 | ||
![]() |
f7eaeb7a39 | ||
![]() |
3e961d3e17 | ||
![]() |
c28e16fa8b | ||
![]() |
e2e8d74aa6 | ||
![]() |
8a9fbd650a | ||
![]() |
243725efe3 | ||
![]() |
8d59489da8 | ||
![]() |
c146413a1a | ||
![]() |
a46d63a11b | ||
![]() |
db4f6fb94d | ||
![]() |
c50c920589 | ||
![]() |
fe22aa0b4b | ||
![]() |
a0162e4986 | ||
![]() |
62c5cf51f5 | ||
![]() |
89aebba3ab | ||
![]() |
6c73b9024b | ||
![]() |
59a9ace171 | ||
![]() |
e751948bc8 | ||
![]() |
702646427d | ||
![]() |
8a605b1377 | ||
![]() |
8eb8415d3f | ||
![]() |
9f3f71d0c3 | ||
![]() |
b82da9418d | ||
![]() |
38cf725075 | ||
![]() |
04cedab8d4 | ||
![]() |
2238a3f201 | ||
![]() |
f58ca17926 | ||
![]() |
d5e517b874 | ||
![]() |
f9eeb4f4d8 | ||
![]() |
86d5e4aaa8 | ||
![]() |
a56935ed7c | ||
![]() |
fc56c958c3 | ||
![]() |
a8e1dc8962 | ||
![]() |
32b138b6c6 | ||
![]() |
2112c66804 | ||
![]() |
72c0526d87 | ||
![]() |
9ed4e01e94 | ||
![]() |
dcf1ecfeb5 | ||
![]() |
b72224ceff | ||
![]() |
96ad5c9666 | ||
![]() |
00b59c142a | ||
![]() |
b054c81e13 | ||
![]() |
b0cbcad440 | ||
![]() |
bafe552af6 | ||
![]() |
d399855e50 | ||
![]() |
d26f430766 | ||
![]() |
f2e4943a53 | ||
![]() |
6512cd901f | ||
![]() |
fbe1524f6c | ||
![]() |
95e337277c | ||
![]() |
1503674bd6 | ||
![]() |
ab6bd75b70 | ||
![]() |
2fff836bd4 | ||
![]() |
d8850758f1 | ||
![]() |
0449856064 | ||
![]() |
e48089e0c9 | ||
![]() |
a7e081f70d | ||
![]() |
fe181425d8 | ||
![]() |
8c7b29db25 | ||
![]() |
aaa5bb9f86 | ||
![]() |
5b78e0c4ff | ||
![]() |
2063dbf00d | ||
![]() |
91a03ab83d | ||
![]() |
ed8f538890 | ||
![]() |
6196607c5d | ||
![]() |
833ccafb76 | ||
![]() |
ca539d0a09 | ||
![]() |
0e3e954000 | ||
![]() |
4ef96c76e4 | ||
![]() |
d5b0c1faa0 | ||
![]() |
2405908cdd | ||
![]() |
b6e50135f5 | ||
![]() |
64197aa5f5 | ||
![]() |
5a2d7a5dd4 | ||
![]() |
2d6f84b2a8 | ||
![]() |
0c6a469218 | ||
![]() |
e69271cb46 | ||
![]() |
02bd3f897d | ||
![]() |
64ad5326dd | ||
![]() |
74696a3fac | ||
![]() |
70e1d14da0 | ||
![]() |
25f066d476 | ||
![]() |
5adf1dcc90 | ||
![]() |
0fb28dcf9e | ||
![]() |
2fddbcedcf | ||
![]() |
951df3df57 | ||
![]() |
35142e456a | ||
![]() |
cfaba87dd6 | ||
![]() |
2db8d4b73a | ||
![]() |
0d2006bf33 | ||
![]() |
45547d226e | ||
![]() |
cebc6dd096 |
@@ -639,10 +639,6 @@ omit =
|
|||||||
homeassistant/components/linode/*
|
homeassistant/components/linode/*
|
||||||
homeassistant/components/linux_battery/sensor.py
|
homeassistant/components/linux_battery/sensor.py
|
||||||
homeassistant/components/lirc/*
|
homeassistant/components/lirc/*
|
||||||
homeassistant/components/livisi/__init__.py
|
|
||||||
homeassistant/components/livisi/climate.py
|
|
||||||
homeassistant/components/livisi/coordinator.py
|
|
||||||
homeassistant/components/livisi/switch.py
|
|
||||||
homeassistant/components/llamalab_automate/notify.py
|
homeassistant/components/llamalab_automate/notify.py
|
||||||
homeassistant/components/logi_circle/__init__.py
|
homeassistant/components/logi_circle/__init__.py
|
||||||
homeassistant/components/logi_circle/camera.py
|
homeassistant/components/logi_circle/camera.py
|
||||||
@@ -807,8 +803,7 @@ omit =
|
|||||||
homeassistant/components/nuki/sensor.py
|
homeassistant/components/nuki/sensor.py
|
||||||
homeassistant/components/nx584/alarm_control_panel.py
|
homeassistant/components/nx584/alarm_control_panel.py
|
||||||
homeassistant/components/oasa_telematics/sensor.py
|
homeassistant/components/oasa_telematics/sensor.py
|
||||||
homeassistant/components/obihai/connectivity.py
|
homeassistant/components/obihai/*
|
||||||
homeassistant/components/obihai/sensor.py
|
|
||||||
homeassistant/components/octoprint/__init__.py
|
homeassistant/components/octoprint/__init__.py
|
||||||
homeassistant/components/oem/climate.py
|
homeassistant/components/oem/climate.py
|
||||||
homeassistant/components/ohmconnect/sensor.py
|
homeassistant/components/ohmconnect/sensor.py
|
||||||
|
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -31,7 +31,7 @@ env:
|
|||||||
CACHE_VERSION: 5
|
CACHE_VERSION: 5
|
||||||
PIP_CACHE_VERSION: 4
|
PIP_CACHE_VERSION: 4
|
||||||
MYPY_CACHE_VERSION: 4
|
MYPY_CACHE_VERSION: 4
|
||||||
HA_SHORT_VERSION: 2023.4
|
HA_SHORT_VERSION: 2023.3
|
||||||
DEFAULT_PYTHON: "3.10"
|
DEFAULT_PYTHON: "3.10"
|
||||||
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
|
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
|
||||||
# 10.3 is the oldest supported version
|
# 10.3 is the oldest supported version
|
||||||
@@ -1073,10 +1073,10 @@ jobs:
|
|||||||
ffmpeg \
|
ffmpeg \
|
||||||
postgresql-server-dev-14
|
postgresql-server-dev-14
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v3.3.0
|
uses: actions/checkout@v3.1.0
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v4.5.0
|
uses: actions/setup-python@v4.3.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
@@ -186,7 +186,6 @@ homeassistant.components.ld2410_ble.*
|
|||||||
homeassistant.components.lidarr.*
|
homeassistant.components.lidarr.*
|
||||||
homeassistant.components.lifx.*
|
homeassistant.components.lifx.*
|
||||||
homeassistant.components.light.*
|
homeassistant.components.light.*
|
||||||
homeassistant.components.litejet.*
|
|
||||||
homeassistant.components.litterrobot.*
|
homeassistant.components.litterrobot.*
|
||||||
homeassistant.components.local_ip.*
|
homeassistant.components.local_ip.*
|
||||||
homeassistant.components.lock.*
|
homeassistant.components.lock.*
|
||||||
|
@@ -825,8 +825,7 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||||
/homeassistant/components/nzbget/ @chriscla
|
/homeassistant/components/nzbget/ @chriscla
|
||||||
/tests/components/nzbget/ @chriscla
|
/tests/components/nzbget/ @chriscla
|
||||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
/homeassistant/components/obihai/ @dshokouhi
|
||||||
/tests/components/obihai/ @dshokouhi @ejpenney
|
|
||||||
/homeassistant/components/octoprint/ @rfleming71
|
/homeassistant/components/octoprint/ @rfleming71
|
||||||
/tests/components/octoprint/ @rfleming71
|
/tests/components/octoprint/ @rfleming71
|
||||||
/homeassistant/components/ohmconnect/ @robbiet480
|
/homeassistant/components/ohmconnect/ @robbiet480
|
||||||
@@ -1101,6 +1100,7 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/smhi/ @gjohansson-ST
|
/homeassistant/components/smhi/ @gjohansson-ST
|
||||||
/tests/components/smhi/ @gjohansson-ST
|
/tests/components/smhi/ @gjohansson-ST
|
||||||
/homeassistant/components/sms/ @ocalvo
|
/homeassistant/components/sms/ @ocalvo
|
||||||
|
/homeassistant/components/snapcast/ @luar123
|
||||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||||
/tests/components/snooz/ @AustinBrunkhorst
|
/tests/components/snooz/ @AustinBrunkhorst
|
||||||
/homeassistant/components/solaredge/ @frenck
|
/homeassistant/components/solaredge/ @frenck
|
||||||
@@ -1139,8 +1139,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/starline/ @anonym-tsk
|
/tests/components/starline/ @anonym-tsk
|
||||||
/homeassistant/components/starlink/ @boswelja
|
/homeassistant/components/starlink/ @boswelja
|
||||||
/tests/components/starlink/ @boswelja
|
/tests/components/starlink/ @boswelja
|
||||||
/homeassistant/components/statistics/ @ThomDietrich
|
/homeassistant/components/statistics/ @fabaff @ThomDietrich
|
||||||
/tests/components/statistics/ @ThomDietrich
|
/tests/components/statistics/ @fabaff @ThomDietrich
|
||||||
/homeassistant/components/steam_online/ @tkdrob
|
/homeassistant/components/steam_online/ @tkdrob
|
||||||
/tests/components/steam_online/ @tkdrob
|
/tests/components/steam_online/ @tkdrob
|
||||||
/homeassistant/components/steamist/ @bdraco
|
/homeassistant/components/steamist/ @bdraco
|
||||||
|
5
homeassistant/brands/heltun.json
Normal file
5
homeassistant/brands/heltun.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "heltun",
|
||||||
|
"name": "HELTUN",
|
||||||
|
"iot_standards": ["zwave"]
|
||||||
|
}
|
@@ -68,7 +68,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
|||||||
AirQEntityDescription(
|
AirQEntityDescription(
|
||||||
key="co",
|
key="co",
|
||||||
name="CO",
|
name="CO",
|
||||||
device_class=SensorDeviceClass.CO,
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value=lambda data: data.get("co"),
|
value=lambda data: data.get("co"),
|
||||||
@@ -289,7 +288,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
|||||||
AirQEntityDescription(
|
AirQEntityDescription(
|
||||||
key="tvoc",
|
key="tvoc",
|
||||||
name="VOC",
|
name="VOC",
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value=lambda data: data.get("tvoc"),
|
value=lambda data: data.get("tvoc"),
|
||||||
@@ -297,7 +295,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
|||||||
AirQEntityDescription(
|
AirQEntityDescription(
|
||||||
key="tvoc_ionsc",
|
key="tvoc_ionsc",
|
||||||
name="VOC (Industrial)",
|
name="VOC (Industrial)",
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value=lambda data: data.get("tvoc_ionsc"),
|
value=lambda data: data.get("tvoc_ionsc"),
|
||||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from AIOAladdinConnect import AladdinConnectClient
|
from AIOAladdinConnect import AladdinConnectClient
|
||||||
@@ -19,6 +20,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
|
|
||||||
from .const import CLIENT_ID, DOMAIN
|
from .const import CLIENT_ID, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_USERNAME): str,
|
vol.Required(CONF_USERNAME): str,
|
||||||
@@ -131,6 +134,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_import(
|
||||||
|
self, import_data: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Import Aladin Connect config from configuration.yaml."""
|
||||||
|
return await self.async_step_user(import_data)
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuth(HomeAssistantError):
|
class InvalidAuth(HomeAssistantError):
|
||||||
"""Error to indicate there is invalid auth."""
|
"""Error to indicate there is invalid auth."""
|
||||||
|
@@ -2,24 +2,63 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
import logging
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
from AIOAladdinConnect import AladdinConnectClient
|
from AIOAladdinConnect import AladdinConnectClient
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
from homeassistant.components.cover import (
|
||||||
from homeassistant.config_entries import ConfigEntry
|
PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA,
|
||||||
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
|
CoverDeviceClass,
|
||||||
|
CoverEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
STATE_CLOSED,
|
||||||
|
STATE_CLOSING,
|
||||||
|
STATE_OPENING,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES
|
from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES
|
||||||
from .model import DoorDevice
|
from .model import DoorDevice
|
||||||
|
|
||||||
|
_LOGGER: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend(
|
||||||
|
{vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
|
||||||
|
)
|
||||||
SCAN_INTERVAL = timedelta(seconds=300)
|
SCAN_INTERVAL = timedelta(seconds=300)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Aladdin Connect devices yaml depreciated."""
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Configuring Aladdin Connect through yaml is deprecated. Please remove it from"
|
||||||
|
" your configuration as it has already been imported to a config entry"
|
||||||
|
)
|
||||||
|
await hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_IMPORT},
|
||||||
|
data=config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
|
@@ -5,7 +5,6 @@ import asyncio
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
@@ -16,7 +15,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
from homeassistant.helpers.event import async_track_state_change
|
from homeassistant.helpers.event import async_track_state_change
|
||||||
from homeassistant.helpers.significant_change import create_checker
|
from homeassistant.helpers.significant_change import create_checker
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
|
||||||
|
|
||||||
from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause
|
from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause
|
||||||
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
||||||
@@ -164,10 +162,9 @@ async def async_send_changereport_message(
|
|||||||
if response.status == HTTPStatus.ACCEPTED:
|
if response.status == HTTPStatus.ACCEPTED:
|
||||||
return
|
return
|
||||||
|
|
||||||
response_json = json_loads_object(response_text)
|
response_json = json.loads(response_text)
|
||||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
|
||||||
|
|
||||||
if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
||||||
if invalidate_access_token:
|
if invalidate_access_token:
|
||||||
# Invalidate the access token and try again
|
# Invalidate the access token and try again
|
||||||
config.async_invalidate_access_token()
|
config.async_invalidate_access_token()
|
||||||
@@ -183,8 +180,8 @@ async def async_send_changereport_message(
|
|||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error when sending ChangeReport for %s to Alexa: %s: %s",
|
"Error when sending ChangeReport for %s to Alexa: %s: %s",
|
||||||
alexa_entity.entity_id,
|
alexa_entity.entity_id,
|
||||||
response_payload["code"],
|
response_json["payload"]["code"],
|
||||||
response_payload["description"],
|
response_json["payload"]["description"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -302,12 +299,11 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity):
|
|||||||
if response.status == HTTPStatus.ACCEPTED:
|
if response.status == HTTPStatus.ACCEPTED:
|
||||||
return
|
return
|
||||||
|
|
||||||
response_json = json_loads_object(response_text)
|
response_json = json.loads(response_text)
|
||||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
|
||||||
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error when sending DoorbellPress event for %s to Alexa: %s: %s",
|
"Error when sending DoorbellPress event for %s to Alexa: %s: %s",
|
||||||
alexa_entity.entity_id,
|
alexa_entity.entity_id,
|
||||||
response_payload["code"],
|
response_json["payload"]["code"],
|
||||||
response_payload["description"],
|
response_json["payload"]["description"],
|
||||||
)
|
)
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
"""Rest API for Home Assistant."""
|
"""Rest API for Home Assistant."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from functools import lru_cache
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -350,6 +351,12 @@ class APIComponentsView(HomeAssistantView):
|
|||||||
return self.json(request.app["hass"].config.components)
|
return self.json(request.app["hass"].config.components)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _cached_template(template_str: str, hass: ha.HomeAssistant) -> template.Template:
|
||||||
|
"""Return a cached template."""
|
||||||
|
return template.Template(template_str, hass)
|
||||||
|
|
||||||
|
|
||||||
class APITemplateView(HomeAssistantView):
|
class APITemplateView(HomeAssistantView):
|
||||||
"""View to handle Template requests."""
|
"""View to handle Template requests."""
|
||||||
|
|
||||||
@@ -362,7 +369,7 @@ class APITemplateView(HomeAssistantView):
|
|||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
tpl = template.Template(data["template"], request.app["hass"])
|
tpl = _cached_template(data["template"], request.app["hass"])
|
||||||
return tpl.async_render(variables=data.get("variables"), parse_result=False)
|
return tpl.async_render(variables=data.get("variables"), parse_result=False)
|
||||||
except (ValueError, TemplateError) as ex:
|
except (ValueError, TemplateError) as ex:
|
||||||
return self.json_message(
|
return self.json_message(
|
||||||
|
@@ -5,5 +5,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/apprise",
|
"documentation": "https://www.home-assistant.io/integrations/apprise",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["apprise"],
|
"loggers": ["apprise"],
|
||||||
"requirements": ["apprise==1.3.0"]
|
"requirements": ["apprise==1.2.1"]
|
||||||
}
|
}
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/aurora",
|
"documentation": "https://www.home-assistant.io/integrations/aurora",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["auroranoaa"],
|
"loggers": ["auroranoaa"],
|
||||||
"requirements": ["auroranoaa==0.0.2"]
|
"requirements": ["auroranoaa==0.0.3"]
|
||||||
}
|
}
|
||||||
|
@@ -227,20 +227,21 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
|||||||
self.hass, self._async_expire_devices, timedelta(seconds=30)
|
self.hass, self._async_expire_devices, timedelta(seconds=30)
|
||||||
)
|
)
|
||||||
cancel_stop = self.hass.bus.async_listen(
|
cancel_stop = self.hass.bus.async_listen(
|
||||||
EVENT_HOMEASSISTANT_STOP, self._save_history
|
EVENT_HOMEASSISTANT_STOP, self._async_save_history
|
||||||
)
|
)
|
||||||
self._async_setup_scanner_watchdog()
|
self._async_setup_scanner_watchdog()
|
||||||
|
|
||||||
@hass_callback
|
@hass_callback
|
||||||
def _cancel() -> None:
|
def _cancel() -> None:
|
||||||
self._save_history()
|
self._async_save_history()
|
||||||
self._async_stop_scanner_watchdog()
|
self._async_stop_scanner_watchdog()
|
||||||
cancel_track()
|
cancel_track()
|
||||||
cancel_stop()
|
cancel_stop()
|
||||||
|
|
||||||
return _cancel
|
return _cancel
|
||||||
|
|
||||||
def _save_history(self, event: Event | None = None) -> None:
|
@hass_callback
|
||||||
|
def _async_save_history(self, event: Event | None = None) -> None:
|
||||||
"""Save the history."""
|
"""Save the history."""
|
||||||
self._storage.async_set_advertisement_history(
|
self._storage.async_set_advertisement_history(
|
||||||
self.source,
|
self.source,
|
||||||
@@ -252,6 +253,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@hass_callback
|
||||||
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
|
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
|
||||||
"""Expire old devices."""
|
"""Expire old devices."""
|
||||||
now = MONOTONIC_TIME()
|
now = MONOTONIC_TIME()
|
||||||
|
@@ -20,5 +20,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==2.7.0"]
|
"requirements": ["bthome-ble==2.5.2"]
|
||||||
}
|
}
|
||||||
|
@@ -119,16 +119,6 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
# Gas (m3)
|
|
||||||
(
|
|
||||||
BTHomeSensorDeviceClass.GAS,
|
|
||||||
Units.VOLUME_CUBIC_METERS,
|
|
||||||
): SensorEntityDescription(
|
|
||||||
key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}",
|
|
||||||
device_class=SensorDeviceClass.GAS,
|
|
||||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
),
|
|
||||||
# Humidity in (percent)
|
# Humidity in (percent)
|
||||||
(BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription(
|
(BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription(
|
||||||
key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}",
|
key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}",
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
DOMAIN = "conversation"
|
DOMAIN = "conversation"
|
||||||
|
|
||||||
DEFAULT_EXPOSED_DOMAINS = {
|
DEFAULT_EXPOSED_DOMAINS = {
|
||||||
"binary_sensor",
|
|
||||||
"climate",
|
"climate",
|
||||||
"cover",
|
"cover",
|
||||||
"fan",
|
"fan",
|
||||||
@@ -17,5 +16,3 @@ DEFAULT_EXPOSED_DOMAINS = {
|
|||||||
"vacuum",
|
"vacuum",
|
||||||
"water_heater",
|
"water_heater",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
|
||||||
|
@@ -28,7 +28,7 @@ from homeassistant.helpers import (
|
|||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
|
||||||
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN
|
from .const import DEFAULT_EXPOSED_DOMAINS, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||||
@@ -257,9 +257,9 @@ class DefaultAgent(AbstractConversationAgent):
|
|||||||
# This is available in the response template as "state".
|
# This is available in the response template as "state".
|
||||||
state1: core.State | None = None
|
state1: core.State | None = None
|
||||||
if intent_response.matched_states:
|
if intent_response.matched_states:
|
||||||
state1 = intent_response.matched_states[0]
|
state1 = matched[0]
|
||||||
elif intent_response.unmatched_states:
|
elif intent_response.unmatched_states:
|
||||||
state1 = intent_response.unmatched_states[0]
|
state1 = unmatched[0]
|
||||||
|
|
||||||
# Render response template
|
# Render response template
|
||||||
speech = response_template.async_render(
|
speech = response_template.async_render(
|
||||||
@@ -479,12 +479,6 @@ class DefaultAgent(AbstractConversationAgent):
|
|||||||
for state in states:
|
for state in states:
|
||||||
# Checked against "requires_context" and "excludes_context" in hassil
|
# Checked against "requires_context" and "excludes_context" in hassil
|
||||||
context = {"domain": state.domain}
|
context = {"domain": state.domain}
|
||||||
if state.attributes:
|
|
||||||
# Include some attributes
|
|
||||||
for attr_key, attr_value in state.attributes.items():
|
|
||||||
if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES:
|
|
||||||
continue
|
|
||||||
context[attr_key] = attr_value
|
|
||||||
|
|
||||||
entity = entities.async_get(state.entity_id)
|
entity = entities.async_get(state.entity_id)
|
||||||
if entity is not None:
|
if entity is not None:
|
||||||
@@ -524,9 +518,6 @@ class DefaultAgent(AbstractConversationAgent):
|
|||||||
for alias in area.aliases:
|
for alias in area.aliases:
|
||||||
area_names.append((alias, area.id))
|
area_names.append((alias, area.id))
|
||||||
|
|
||||||
_LOGGER.debug("Exposed areas: %s", area_names)
|
|
||||||
_LOGGER.debug("Exposed entities: %s", entity_names)
|
|
||||||
|
|
||||||
self._slot_lists = {
|
self._slot_lists = {
|
||||||
"area": TextSlotList.from_tuples(area_names, allow_template=False),
|
"area": TextSlotList.from_tuples(area_names, allow_template=False),
|
||||||
"name": TextSlotList.from_tuples(entity_names, allow_template=False),
|
"name": TextSlotList.from_tuples(entity_names, allow_template=False),
|
||||||
|
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["hassil==1.0.5", "home-assistant-intents==2023.2.22"]
|
"requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"]
|
||||||
}
|
}
|
||||||
|
@@ -8,11 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||||
PLATFORM_SCHEMA,
|
|
||||||
SensorEntity,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
@@ -135,7 +131,6 @@ class DerivativeSensor(RestoreEntity, SensorEntity):
|
|||||||
|
|
||||||
_attr_icon = ICON
|
_attr_icon = ICON
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@@ -8,7 +8,6 @@ import voluptuous as vol
|
|||||||
from homeassistant.const import CONF_DOMAIN
|
from homeassistant.const import CONF_DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||||
@@ -18,13 +17,24 @@ if TYPE_CHECKING:
|
|||||||
from homeassistant.helpers import condition
|
from homeassistant.helpers import condition
|
||||||
|
|
||||||
|
|
||||||
class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol):
|
class DeviceAutomationConditionProtocol(Protocol):
|
||||||
"""Define the format of device_condition modules.
|
"""Define the format of device_condition modules.
|
||||||
|
|
||||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config
|
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
|
||||||
from ConditionProtocol.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CONDITION_SCHEMA: vol.Schema
|
||||||
|
|
||||||
|
async def async_validate_condition_config(
|
||||||
|
self, hass: HomeAssistant, config: ConfigType
|
||||||
|
) -> ConfigType:
|
||||||
|
"""Validate config."""
|
||||||
|
|
||||||
|
def async_condition_from_config(
|
||||||
|
self, hass: HomeAssistant, config: ConfigType
|
||||||
|
) -> condition.ConditionCheckerType:
|
||||||
|
"""Evaluate state based on configuration."""
|
||||||
|
|
||||||
async def async_get_condition_capabilities(
|
async def async_get_condition_capabilities(
|
||||||
self, hass: HomeAssistant, config: ConfigType
|
self, hass: HomeAssistant, config: ConfigType
|
||||||
) -> dict[str, vol.Schema]:
|
) -> dict[str, vol.Schema]:
|
||||||
@@ -52,4 +62,4 @@ async def async_condition_from_config(
|
|||||||
platform = await async_get_device_automation_platform(
|
platform = await async_get_device_automation_platform(
|
||||||
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||||
)
|
)
|
||||||
return trace_condition_function(platform.async_condition_from_config(hass, config))
|
return platform.async_condition_from_config(hass, config)
|
||||||
|
@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
|||||||
from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS
|
from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS
|
||||||
from .models import DormakabaDkeyData
|
from .models import DormakabaDkeyData
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@@ -132,7 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
association_data = await lock.associate(user_input["activation_code"])
|
association_data = await lock.associate(user_input["activation_code"])
|
||||||
except BleakError:
|
except BleakError as err:
|
||||||
|
_LOGGER.warning("BleakError", exc_info=err)
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
except dkey_errors.InvalidActivationCode:
|
except dkey_errors.InvalidActivationCode:
|
||||||
errors["base"] = "invalid_code"
|
errors["base"] = "invalid_code"
|
||||||
|
@@ -11,5 +11,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
|
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["py-dormakaba-dkey==1.0.2"]
|
"requirements": ["py-dormakaba-dkey==1.0.4"]
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from random import randint
|
||||||
|
|
||||||
from enturclient import EnturPublicTransportData
|
from enturclient import EnturPublicTransportData
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -22,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
API_CLIENT_NAME = "homeassistant-homeassistant"
|
API_CLIENT_NAME = "homeassistant-{}"
|
||||||
|
|
||||||
CONF_STOP_IDS = "stop_ids"
|
CONF_STOP_IDS = "stop_ids"
|
||||||
CONF_EXPAND_PLATFORMS = "expand_platforms"
|
CONF_EXPAND_PLATFORMS = "expand_platforms"
|
||||||
@@ -105,7 +106,7 @@ async def async_setup_platform(
|
|||||||
quays = [s for s in stop_ids if "Quay" in s]
|
quays = [s for s in stop_ids if "Quay" in s]
|
||||||
|
|
||||||
data = EnturPublicTransportData(
|
data = EnturPublicTransportData(
|
||||||
API_CLIENT_NAME,
|
API_CLIENT_NAME.format(str(randint(100000, 999999))),
|
||||||
stops=stops,
|
stops=stops,
|
||||||
quays=quays,
|
quays=quays,
|
||||||
line_whitelist=line_whitelist,
|
line_whitelist=line_whitelist,
|
||||||
|
@@ -14,6 +14,6 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
||||||
"requirements": ["aioesphomeapi==13.4.1", "esphome-dashboard-api==1.2.3"],
|
"requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."]
|
"zeroconf": ["_esphomelib._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pyfibaro"],
|
"loggers": ["pyfibaro"],
|
||||||
"requirements": ["pyfibaro==0.6.8"]
|
"requirements": ["pyfibaro==0.6.9"]
|
||||||
}
|
}
|
||||||
|
@@ -341,11 +341,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
is_dev = repo_path is not None
|
is_dev = repo_path is not None
|
||||||
root_path = _frontend_root(repo_path)
|
root_path = _frontend_root(repo_path)
|
||||||
|
|
||||||
if is_dev:
|
|
||||||
from .dev import async_setup_frontend_dev
|
|
||||||
|
|
||||||
async_setup_frontend_dev(hass)
|
|
||||||
|
|
||||||
for path, should_cache in (
|
for path, should_cache in (
|
||||||
("service_worker.js", False),
|
("service_worker.js", False),
|
||||||
("robots.txt", False),
|
("robots.txt", False),
|
||||||
|
@@ -1,60 +0,0 @@
|
|||||||
"""Development helpers for the frontend."""
|
|
||||||
import aiohttp
|
|
||||||
from aiohttp import hdrs, web
|
|
||||||
|
|
||||||
from homeassistant.components.http.view import HomeAssistantView
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.helpers import aiohttp_client
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_frontend_dev(hass: HomeAssistant) -> None:
|
|
||||||
"""Set up frontend dev views."""
|
|
||||||
hass.http.register_view( # type: ignore
|
|
||||||
FrontendDevView(
|
|
||||||
"http://localhost:8000", aiohttp_client.async_get_clientsession(hass)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
FILTER_RESPONSE_HEADERS = {hdrs.CONTENT_LENGTH, hdrs.CONTENT_ENCODING}
|
|
||||||
|
|
||||||
|
|
||||||
class FrontendDevView(HomeAssistantView):
|
|
||||||
"""Frontend dev view."""
|
|
||||||
|
|
||||||
name = "_dev:frontend"
|
|
||||||
url = "/_dev_frontend/{path:.*}"
|
|
||||||
requires_auth = False
|
|
||||||
extra_urls = ["/__web-dev-server__/{path:.*}"]
|
|
||||||
|
|
||||||
def __init__(self, forward_base: str, websession: aiohttp.ClientSession):
|
|
||||||
"""Initialize a Hass.io ingress view."""
|
|
||||||
self._forward_base = forward_base
|
|
||||||
self._websession = websession
|
|
||||||
|
|
||||||
async def get(self, request: web.Request, path: str) -> web.Response:
|
|
||||||
"""Frontend routing."""
|
|
||||||
# To deal with: import * as commonjsHelpers from '/__web-dev-server__/rollup/commonjsHelpers.js
|
|
||||||
if request.path.startswith("/__web-dev-server__/"):
|
|
||||||
path = f"__web-dev-server__/{path}"
|
|
||||||
|
|
||||||
url = f"{self._forward_base}/{path}"
|
|
||||||
|
|
||||||
if request.query_string:
|
|
||||||
url += f"?{request.query_string}"
|
|
||||||
|
|
||||||
async with self._websession.get(
|
|
||||||
url,
|
|
||||||
headers=request.headers,
|
|
||||||
allow_redirects=False,
|
|
||||||
) as result:
|
|
||||||
return web.Response(
|
|
||||||
headers={
|
|
||||||
hdr: val
|
|
||||||
for hdr, val in result.headers.items()
|
|
||||||
if hdr not in FILTER_RESPONSE_HEADERS
|
|
||||||
},
|
|
||||||
status=result.status,
|
|
||||||
body=await result.read(),
|
|
||||||
)
|
|
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20230227.0"]
|
"requirements": ["home-assistant-frontend==20230306.0"]
|
||||||
}
|
}
|
||||||
|
@@ -41,7 +41,7 @@ async def async_setup_platform(
|
|||||||
[
|
[
|
||||||
GeniusClimateZone(broker, z)
|
GeniusClimateZone(broker, z)
|
||||||
for z in broker.client.zone_objs
|
for z in broker.client.zone_objs
|
||||||
if z.data["type"] in GH_ZONES
|
if z.data.get("type") in GH_ZONES
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,10 +79,10 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity):
|
|||||||
def hvac_action(self) -> str | None:
|
def hvac_action(self) -> str | None:
|
||||||
"""Return the current running hvac operation if supported."""
|
"""Return the current running hvac operation if supported."""
|
||||||
if "_state" in self._zone.data: # only for v3 API
|
if "_state" in self._zone.data: # only for v3 API
|
||||||
|
if self._zone.data["output"] == 1:
|
||||||
|
return HVACAction.HEATING
|
||||||
if not self._zone.data["_state"].get("bIsActive"):
|
if not self._zone.data["_state"].get("bIsActive"):
|
||||||
return HVACAction.OFF
|
return HVACAction.OFF
|
||||||
if self._zone.data["_state"].get("bOutRequestHeat"):
|
|
||||||
return HVACAction.HEATING
|
|
||||||
return HVACAction.IDLE
|
return HVACAction.IDLE
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@@ -42,7 +42,7 @@ async def async_setup_platform(
|
|||||||
[
|
[
|
||||||
GeniusSwitch(broker, z)
|
GeniusSwitch(broker, z)
|
||||||
for z in broker.client.zone_objs
|
for z in broker.client.zone_objs
|
||||||
if z.data["type"] == GH_ON_OFF_ZONE
|
if z.data.get("type") == GH_ON_OFF_ZONE
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -48,7 +48,7 @@ async def async_setup_platform(
|
|||||||
[
|
[
|
||||||
GeniusWaterHeater(broker, z)
|
GeniusWaterHeater(broker, z)
|
||||||
for z in broker.client.zone_objs
|
for z in broker.client.zone_objs
|
||||||
if z.data["type"] in GH_HEATERS
|
if z.data.get("type") in GH_HEATERS
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -832,7 +832,7 @@ class TemperatureControlTrait(_Trait):
|
|||||||
"temperatureUnitForUX": _google_temp_unit(
|
"temperatureUnitForUX": _google_temp_unit(
|
||||||
self.hass.config.units.temperature_unit
|
self.hass.config.units.temperature_unit
|
||||||
),
|
),
|
||||||
"queryOnlyTemperatureSetting": True,
|
"queryOnlyTemperatureControl": True,
|
||||||
"temperatureRange": {
|
"temperatureRange": {
|
||||||
"minThresholdCelsius": -100,
|
"minThresholdCelsius": -100,
|
||||||
"maxThresholdCelsius": 100,
|
"maxThresholdCelsius": 100,
|
||||||
|
@@ -36,6 +36,7 @@ X_AUTH_TOKEN = "X-Supervisor-Token"
|
|||||||
X_INGRESS_PATH = "X-Ingress-Path"
|
X_INGRESS_PATH = "X-Ingress-Path"
|
||||||
X_HASS_USER_ID = "X-Hass-User-ID"
|
X_HASS_USER_ID = "X-Hass-User-ID"
|
||||||
X_HASS_IS_ADMIN = "X-Hass-Is-Admin"
|
X_HASS_IS_ADMIN = "X-Hass-Is-Admin"
|
||||||
|
X_HASS_SOURCE = "X-Hass-Source"
|
||||||
|
|
||||||
WS_TYPE = "type"
|
WS_TYPE = "type"
|
||||||
WS_ID = "id"
|
WS_ID = "id"
|
||||||
|
@@ -17,7 +17,7 @@ from homeassistant.const import SERVER_PORT
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
|
|
||||||
from .const import ATTR_DISCOVERY, DOMAIN
|
from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -445,6 +445,8 @@ class HassIO:
|
|||||||
payload=None,
|
payload=None,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
return_text=False,
|
return_text=False,
|
||||||
|
*,
|
||||||
|
source="core.handler",
|
||||||
):
|
):
|
||||||
"""Send API command to Hass.io.
|
"""Send API command to Hass.io.
|
||||||
|
|
||||||
@@ -458,7 +460,8 @@ class HassIO:
|
|||||||
headers={
|
headers={
|
||||||
aiohttp.hdrs.AUTHORIZATION: (
|
aiohttp.hdrs.AUTHORIZATION: (
|
||||||
f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
|
f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
|
||||||
)
|
),
|
||||||
|
X_HASS_SOURCE: source,
|
||||||
},
|
},
|
||||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
)
|
)
|
||||||
|
@@ -6,6 +6,7 @@ from http import HTTPStatus
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -19,13 +20,16 @@ from aiohttp.hdrs import (
|
|||||||
TRANSFER_ENCODING,
|
TRANSFER_ENCODING,
|
||||||
)
|
)
|
||||||
from aiohttp.web_exceptions import HTTPBadGateway
|
from aiohttp.web_exceptions import HTTPBadGateway
|
||||||
from multidict import istr
|
|
||||||
|
|
||||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
from homeassistant.components.http import (
|
||||||
|
KEY_AUTHENTICATED,
|
||||||
|
KEY_HASS_USER,
|
||||||
|
HomeAssistantView,
|
||||||
|
)
|
||||||
from homeassistant.components.onboarding import async_is_onboarded
|
from homeassistant.components.onboarding import async_is_onboarded
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID
|
from .const import X_HASS_SOURCE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,23 +38,53 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 1024
|
|||||||
# pylint: disable=implicit-str-concat
|
# pylint: disable=implicit-str-concat
|
||||||
NO_TIMEOUT = re.compile(
|
NO_TIMEOUT = re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|homeassistant/update"
|
|
||||||
r"|hassos/update"
|
|
||||||
r"|hassos/update/cli"
|
|
||||||
r"|supervisor/update"
|
|
||||||
r"|addons/[^/]+/(?:update|install|rebuild)"
|
|
||||||
r"|backups/.+/full"
|
r"|backups/.+/full"
|
||||||
r"|backups/.+/partial"
|
r"|backups/.+/partial"
|
||||||
r"|backups/[^/]+/(?:upload|download)"
|
r"|backups/[^/]+/(?:upload|download)"
|
||||||
r")$"
|
r")$"
|
||||||
)
|
)
|
||||||
|
|
||||||
NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$")
|
# fmt: off
|
||||||
|
# Onboarding can upload backups and restore it
|
||||||
|
PATHS_NOT_ONBOARDED = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?"
|
||||||
|
r"|backups/new/upload"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|icon)" r")$")
|
# Authenticated users manage backups + download logs
|
||||||
|
PATHS_ADMIN = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?"
|
||||||
|
r"|backups/new/upload"
|
||||||
|
r"|audio/logs"
|
||||||
|
r"|cli/logs"
|
||||||
|
r"|core/logs"
|
||||||
|
r"|dns/logs"
|
||||||
|
r"|host/logs"
|
||||||
|
r"|multicast/logs"
|
||||||
|
r"|observer/logs"
|
||||||
|
r"|supervisor/logs"
|
||||||
|
r"|addons/[^/]+/logs"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$")
|
# Unauthenticated requests come in for Supervisor panel + add-on images
|
||||||
|
PATHS_NO_AUTH = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|app/.*"
|
||||||
|
r"|(store/)?addons/[^/]+/(logo|icon)"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
NO_STORE = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|app/entrypoint.js"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
# pylint: enable=implicit-str-concat
|
# pylint: enable=implicit-str-concat
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
class HassIOView(HomeAssistantView):
|
class HassIOView(HomeAssistantView):
|
||||||
@@ -65,38 +99,66 @@ class HassIOView(HomeAssistantView):
|
|||||||
self._host = host
|
self._host = host
|
||||||
self._websession = websession
|
self._websession = websession
|
||||||
|
|
||||||
async def _handle(
|
async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||||
self, request: web.Request, path: str
|
|
||||||
) -> web.Response | web.StreamResponse:
|
|
||||||
"""Route data to Hass.io."""
|
|
||||||
hass = request.app["hass"]
|
|
||||||
if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]:
|
|
||||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
|
||||||
|
|
||||||
return await self._command_proxy(path, request)
|
|
||||||
|
|
||||||
delete = _handle
|
|
||||||
get = _handle
|
|
||||||
post = _handle
|
|
||||||
|
|
||||||
async def _command_proxy(
|
|
||||||
self, path: str, request: web.Request
|
|
||||||
) -> web.StreamResponse:
|
|
||||||
"""Return a client request with proxy origin for Hass.io supervisor.
|
"""Return a client request with proxy origin for Hass.io supervisor.
|
||||||
|
|
||||||
This method is a coroutine.
|
Use cases:
|
||||||
|
- Onboarding allows restoring backups
|
||||||
|
- Load Supervisor panel and add-on logo unauthenticated
|
||||||
|
- User upload/restore backups
|
||||||
"""
|
"""
|
||||||
headers = _init_header(request)
|
# No bullshit
|
||||||
if path == "backups/new/upload":
|
if path != unquote(path):
|
||||||
# We need to reuse the full content type that includes the boundary
|
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||||
headers[
|
|
||||||
CONTENT_TYPE
|
hass: HomeAssistant = request.app["hass"]
|
||||||
] = request._stored_content_type # pylint: disable=protected-access
|
is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin
|
||||||
|
authorized = is_admin
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
allowed_paths = PATHS_ADMIN
|
||||||
|
|
||||||
|
elif not async_is_onboarded(hass):
|
||||||
|
allowed_paths = PATHS_NOT_ONBOARDED
|
||||||
|
|
||||||
|
# During onboarding we need the user to manage backups
|
||||||
|
authorized = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Either unauthenticated or not an admin
|
||||||
|
allowed_paths = PATHS_NO_AUTH
|
||||||
|
|
||||||
|
no_auth_path = PATHS_NO_AUTH.match(path)
|
||||||
|
headers = {
|
||||||
|
X_HASS_SOURCE: "core.http",
|
||||||
|
}
|
||||||
|
|
||||||
|
if no_auth_path:
|
||||||
|
if request.method != "GET":
|
||||||
|
return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not allowed_paths.match(path):
|
||||||
|
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
if authorized:
|
||||||
|
headers[
|
||||||
|
AUTHORIZATION
|
||||||
|
] = f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
headers[CONTENT_TYPE] = request.content_type
|
||||||
|
# _stored_content_type is only computed once `content_type` is accessed
|
||||||
|
if path == "backups/new/upload":
|
||||||
|
# We need to reuse the full content type that includes the boundary
|
||||||
|
headers[
|
||||||
|
CONTENT_TYPE
|
||||||
|
] = request._stored_content_type # pylint: disable=protected-access
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = await self._websession.request(
|
client = await self._websession.request(
|
||||||
method=request.method,
|
method=request.method,
|
||||||
url=f"http://{self._host}/{path}",
|
url=f"http://{self._host}/{quote(path)}",
|
||||||
params=request.query,
|
params=request.query,
|
||||||
data=request.content,
|
data=request.content,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -123,20 +185,8 @@ class HassIOView(HomeAssistantView):
|
|||||||
|
|
||||||
raise HTTPBadGateway()
|
raise HTTPBadGateway()
|
||||||
|
|
||||||
|
get = _handle
|
||||||
def _init_header(request: web.Request) -> dict[istr, str]:
|
post = _handle
|
||||||
"""Create initial header."""
|
|
||||||
headers = {
|
|
||||||
AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}",
|
|
||||||
CONTENT_TYPE: request.content_type,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add user data
|
|
||||||
if request.get("hass_user") is not None:
|
|
||||||
headers[istr(X_HASS_USER_ID)] = request["hass_user"].id
|
|
||||||
headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin))
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
|
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
|
||||||
@@ -164,12 +214,3 @@ def _get_timeout(path: str) -> ClientTimeout:
|
|||||||
if NO_TIMEOUT.match(path):
|
if NO_TIMEOUT.match(path):
|
||||||
return ClientTimeout(connect=10, total=None)
|
return ClientTimeout(connect=10, total=None)
|
||||||
return ClientTimeout(connect=10, total=300)
|
return ClientTimeout(connect=10, total=300)
|
||||||
|
|
||||||
|
|
||||||
def _need_auth(hass: HomeAssistant, path: str) -> bool:
|
|
||||||
"""Return if a path need authentication."""
|
|
||||||
if not async_is_onboarded(hass) and NO_AUTH_ONBOARDING.match(path):
|
|
||||||
return False
|
|
||||||
if NO_AUTH.match(path):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
@@ -3,20 +3,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from functools import lru_cache
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
import os
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import ClientTimeout, hdrs, web
|
from aiohttp import ClientTimeout, hdrs, web
|
||||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
|
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
|
||||||
from multidict import CIMultiDict
|
from multidict import CIMultiDict
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import X_AUTH_TOKEN, X_INGRESS_PATH
|
from .const import X_HASS_SOURCE, X_INGRESS_PATH
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,9 +44,19 @@ class HassIOIngress(HomeAssistantView):
|
|||||||
self._host = host
|
self._host = host
|
||||||
self._websession = websession
|
self._websession = websession
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
def _create_url(self, token: str, path: str) -> str:
|
def _create_url(self, token: str, path: str) -> str:
|
||||||
"""Create URL to service."""
|
"""Create URL to service."""
|
||||||
return f"http://{self._host}/ingress/{token}/{path}"
|
base_path = f"/ingress/{token}/"
|
||||||
|
url = f"http://{self._host}{base_path}{quote(path)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not URL(url).path.startswith(base_path):
|
||||||
|
raise HTTPBadRequest()
|
||||||
|
except ValueError as err:
|
||||||
|
raise HTTPBadRequest() from err
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
async def _handle(
|
async def _handle(
|
||||||
self, request: web.Request, token: str, path: str
|
self, request: web.Request, token: str, path: str
|
||||||
@@ -185,10 +197,8 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st
|
|||||||
continue
|
continue
|
||||||
headers[name] = value
|
headers[name] = value
|
||||||
|
|
||||||
# Inject token / cleanup later on Supervisor
|
|
||||||
headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "")
|
|
||||||
|
|
||||||
# Ingress information
|
# Ingress information
|
||||||
|
headers[X_HASS_SOURCE] = "core.ingress"
|
||||||
headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}"
|
headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}"
|
||||||
|
|
||||||
# Set X-Forwarded-For
|
# Set X-Forwarded-For
|
||||||
|
@@ -116,6 +116,7 @@ async def websocket_supervisor_api(
|
|||||||
method=msg[ATTR_METHOD],
|
method=msg[ATTR_METHOD],
|
||||||
timeout=msg.get(ATTR_TIMEOUT, 10),
|
timeout=msg.get(ATTR_TIMEOUT, 10),
|
||||||
payload=msg.get(ATTR_DATA, {}),
|
payload=msg.get(ATTR_DATA, {}),
|
||||||
|
source="core.websocket_api",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get(ATTR_RESULT) == "error":
|
if result.get(ATTR_RESULT) == "error":
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Config flow for HLK-SW16."""
|
"""Config flow for HLK-SW16."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
from hlk_sw16 import create_hlk_sw16_connection
|
from hlk_sw16 import create_hlk_sw16_connection
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -36,8 +35,7 @@ async def connect_client(hass, user_input):
|
|||||||
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
|
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
|
||||||
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
|
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
|
||||||
)
|
)
|
||||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT)
|
||||||
return await client_aw
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, user_input):
|
async def validate_input(hass: HomeAssistant, user_input):
|
||||||
|
@@ -14,7 +14,6 @@ PLATFORMS = [
|
|||||||
Platform.CLIMATE,
|
Platform.CLIMATE,
|
||||||
Platform.COVER,
|
Platform.COVER,
|
||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
Platform.LOCK,
|
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.WEATHER,
|
Platform.WEATHER,
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
"""Helper functions for Homematicip Cloud Integration."""
|
|
||||||
|
|
||||||
from functools import wraps
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from . import HomematicipGenericEntity
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def is_error_response(response) -> bool:
|
|
||||||
"""Response from async call contains errors or not."""
|
|
||||||
if isinstance(response, dict):
|
|
||||||
return response.get("errorCode") not in ("", None)
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def handle_errors(func):
|
|
||||||
"""Handle async errors."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def inner(self: HomematicipGenericEntity) -> None:
|
|
||||||
"""Handle errors from async call."""
|
|
||||||
result = await func(self)
|
|
||||||
if is_error_response(result):
|
|
||||||
_LOGGER.error(
|
|
||||||
"Error while execute function %s: %s",
|
|
||||||
__name__,
|
|
||||||
json.dumps(result),
|
|
||||||
)
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information."
|
|
||||||
)
|
|
||||||
|
|
||||||
return inner
|
|
@@ -1,95 +0,0 @@
|
|||||||
"""Support for HomematicIP Cloud lock devices."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homematicip.aio.device import AsyncDoorLockDrive
|
|
||||||
from homematicip.base.enums import LockState, MotorState
|
|
||||||
|
|
||||||
from homeassistant.components.lock import LockEntity, LockEntityFeature
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
|
|
||||||
from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity
|
|
||||||
from .helpers import handle_errors
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ATTR_AUTO_RELOCK_DELAY = "auto_relock_delay"
|
|
||||||
ATTR_DOOR_HANDLE_TYPE = "door_handle_type"
|
|
||||||
ATTR_DOOR_LOCK_DIRECTION = "door_lock_direction"
|
|
||||||
ATTR_DOOR_LOCK_NEUTRAL_POSITION = "door_lock_neutral_position"
|
|
||||||
ATTR_DOOR_LOCK_TURNS = "door_lock_turns"
|
|
||||||
|
|
||||||
DEVICE_DLD_ATTRIBUTES = {
|
|
||||||
"autoRelockDelay": ATTR_AUTO_RELOCK_DELAY,
|
|
||||||
"doorHandleType": ATTR_DOOR_HANDLE_TYPE,
|
|
||||||
"doorLockDirection": ATTR_DOOR_LOCK_DIRECTION,
|
|
||||||
"doorLockNeutralPosition": ATTR_DOOR_LOCK_NEUTRAL_POSITION,
|
|
||||||
"doorLockTurns": ATTR_DOOR_LOCK_TURNS,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the HomematicIP locks from a config entry."""
|
|
||||||
hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id]
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
HomematicipDoorLockDrive(hap, device)
|
|
||||||
for device in hap.home.devices
|
|
||||||
if isinstance(device, AsyncDoorLockDrive)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity):
|
|
||||||
"""Representation of the HomematicIP DoorLockDrive."""
|
|
||||||
|
|
||||||
_attr_supported_features = LockEntityFeature.OPEN
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_locked(self) -> bool | None:
|
|
||||||
"""Return true if device is locked."""
|
|
||||||
return (
|
|
||||||
self._device.lockState == LockState.LOCKED
|
|
||||||
and self._device.motorState == MotorState.STOPPED
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_locking(self) -> bool:
|
|
||||||
"""Return true if device is locking."""
|
|
||||||
return self._device.motorState == MotorState.CLOSING
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_unlocking(self) -> bool:
|
|
||||||
"""Return true if device is unlocking."""
|
|
||||||
return self._device.motorState == MotorState.OPENING
|
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
async def async_lock(self, **kwargs: Any) -> None:
|
|
||||||
"""Lock the device."""
|
|
||||||
return await self._device.set_lock_state(LockState.LOCKED)
|
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
async def async_unlock(self, **kwargs: Any) -> None:
|
|
||||||
"""Unlock the device."""
|
|
||||||
return await self._device.set_lock_state(LockState.UNLOCKED)
|
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
async def async_open(self, **kwargs: Any) -> None:
|
|
||||||
"""Open the door latch."""
|
|
||||||
return await self._device.set_lock_state(LockState.OPEN)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return the state attributes of the device."""
|
|
||||||
return super().extra_state_attributes | {
|
|
||||||
attr_key: attr_value
|
|
||||||
for attr, attr_key in DEVICE_DLD_ATTRIBUTES.items()
|
|
||||||
if (attr_value := getattr(self._device, attr, None)) is not None
|
|
||||||
}
|
|
@@ -421,6 +421,7 @@ class HoneywellUSThermostat(ClimateEntity):
|
|||||||
"""Get the latest state from the service."""
|
"""Get the latest state from the service."""
|
||||||
try:
|
try:
|
||||||
await self._device.refresh()
|
await self._device.refresh()
|
||||||
|
self._attr_available = True
|
||||||
except (
|
except (
|
||||||
aiosomecomfort.SomeComfortError,
|
aiosomecomfort.SomeComfortError,
|
||||||
OSError,
|
OSError,
|
||||||
@@ -428,8 +429,10 @@ class HoneywellUSThermostat(ClimateEntity):
|
|||||||
try:
|
try:
|
||||||
await self._data.client.login()
|
await self._data.client.login()
|
||||||
|
|
||||||
except aiosomecomfort.SomeComfortError:
|
except aiosomecomfort.AuthError:
|
||||||
self._attr_available = False
|
self._attr_available = False
|
||||||
await self.hass.async_create_task(
|
await self.hass.async_create_task(
|
||||||
self.hass.config_entries.async_reload(self._data.entry_id)
|
self.hass.config_entries.async_reload(self._data.entry_id)
|
||||||
)
|
)
|
||||||
|
except aiosomecomfort.SomeComfortError:
|
||||||
|
self._attr_available = False
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["somecomfort"],
|
"loggers": ["somecomfort"],
|
||||||
"requirements": ["aiosomecomfort==0.0.10"]
|
"requirements": ["aiosomecomfort==0.0.11"]
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware
|
from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware
|
||||||
|
|
||||||
@@ -39,18 +40,24 @@ FILTERS: Final = re.compile(
|
|||||||
def setup_security_filter(app: Application) -> None:
|
def setup_security_filter(app: Application) -> None:
|
||||||
"""Create security filter middleware for the app."""
|
"""Create security filter middleware for the app."""
|
||||||
|
|
||||||
|
def _recursive_unquote(value: str) -> str:
|
||||||
|
"""Handle values that are encoded multiple times."""
|
||||||
|
if (unquoted := unquote(value)) != value:
|
||||||
|
unquoted = _recursive_unquote(unquoted)
|
||||||
|
return unquoted
|
||||||
|
|
||||||
@middleware
|
@middleware
|
||||||
async def security_filter_middleware(
|
async def security_filter_middleware(
|
||||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||||
) -> StreamResponse:
|
) -> StreamResponse:
|
||||||
"""Process request and tblock commonly known exploit attempts."""
|
"""Process request and block commonly known exploit attempts."""
|
||||||
if FILTERS.search(request.path):
|
if FILTERS.search(_recursive_unquote(request.path)):
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Filtered a potential harmful request to: %s", request.raw_path
|
"Filtered a potential harmful request to: %s", request.raw_path
|
||||||
)
|
)
|
||||||
raise HTTPBadRequest
|
raise HTTPBadRequest
|
||||||
|
|
||||||
if FILTERS.search(request.query_string):
|
if FILTERS.search(_recursive_unquote(request.query_string)):
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Filtered a request with a potential harmful query string: %s",
|
"Filtered a request with a potential harmful query string: %s",
|
||||||
request.raw_path,
|
request.raw_path,
|
||||||
|
@@ -35,6 +35,7 @@ TRIGGER_TYPE = {
|
|||||||
"remote_double_button_long_press": "both {subtype} released after long press",
|
"remote_double_button_long_press": "both {subtype} released after long press",
|
||||||
"remote_double_button_short_press": "both {subtype} released",
|
"remote_double_button_short_press": "both {subtype} released",
|
||||||
"initial_press": "{subtype} pressed initially",
|
"initial_press": "{subtype} pressed initially",
|
||||||
|
"long_press": "{subtype} long press",
|
||||||
"repeat": "{subtype} held down",
|
"repeat": "{subtype} held down",
|
||||||
"short_release": "{subtype} released after short press",
|
"short_release": "{subtype} released after short press",
|
||||||
"long_release": "{subtype} released after long press",
|
"long_release": "{subtype} released after long press",
|
||||||
|
@@ -11,6 +11,6 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiohue"],
|
"loggers": ["aiohue"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aiohue==4.6.1"],
|
"requirements": ["aiohue==4.6.2"],
|
||||||
"zeroconf": ["_hue._tcp.local."]
|
"zeroconf": ["_hue._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -118,13 +118,14 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity):
|
|||||||
"""Return device (service) info."""
|
"""Return device (service) info."""
|
||||||
# we create a virtual service/device for Hue scenes
|
# we create a virtual service/device for Hue scenes
|
||||||
# so we have a parent for grouped lights and scenes
|
# so we have a parent for grouped lights and scenes
|
||||||
|
group_type = self.group.type.value.title()
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.group.id)},
|
identifiers={(DOMAIN, self.group.id)},
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
name=self.group.metadata.name,
|
name=self.group.metadata.name,
|
||||||
manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name,
|
manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name,
|
||||||
model=self.group.type.value.title(),
|
model=self.group.type.value.title(),
|
||||||
suggested_area=self.group.metadata.name,
|
suggested_area=self.group.metadata.name if group_type == "Room" else None,
|
||||||
via_device=(DOMAIN, self.bridge.api.config.bridge_device.id),
|
via_device=(DOMAIN, self.bridge.api.config.bridge_device.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -46,6 +46,7 @@ DEFAULT_BUTTON_EVENT_TYPES = (
|
|||||||
ButtonEvent.INITIAL_PRESS,
|
ButtonEvent.INITIAL_PRESS,
|
||||||
ButtonEvent.REPEAT,
|
ButtonEvent.REPEAT,
|
||||||
ButtonEvent.SHORT_RELEASE,
|
ButtonEvent.SHORT_RELEASE,
|
||||||
|
ButtonEvent.LONG_PRESS,
|
||||||
ButtonEvent.LONG_RELEASE,
|
ButtonEvent.LONG_RELEASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -55,7 +55,13 @@ class HueBaseEntity(Entity):
|
|||||||
self._attr_unique_id = resource.id
|
self._attr_unique_id = resource.id
|
||||||
# device is precreated in main handler
|
# device is precreated in main handler
|
||||||
# this attaches the entity to the precreated device
|
# this attaches the entity to the precreated device
|
||||||
if self.device is not None:
|
if self.device is None:
|
||||||
|
# attach all device-less entities to the bridge itself
|
||||||
|
# e.g. config based sensors like entertainment area
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, bridge.api.config.bridge.bridge_id)},
|
||||||
|
)
|
||||||
|
else:
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.device.id)},
|
identifiers={(DOMAIN, self.device.id)},
|
||||||
)
|
)
|
||||||
@@ -137,17 +143,14 @@ class HueBaseEntity(Entity):
|
|||||||
def _handle_event(self, event_type: EventType, resource: HueResource) -> None:
|
def _handle_event(self, event_type: EventType, resource: HueResource) -> None:
|
||||||
"""Handle status event for this resource (or it's parent)."""
|
"""Handle status event for this resource (or it's parent)."""
|
||||||
if event_type == EventType.RESOURCE_DELETED:
|
if event_type == EventType.RESOURCE_DELETED:
|
||||||
# remove any services created for zones/rooms
|
# handle removal of room and zone 'virtual' devices/services
|
||||||
# regular devices are removed automatically by the logic in device.py.
|
# regular devices are removed automatically by the logic in device.py.
|
||||||
if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE):
|
if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE):
|
||||||
dev_reg = async_get_device_registry(self.hass)
|
dev_reg = async_get_device_registry(self.hass)
|
||||||
if device := dev_reg.async_get_device({(DOMAIN, resource.id)}):
|
if device := dev_reg.async_get_device({(DOMAIN, resource.id)}):
|
||||||
dev_reg.async_remove_device(device.id)
|
dev_reg.async_remove_device(device.id)
|
||||||
if resource.type in (
|
# cleanup entities that are not strictly device-bound and have the bridge as parent
|
||||||
ResourceTypes.GROUPED_LIGHT,
|
if self.device is None:
|
||||||
ResourceTypes.SCENE,
|
|
||||||
ResourceTypes.SMART_SCENE,
|
|
||||||
):
|
|
||||||
ent_reg = async_get_entity_registry(self.hass)
|
ent_reg = async_get_entity_registry(self.hass)
|
||||||
ent_reg.async_remove(self.entity_id)
|
ent_reg.async_remove(self.entity_id)
|
||||||
return
|
return
|
||||||
|
@@ -153,6 +153,7 @@ async def async_setup_entry( # noqa: C901
|
|||||||
system.serial,
|
system.serial,
|
||||||
svc_exception,
|
svc_exception,
|
||||||
)
|
)
|
||||||
|
await system.aqualink.close()
|
||||||
else:
|
else:
|
||||||
cur = system.online
|
cur = system.online
|
||||||
if cur and not prev:
|
if cur and not prev:
|
||||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
|
import httpx
|
||||||
from iaqualink.exception import AqualinkServiceException
|
from iaqualink.exception import AqualinkServiceException
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@@ -12,5 +13,5 @@ async def await_or_reraise(awaitable: Awaitable) -> None:
|
|||||||
"""Execute API call while catching service exceptions."""
|
"""Execute API call while catching service exceptions."""
|
||||||
try:
|
try:
|
||||||
await awaitable
|
await awaitable
|
||||||
except AqualinkServiceException as svc_exception:
|
except (AqualinkServiceException, httpx.HTTPError) as svc_exception:
|
||||||
raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception
|
raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception
|
||||||
|
@@ -17,8 +17,8 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pyinsteon", "pypubsub"],
|
"loggers": ["pyinsteon", "pypubsub"],
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"pyinsteon==1.3.2",
|
"pyinsteon==1.3.4",
|
||||||
"insteon-frontend-home-assistant==0.3.2"
|
"insteon-frontend-home-assistant==0.3.3"
|
||||||
],
|
],
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
|
@@ -1,11 +1,13 @@
|
|||||||
"""Utilities used by insteon component."""
|
"""Utilities used by insteon component."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyinsteon import devices
|
from pyinsteon import devices
|
||||||
from pyinsteon.address import Address
|
from pyinsteon.address import Address
|
||||||
from pyinsteon.constants import ALDBStatus, DeviceAction
|
from pyinsteon.constants import ALDBStatus, DeviceAction
|
||||||
from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT
|
from pyinsteon.device_types.device_base import Device
|
||||||
|
from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event
|
||||||
from pyinsteon.managers.link_manager import (
|
from pyinsteon.managers.link_manager import (
|
||||||
async_enter_linking_mode,
|
async_enter_linking_mode,
|
||||||
async_enter_unlinking_mode,
|
async_enter_unlinking_mode,
|
||||||
@@ -27,7 +29,7 @@ from homeassistant.const import (
|
|||||||
CONF_PLATFORM,
|
CONF_PLATFORM,
|
||||||
ENTITY_MATCH_ALL,
|
ENTITY_MATCH_ALL,
|
||||||
)
|
)
|
||||||
from homeassistant.core import ServiceCall, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.dispatcher import (
|
from homeassistant.helpers.dispatcher import (
|
||||||
async_dispatcher_connect,
|
async_dispatcher_connect,
|
||||||
@@ -89,49 +91,52 @@ from .schemas import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def add_on_off_event_device(hass, device):
|
def _register_event(event: Event, listener: Callable) -> None:
|
||||||
|
"""Register the events raised by a device."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Registering on/off event for %s %d %s",
|
||||||
|
str(event.address),
|
||||||
|
event.group,
|
||||||
|
event.name,
|
||||||
|
)
|
||||||
|
event.subscribe(listener, force_strong_ref=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_on_off_event_device(hass: HomeAssistant, device: Device) -> None:
|
||||||
"""Register an Insteon device as an on/off event device."""
|
"""Register an Insteon device as an on/off event device."""
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_fire_group_on_off_event(name, address, group, button):
|
def async_fire_group_on_off_event(
|
||||||
|
name: str, address: Address, group: int, button: str
|
||||||
|
):
|
||||||
# Firing an event when a button is pressed.
|
# Firing an event when a button is pressed.
|
||||||
if button and button[-2] == "_":
|
if button and button[-2] == "_":
|
||||||
button_id = button[-1].lower()
|
button_id = button[-1].lower()
|
||||||
else:
|
else:
|
||||||
button_id = None
|
button_id = None
|
||||||
|
|
||||||
schema = {CONF_ADDRESS: address}
|
schema = {CONF_ADDRESS: address, "group": group}
|
||||||
if button_id:
|
if button_id:
|
||||||
schema[EVENT_CONF_BUTTON] = button_id
|
schema[EVENT_CONF_BUTTON] = button_id
|
||||||
if name == ON_EVENT:
|
if name == ON_EVENT:
|
||||||
event = EVENT_GROUP_ON
|
event = EVENT_GROUP_ON
|
||||||
if name == OFF_EVENT:
|
elif name == OFF_EVENT:
|
||||||
event = EVENT_GROUP_OFF
|
event = EVENT_GROUP_OFF
|
||||||
if name == ON_FAST_EVENT:
|
elif name == ON_FAST_EVENT:
|
||||||
event = EVENT_GROUP_ON_FAST
|
event = EVENT_GROUP_ON_FAST
|
||||||
if name == OFF_FAST_EVENT:
|
elif name == OFF_FAST_EVENT:
|
||||||
event = EVENT_GROUP_OFF_FAST
|
event = EVENT_GROUP_OFF_FAST
|
||||||
|
else:
|
||||||
|
event = f"insteon.{name}"
|
||||||
_LOGGER.debug("Firing event %s with %s", event, schema)
|
_LOGGER.debug("Firing event %s with %s", event, schema)
|
||||||
hass.bus.async_fire(event, schema)
|
hass.bus.async_fire(event, schema)
|
||||||
|
|
||||||
for group in device.events:
|
for name_or_group, event in device.events.items():
|
||||||
if isinstance(group, int):
|
if isinstance(name_or_group, int):
|
||||||
for event in device.events[group]:
|
for _, event in device.events[name_or_group].items():
|
||||||
if event in [
|
_register_event(event, async_fire_group_on_off_event)
|
||||||
OFF_EVENT,
|
else:
|
||||||
ON_EVENT,
|
_register_event(event, async_fire_group_on_off_event)
|
||||||
OFF_FAST_EVENT,
|
|
||||||
ON_FAST_EVENT,
|
|
||||||
]:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Registering on/off event for %s %d %s",
|
|
||||||
str(device.address),
|
|
||||||
group,
|
|
||||||
event,
|
|
||||||
)
|
|
||||||
device.events[group][event].subscribe(
|
|
||||||
async_fire_group_on_off_event, force_strong_ref=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_new_device_callback(hass):
|
def register_new_device_callback(hass):
|
||||||
|
@@ -1,13 +1,22 @@
|
|||||||
"""The islamic_prayer_times component."""
|
"""The islamic_prayer_times component."""
|
||||||
from __future__ import annotations
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from prayer_times_calculator import PrayerTimesCalculator, exceptions
|
||||||
|
from requests.exceptions import ConnectionError as ConnError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN
|
||||||
from .coordinator import IslamicPrayerDataUpdateCoordinator
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
@@ -16,32 +25,154 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
|||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
"""Set up the Islamic Prayer Component."""
|
"""Set up the Islamic Prayer Component."""
|
||||||
coordinator = IslamicPrayerDataUpdateCoordinator(hass)
|
client = IslamicPrayerClient(hass, config_entry)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
hass.data[DOMAIN] = client
|
||||||
|
await client.async_setup()
|
||||||
hass.data.setdefault(DOMAIN, coordinator)
|
|
||||||
config_entry.async_on_unload(
|
|
||||||
config_entry.add_update_listener(async_options_updated)
|
|
||||||
)
|
|
||||||
hass.config_entries.async_setup_platforms(config_entry, PLATFORMS)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
"""Unload Islamic Prayer entry from config_entry."""
|
"""Unload Islamic Prayer entry from config_entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
if hass.data[DOMAIN].event_unsub:
|
||||||
config_entry, PLATFORMS
|
hass.data[DOMAIN].event_unsub()
|
||||||
):
|
hass.data.pop(DOMAIN)
|
||||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN)
|
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||||
if coordinator.event_unsub:
|
|
||||||
coordinator.event_unsub()
|
|
||||||
return unload_ok
|
|
||||||
|
|
||||||
|
|
||||||
async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
class IslamicPrayerClient:
|
||||||
"""Triggered by config entry options updates."""
|
"""Islamic Prayer Client Object."""
|
||||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN]
|
|
||||||
if coordinator.event_unsub:
|
def __init__(self, hass, config_entry):
|
||||||
coordinator.event_unsub()
|
"""Initialize the Islamic Prayer client."""
|
||||||
await coordinator.async_request_refresh()
|
self.hass = hass
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self.prayer_times_info = {}
|
||||||
|
self.available = True
|
||||||
|
self.event_unsub = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def calc_method(self):
|
||||||
|
"""Return the calculation method."""
|
||||||
|
return self.config_entry.options[CONF_CALC_METHOD]
|
||||||
|
|
||||||
|
def get_new_prayer_times(self):
|
||||||
|
"""Fetch prayer times for today."""
|
||||||
|
calc = PrayerTimesCalculator(
|
||||||
|
latitude=self.hass.config.latitude,
|
||||||
|
longitude=self.hass.config.longitude,
|
||||||
|
calculation_method=self.calc_method,
|
||||||
|
date=str(dt_util.now().date()),
|
||||||
|
)
|
||||||
|
return calc.fetch_prayer_times()
|
||||||
|
|
||||||
|
async def async_schedule_future_update(self):
|
||||||
|
"""Schedule future update for sensors.
|
||||||
|
|
||||||
|
Midnight is a calculated time. The specifics of the calculation
|
||||||
|
depends on the method of the prayer time calculation. This calculated
|
||||||
|
midnight is the time at which the time to pray the Isha prayers have
|
||||||
|
expired.
|
||||||
|
|
||||||
|
Calculated Midnight: The Islamic midnight.
|
||||||
|
Traditional Midnight: 12:00AM
|
||||||
|
|
||||||
|
Update logic for prayer times:
|
||||||
|
|
||||||
|
If the Calculated Midnight is before the traditional midnight then wait
|
||||||
|
until the traditional midnight to run the update. This way the day
|
||||||
|
will have changed over and we don't need to do any fancy calculations.
|
||||||
|
|
||||||
|
If the Calculated Midnight is after the traditional midnight, then wait
|
||||||
|
until after the calculated Midnight. We don't want to update the prayer
|
||||||
|
times too early or else the timings might be incorrect.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
calculated midnight = 11:23PM (before traditional midnight)
|
||||||
|
Update time: 12:00AM
|
||||||
|
|
||||||
|
calculated midnight = 1:35AM (after traditional midnight)
|
||||||
|
update time: 1:36AM.
|
||||||
|
|
||||||
|
"""
|
||||||
|
_LOGGER.debug("Scheduling next update for Islamic prayer times")
|
||||||
|
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
|
||||||
|
midnight_dt = self.prayer_times_info["Midnight"]
|
||||||
|
|
||||||
|
if now > dt_util.as_utc(midnight_dt):
|
||||||
|
next_update_at = midnight_dt + timedelta(days=1, minutes=1)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Midnight is after day the changes so schedule update for after"
|
||||||
|
" Midnight the next day"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Midnight is before the day changes so schedule update for the next"
|
||||||
|
" start of day"
|
||||||
|
)
|
||||||
|
next_update_at = dt_util.start_of_local_day(now + timedelta(days=1))
|
||||||
|
|
||||||
|
_LOGGER.info("Next update scheduled for: %s", next_update_at)
|
||||||
|
|
||||||
|
self.event_unsub = async_track_point_in_time(
|
||||||
|
self.hass, self.async_update, next_update_at
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self, *_):
|
||||||
|
"""Update sensors with new prayer times."""
|
||||||
|
try:
|
||||||
|
prayer_times = await self.hass.async_add_executor_job(
|
||||||
|
self.get_new_prayer_times
|
||||||
|
)
|
||||||
|
self.available = True
|
||||||
|
except (exceptions.InvalidResponseError, ConnError):
|
||||||
|
self.available = False
|
||||||
|
_LOGGER.debug("Error retrieving prayer times")
|
||||||
|
async_call_later(self.hass, 60, self.async_update)
|
||||||
|
return
|
||||||
|
|
||||||
|
for prayer, time in prayer_times.items():
|
||||||
|
self.prayer_times_info[prayer] = dt_util.parse_datetime(
|
||||||
|
f"{dt_util.now().date()} {time}"
|
||||||
|
)
|
||||||
|
await self.async_schedule_future_update()
|
||||||
|
|
||||||
|
_LOGGER.debug("New prayer times retrieved. Updating sensors")
|
||||||
|
async_dispatcher_send(self.hass, DATA_UPDATED)
|
||||||
|
|
||||||
|
async def async_setup(self):
|
||||||
|
"""Set up the Islamic prayer client."""
|
||||||
|
await self.async_add_options()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.hass.async_add_executor_job(self.get_new_prayer_times)
|
||||||
|
except (exceptions.InvalidResponseError, ConnError) as err:
|
||||||
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
await self.async_update()
|
||||||
|
self.config_entry.add_update_listener(self.async_options_updated)
|
||||||
|
|
||||||
|
await self.hass.config_entries.async_forward_entry_setups(
|
||||||
|
self.config_entry, PLATFORMS
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_add_options(self):
|
||||||
|
"""Add options for entry."""
|
||||||
|
if not self.config_entry.options:
|
||||||
|
data = dict(self.config_entry.data)
|
||||||
|
calc_method = data.pop(CONF_CALC_METHOD, DEFAULT_CALC_METHOD)
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry, data=data, options={CONF_CALC_METHOD: calc_method}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Triggered by config entry options updates."""
|
||||||
|
if hass.data[DOMAIN].event_unsub:
|
||||||
|
hass.data[DOMAIN].event_unsub()
|
||||||
|
await hass.data[DOMAIN].async_update()
|
||||||
|
@@ -1,13 +1,10 @@
|
|||||||
"""Config flow for Islamic Prayer Times integration."""
|
"""Config flow for Islamic Prayer Times integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
|
||||||
|
|
||||||
from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME
|
from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME
|
||||||
|
|
||||||
@@ -25,9 +22,7 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return IslamicPrayerOptionsFlowHandler(config_entry)
|
return IslamicPrayerOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(self, user_input=None):
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle a flow initialized by the user."""
|
"""Handle a flow initialized by the user."""
|
||||||
if self._async_current_entries():
|
if self._async_current_entries():
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
@@ -45,9 +40,7 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
"""Initialize options flow."""
|
"""Initialize options flow."""
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(self, user_input=None):
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Manage options."""
|
"""Manage options."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self.async_create_entry(title="", data=user_input)
|
return self.async_create_entry(title="", data=user_input)
|
||||||
|
@@ -1,12 +1,23 @@
|
|||||||
"""Constants for the Islamic Prayer component."""
|
"""Constants for the Islamic Prayer component."""
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from prayer_times_calculator import PrayerTimesCalculator
|
from prayer_times_calculator import PrayerTimesCalculator
|
||||||
|
|
||||||
DOMAIN: Final = "islamic_prayer_times"
|
DOMAIN = "islamic_prayer_times"
|
||||||
NAME: Final = "Islamic Prayer Times"
|
NAME = "Islamic Prayer Times"
|
||||||
|
PRAYER_TIMES_ICON = "mdi:calendar-clock"
|
||||||
|
|
||||||
CONF_CALC_METHOD: Final = "calculation_method"
|
SENSOR_TYPES = {
|
||||||
|
"Fajr": "prayer",
|
||||||
|
"Sunrise": "time",
|
||||||
|
"Dhuhr": "prayer",
|
||||||
|
"Asr": "prayer",
|
||||||
|
"Maghrib": "prayer",
|
||||||
|
"Isha": "prayer",
|
||||||
|
"Midnight": "time",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONF_CALC_METHOD = "calculation_method"
|
||||||
|
|
||||||
CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS)
|
CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS)
|
||||||
DEFAULT_CALC_METHOD: Final = "isna"
|
DEFAULT_CALC_METHOD = "isna"
|
||||||
|
|
||||||
|
DATA_UPDATED = "Islamic_prayer_data_updated"
|
||||||
|
@@ -1,121 +0,0 @@
|
|||||||
"""Coordinator for the Islamic prayer times integration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from prayer_times_calculator import PrayerTimesCalculator, exceptions
|
|
||||||
from requests.exceptions import ConnectionError as ConnError
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
import homeassistant.util.dt as dt_util
|
|
||||||
|
|
||||||
from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetime]]):
|
|
||||||
"""Islamic Prayer Client Object."""
|
|
||||||
|
|
||||||
config_entry: ConfigEntry
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize the Islamic Prayer client."""
|
|
||||||
self.event_unsub: CALLBACK_TYPE | None = None
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=DOMAIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def calc_method(self) -> str:
|
|
||||||
"""Return the calculation method."""
|
|
||||||
return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD)
|
|
||||||
|
|
||||||
def get_new_prayer_times(self) -> dict[str, str]:
|
|
||||||
"""Fetch prayer times for today."""
|
|
||||||
calc = PrayerTimesCalculator(
|
|
||||||
latitude=self.hass.config.latitude,
|
|
||||||
longitude=self.hass.config.longitude,
|
|
||||||
calculation_method=self.calc_method,
|
|
||||||
date=str(dt_util.now().date()),
|
|
||||||
)
|
|
||||||
return calc.fetch_prayer_times()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_schedule_future_update(self, midnight_dt: datetime) -> None:
|
|
||||||
"""Schedule future update for sensors.
|
|
||||||
|
|
||||||
Midnight is a calculated time. The specifics of the calculation
|
|
||||||
depends on the method of the prayer time calculation. This calculated
|
|
||||||
midnight is the time at which the time to pray the Isha prayers have
|
|
||||||
expired.
|
|
||||||
|
|
||||||
Calculated Midnight: The Islamic midnight.
|
|
||||||
Traditional Midnight: 12:00AM
|
|
||||||
|
|
||||||
Update logic for prayer times:
|
|
||||||
|
|
||||||
If the Calculated Midnight is before the traditional midnight then wait
|
|
||||||
until the traditional midnight to run the update. This way the day
|
|
||||||
will have changed over and we don't need to do any fancy calculations.
|
|
||||||
|
|
||||||
If the Calculated Midnight is after the traditional midnight, then wait
|
|
||||||
until after the calculated Midnight. We don't want to update the prayer
|
|
||||||
times too early or else the timings might be incorrect.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
calculated midnight = 11:23PM (before traditional midnight)
|
|
||||||
Update time: 12:00AM
|
|
||||||
|
|
||||||
calculated midnight = 1:35AM (after traditional midnight)
|
|
||||||
update time: 1:36AM.
|
|
||||||
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Scheduling next update for Islamic prayer times")
|
|
||||||
|
|
||||||
now = dt_util.utcnow()
|
|
||||||
|
|
||||||
if now > midnight_dt:
|
|
||||||
next_update_at = midnight_dt + timedelta(days=1, minutes=1)
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Midnight is after the day changes so schedule update for after Midnight the next day"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Midnight is before the day changes so schedule update for the next start of day"
|
|
||||||
)
|
|
||||||
next_update_at = dt_util.start_of_local_day(now + timedelta(days=1))
|
|
||||||
|
|
||||||
_LOGGER.debug("Next update scheduled for: %s", next_update_at)
|
|
||||||
|
|
||||||
self.event_unsub = async_track_point_in_time(
|
|
||||||
self.hass, self.async_request_update, next_update_at
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_request_update(self, *_) -> None:
|
|
||||||
"""Request update from coordinator."""
|
|
||||||
await self.async_request_refresh()
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, datetime]:
|
|
||||||
"""Update sensors with new prayer times."""
|
|
||||||
try:
|
|
||||||
prayer_times = await self.hass.async_add_executor_job(
|
|
||||||
self.get_new_prayer_times
|
|
||||||
)
|
|
||||||
except (exceptions.InvalidResponseError, ConnError) as err:
|
|
||||||
async_call_later(self.hass, 60, self.async_request_update)
|
|
||||||
raise UpdateFailed from err
|
|
||||||
|
|
||||||
prayer_times_info: dict[str, datetime] = {}
|
|
||||||
for prayer, time in prayer_times.items():
|
|
||||||
if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"):
|
|
||||||
prayer_times_info[prayer] = dt_util.as_utc(prayer_time)
|
|
||||||
|
|
||||||
self.async_schedule_future_update(prayer_times_info["Midnight"])
|
|
||||||
return prayer_times_info
|
|
@@ -1,51 +1,12 @@
|
|||||||
"""Platform to retrieve Islamic prayer times information for Home Assistant."""
|
"""Platform to retrieve Islamic prayer times information for Home Assistant."""
|
||||||
from datetime import datetime
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from . import IslamicPrayerDataUpdateCoordinator
|
from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES
|
||||||
from .const import DOMAIN, NAME
|
|
||||||
|
|
||||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Fajr",
|
|
||||||
name="Fajr prayer",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Sunrise",
|
|
||||||
name="Sunrise time",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Dhuhr",
|
|
||||||
name="Dhuhr prayer",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Asr",
|
|
||||||
name="Asr prayer",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Maghrib",
|
|
||||||
name="Maghrib prayer",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Isha",
|
|
||||||
name="Isha prayer",
|
|
||||||
),
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="Midnight",
|
|
||||||
name="Midnight time",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -55,38 +16,46 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Islamic prayer times sensor platform."""
|
"""Set up the Islamic prayer times sensor platform."""
|
||||||
|
|
||||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN]
|
client = hass.data[DOMAIN]
|
||||||
|
|
||||||
async_add_entities(
|
entities = []
|
||||||
IslamicPrayerTimeSensor(coordinator, description)
|
for sensor_type in SENSOR_TYPES:
|
||||||
for description in SENSOR_TYPES
|
entities.append(IslamicPrayerTimeSensor(sensor_type, client))
|
||||||
)
|
|
||||||
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
class IslamicPrayerTimeSensor(
|
class IslamicPrayerTimeSensor(SensorEntity):
|
||||||
CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity
|
|
||||||
):
|
|
||||||
"""Representation of an Islamic prayer time sensor."""
|
"""Representation of an Islamic prayer time sensor."""
|
||||||
|
|
||||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
_attr_has_entity_name = True
|
_attr_icon = PRAYER_TIMES_ICON
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, sensor_type, client):
|
||||||
self,
|
|
||||||
coordinator: IslamicPrayerDataUpdateCoordinator,
|
|
||||||
description: SensorEntityDescription,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the Islamic prayer time sensor."""
|
"""Initialize the Islamic prayer time sensor."""
|
||||||
super().__init__(coordinator)
|
self.sensor_type = sensor_type
|
||||||
self.entity_description = description
|
self.client = client
|
||||||
self._attr_unique_id = description.key
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
|
||||||
name=NAME,
|
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> datetime:
|
def name(self):
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return f"{self.sensor_type} {SENSOR_TYPES[self.sensor_type]}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
"""Return the unique id of the entity."""
|
||||||
|
return self.sensor_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
return self.coordinator.data[self.entity_description.key]
|
return self.client.prayer_times_info.get(self.sensor_type).astimezone(
|
||||||
|
dt_util.UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Handle entity which will be added."""
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
@@ -8,43 +8,16 @@ from pyisy.constants import ISY_VALUE_UNKNOWN
|
|||||||
from homeassistant.components.lock import LockEntity
|
from homeassistant.components.lock import LockEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
AddEntitiesCallback,
|
|
||||||
async_get_current_platform,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import _LOGGER, DOMAIN
|
||||||
from .entity import ISYNodeEntity, ISYProgramEntity
|
from .entity import ISYNodeEntity, ISYProgramEntity
|
||||||
from .services import (
|
|
||||||
SERVICE_DELETE_USER_CODE_SCHEMA,
|
|
||||||
SERVICE_DELETE_ZWAVE_LOCK_USER_CODE,
|
|
||||||
SERVICE_SET_USER_CODE_SCHEMA,
|
|
||||||
SERVICE_SET_ZWAVE_LOCK_USER_CODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
VALUE_TO_STATE = {0: False, 100: True}
|
VALUE_TO_STATE = {0: False, 100: True}
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_lock_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Create lock-specific services for the ISY Integration."""
|
|
||||||
platform = async_get_current_platform()
|
|
||||||
|
|
||||||
platform.async_register_entity_service(
|
|
||||||
SERVICE_SET_ZWAVE_LOCK_USER_CODE,
|
|
||||||
SERVICE_SET_USER_CODE_SCHEMA,
|
|
||||||
"async_set_zwave_lock_user_code",
|
|
||||||
)
|
|
||||||
platform.async_register_entity_service(
|
|
||||||
SERVICE_DELETE_ZWAVE_LOCK_USER_CODE,
|
|
||||||
SERVICE_DELETE_USER_CODE_SCHEMA,
|
|
||||||
"async_delete_zwave_lock_user_code",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -59,7 +32,6 @@ async def async_setup_entry(
|
|||||||
entities.append(ISYLockProgramEntity(name, status, actions))
|
entities.append(ISYLockProgramEntity(name, status, actions))
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
async_setup_lock_services(hass)
|
|
||||||
|
|
||||||
|
|
||||||
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||||
@@ -75,26 +47,12 @@ class ISYLockEntity(ISYNodeEntity, LockEntity):
|
|||||||
async def async_lock(self, **kwargs: Any) -> None:
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
"""Send the lock command to the ISY device."""
|
"""Send the lock command to the ISY device."""
|
||||||
if not await self._node.secure_lock():
|
if not await self._node.secure_lock():
|
||||||
raise HomeAssistantError(f"Unable to lock device {self._node.address}")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
async def async_unlock(self, **kwargs: Any) -> None:
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
"""Send the unlock command to the ISY device."""
|
"""Send the unlock command to the ISY device."""
|
||||||
if not await self._node.secure_unlock():
|
if not await self._node.secure_unlock():
|
||||||
raise HomeAssistantError(f"Unable to unlock device {self._node.address}")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
async def async_set_zwave_lock_user_code(self, user_num: int, code: int) -> None:
|
|
||||||
"""Set a user lock code for a Z-Wave Lock."""
|
|
||||||
if not await self._node.set_zwave_lock_code(user_num, code):
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Could not set user code {user_num} for {self._node.address}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_delete_zwave_lock_user_code(self, user_num: int) -> None:
|
|
||||||
"""Delete a user lock code for a Z-Wave Lock."""
|
|
||||||
if not await self._node.delete_zwave_lock_code(user_num):
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Could not delete user code {user_num} for {self._node.address}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
||||||
@@ -108,9 +66,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
|||||||
async def async_lock(self, **kwargs: Any) -> None:
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
"""Lock the device."""
|
"""Lock the device."""
|
||||||
if not await self._actions.run_then():
|
if not await self._actions.run_then():
|
||||||
raise HomeAssistantError(f"Unable to lock device {self._node.address}")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
async def async_unlock(self, **kwargs: Any) -> None:
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
"""Unlock the device."""
|
"""Unlock the device."""
|
||||||
if not await self._actions.run_else():
|
if not await self._actions.run_else():
|
||||||
raise HomeAssistantError(f"Unable to unlock device {self._node.address}")
|
_LOGGER.error("Unable to unlock device")
|
||||||
|
@@ -24,7 +24,7 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pyisy"],
|
"loggers": ["pyisy"],
|
||||||
"requirements": ["pyisy==3.1.14"],
|
"requirements": ["pyisy==3.1.13"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"manufacturer": "Universal Devices Inc.",
|
"manufacturer": "Universal Devices Inc.",
|
||||||
|
@@ -52,14 +52,8 @@ SERVICE_RENAME_NODE = "rename_node"
|
|||||||
SERVICE_SET_ON_LEVEL = "set_on_level"
|
SERVICE_SET_ON_LEVEL = "set_on_level"
|
||||||
SERVICE_SET_RAMP_RATE = "set_ramp_rate"
|
SERVICE_SET_RAMP_RATE = "set_ramp_rate"
|
||||||
|
|
||||||
# Services valid only for Z-Wave Locks
|
|
||||||
SERVICE_SET_ZWAVE_LOCK_USER_CODE = "set_zwave_lock_user_code"
|
|
||||||
SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code"
|
|
||||||
|
|
||||||
CONF_PARAMETER = "parameter"
|
CONF_PARAMETER = "parameter"
|
||||||
CONF_PARAMETERS = "parameters"
|
CONF_PARAMETERS = "parameters"
|
||||||
CONF_USER_NUM = "user_num"
|
|
||||||
CONF_CODE = "code"
|
|
||||||
CONF_VALUE = "value"
|
CONF_VALUE = "value"
|
||||||
CONF_INIT = "init"
|
CONF_INIT = "init"
|
||||||
CONF_ISY = "isy"
|
CONF_ISY = "isy"
|
||||||
@@ -135,13 +129,6 @@ SERVICE_SET_ZWAVE_PARAMETER_SCHEMA = {
|
|||||||
vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)),
|
vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)),
|
||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_SET_USER_CODE_SCHEMA = {
|
|
||||||
vol.Required(CONF_USER_NUM): vol.Coerce(int),
|
|
||||||
vol.Required(CONF_CODE): vol.Coerce(int),
|
|
||||||
}
|
|
||||||
|
|
||||||
SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)}
|
|
||||||
|
|
||||||
SERVICE_SET_VARIABLE_SCHEMA = vol.All(
|
SERVICE_SET_VARIABLE_SCHEMA = vol.All(
|
||||||
cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME),
|
cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME),
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
|
@@ -118,52 +118,6 @@ set_zwave_parameter:
|
|||||||
- "1"
|
- "1"
|
||||||
- "2"
|
- "2"
|
||||||
- "4"
|
- "4"
|
||||||
set_zwave_lock_user_code:
|
|
||||||
name: Set Z-Wave Lock User Code
|
|
||||||
description: >-
|
|
||||||
Set a Z-Wave Lock User Code via the ISY.
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
integration: isy994
|
|
||||||
domain: lock
|
|
||||||
fields:
|
|
||||||
user_num:
|
|
||||||
name: User Number
|
|
||||||
description: The user slot number on the lock
|
|
||||||
required: true
|
|
||||||
example: 8
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 1
|
|
||||||
max: 255
|
|
||||||
code:
|
|
||||||
name: Code
|
|
||||||
description: The code to set for the user.
|
|
||||||
required: true
|
|
||||||
example: 33491663
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 1
|
|
||||||
max: 99999999
|
|
||||||
mode: box
|
|
||||||
delete_zwave_lock_user_code:
|
|
||||||
name: Delete Z-Wave Lock User Code
|
|
||||||
description: >-
|
|
||||||
Delete a Z-Wave Lock User Code via the ISY.
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
integration: isy994
|
|
||||||
domain: lock
|
|
||||||
fields:
|
|
||||||
user_num:
|
|
||||||
name: User Number
|
|
||||||
description: The user slot number on the lock
|
|
||||||
required: true
|
|
||||||
example: 8
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 1
|
|
||||||
max: 255
|
|
||||||
rename_node:
|
rename_node:
|
||||||
name: Rename Node on ISY
|
name: Rename Node on ISY
|
||||||
description: >-
|
description: >-
|
||||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -549,9 +550,12 @@ class KNXCommonFlow(ABC, FlowHandler):
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
_tunnel_identifier = selected_tunnel_ia or self.new_entry_data.get(
|
||||||
|
CONF_HOST
|
||||||
|
)
|
||||||
|
_tunnel_suffix = f" @ {_tunnel_identifier}" if _tunnel_identifier else ""
|
||||||
self.new_title = (
|
self.new_title = (
|
||||||
f"{'Secure ' if _if_user_id else ''}"
|
f"{'Secure ' if _if_user_id else ''}Tunneling{_tunnel_suffix}"
|
||||||
f"Tunneling @ {selected_tunnel_ia or self.new_entry_data[CONF_HOST]}"
|
|
||||||
)
|
)
|
||||||
return self.finish_flow()
|
return self.finish_flow()
|
||||||
|
|
||||||
@@ -708,7 +712,8 @@ class KNXCommonFlow(ABC, FlowHandler):
|
|||||||
else:
|
else:
|
||||||
dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN))
|
dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN))
|
||||||
dest_path.mkdir(exist_ok=True)
|
dest_path.mkdir(exist_ok=True)
|
||||||
file_path.rename(dest_path / DEFAULT_KNX_KEYRING_FILENAME)
|
dest_file = dest_path / DEFAULT_KNX_KEYRING_FILENAME
|
||||||
|
shutil.move(file_path, dest_file)
|
||||||
return keyring, errors
|
return keyring, errors
|
||||||
|
|
||||||
keyring, errors = await self.hass.async_add_executor_job(_process_upload)
|
keyring, errors = await self.hass.async_add_executor_job(_process_upload)
|
||||||
|
@@ -84,7 +84,7 @@ def ensure_zone(value):
|
|||||||
if value is None:
|
if value is None:
|
||||||
raise vol.Invalid("zone value is None")
|
raise vol.Invalid("zone value is None")
|
||||||
|
|
||||||
if str(value) not in ZONES is None:
|
if str(value) not in ZONES:
|
||||||
raise vol.Invalid("zone not valid")
|
raise vol.Invalid("zone not valid")
|
||||||
|
|
||||||
return str(value)
|
return str(value)
|
||||||
|
@@ -17,9 +17,10 @@ from homeassistant.const import (
|
|||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
EVENT_HOMEASSISTANT_STARTED,
|
EVENT_HOMEASSISTANT_STARTED,
|
||||||
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
@@ -166,9 +167,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
We do not want the discovery task to block startup.
|
We do not want the discovery task to block startup.
|
||||||
"""
|
"""
|
||||||
hass.async_create_background_task(
|
task = asyncio.create_task(discovery_manager.async_discovery())
|
||||||
discovery_manager.async_discovery(), "lifx-discovery"
|
|
||||||
)
|
@callback
|
||||||
|
def _async_stop(_: Event) -> None:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Task must be shut down when home assistant is closing
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
|
||||||
|
|
||||||
# Let the system settle a bit before starting discovery
|
# Let the system settle a bit before starting discovery
|
||||||
# to reduce the risk we miss devices because the event
|
# to reduce the risk we miss devices because the event
|
||||||
|
@@ -6,7 +6,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import Event, HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
system.on_connected_changed(handle_connected_changed)
|
system.on_connected_changed(handle_connected_changed)
|
||||||
|
|
||||||
async def handle_stop(event: Event) -> None:
|
async def handle_stop(event) -> None:
|
||||||
await system.close()
|
await system.close()
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
|
@@ -76,7 +76,7 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult:
|
async def async_step_import(self, import_data):
|
||||||
"""Import litejet config from configuration.yaml."""
|
"""Import litejet config from configuration.yaml."""
|
||||||
return self.async_create_entry(title=import_data[CONF_PORT], data=import_data)
|
return self.async_create_entry(title=import_data[CONF_PORT], data=import_data)
|
||||||
|
|
||||||
|
@@ -2,8 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from pylitejet import LiteJet
|
from pylitejet import LiteJet
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -44,7 +42,7 @@ async def async_attach_trigger(
|
|||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Listen for events based on configuration."""
|
"""Listen for events based on configuration."""
|
||||||
trigger_data = trigger_info["trigger_data"]
|
trigger_data = trigger_info["trigger_data"]
|
||||||
number = cast(int, config[CONF_NUMBER])
|
number = config.get(CONF_NUMBER)
|
||||||
held_more_than = config.get(CONF_HELD_MORE_THAN)
|
held_more_than = config.get(CONF_HELD_MORE_THAN)
|
||||||
held_less_than = config.get(CONF_HELD_LESS_THAN)
|
held_less_than = config.get(CONF_HELD_LESS_THAN)
|
||||||
pressed_time = None
|
pressed_time = None
|
||||||
@@ -52,7 +50,7 @@ async def async_attach_trigger(
|
|||||||
job = HassJob(action)
|
job = HassJob(action)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def call_action() -> None:
|
def call_action():
|
||||||
"""Call action with right context."""
|
"""Call action with right context."""
|
||||||
hass.async_run_hass_job(
|
hass.async_run_hass_job(
|
||||||
job,
|
job,
|
||||||
@@ -74,11 +72,11 @@ async def async_attach_trigger(
|
|||||||
# neither: trigger on pressed
|
# neither: trigger on pressed
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def pressed_more_than_satisfied(now: datetime) -> None:
|
def pressed_more_than_satisfied(now):
|
||||||
"""Handle the LiteJet's switch's button pressed >= held_more_than."""
|
"""Handle the LiteJet's switch's button pressed >= held_more_than."""
|
||||||
call_action()
|
call_action()
|
||||||
|
|
||||||
def pressed() -> None:
|
def pressed():
|
||||||
"""Handle the press of the LiteJet switch's button."""
|
"""Handle the press of the LiteJet switch's button."""
|
||||||
nonlocal cancel_pressed_more_than, pressed_time
|
nonlocal cancel_pressed_more_than, pressed_time
|
||||||
nonlocal held_less_than, held_more_than
|
nonlocal held_less_than, held_more_than
|
||||||
@@ -90,12 +88,10 @@ async def async_attach_trigger(
|
|||||||
hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than
|
hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than
|
||||||
)
|
)
|
||||||
|
|
||||||
def released() -> None:
|
def released():
|
||||||
"""Handle the release of the LiteJet switch's button."""
|
"""Handle the release of the LiteJet switch's button."""
|
||||||
nonlocal cancel_pressed_more_than, pressed_time
|
nonlocal cancel_pressed_more_than, pressed_time
|
||||||
nonlocal held_less_than, held_more_than
|
nonlocal held_less_than, held_more_than
|
||||||
if pressed_time is None:
|
|
||||||
return
|
|
||||||
if cancel_pressed_more_than is not None:
|
if cancel_pressed_more_than is not None:
|
||||||
cancel_pressed_more_than()
|
cancel_pressed_more_than()
|
||||||
cancel_pressed_more_than = None
|
cancel_pressed_more_than = None
|
||||||
@@ -114,7 +110,7 @@ async def async_attach_trigger(
|
|||||||
system.on_switch_released(number, released)
|
system.on_switch_released(number, released)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_remove() -> None:
|
def async_remove():
|
||||||
"""Remove all subscriptions used for this trigger."""
|
"""Remove all subscriptions used for this trigger."""
|
||||||
system.unsubscribe(pressed)
|
system.unsubscribe(pressed)
|
||||||
system.unsubscribe(released)
|
system.unsubscribe(released)
|
||||||
|
@@ -140,7 +140,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
|
|||||||
name="Pet weight",
|
name="Pet weight",
|
||||||
native_unit_of_measurement=UnitOfMass.POUNDS,
|
native_unit_of_measurement=UnitOfMass.POUNDS,
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
FeederRobot: [
|
FeederRobot: [
|
||||||
|
@@ -8,15 +8,14 @@ from aiolivisi import AioLivisi
|
|||||||
|
|
||||||
from homeassistant import core
|
from homeassistant import core
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, SWITCH_PLATFORM
|
||||||
from .coordinator import LivisiDataUpdateCoordinator
|
from .coordinator import LivisiDataUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH]
|
PLATFORMS: Final = [SWITCH_PLATFORM]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
@@ -1,212 +0,0 @@
|
|||||||
"""Code to handle a Livisi Virtual Climate Control."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiolivisi.const import CAPABILITY_MAP
|
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
|
||||||
ClimateEntity,
|
|
||||||
ClimateEntityFeature,
|
|
||||||
HVACMode,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
DOMAIN,
|
|
||||||
LIVISI_REACHABILITY_CHANGE,
|
|
||||||
LIVISI_STATE_CHANGE,
|
|
||||||
LOGGER,
|
|
||||||
MAX_TEMPERATURE,
|
|
||||||
MIN_TEMPERATURE,
|
|
||||||
VRCC_DEVICE_TYPE,
|
|
||||||
)
|
|
||||||
from .coordinator import LivisiDataUpdateCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up climate device."""
|
|
||||||
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def handle_coordinator_update() -> None:
|
|
||||||
"""Add climate device."""
|
|
||||||
shc_devices: list[dict[str, Any]] = coordinator.data
|
|
||||||
entities: list[ClimateEntity] = []
|
|
||||||
for device in shc_devices:
|
|
||||||
if (
|
|
||||||
device["type"] == VRCC_DEVICE_TYPE
|
|
||||||
and device["id"] not in coordinator.devices
|
|
||||||
):
|
|
||||||
livisi_climate: ClimateEntity = create_entity(
|
|
||||||
config_entry, device, coordinator
|
|
||||||
)
|
|
||||||
LOGGER.debug("Include device type: %s", device.get("type"))
|
|
||||||
coordinator.devices.add(device["id"])
|
|
||||||
entities.append(livisi_climate)
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
config_entry.async_on_unload(
|
|
||||||
coordinator.async_add_listener(handle_coordinator_update)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_entity(
|
|
||||||
config_entry: ConfigEntry,
|
|
||||||
device: dict[str, Any],
|
|
||||||
coordinator: LivisiDataUpdateCoordinator,
|
|
||||||
) -> ClimateEntity:
|
|
||||||
"""Create Climate Entity."""
|
|
||||||
capabilities: Mapping[str, Any] = device[CAPABILITY_MAP]
|
|
||||||
room_id: str = device["location"]
|
|
||||||
room_name: str = coordinator.rooms[room_id]
|
|
||||||
livisi_climate = LivisiClimate(
|
|
||||||
config_entry,
|
|
||||||
coordinator,
|
|
||||||
unique_id=device["id"],
|
|
||||||
manufacturer=device["manufacturer"],
|
|
||||||
device_type=device["type"],
|
|
||||||
target_temperature_capability=capabilities["RoomSetpoint"],
|
|
||||||
temperature_capability=capabilities["RoomTemperature"],
|
|
||||||
humidity_capability=capabilities["RoomHumidity"],
|
|
||||||
room=room_name,
|
|
||||||
)
|
|
||||||
return livisi_climate
|
|
||||||
|
|
||||||
|
|
||||||
class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity):
|
|
||||||
"""Represents the Livisi Climate."""
|
|
||||||
|
|
||||||
_attr_hvac_modes = [HVACMode.HEAT]
|
|
||||||
_attr_hvac_mode = HVACMode.HEAT
|
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
|
||||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
|
||||||
_attr_target_temperature_high = MAX_TEMPERATURE
|
|
||||||
_attr_target_temperature_low = MIN_TEMPERATURE
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config_entry: ConfigEntry,
|
|
||||||
coordinator: LivisiDataUpdateCoordinator,
|
|
||||||
unique_id: str,
|
|
||||||
manufacturer: str,
|
|
||||||
device_type: str,
|
|
||||||
target_temperature_capability: str,
|
|
||||||
temperature_capability: str,
|
|
||||||
humidity_capability: str,
|
|
||||||
room: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the Livisi Climate."""
|
|
||||||
self.config_entry = config_entry
|
|
||||||
self._attr_unique_id = unique_id
|
|
||||||
self._target_temperature_capability = target_temperature_capability
|
|
||||||
self._temperature_capability = temperature_capability
|
|
||||||
self._humidity_capability = humidity_capability
|
|
||||||
self.aio_livisi = coordinator.aiolivisi
|
|
||||||
self._attr_available = False
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, unique_id)},
|
|
||||||
manufacturer=manufacturer,
|
|
||||||
model=device_type,
|
|
||||||
name=room,
|
|
||||||
suggested_area=room,
|
|
||||||
via_device=(DOMAIN, config_entry.entry_id),
|
|
||||||
)
|
|
||||||
super().__init__(coordinator)
|
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
|
||||||
"""Set new target temperature."""
|
|
||||||
response = await self.aio_livisi.async_vrcc_set_temperature(
|
|
||||||
self._target_temperature_capability,
|
|
||||||
kwargs.get(ATTR_TEMPERATURE),
|
|
||||||
self.coordinator.is_avatar,
|
|
||||||
)
|
|
||||||
if response is None:
|
|
||||||
self._attr_available = False
|
|
||||||
raise HomeAssistantError(f"Failed to turn off {self._attr_name}")
|
|
||||||
|
|
||||||
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
|
||||||
"""Do nothing as LIVISI devices do not support changing the hvac mode."""
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Register callbacks."""
|
|
||||||
target_temperature = await self.coordinator.async_get_vrcc_target_temperature(
|
|
||||||
self._target_temperature_capability
|
|
||||||
)
|
|
||||||
temperature = await self.coordinator.async_get_vrcc_temperature(
|
|
||||||
self._temperature_capability
|
|
||||||
)
|
|
||||||
humidity = await self.coordinator.async_get_vrcc_humidity(
|
|
||||||
self._humidity_capability
|
|
||||||
)
|
|
||||||
if temperature is None:
|
|
||||||
self._attr_current_temperature = None
|
|
||||||
self._attr_available = False
|
|
||||||
else:
|
|
||||||
self._attr_target_temperature = target_temperature
|
|
||||||
self._attr_current_temperature = temperature
|
|
||||||
self._attr_current_humidity = humidity
|
|
||||||
self.async_on_remove(
|
|
||||||
async_dispatcher_connect(
|
|
||||||
self.hass,
|
|
||||||
f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}",
|
|
||||||
self.update_target_temperature,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.async_on_remove(
|
|
||||||
async_dispatcher_connect(
|
|
||||||
self.hass,
|
|
||||||
f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}",
|
|
||||||
self.update_temperature,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.async_on_remove(
|
|
||||||
async_dispatcher_connect(
|
|
||||||
self.hass,
|
|
||||||
f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}",
|
|
||||||
self.update_humidity,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.async_on_remove(
|
|
||||||
async_dispatcher_connect(
|
|
||||||
self.hass,
|
|
||||||
f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}",
|
|
||||||
self.update_reachability,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def update_target_temperature(self, target_temperature: float) -> None:
|
|
||||||
"""Update the target temperature of the climate device."""
|
|
||||||
self._attr_target_temperature = target_temperature
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def update_temperature(self, current_temperature: float) -> None:
|
|
||||||
"""Update the current temperature of the climate device."""
|
|
||||||
self._attr_current_temperature = current_temperature
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def update_humidity(self, humidity: int) -> None:
|
|
||||||
"""Update the humidity temperature of the climate device."""
|
|
||||||
self._attr_current_humidity = humidity
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def update_reachability(self, is_reachable: bool) -> None:
|
|
||||||
"""Update the reachability of the climate device."""
|
|
||||||
self._attr_available = is_reachable
|
|
||||||
self.async_write_ha_state()
|
|
@@ -7,15 +7,12 @@ DOMAIN = "livisi"
|
|||||||
|
|
||||||
CONF_HOST = "host"
|
CONF_HOST = "host"
|
||||||
CONF_PASSWORD: Final = "password"
|
CONF_PASSWORD: Final = "password"
|
||||||
AVATAR = "Avatar"
|
|
||||||
AVATAR_PORT: Final = 9090
|
AVATAR_PORT: Final = 9090
|
||||||
CLASSIC_PORT: Final = 8080
|
CLASSIC_PORT: Final = 8080
|
||||||
DEVICE_POLLING_DELAY: Final = 60
|
DEVICE_POLLING_DELAY: Final = 60
|
||||||
LIVISI_STATE_CHANGE: Final = "livisi_state_change"
|
LIVISI_STATE_CHANGE: Final = "livisi_state_change"
|
||||||
LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change"
|
LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change"
|
||||||
|
|
||||||
PSS_DEVICE_TYPE: Final = "PSS"
|
SWITCH_PLATFORM: Final = "switch"
|
||||||
VRCC_DEVICE_TYPE: Final = "VRCC"
|
|
||||||
|
|
||||||
MAX_TEMPERATURE: Final = 30.0
|
PSS_DEVICE_TYPE: Final = "PSS"
|
||||||
MIN_TEMPERATURE: Final = 6.0
|
|
||||||
|
@@ -13,7 +13,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
AVATAR,
|
|
||||||
AVATAR_PORT,
|
AVATAR_PORT,
|
||||||
CLASSIC_PORT,
|
CLASSIC_PORT,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@@ -70,14 +69,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
|||||||
livisi_connection_data=livisi_connection_data
|
livisi_connection_data=livisi_connection_data
|
||||||
)
|
)
|
||||||
controller_data = await self.aiolivisi.async_get_controller()
|
controller_data = await self.aiolivisi.async_get_controller()
|
||||||
if (controller_type := controller_data["controllerType"]) == AVATAR:
|
if controller_data["controllerType"] == "Avatar":
|
||||||
self.port = AVATAR_PORT
|
self.port = AVATAR_PORT
|
||||||
self.is_avatar = True
|
self.is_avatar = True
|
||||||
else:
|
else:
|
||||||
self.port = CLASSIC_PORT
|
self.port = CLASSIC_PORT
|
||||||
self.is_avatar = False
|
self.is_avatar = False
|
||||||
self.controller_type = controller_type
|
|
||||||
self.serial_number = controller_data["serialNumber"]
|
self.serial_number = controller_data["serialNumber"]
|
||||||
|
self.controller_type = controller_data["controllerType"]
|
||||||
|
|
||||||
async def async_get_devices(self) -> list[dict[str, Any]]:
|
async def async_get_devices(self) -> list[dict[str, Any]]:
|
||||||
"""Set the discovered devices list."""
|
"""Set the discovered devices list."""
|
||||||
@@ -85,7 +84,7 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
|||||||
|
|
||||||
async def async_get_pss_state(self, capability: str) -> bool | None:
|
async def async_get_pss_state(self, capability: str) -> bool | None:
|
||||||
"""Set the PSS state."""
|
"""Set the PSS state."""
|
||||||
response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state(
|
response: dict[str, Any] = await self.aiolivisi.async_get_device_state(
|
||||||
capability[1:]
|
capability[1:]
|
||||||
)
|
)
|
||||||
if response is None:
|
if response is None:
|
||||||
@@ -93,35 +92,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
|||||||
on_state = response["onState"]
|
on_state = response["onState"]
|
||||||
return on_state["value"]
|
return on_state["value"]
|
||||||
|
|
||||||
async def async_get_vrcc_target_temperature(self, capability: str) -> float | None:
|
|
||||||
"""Get the target temperature of the climate device."""
|
|
||||||
response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state(
|
|
||||||
capability[1:]
|
|
||||||
)
|
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
if self.is_avatar:
|
|
||||||
return response["setpointTemperature"]["value"]
|
|
||||||
return response["pointTemperature"]["value"]
|
|
||||||
|
|
||||||
async def async_get_vrcc_temperature(self, capability: str) -> float | None:
|
|
||||||
"""Get the temperature of the climate device."""
|
|
||||||
response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state(
|
|
||||||
capability[1:]
|
|
||||||
)
|
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
return response["temperature"]["value"]
|
|
||||||
|
|
||||||
async def async_get_vrcc_humidity(self, capability: str) -> int | None:
|
|
||||||
"""Get the humidity of the climate device."""
|
|
||||||
response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state(
|
|
||||||
capability[1:]
|
|
||||||
)
|
|
||||||
if response is None:
|
|
||||||
return None
|
|
||||||
return response["humidity"]["value"]
|
|
||||||
|
|
||||||
async def async_set_all_rooms(self) -> None:
|
async def async_set_all_rooms(self) -> None:
|
||||||
"""Set the room list."""
|
"""Set the room list."""
|
||||||
response: list[dict[str, Any]] = await self.aiolivisi.async_get_all_rooms()
|
response: list[dict[str, Any]] = await self.aiolivisi.async_get_all_rooms()
|
||||||
@@ -138,12 +108,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
|||||||
f"{LIVISI_STATE_CHANGE}_{event_data.source}",
|
f"{LIVISI_STATE_CHANGE}_{event_data.source}",
|
||||||
event_data.onState,
|
event_data.onState,
|
||||||
)
|
)
|
||||||
if event_data.vrccData is not None:
|
|
||||||
async_dispatcher_send(
|
|
||||||
self.hass,
|
|
||||||
f"{LIVISI_STATE_CHANGE}_{event_data.source}",
|
|
||||||
event_data.vrccData,
|
|
||||||
)
|
|
||||||
if event_data.isReachable is not None:
|
if event_data.isReachable is not None:
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/livisi",
|
"documentation": "https://www.home-assistant.io/integrations/livisi",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["aiolivisi==0.0.16"]
|
"requirements": ["aiolivisi==0.0.15"]
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import lru_cache, wraps
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -365,6 +365,12 @@ async def webhook_stream_camera(
|
|||||||
return webhook_response(resp, registration=config_entry.data)
|
return webhook_response(resp, registration=config_entry.data)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template:
|
||||||
|
"""Return a cached template."""
|
||||||
|
return template.Template(template_str, hass)
|
||||||
|
|
||||||
|
|
||||||
@WEBHOOK_COMMANDS.register("render_template")
|
@WEBHOOK_COMMANDS.register("render_template")
|
||||||
@validate_schema(
|
@validate_schema(
|
||||||
{
|
{
|
||||||
@@ -381,7 +387,7 @@ async def webhook_render_template(
|
|||||||
resp = {}
|
resp = {}
|
||||||
for key, item in data.items():
|
for key, item in data.items():
|
||||||
try:
|
try:
|
||||||
tpl = template.Template(item[ATTR_TEMPLATE], hass)
|
tpl = _cached_template(item[ATTR_TEMPLATE], hass)
|
||||||
resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES))
|
resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES))
|
||||||
except TemplateError as ex:
|
except TemplateError as ex:
|
||||||
resp[key] = {"error": str(ex)}
|
resp[key] = {"error": str(ex)}
|
||||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from motionblinds import MotionDiscovery
|
from motionblinds import MotionDiscovery, MotionGateway
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
@@ -86,6 +86,16 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
await self.async_set_unique_id(mac_address)
|
await self.async_set_unique_id(mac_address)
|
||||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
||||||
|
|
||||||
|
gateway = MotionGateway(ip=discovery_info.ip, key="abcd1234-56ef-78")
|
||||||
|
try:
|
||||||
|
# key not needed for GetDeviceList request
|
||||||
|
await self.hass.async_add_executor_job(gateway.GetDeviceList)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
return self.async_abort(reason="not_motionblinds")
|
||||||
|
|
||||||
|
if not gateway.available:
|
||||||
|
return self.async_abort(reason="not_motionblinds")
|
||||||
|
|
||||||
short_mac = mac_address[-6:].upper()
|
short_mac = mac_address[-6:].upper()
|
||||||
self.context["title_placeholders"] = {
|
self.context["title_placeholders"] = {
|
||||||
"short_mac": short_mac,
|
"short_mac": short_mac,
|
||||||
|
@@ -28,7 +28,8 @@
|
|||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||||
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
|
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"not_motionblinds": "Discovered device is not a Motion gateway"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@@ -12,5 +12,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["nuheat"],
|
"loggers": ["nuheat"],
|
||||||
"requirements": ["nuheat==1.0.0"]
|
"requirements": ["nuheat==1.0.1"]
|
||||||
}
|
}
|
||||||
|
@@ -1,18 +1 @@
|
|||||||
"""The Obihai integration."""
|
"""The Obihai integration."""
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .const import PLATFORMS
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Set up from a config entry."""
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
||||||
|
@@ -1,73 +0,0 @@
|
|||||||
"""Config flow to configure the Obihai integration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
|
||||||
|
|
||||||
from .connectivity import validate_auth
|
|
||||||
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN
|
|
||||||
|
|
||||||
DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_HOST): str,
|
|
||||||
vol.Optional(
|
|
||||||
CONF_USERNAME,
|
|
||||||
default=DEFAULT_USERNAME,
|
|
||||||
): str,
|
|
||||||
vol.Optional(
|
|
||||||
CONF_PASSWORD,
|
|
||||||
default=DEFAULT_PASSWORD,
|
|
||||||
): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Config flow for Obihai."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle a flow initialized by the user."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
|
||||||
if await self.hass.async_add_executor_job(
|
|
||||||
validate_auth,
|
|
||||||
user_input[CONF_HOST],
|
|
||||||
user_input[CONF_USERNAME],
|
|
||||||
user_input[CONF_PASSWORD],
|
|
||||||
):
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=user_input[CONF_HOST],
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
|
|
||||||
data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input)
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
errors=errors,
|
|
||||||
data_schema=data_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
# DEPRECATED
|
|
||||||
async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
|
|
||||||
"""Handle a flow initialized by importing a config."""
|
|
||||||
self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]})
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=config.get(CONF_NAME, config[CONF_HOST]),
|
|
||||||
data={
|
|
||||||
CONF_HOST: config[CONF_HOST],
|
|
||||||
CONF_PASSWORD: config[CONF_PASSWORD],
|
|
||||||
CONF_USERNAME: config[CONF_USERNAME],
|
|
||||||
},
|
|
||||||
)
|
|
@@ -1,67 +0,0 @@
|
|||||||
"""Support for Obihai Connectivity."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pyobihai import PyObihai
|
|
||||||
|
|
||||||
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER
|
|
||||||
|
|
||||||
|
|
||||||
def get_pyobihai(
|
|
||||||
host: str,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
) -> PyObihai:
|
|
||||||
"""Retrieve an authenticated PyObihai."""
|
|
||||||
return PyObihai(host, username, password)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_auth(
|
|
||||||
host: str,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Test if the given setting works as expected."""
|
|
||||||
obi = get_pyobihai(host, username, password)
|
|
||||||
|
|
||||||
login = obi.check_account()
|
|
||||||
if not login:
|
|
||||||
LOGGER.debug("Invalid credentials")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class ObihaiConnection:
|
|
||||||
"""Contains a list of Obihai Sensors."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
username: str = DEFAULT_USERNAME,
|
|
||||||
password: str = DEFAULT_PASSWORD,
|
|
||||||
) -> None:
|
|
||||||
"""Store configuration."""
|
|
||||||
self.sensors: list = []
|
|
||||||
self.host = host
|
|
||||||
self.username = username
|
|
||||||
self.password = password
|
|
||||||
self.serial: list = []
|
|
||||||
self.services: list = []
|
|
||||||
self.line_services: list = []
|
|
||||||
self.call_direction: list = []
|
|
||||||
self.pyobihai: PyObihai = None
|
|
||||||
|
|
||||||
def update(self) -> bool:
|
|
||||||
"""Validate connection and retrieve a list of sensors."""
|
|
||||||
if not self.pyobihai:
|
|
||||||
self.pyobihai = get_pyobihai(self.host, self.username, self.password)
|
|
||||||
|
|
||||||
if not self.pyobihai.check_account():
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.serial = self.pyobihai.get_device_serial()
|
|
||||||
self.services = self.pyobihai.get_state()
|
|
||||||
self.line_services = self.pyobihai.get_line_state()
|
|
||||||
self.call_direction = self.pyobihai.get_call_direction()
|
|
||||||
|
|
||||||
return True
|
|
@@ -1,15 +0,0 @@
|
|||||||
"""Constants for the Obihai integration."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
|
|
||||||
DOMAIN: Final = "obihai"
|
|
||||||
DEFAULT_USERNAME = "admin"
|
|
||||||
DEFAULT_PASSWORD = "admin"
|
|
||||||
OBIHAI = "Obihai"
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
|
||||||
|
|
||||||
PLATFORMS: Final = [Platform.SENSOR]
|
|
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "obihai",
|
"domain": "obihai",
|
||||||
"name": "Obihai",
|
"name": "Obihai",
|
||||||
"codeowners": ["@dshokouhi", "@ejpenney"],
|
"codeowners": ["@dshokouhi"],
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyobihai"],
|
"loggers": ["pyobihai"],
|
||||||
|
@@ -2,7 +2,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pyobihai import PyObihai
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -10,19 +12,20 @@ from homeassistant.components.sensor import (
|
|||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .connectivity import ObihaiConnection
|
_LOGGER = logging.getLogger(__name__)
|
||||||
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI
|
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=5)
|
SCAN_INTERVAL = timedelta(seconds=5)
|
||||||
|
|
||||||
|
OBIHAI = "Obihai"
|
||||||
|
DEFAULT_USERNAME = "admin"
|
||||||
|
DEFAULT_PASSWORD = "admin"
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_HOST): cv.string,
|
vol.Required(CONF_HOST): cv.string,
|
||||||
@@ -32,58 +35,46 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# DEPRECATED
|
def setup_platform(
|
||||||
async def async_setup_platform(
|
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
async_add_entities: AddEntitiesCallback,
|
add_entities: AddEntitiesCallback,
|
||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Obihai sensor platform."""
|
"""Set up the Obihai sensor platform."""
|
||||||
issue_registry.async_create_issue(
|
|
||||||
hass,
|
|
||||||
DOMAIN,
|
|
||||||
"manual_migration",
|
|
||||||
breaks_in_ha_version="2023.6.0",
|
|
||||||
is_fixable=False,
|
|
||||||
severity=issue_registry.IssueSeverity.ERROR,
|
|
||||||
translation_key="manual_migration",
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.async_create_task(
|
username = config[CONF_USERNAME]
|
||||||
hass.config_entries.flow.async_init(
|
password = config[CONF_PASSWORD]
|
||||||
DOMAIN,
|
host = config[CONF_HOST]
|
||||||
context={"source": SOURCE_IMPORT},
|
|
||||||
data=config,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
|
||||||
) -> None:
|
|
||||||
"""Set up the Obihai sensor entries."""
|
|
||||||
|
|
||||||
username = entry.data[CONF_USERNAME]
|
|
||||||
password = entry.data[CONF_PASSWORD]
|
|
||||||
host = entry.data[CONF_HOST]
|
|
||||||
requester = ObihaiConnection(host, username, password)
|
|
||||||
|
|
||||||
await hass.async_add_executor_job(requester.update)
|
|
||||||
sensors = []
|
sensors = []
|
||||||
for key in requester.services:
|
|
||||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
|
||||||
|
|
||||||
if requester.line_services is not None:
|
pyobihai = PyObihai(host, username, password)
|
||||||
for key in requester.line_services:
|
|
||||||
sensors.append(
|
|
||||||
ObihaiServiceSensors(requester.pyobihai, requester.serial, key)
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in requester.call_direction:
|
login = pyobihai.check_account()
|
||||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
if not login:
|
||||||
|
_LOGGER.error("Invalid credentials")
|
||||||
|
return
|
||||||
|
|
||||||
async_add_entities(sensors, update_before_add=True)
|
serial = pyobihai.get_device_serial()
|
||||||
|
|
||||||
|
services = pyobihai.get_state()
|
||||||
|
|
||||||
|
line_services = pyobihai.get_line_state()
|
||||||
|
|
||||||
|
call_direction = pyobihai.get_call_direction()
|
||||||
|
|
||||||
|
for key in services:
|
||||||
|
sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
|
||||||
|
|
||||||
|
if line_services is not None:
|
||||||
|
for key in line_services:
|
||||||
|
sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
|
||||||
|
|
||||||
|
for key in call_direction:
|
||||||
|
sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
|
||||||
|
|
||||||
|
add_entities(sensors)
|
||||||
|
|
||||||
|
|
||||||
class ObihaiServiceSensors(SensorEntity):
|
class ObihaiServiceSensors(SensorEntity):
|
||||||
@@ -157,10 +148,6 @@ class ObihaiServiceSensors(SensorEntity):
|
|||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Update the sensor."""
|
"""Update the sensor."""
|
||||||
if not self._pyobihai.check_account():
|
|
||||||
self._state = None
|
|
||||||
return
|
|
||||||
|
|
||||||
services = self._pyobihai.get_state()
|
services = self._pyobihai.get_state()
|
||||||
|
|
||||||
if self._service_name in services:
|
if self._service_name in services:
|
||||||
|
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
|
||||||
"username": "[%key:common::config_flow::data::username%]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"issues": {
|
|
||||||
"manual_migration": {
|
|
||||||
"title": "Manual migration required for Obihai",
|
|
||||||
"description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,7 +3,6 @@ import asyncio
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
import pyotgw
|
import pyotgw
|
||||||
import pyotgw.vars as gw_vars
|
import pyotgw.vars as gw_vars
|
||||||
from serial import SerialException
|
from serial import SerialException
|
||||||
@@ -113,8 +112,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
config_entry.add_update_listener(options_updated)
|
config_entry.add_update_listener(options_updated)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
await asyncio.wait_for(
|
||||||
await gateway.connect_and_subscribe()
|
gateway.connect_and_subscribe(),
|
||||||
|
timeout=CONNECTION_TIMEOUT,
|
||||||
|
)
|
||||||
except (asyncio.TimeoutError, ConnectionError, SerialException) as ex:
|
except (asyncio.TimeoutError, ConnectionError, SerialException) as ex:
|
||||||
await gateway.cleanup()
|
await gateway.cleanup()
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
import pyotgw
|
import pyotgw
|
||||||
from pyotgw import vars as gw_vars
|
from pyotgw import vars as gw_vars
|
||||||
from serial import SerialException
|
from serial import SerialException
|
||||||
@@ -69,8 +68,10 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
await asyncio.wait_for(
|
||||||
await test_connection()
|
test_connection(),
|
||||||
|
timeout=CONNECTION_TIMEOUT,
|
||||||
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return self._show_form({"base": "timeout_connect"})
|
return self._show_form({"base": "timeout_connect"})
|
||||||
except (ConnectionError, SerialException):
|
except (ConnectionError, SerialException):
|
||||||
|
@@ -9,11 +9,14 @@ from typing import Any, Concatenate, ParamSpec, TypeVar
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
|
from python_otbr_api import tlv_parser
|
||||||
|
from python_otbr_api.pskc import compute_pskc
|
||||||
|
|
||||||
from homeassistant.components.thread import async_add_dataset
|
from homeassistant.components.thread import async_add_dataset
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
@@ -23,6 +26,18 @@ from .const import DOMAIN
|
|||||||
_R = TypeVar("_R")
|
_R = TypeVar("_R")
|
||||||
_P = ParamSpec("_P")
|
_P = ParamSpec("_P")
|
||||||
|
|
||||||
|
INSECURE_NETWORK_KEYS = (
|
||||||
|
# Thread web UI default
|
||||||
|
bytes.fromhex("00112233445566778899AABBCCDDEEFF"),
|
||||||
|
)
|
||||||
|
|
||||||
|
INSECURE_PASSPHRASES = (
|
||||||
|
# Thread web UI default
|
||||||
|
"j01Nme",
|
||||||
|
# Thread documentation default
|
||||||
|
"J01NME",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _handle_otbr_error(
|
def _handle_otbr_error(
|
||||||
func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]
|
func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]
|
||||||
@@ -70,21 +85,65 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_on_default_network_settings(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes
|
||||||
|
) -> None:
|
||||||
|
"""Warn user if insecure default network settings are used."""
|
||||||
|
dataset = tlv_parser.parse_tlv(dataset_tlvs.hex())
|
||||||
|
insecure = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY)
|
||||||
|
) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS:
|
||||||
|
insecure = True
|
||||||
|
if (
|
||||||
|
not insecure
|
||||||
|
and tlv_parser.MeshcopTLVType.EXTPANID in dataset
|
||||||
|
and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset
|
||||||
|
and tlv_parser.MeshcopTLVType.PSKC in dataset
|
||||||
|
):
|
||||||
|
ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID]
|
||||||
|
network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME]
|
||||||
|
pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC])
|
||||||
|
for passphrase in INSECURE_PASSPHRASES:
|
||||||
|
if pskc == compute_pskc(ext_pan_id, network_name, passphrase):
|
||||||
|
insecure = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if insecure:
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"insecure_thread_network_{entry.entry_id}",
|
||||||
|
is_fixable=False,
|
||||||
|
is_persistent=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key="insecure_thread_network",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ir.async_delete_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"insecure_thread_network_{entry.entry_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up an Open Thread Border Router config entry."""
|
"""Set up an Open Thread Border Router config entry."""
|
||||||
api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10)
|
api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10)
|
||||||
|
|
||||||
otbrdata = OTBRData(entry.data["url"], api)
|
otbrdata = OTBRData(entry.data["url"], api)
|
||||||
try:
|
try:
|
||||||
dataset = await otbrdata.get_active_dataset_tlvs()
|
dataset_tlvs = await otbrdata.get_active_dataset_tlvs()
|
||||||
except (
|
except (
|
||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
aiohttp.ClientError,
|
aiohttp.ClientError,
|
||||||
asyncio.TimeoutError,
|
asyncio.TimeoutError,
|
||||||
) as err:
|
) as err:
|
||||||
raise ConfigEntryNotReady("Unable to connect") from err
|
raise ConfigEntryNotReady("Unable to connect") from err
|
||||||
if dataset:
|
if dataset_tlvs:
|
||||||
await async_add_dataset(hass, entry.title, dataset.hex())
|
_warn_on_default_network_settings(hass, entry, dataset_tlvs)
|
||||||
|
await async_add_dataset(hass, entry.title, dataset_tlvs.hex())
|
||||||
|
|
||||||
hass.data[DOMAIN] = otbrdata
|
hass.data[DOMAIN] = otbrdata
|
||||||
|
|
||||||
|
@@ -6,6 +6,7 @@ import logging
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
|
from python_otbr_api import tlv_parser
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.hassio import HassioServiceInfo
|
from homeassistant.components.hassio import HassioServiceInfo
|
||||||
@@ -15,7 +16,7 @@ from homeassistant.const import CONF_URL
|
|||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,11 +30,26 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Connect to the OTBR and create a dataset if it doesn't have one."""
|
"""Connect to the OTBR and create a dataset if it doesn't have one."""
|
||||||
api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10)
|
api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10)
|
||||||
if await api.get_active_dataset_tlvs() is None:
|
if await api.get_active_dataset_tlvs() is None:
|
||||||
if dataset := await async_get_preferred_dataset(self.hass):
|
# We currently have no way to know which channel zha is using, assume it's
|
||||||
await api.set_active_dataset_tlvs(bytes.fromhex(dataset))
|
# the default
|
||||||
|
zha_channel = DEFAULT_CHANNEL
|
||||||
|
thread_dataset_channel = None
|
||||||
|
thread_dataset_tlv = await async_get_preferred_dataset(self.hass)
|
||||||
|
if thread_dataset_tlv:
|
||||||
|
dataset = tlv_parser.parse_tlv(thread_dataset_tlv)
|
||||||
|
if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL):
|
||||||
|
thread_dataset_channel = int(channel_str, base=16)
|
||||||
|
|
||||||
|
if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel:
|
||||||
|
await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv))
|
||||||
else:
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"not importing TLV with channel %s", thread_dataset_channel
|
||||||
|
)
|
||||||
await api.create_active_dataset(
|
await api.create_active_dataset(
|
||||||
python_otbr_api.OperationalDataSet(network_name="home-assistant")
|
python_otbr_api.OperationalDataSet(
|
||||||
|
channel=zha_channel, network_name="home-assistant"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await api.set_enabled(True)
|
await api.set_enabled(True)
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
"""Constants for the Open Thread Border Router integration."""
|
"""Constants for the Open Thread Border Router integration."""
|
||||||
|
|
||||||
DOMAIN = "otbr"
|
DOMAIN = "otbr"
|
||||||
|
|
||||||
|
DEFAULT_CHANNEL = 15
|
||||||
|
@@ -8,5 +8,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["python-otbr-api==1.0.4"]
|
"requirements": ["python-otbr-api==1.0.5"]
|
||||||
}
|
}
|
||||||
|
@@ -12,7 +12,13 @@
|
|||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"insecure_thread_network": {
|
||||||
|
"title": "Insecure Thread network settings detected",
|
||||||
|
"description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import (
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import OTBRData
|
from . import OTBRData
|
||||||
@@ -70,6 +70,10 @@ async def websocket_create_network(
|
|||||||
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# We currently have no way to know which channel zha is using, assume it's
|
||||||
|
# the default
|
||||||
|
zha_channel = DEFAULT_CHANNEL
|
||||||
|
|
||||||
data: OTBRData = hass.data[DOMAIN]
|
data: OTBRData = hass.data[DOMAIN]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -80,7 +84,9 @@ async def websocket_create_network(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await data.create_active_dataset(
|
await data.create_active_dataset(
|
||||||
python_otbr_api.OperationalDataSet(network_name="home-assistant")
|
python_otbr_api.OperationalDataSet(
|
||||||
|
channel=zha_channel, network_name="home-assistant"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except HomeAssistantError as exc:
|
except HomeAssistantError as exc:
|
||||||
connection.send_error(msg["id"], "create_active_dataset_failed", str(exc))
|
connection.send_error(msg["id"], "create_active_dataset_failed", str(exc))
|
||||||
|
@@ -10,7 +10,6 @@ from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation
|
|||||||
from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
|
from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
|
||||||
from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl
|
from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl
|
||||||
from .somfy_thermostat import SomfyThermostat
|
from .somfy_thermostat import SomfyThermostat
|
||||||
from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface
|
|
||||||
|
|
||||||
WIDGET_TO_CLIMATE_ENTITY = {
|
WIDGET_TO_CLIMATE_ENTITY = {
|
||||||
UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater,
|
UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater,
|
||||||
@@ -22,5 +21,4 @@ WIDGET_TO_CLIMATE_ENTITY = {
|
|||||||
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone,
|
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone,
|
||||||
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
|
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
|
||||||
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
|
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
|
||||||
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
|
|
||||||
}
|
}
|
||||||
|
@@ -15,7 +15,6 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import UnitOfTemperature
|
from homeassistant.const import UnitOfTemperature
|
||||||
|
|
||||||
from ..const import DOMAIN
|
|
||||||
from ..entity import OverkizEntity
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
PRESET_COMFORT1 = "comfort-1"
|
PRESET_COMFORT1 = "comfort-1"
|
||||||
@@ -48,7 +47,6 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity):
|
|||||||
_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
|
_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
|
||||||
_attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
_attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_translation_key = DOMAIN
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hvac_mode(self) -> HVACMode:
|
def hvac_mode(self) -> HVACMode:
|
||||||
|
@@ -16,7 +16,6 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||||
|
|
||||||
from ..const import DOMAIN
|
|
||||||
from ..coordinator import OverkizDataUpdateCoordinator
|
from ..coordinator import OverkizDataUpdateCoordinator
|
||||||
from ..entity import OverkizEntity
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
|||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
)
|
)
|
||||||
_attr_translation_key = DOMAIN
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||||
|
|
||||||
from ..const import DOMAIN
|
|
||||||
from ..coordinator import OverkizDataUpdateCoordinator
|
from ..coordinator import OverkizDataUpdateCoordinator
|
||||||
from ..entity import OverkizEntity
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
|
|||||||
_attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ]
|
_attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ]
|
||||||
_attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
|
_attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_translation_key = DOMAIN
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
@@ -13,7 +13,6 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import UnitOfTemperature
|
from homeassistant.const import UnitOfTemperature
|
||||||
|
|
||||||
from ..const import DOMAIN
|
|
||||||
from ..coordinator import OverkizDataUpdateCoordinator
|
from ..coordinator import OverkizDataUpdateCoordinator
|
||||||
from ..entity import OverkizEntity
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
|
|||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE
|
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE
|
||||||
)
|
)
|
||||||
_attr_translation_key = DOMAIN
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
@@ -17,7 +17,6 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||||
|
|
||||||
from ..const import DOMAIN
|
|
||||||
from ..coordinator import OverkizDataUpdateCoordinator
|
from ..coordinator import OverkizDataUpdateCoordinator
|
||||||
from ..entity import OverkizEntity
|
from ..entity import OverkizEntity
|
||||||
|
|
||||||
@@ -79,7 +78,6 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
|
|||||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
||||||
)
|
)
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_translation_key = DOMAIN
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user