mirror of
https://github.com/home-assistant/core.git
synced 2025-09-20 02:19:36 +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/linux_battery/sensor.py
|
||||
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/logi_circle/__init__.py
|
||||
homeassistant/components/logi_circle/camera.py
|
||||
@@ -807,8 +803,7 @@ omit =
|
||||
homeassistant/components/nuki/sensor.py
|
||||
homeassistant/components/nx584/alarm_control_panel.py
|
||||
homeassistant/components/oasa_telematics/sensor.py
|
||||
homeassistant/components/obihai/connectivity.py
|
||||
homeassistant/components/obihai/sensor.py
|
||||
homeassistant/components/obihai/*
|
||||
homeassistant/components/octoprint/__init__.py
|
||||
homeassistant/components/oem/climate.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
|
||||
PIP_CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 4
|
||||
HA_SHORT_VERSION: 2023.4
|
||||
HA_SHORT_VERSION: 2023.3
|
||||
DEFAULT_PYTHON: "3.10"
|
||||
ALL_PYTHON_VERSIONS: "['3.10', '3.11']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -1073,10 +1073,10 @@ jobs:
|
||||
ffmpeg \
|
||||
postgresql-server-dev-14
|
||||
- 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 }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.5.0
|
||||
uses: actions/setup-python@v4.3.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
@@ -186,7 +186,6 @@ homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.local_ip.*
|
||||
homeassistant.components.lock.*
|
||||
|
@@ -825,8 +825,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nzbget/ @chriscla
|
||||
/tests/components/nzbget/ @chriscla
|
||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
||||
/tests/components/obihai/ @dshokouhi @ejpenney
|
||||
/homeassistant/components/obihai/ @dshokouhi
|
||||
/homeassistant/components/octoprint/ @rfleming71
|
||||
/tests/components/octoprint/ @rfleming71
|
||||
/homeassistant/components/ohmconnect/ @robbiet480
|
||||
@@ -1101,6 +1100,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/smhi/ @gjohansson-ST
|
||||
/tests/components/smhi/ @gjohansson-ST
|
||||
/homeassistant/components/sms/ @ocalvo
|
||||
/homeassistant/components/snapcast/ @luar123
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck
|
||||
@@ -1139,8 +1139,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/starline/ @anonym-tsk
|
||||
/homeassistant/components/starlink/ @boswelja
|
||||
/tests/components/starlink/ @boswelja
|
||||
/homeassistant/components/statistics/ @ThomDietrich
|
||||
/tests/components/statistics/ @ThomDietrich
|
||||
/homeassistant/components/statistics/ @fabaff @ThomDietrich
|
||||
/tests/components/statistics/ @fabaff @ThomDietrich
|
||||
/homeassistant/components/steam_online/ @tkdrob
|
||||
/tests/components/steam_online/ @tkdrob
|
||||
/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(
|
||||
key="co",
|
||||
name="CO",
|
||||
device_class=SensorDeviceClass.CO,
|
||||
native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("co"),
|
||||
@@ -289,7 +288,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
AirQEntityDescription(
|
||||
key="tvoc",
|
||||
name="VOC",
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("tvoc"),
|
||||
@@ -297,7 +295,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
AirQEntityDescription(
|
||||
key="tvoc_ionsc",
|
||||
name="VOC (Industrial)",
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("tvoc_ionsc"),
|
||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from AIOAladdinConnect import AladdinConnectClient
|
||||
@@ -19,6 +20,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CLIENT_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
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
|
||||
)
|
||||
|
||||
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):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
@@ -2,24 +2,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from AIOAladdinConnect import AladdinConnectClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
|
||||
from homeassistant.components.cover import (
|
||||
PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA,
|
||||
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.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
|
@@ -5,7 +5,6 @@ import asyncio
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import aiohttp
|
||||
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.significant_change import create_checker
|
||||
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 .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
||||
@@ -164,10 +162,9 @@ async def async_send_changereport_message(
|
||||
if response.status == HTTPStatus.ACCEPTED:
|
||||
return
|
||||
|
||||
response_json = json_loads_object(response_text)
|
||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
||||
response_json = json.loads(response_text)
|
||||
|
||||
if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
||||
if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
||||
if invalidate_access_token:
|
||||
# Invalidate the access token and try again
|
||||
config.async_invalidate_access_token()
|
||||
@@ -183,8 +180,8 @@ async def async_send_changereport_message(
|
||||
_LOGGER.error(
|
||||
"Error when sending ChangeReport for %s to Alexa: %s: %s",
|
||||
alexa_entity.entity_id,
|
||||
response_payload["code"],
|
||||
response_payload["description"],
|
||||
response_json["payload"]["code"],
|
||||
response_json["payload"]["description"],
|
||||
)
|
||||
|
||||
|
||||
@@ -302,12 +299,11 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity):
|
||||
if response.status == HTTPStatus.ACCEPTED:
|
||||
return
|
||||
|
||||
response_json = json_loads_object(response_text)
|
||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
||||
response_json = json.loads(response_text)
|
||||
|
||||
_LOGGER.error(
|
||||
"Error when sending DoorbellPress event for %s to Alexa: %s: %s",
|
||||
alexa_entity.entity_id,
|
||||
response_payload["code"],
|
||||
response_payload["description"],
|
||||
response_json["payload"]["code"],
|
||||
response_json["payload"]["description"],
|
||||
)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Rest API for Home Assistant."""
|
||||
import asyncio
|
||||
from functools import lru_cache
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
@@ -350,6 +351,12 @@ class APIComponentsView(HomeAssistantView):
|
||||
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):
|
||||
"""View to handle Template requests."""
|
||||
|
||||
@@ -362,7 +369,7 @@ class APITemplateView(HomeAssistantView):
|
||||
raise Unauthorized()
|
||||
try:
|
||||
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)
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message(
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apprise",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"requirements": ["apprise==1.3.0"]
|
||||
"requirements": ["apprise==1.2.1"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aurora",
|
||||
"iot_class": "cloud_polling",
|
||||
"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)
|
||||
)
|
||||
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()
|
||||
|
||||
@hass_callback
|
||||
def _cancel() -> None:
|
||||
self._save_history()
|
||||
self._async_save_history()
|
||||
self._async_stop_scanner_watchdog()
|
||||
cancel_track()
|
||||
cancel_stop()
|
||||
|
||||
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."""
|
||||
self._storage.async_set_advertisement_history(
|
||||
self.source,
|
||||
@@ -252,6 +253,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
),
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
|
||||
"""Expire old devices."""
|
||||
now = MONOTONIC_TIME()
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"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,
|
||||
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)
|
||||
(BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}",
|
||||
|
@@ -3,7 +3,6 @@
|
||||
DOMAIN = "conversation"
|
||||
|
||||
DEFAULT_EXPOSED_DOMAINS = {
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
@@ -17,5 +16,3 @@ DEFAULT_EXPOSED_DOMAINS = {
|
||||
"vacuum",
|
||||
"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 .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__)
|
||||
_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".
|
||||
state1: core.State | None = None
|
||||
if intent_response.matched_states:
|
||||
state1 = intent_response.matched_states[0]
|
||||
state1 = matched[0]
|
||||
elif intent_response.unmatched_states:
|
||||
state1 = intent_response.unmatched_states[0]
|
||||
state1 = unmatched[0]
|
||||
|
||||
# Render response template
|
||||
speech = response_template.async_render(
|
||||
@@ -479,12 +479,6 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
for state in states:
|
||||
# Checked against "requires_context" and "excludes_context" in hassil
|
||||
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)
|
||||
if entity is not None:
|
||||
@@ -524,9 +518,6 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
for alias in area.aliases:
|
||||
area_names.append((alias, area.id))
|
||||
|
||||
_LOGGER.debug("Exposed areas: %s", area_names)
|
||||
_LOGGER.debug("Exposed entities: %s", entity_names)
|
||||
|
||||
self._slot_lists = {
|
||||
"area": TextSlotList.from_tuples(area_names, allow_template=False),
|
||||
"name": TextSlotList.from_tuples(entity_names, allow_template=False),
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"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
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
@@ -135,7 +131,6 @@ class DerivativeSensor(RestoreEntity, SensorEntity):
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_should_poll = False
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -8,7 +8,6 @@ import voluptuous as vol
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||
@@ -18,13 +17,24 @@ if TYPE_CHECKING:
|
||||
from homeassistant.helpers import condition
|
||||
|
||||
|
||||
class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol):
|
||||
class DeviceAutomationConditionProtocol(Protocol):
|
||||
"""Define the format of device_condition modules.
|
||||
|
||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config
|
||||
from ConditionProtocol.
|
||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
|
||||
"""
|
||||
|
||||
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(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> dict[str, vol.Schema]:
|
||||
@@ -52,4 +62,4 @@ async def async_condition_from_config(
|
||||
platform = await async_get_device_automation_platform(
|
||||
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 .models import DormakabaDkeyData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -132,7 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
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")
|
||||
except dkey_errors.InvalidActivationCode:
|
||||
errors["base"] = "invalid_code"
|
||||
|
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
|
||||
"integration_type": "device",
|
||||
"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 datetime import datetime, timedelta
|
||||
from random import randint
|
||||
|
||||
from enturclient import EnturPublicTransportData
|
||||
import voluptuous as vol
|
||||
@@ -22,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
API_CLIENT_NAME = "homeassistant-homeassistant"
|
||||
API_CLIENT_NAME = "homeassistant-{}"
|
||||
|
||||
CONF_STOP_IDS = "stop_ids"
|
||||
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]
|
||||
|
||||
data = EnturPublicTransportData(
|
||||
API_CLIENT_NAME,
|
||||
API_CLIENT_NAME.format(str(randint(100000, 999999))),
|
||||
stops=stops,
|
||||
quays=quays,
|
||||
line_whitelist=line_whitelist,
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"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."]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"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
|
||||
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 (
|
||||
("service_worker.js", 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",
|
||||
"integration_type": "system",
|
||||
"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)
|
||||
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:
|
||||
"""Return the current running hvac operation if supported."""
|
||||
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"):
|
||||
return HVACAction.OFF
|
||||
if self._zone.data["_state"].get("bOutRequestHeat"):
|
||||
return HVACAction.HEATING
|
||||
return HVACAction.IDLE
|
||||
return None
|
||||
|
||||
|
@@ -42,7 +42,7 @@ async def async_setup_platform(
|
||||
[
|
||||
GeniusSwitch(broker, z)
|
||||
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)
|
||||
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(
|
||||
self.hass.config.units.temperature_unit
|
||||
),
|
||||
"queryOnlyTemperatureSetting": True,
|
||||
"queryOnlyTemperatureControl": True,
|
||||
"temperatureRange": {
|
||||
"minThresholdCelsius": -100,
|
||||
"maxThresholdCelsius": 100,
|
||||
|
@@ -36,6 +36,7 @@ X_AUTH_TOKEN = "X-Supervisor-Token"
|
||||
X_INGRESS_PATH = "X-Ingress-Path"
|
||||
X_HASS_USER_ID = "X-Hass-User-ID"
|
||||
X_HASS_IS_ADMIN = "X-Hass-Is-Admin"
|
||||
X_HASS_SOURCE = "X-Hass-Source"
|
||||
|
||||
WS_TYPE = "type"
|
||||
WS_ID = "id"
|
||||
|
@@ -17,7 +17,7 @@ from homeassistant.const import SERVER_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
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__)
|
||||
|
||||
@@ -445,6 +445,8 @@ class HassIO:
|
||||
payload=None,
|
||||
timeout=10,
|
||||
return_text=False,
|
||||
*,
|
||||
source="core.handler",
|
||||
):
|
||||
"""Send API command to Hass.io.
|
||||
|
||||
@@ -458,7 +460,8 @@ class HassIO:
|
||||
headers={
|
||||
aiohttp.hdrs.AUTHORIZATION: (
|
||||
f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
|
||||
)
|
||||
),
|
||||
X_HASS_SOURCE: source,
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
)
|
||||
|
@@ -6,6 +6,7 @@ from http import HTTPStatus
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
@@ -19,13 +20,16 @@ from aiohttp.hdrs import (
|
||||
TRANSFER_ENCODING,
|
||||
)
|
||||
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.core import HomeAssistant
|
||||
|
||||
from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID
|
||||
from .const import X_HASS_SOURCE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,23 +38,53 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 1024
|
||||
# pylint: disable=implicit-str-concat
|
||||
NO_TIMEOUT = re.compile(
|
||||
r"^(?:"
|
||||
r"|homeassistant/update"
|
||||
r"|hassos/update"
|
||||
r"|hassos/update/cli"
|
||||
r"|supervisor/update"
|
||||
r"|addons/[^/]+/(?:update|install|rebuild)"
|
||||
r"|backups/.+/full"
|
||||
r"|backups/.+/partial"
|
||||
r"|backups/[^/]+/(?:upload|download)"
|
||||
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
|
||||
# fmt: on
|
||||
|
||||
|
||||
class HassIOView(HomeAssistantView):
|
||||
@@ -65,38 +99,66 @@ class HassIOView(HomeAssistantView):
|
||||
self._host = host
|
||||
self._websession = websession
|
||||
|
||||
async def _handle(
|
||||
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:
|
||||
async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""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)
|
||||
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
|
||||
# No bullshit
|
||||
if path != unquote(path):
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
hass: HomeAssistant = request.app["hass"]
|
||||
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:
|
||||
client = await self._websession.request(
|
||||
method=request.method,
|
||||
url=f"http://{self._host}/{path}",
|
||||
url=f"http://{self._host}/{quote(path)}",
|
||||
params=request.query,
|
||||
data=request.content,
|
||||
headers=headers,
|
||||
@@ -123,20 +185,8 @@ class HassIOView(HomeAssistantView):
|
||||
|
||||
raise HTTPBadGateway()
|
||||
|
||||
|
||||
def _init_header(request: web.Request) -> dict[istr, str]:
|
||||
"""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
|
||||
get = _handle
|
||||
post = _handle
|
||||
|
||||
|
||||
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):
|
||||
return ClientTimeout(connect=10, total=None)
|
||||
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
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout, hdrs, web
|
||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
|
||||
from multidict import CIMultiDict
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
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__)
|
||||
|
||||
@@ -42,9 +44,19 @@ class HassIOIngress(HomeAssistantView):
|
||||
self._host = host
|
||||
self._websession = websession
|
||||
|
||||
@lru_cache
|
||||
def _create_url(self, token: str, path: str) -> str:
|
||||
"""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(
|
||||
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
|
||||
headers[name] = value
|
||||
|
||||
# Inject token / cleanup later on Supervisor
|
||||
headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
|
||||
# Ingress information
|
||||
headers[X_HASS_SOURCE] = "core.ingress"
|
||||
headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}"
|
||||
|
||||
# Set X-Forwarded-For
|
||||
|
@@ -116,6 +116,7 @@ async def websocket_supervisor_api(
|
||||
method=msg[ATTR_METHOD],
|
||||
timeout=msg.get(ATTR_TIMEOUT, 10),
|
||||
payload=msg.get(ATTR_DATA, {}),
|
||||
source="core.websocket_api",
|
||||
)
|
||||
|
||||
if result.get(ATTR_RESULT) == "error":
|
||||
|
@@ -1,7 +1,6 @@
|
||||
"""Config flow for HLK-SW16."""
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
from hlk_sw16 import create_hlk_sw16_connection
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -36,8 +35,7 @@ async def connect_client(hass, user_input):
|
||||
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
|
||||
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
|
||||
)
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
return await client_aw
|
||||
return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, user_input):
|
||||
|
@@ -14,7 +14,6 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
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."""
|
||||
try:
|
||||
await self._device.refresh()
|
||||
self._attr_available = True
|
||||
except (
|
||||
aiosomecomfort.SomeComfortError,
|
||||
OSError,
|
||||
@@ -428,8 +429,10 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
try:
|
||||
await self._data.client.login()
|
||||
|
||||
except aiosomecomfort.SomeComfortError:
|
||||
except aiosomecomfort.AuthError:
|
||||
self._attr_available = False
|
||||
await self.hass.async_create_task(
|
||||
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",
|
||||
"iot_class": "cloud_polling",
|
||||
"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 re
|
||||
from typing import Final
|
||||
from urllib.parse import unquote
|
||||
|
||||
from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware
|
||||
|
||||
@@ -39,18 +40,24 @@ FILTERS: Final = re.compile(
|
||||
def setup_security_filter(app: Application) -> None:
|
||||
"""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
|
||||
async def security_filter_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
) -> StreamResponse:
|
||||
"""Process request and tblock commonly known exploit attempts."""
|
||||
if FILTERS.search(request.path):
|
||||
"""Process request and block commonly known exploit attempts."""
|
||||
if FILTERS.search(_recursive_unquote(request.path)):
|
||||
_LOGGER.warning(
|
||||
"Filtered a potential harmful request to: %s", request.raw_path
|
||||
)
|
||||
raise HTTPBadRequest
|
||||
|
||||
if FILTERS.search(request.query_string):
|
||||
if FILTERS.search(_recursive_unquote(request.query_string)):
|
||||
_LOGGER.warning(
|
||||
"Filtered a request with a potential harmful query string: %s",
|
||||
request.raw_path,
|
||||
|
@@ -35,6 +35,7 @@ TRIGGER_TYPE = {
|
||||
"remote_double_button_long_press": "both {subtype} released after long press",
|
||||
"remote_double_button_short_press": "both {subtype} released",
|
||||
"initial_press": "{subtype} pressed initially",
|
||||
"long_press": "{subtype} long press",
|
||||
"repeat": "{subtype} held down",
|
||||
"short_release": "{subtype} released after short press",
|
||||
"long_release": "{subtype} released after long press",
|
||||
|
@@ -11,6 +11,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohue==4.6.1"],
|
||||
"requirements": ["aiohue==4.6.2"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
@@ -118,13 +118,14 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity):
|
||||
"""Return device (service) info."""
|
||||
# we create a virtual service/device for Hue scenes
|
||||
# so we have a parent for grouped lights and scenes
|
||||
group_type = self.group.type.value.title()
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.group.id)},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=self.group.metadata.name,
|
||||
manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name,
|
||||
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),
|
||||
)
|
||||
|
||||
|
@@ -46,6 +46,7 @@ DEFAULT_BUTTON_EVENT_TYPES = (
|
||||
ButtonEvent.INITIAL_PRESS,
|
||||
ButtonEvent.REPEAT,
|
||||
ButtonEvent.SHORT_RELEASE,
|
||||
ButtonEvent.LONG_PRESS,
|
||||
ButtonEvent.LONG_RELEASE,
|
||||
)
|
||||
|
||||
|
@@ -55,7 +55,13 @@ class HueBaseEntity(Entity):
|
||||
self._attr_unique_id = resource.id
|
||||
# device is precreated in main handler
|
||||
# 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(
|
||||
identifiers={(DOMAIN, self.device.id)},
|
||||
)
|
||||
@@ -137,17 +143,14 @@ class HueBaseEntity(Entity):
|
||||
def _handle_event(self, event_type: EventType, resource: HueResource) -> None:
|
||||
"""Handle status event for this resource (or it's parent)."""
|
||||
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.
|
||||
if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE):
|
||||
dev_reg = async_get_device_registry(self.hass)
|
||||
if device := dev_reg.async_get_device({(DOMAIN, resource.id)}):
|
||||
dev_reg.async_remove_device(device.id)
|
||||
if resource.type in (
|
||||
ResourceTypes.GROUPED_LIGHT,
|
||||
ResourceTypes.SCENE,
|
||||
ResourceTypes.SMART_SCENE,
|
||||
):
|
||||
# cleanup entities that are not strictly device-bound and have the bridge as parent
|
||||
if self.device is None:
|
||||
ent_reg = async_get_entity_registry(self.hass)
|
||||
ent_reg.async_remove(self.entity_id)
|
||||
return
|
||||
|
@@ -153,6 +153,7 @@ async def async_setup_entry( # noqa: C901
|
||||
system.serial,
|
||||
svc_exception,
|
||||
)
|
||||
await system.aqualink.close()
|
||||
else:
|
||||
cur = system.online
|
||||
if cur and not prev:
|
||||
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
|
||||
import httpx
|
||||
from iaqualink.exception import AqualinkServiceException
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -12,5 +13,5 @@ async def await_or_reraise(awaitable: Awaitable) -> None:
|
||||
"""Execute API call while catching service exceptions."""
|
||||
try:
|
||||
await awaitable
|
||||
except AqualinkServiceException as svc_exception:
|
||||
except (AqualinkServiceException, httpx.HTTPError) as svc_exception:
|
||||
raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception
|
||||
|
@@ -17,8 +17,8 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.3.2",
|
||||
"insteon-frontend-home-assistant==0.3.2"
|
||||
"pyinsteon==1.3.4",
|
||||
"insteon-frontend-home-assistant==0.3.3"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
|
@@ -1,11 +1,13 @@
|
||||
"""Utilities used by insteon component."""
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from pyinsteon import devices
|
||||
from pyinsteon.address import Address
|
||||
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 (
|
||||
async_enter_linking_mode,
|
||||
async_enter_unlinking_mode,
|
||||
@@ -27,7 +29,7 @@ from homeassistant.const import (
|
||||
CONF_PLATFORM,
|
||||
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.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
@@ -89,49 +91,52 @@ from .schemas import (
|
||||
_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."""
|
||||
|
||||
@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.
|
||||
if button and button[-2] == "_":
|
||||
button_id = button[-1].lower()
|
||||
else:
|
||||
button_id = None
|
||||
|
||||
schema = {CONF_ADDRESS: address}
|
||||
schema = {CONF_ADDRESS: address, "group": group}
|
||||
if button_id:
|
||||
schema[EVENT_CONF_BUTTON] = button_id
|
||||
if name == ON_EVENT:
|
||||
event = EVENT_GROUP_ON
|
||||
if name == OFF_EVENT:
|
||||
elif name == OFF_EVENT:
|
||||
event = EVENT_GROUP_OFF
|
||||
if name == ON_FAST_EVENT:
|
||||
elif name == ON_FAST_EVENT:
|
||||
event = EVENT_GROUP_ON_FAST
|
||||
if name == OFF_FAST_EVENT:
|
||||
elif name == OFF_FAST_EVENT:
|
||||
event = EVENT_GROUP_OFF_FAST
|
||||
else:
|
||||
event = f"insteon.{name}"
|
||||
_LOGGER.debug("Firing event %s with %s", event, schema)
|
||||
hass.bus.async_fire(event, schema)
|
||||
|
||||
for group in device.events:
|
||||
if isinstance(group, int):
|
||||
for event in device.events[group]:
|
||||
if event in [
|
||||
OFF_EVENT,
|
||||
ON_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
|
||||
)
|
||||
for name_or_group, event in device.events.items():
|
||||
if isinstance(name_or_group, int):
|
||||
for _, event in device.events[name_or_group].items():
|
||||
_register_event(event, async_fire_group_on_off_event)
|
||||
else:
|
||||
_register_event(event, async_fire_group_on_off_event)
|
||||
|
||||
|
||||
def register_new_device_callback(hass):
|
||||
|
@@ -1,13 +1,22 @@
|
||||
"""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.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
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 .coordinator import IslamicPrayerDataUpdateCoordinator
|
||||
from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
"""Set up the Islamic Prayer Component."""
|
||||
coordinator = IslamicPrayerDataUpdateCoordinator(hass)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
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)
|
||||
client = IslamicPrayerClient(hass, config_entry)
|
||||
hass.data[DOMAIN] = client
|
||||
await client.async_setup()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload Islamic Prayer entry from config_entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN)
|
||||
if coordinator.event_unsub:
|
||||
coordinator.event_unsub()
|
||||
return unload_ok
|
||||
if hass.data[DOMAIN].event_unsub:
|
||||
hass.data[DOMAIN].event_unsub()
|
||||
hass.data.pop(DOMAIN)
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Triggered by config entry options updates."""
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN]
|
||||
if coordinator.event_unsub:
|
||||
coordinator.event_unsub()
|
||||
await coordinator.async_request_refresh()
|
||||
class IslamicPrayerClient:
|
||||
"""Islamic Prayer Client Object."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the Islamic Prayer client."""
|
||||
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."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
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
|
||||
|
||||
@@ -25,9 +22,7 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return IslamicPrayerOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
@@ -45,9 +40,7 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
@@ -1,12 +1,23 @@
|
||||
"""Constants for the Islamic Prayer component."""
|
||||
from typing import Final
|
||||
|
||||
from prayer_times_calculator import PrayerTimesCalculator
|
||||
|
||||
DOMAIN: Final = "islamic_prayer_times"
|
||||
NAME: Final = "Islamic Prayer Times"
|
||||
DOMAIN = "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)
|
||||
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."""
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
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 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",
|
||||
),
|
||||
)
|
||||
from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -55,38 +16,46 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Islamic prayer times sensor platform."""
|
||||
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN]
|
||||
client = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
IslamicPrayerTimeSensor(coordinator, description)
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
entities = []
|
||||
for sensor_type in SENSOR_TYPES:
|
||||
entities.append(IslamicPrayerTimeSensor(sensor_type, client))
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class IslamicPrayerTimeSensor(
|
||||
CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
class IslamicPrayerTimeSensor(SensorEntity):
|
||||
"""Representation of an Islamic prayer time sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = PRAYER_TIMES_ICON
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
def __init__(self, sensor_type, client):
|
||||
"""Initialize the Islamic prayer time sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = description.key
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=NAME,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
self.sensor_type = sensor_type
|
||||
self.client = client
|
||||
|
||||
@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 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.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import _LOGGER, DOMAIN
|
||||
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}
|
||||
|
||||
|
||||
@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(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
@@ -59,7 +32,6 @@ async def async_setup_entry(
|
||||
entities.append(ISYLockProgramEntity(name, status, actions))
|
||||
|
||||
async_add_entities(entities)
|
||||
async_setup_lock_services(hass)
|
||||
|
||||
|
||||
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||
@@ -75,26 +47,12 @@ class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Send the lock command to the ISY device."""
|
||||
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:
|
||||
"""Send the unlock command to the ISY device."""
|
||||
if not await self._node.secure_unlock():
|
||||
raise HomeAssistantError(f"Unable to unlock device {self._node.address}")
|
||||
|
||||
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}"
|
||||
)
|
||||
_LOGGER.error("Unable to lock device")
|
||||
|
||||
|
||||
class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
||||
@@ -108,9 +66,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
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:
|
||||
"""Unlock the device."""
|
||||
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",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyisy"],
|
||||
"requirements": ["pyisy==3.1.14"],
|
||||
"requirements": ["pyisy==3.1.13"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Universal Devices Inc.",
|
||||
|
@@ -52,14 +52,8 @@ SERVICE_RENAME_NODE = "rename_node"
|
||||
SERVICE_SET_ON_LEVEL = "set_on_level"
|
||||
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_PARAMETERS = "parameters"
|
||||
CONF_USER_NUM = "user_num"
|
||||
CONF_CODE = "code"
|
||||
CONF_VALUE = "value"
|
||||
CONF_INIT = "init"
|
||||
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)),
|
||||
}
|
||||
|
||||
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(
|
||||
cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME),
|
||||
vol.Schema(
|
||||
|
@@ -118,52 +118,6 @@ set_zwave_parameter:
|
||||
- "1"
|
||||
- "2"
|
||||
- "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:
|
||||
name: Rename Node on ISY
|
||||
description: >-
|
||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Final
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -549,9 +550,12 @@ class KNXCommonFlow(ABC, FlowHandler):
|
||||
),
|
||||
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 = (
|
||||
f"{'Secure ' if _if_user_id else ''}"
|
||||
f"Tunneling @ {selected_tunnel_ia or self.new_entry_data[CONF_HOST]}"
|
||||
f"{'Secure ' if _if_user_id else ''}Tunneling{_tunnel_suffix}"
|
||||
)
|
||||
return self.finish_flow()
|
||||
|
||||
@@ -708,7 +712,8 @@ class KNXCommonFlow(ABC, FlowHandler):
|
||||
else:
|
||||
dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN))
|
||||
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
|
||||
|
||||
keyring, errors = await self.hass.async_add_executor_job(_process_upload)
|
||||
|
@@ -84,7 +84,7 @@ def ensure_zone(value):
|
||||
if 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")
|
||||
|
||||
return str(value)
|
||||
|
@@ -17,9 +17,10 @@ from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
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.
|
||||
"""
|
||||
hass.async_create_background_task(
|
||||
discovery_manager.async_discovery(), "lifx-discovery"
|
||||
)
|
||||
task = asyncio.create_task(discovery_manager.async_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
|
||||
# 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.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
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)
|
||||
|
||||
async def handle_stop(event: Event) -> None:
|
||||
async def handle_stop(event) -> None:
|
||||
await system.close()
|
||||
|
||||
entry.async_on_unload(
|
||||
|
@@ -76,7 +76,7 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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."""
|
||||
return self.async_create_entry(title=import_data[CONF_PORT], data=import_data)
|
||||
|
||||
|
@@ -2,8 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from pylitejet import LiteJet
|
||||
import voluptuous as vol
|
||||
@@ -44,7 +42,7 @@ async def async_attach_trigger(
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Listen for events based on configuration."""
|
||||
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_less_than = config.get(CONF_HELD_LESS_THAN)
|
||||
pressed_time = None
|
||||
@@ -52,7 +50,7 @@ async def async_attach_trigger(
|
||||
job = HassJob(action)
|
||||
|
||||
@callback
|
||||
def call_action() -> None:
|
||||
def call_action():
|
||||
"""Call action with right context."""
|
||||
hass.async_run_hass_job(
|
||||
job,
|
||||
@@ -74,11 +72,11 @@ async def async_attach_trigger(
|
||||
# neither: trigger on pressed
|
||||
|
||||
@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."""
|
||||
call_action()
|
||||
|
||||
def pressed() -> None:
|
||||
def pressed():
|
||||
"""Handle the press of the LiteJet switch's button."""
|
||||
nonlocal cancel_pressed_more_than, pressed_time
|
||||
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
|
||||
)
|
||||
|
||||
def released() -> None:
|
||||
def released():
|
||||
"""Handle the release of the LiteJet switch's button."""
|
||||
nonlocal cancel_pressed_more_than, pressed_time
|
||||
nonlocal held_less_than, held_more_than
|
||||
if pressed_time is None:
|
||||
return
|
||||
if cancel_pressed_more_than is not None:
|
||||
cancel_pressed_more_than()
|
||||
cancel_pressed_more_than = None
|
||||
@@ -114,7 +110,7 @@ async def async_attach_trigger(
|
||||
system.on_switch_released(number, released)
|
||||
|
||||
@callback
|
||||
def async_remove() -> None:
|
||||
def async_remove():
|
||||
"""Remove all subscriptions used for this trigger."""
|
||||
system.unsubscribe(pressed)
|
||||
system.unsubscribe(released)
|
||||
|
@@ -140,7 +140,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
|
||||
name="Pet weight",
|
||||
native_unit_of_measurement=UnitOfMass.POUNDS,
|
||||
device_class=SensorDeviceClass.WEIGHT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
],
|
||||
FeederRobot: [
|
||||
|
@@ -8,15 +8,14 @@ from aiolivisi import AioLivisi
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, SWITCH_PLATFORM
|
||||
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:
|
||||
|
@@ -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_PASSWORD: Final = "password"
|
||||
AVATAR = "Avatar"
|
||||
AVATAR_PORT: Final = 9090
|
||||
CLASSIC_PORT: Final = 8080
|
||||
DEVICE_POLLING_DELAY: Final = 60
|
||||
LIVISI_STATE_CHANGE: Final = "livisi_state_change"
|
||||
LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change"
|
||||
|
||||
PSS_DEVICE_TYPE: Final = "PSS"
|
||||
VRCC_DEVICE_TYPE: Final = "VRCC"
|
||||
SWITCH_PLATFORM: Final = "switch"
|
||||
|
||||
MAX_TEMPERATURE: Final = 30.0
|
||||
MIN_TEMPERATURE: Final = 6.0
|
||||
PSS_DEVICE_TYPE: Final = "PSS"
|
||||
|
@@ -13,7 +13,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
AVATAR,
|
||||
AVATAR_PORT,
|
||||
CLASSIC_PORT,
|
||||
CONF_HOST,
|
||||
@@ -70,14 +69,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
||||
livisi_connection_data=livisi_connection_data
|
||||
)
|
||||
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.is_avatar = True
|
||||
else:
|
||||
self.port = CLASSIC_PORT
|
||||
self.is_avatar = False
|
||||
self.controller_type = controller_type
|
||||
self.serial_number = controller_data["serialNumber"]
|
||||
self.controller_type = controller_data["controllerType"]
|
||||
|
||||
async def async_get_devices(self) -> list[dict[str, Any]]:
|
||||
"""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:
|
||||
"""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:]
|
||||
)
|
||||
if response is None:
|
||||
@@ -93,35 +92,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
||||
on_state = response["onState"]
|
||||
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:
|
||||
"""Set the room list."""
|
||||
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}",
|
||||
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:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/livisi",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["aiolivisi==0.0.16"]
|
||||
"requirements": ["aiolivisi==0.0.15"]
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from functools import lru_cache, wraps
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
@@ -365,6 +365,12 @@ async def webhook_stream_camera(
|
||||
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")
|
||||
@validate_schema(
|
||||
{
|
||||
@@ -381,7 +387,7 @@ async def webhook_render_template(
|
||||
resp = {}
|
||||
for key, item in data.items():
|
||||
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))
|
||||
except TemplateError as ex:
|
||||
resp[key] = {"error": str(ex)}
|
||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from motionblinds import MotionDiscovery
|
||||
from motionblinds import MotionDiscovery, MotionGateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -86,6 +86,16 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(mac_address)
|
||||
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()
|
||||
self.context["title_placeholders"] = {
|
||||
"short_mac": short_mac,
|
||||
|
@@ -28,7 +28,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"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": {
|
||||
|
@@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nuheat"],
|
||||
"requirements": ["nuheat==1.0.0"]
|
||||
"requirements": ["nuheat==1.0.1"]
|
||||
}
|
||||
|
@@ -1,18 +1 @@
|
||||
"""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",
|
||||
"name": "Obihai",
|
||||
"codeowners": ["@dshokouhi", "@ejpenney"],
|
||||
"config_flow": true,
|
||||
"codeowners": ["@dshokouhi"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyobihai"],
|
||||
|
@@ -2,7 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyobihai import PyObihai
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -10,19 +12,20 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .connectivity import ObihaiConnection
|
||||
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
OBIHAI = "Obihai"
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
@@ -32,58 +35,46 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
# DEPRECATED
|
||||
async def async_setup_platform(
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""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(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
)
|
||||
username = config[CONF_USERNAME]
|
||||
password = config[CONF_PASSWORD]
|
||||
host = config[CONF_HOST]
|
||||
|
||||
|
||||
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 = []
|
||||
for key in requester.services:
|
||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
||||
|
||||
if requester.line_services is not None:
|
||||
for key in requester.line_services:
|
||||
sensors.append(
|
||||
ObihaiServiceSensors(requester.pyobihai, requester.serial, key)
|
||||
)
|
||||
pyobihai = PyObihai(host, username, password)
|
||||
|
||||
for key in requester.call_direction:
|
||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
||||
login = pyobihai.check_account()
|
||||
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):
|
||||
@@ -157,10 +148,6 @@ class ObihaiServiceSensors(SensorEntity):
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the sensor."""
|
||||
if not self._pyobihai.check_account():
|
||||
self._state = None
|
||||
return
|
||||
|
||||
services = self._pyobihai.get_state()
|
||||
|
||||
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
|
||||
import logging
|
||||
|
||||
import async_timeout
|
||||
import pyotgw
|
||||
import pyotgw.vars as gw_vars
|
||||
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)
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
await gateway.connect_and_subscribe()
|
||||
await asyncio.wait_for(
|
||||
gateway.connect_and_subscribe(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
except (asyncio.TimeoutError, ConnectionError, SerialException) as ex:
|
||||
await gateway.cleanup()
|
||||
raise ConfigEntryNotReady(
|
||||
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
import pyotgw
|
||||
from pyotgw import vars as gw_vars
|
||||
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)
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
await test_connection()
|
||||
await asyncio.wait_for(
|
||||
test_connection(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return self._show_form({"base": "timeout_connect"})
|
||||
except (ConnectionError, SerialException):
|
||||
|
@@ -9,11 +9,14 @@ from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
import aiohttp
|
||||
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.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
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.typing import ConfigType
|
||||
|
||||
@@ -23,6 +26,18 @@ from .const import DOMAIN
|
||||
_R = TypeVar("_R")
|
||||
_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(
|
||||
func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]
|
||||
@@ -70,21 +85,65 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
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:
|
||||
"""Set up an Open Thread Border Router config entry."""
|
||||
api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10)
|
||||
|
||||
otbrdata = OTBRData(entry.data["url"], api)
|
||||
try:
|
||||
dataset = await otbrdata.get_active_dataset_tlvs()
|
||||
dataset_tlvs = await otbrdata.get_active_dataset_tlvs()
|
||||
except (
|
||||
HomeAssistantError,
|
||||
aiohttp.ClientError,
|
||||
asyncio.TimeoutError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
if dataset:
|
||||
await async_add_dataset(hass, entry.title, dataset.hex())
|
||||
if dataset_tlvs:
|
||||
_warn_on_default_network_settings(hass, entry, dataset_tlvs)
|
||||
await async_add_dataset(hass, entry.title, dataset_tlvs.hex())
|
||||
|
||||
hass.data[DOMAIN] = otbrdata
|
||||
|
||||
|
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
import aiohttp
|
||||
import python_otbr_api
|
||||
from python_otbr_api import tlv_parser
|
||||
import voluptuous as vol
|
||||
|
||||
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.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||
|
||||
_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."""
|
||||
api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10)
|
||||
if await api.get_active_dataset_tlvs() is None:
|
||||
if dataset := await async_get_preferred_dataset(self.hass):
|
||||
await api.set_active_dataset_tlvs(bytes.fromhex(dataset))
|
||||
# We currently have no way to know which channel zha is using, assume it's
|
||||
# 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:
|
||||
_LOGGER.debug(
|
||||
"not importing TLV with channel %s", thread_dataset_channel
|
||||
)
|
||||
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)
|
||||
|
||||
|
@@ -1,3 +1,5 @@
|
||||
"""Constants for the Open Thread Border Router integration."""
|
||||
|
||||
DOMAIN = "otbr"
|
||||
|
||||
DEFAULT_CHANNEL = 15
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"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%]"
|
||||
},
|
||||
"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.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OTBRData
|
||||
@@ -70,6 +70,10 @@ async def websocket_create_network(
|
||||
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
||||
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]
|
||||
|
||||
try:
|
||||
@@ -80,7 +84,9 @@ async def websocket_create_network(
|
||||
|
||||
try:
|
||||
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:
|
||||
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_zone_control import AtlanticPassAPCZoneControl
|
||||
from .somfy_thermostat import SomfyThermostat
|
||||
from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface
|
||||
|
||||
WIDGET_TO_CLIMATE_ENTITY = {
|
||||
UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater,
|
||||
@@ -22,5 +21,4 @@ WIDGET_TO_CLIMATE_ENTITY = {
|
||||
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone,
|
||||
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl,
|
||||
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 ..const import DOMAIN
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
PRESET_COMFORT1 = "comfort-1"
|
||||
@@ -48,7 +47,6 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity):
|
||||
_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
|
||||
_attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
@@ -16,7 +16,6 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -71,7 +70,6 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
)
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||
|
@@ -14,7 +14,6 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -44,7 +43,6 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
|
||||
_attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ]
|
||||
_attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||
|
@@ -13,7 +13,6 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -50,7 +49,6 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE
|
||||
)
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||
|
@@ -17,7 +17,6 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -79,7 +78,6 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
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