forked from home-assistant/core
Compare commits
99 Commits
2023.3.0b7
...
frontend-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
651312fb42 | ||
|
|
7b3cab1bfe | ||
|
|
c096ef3fce | ||
|
|
9fed4472f1 | ||
|
|
7a5a882687 | ||
|
|
73c7ee4326 | ||
|
|
79f96fe900 | ||
|
|
7cc8712a0c | ||
|
|
0e8d28dab0 | ||
|
|
fd87748b99 | ||
|
|
00954dfc1f | ||
|
|
e95944bf9f | ||
|
|
ac70612ec5 | ||
|
|
7419a92a1b | ||
|
|
ff4de8cd06 | ||
|
|
bdb9994b7e | ||
|
|
2dcc2f88cc | ||
|
|
db1dd16ab0 | ||
|
|
2c2489284b | ||
|
|
198ebaff6e | ||
|
|
5cc9e7fedd | ||
|
|
76819fbb23 | ||
|
|
aeb6c4f078 | ||
|
|
b25f6e3ffc | ||
|
|
b542f6b3ac | ||
|
|
a8d587bc53 | ||
|
|
fe8f3602ff | ||
|
|
735000475a | ||
|
|
ae3e8746f7 | ||
|
|
10bf910f88 | ||
|
|
b7846de311 | ||
|
|
66b33e1090 | ||
|
|
4fd7ca503f | ||
|
|
33466cdddd | ||
|
|
0d25eef19c | ||
|
|
b5223e1196 | ||
|
|
1d1c553d9b | ||
|
|
f8934175cb | ||
|
|
4898d22960 | ||
|
|
480a495239 | ||
|
|
d219e7c8b1 | ||
|
|
c8fc2dc440 | ||
|
|
9be3f86a4c | ||
|
|
bea81d3f63 | ||
|
|
0f01866508 | ||
|
|
588b51bdfa | ||
|
|
0fb41bdffe | ||
|
|
c9dfa15ed6 | ||
|
|
e00ff54869 | ||
|
|
7c23de469e | ||
|
|
490a0908d4 | ||
|
|
327edabb64 | ||
|
|
b4a3a663cf | ||
|
|
1519a78567 | ||
|
|
57360a7528 | ||
|
|
7b61d3763b | ||
|
|
0f204d6502 | ||
|
|
0a3a8c4b3c | ||
|
|
091305fc57 | ||
|
|
3499d60401 | ||
|
|
f18c0bf626 | ||
|
|
f52a5f6965 | ||
|
|
1edef73c9a | ||
|
|
5a365788b5 | ||
|
|
a60fd18386 | ||
|
|
0223058d25 | ||
|
|
7b2e743a6b | ||
|
|
69a3738bdb | ||
|
|
e69091c6db | ||
|
|
ee7dfdae30 | ||
|
|
fdc06c2fc2 | ||
|
|
ba929dfc79 | ||
|
|
753c790a25 | ||
|
|
ee8f746808 | ||
|
|
84823d2fcf | ||
|
|
0ae2fdc08b | ||
|
|
d90ee85118 | ||
|
|
2f826a6f86 | ||
|
|
af49b98475 | ||
|
|
9575cd9161 | ||
|
|
f0b029c363 | ||
|
|
a71487a42b | ||
|
|
d5f1713498 | ||
|
|
301144993c | ||
|
|
e0601530a0 | ||
|
|
e1e0400b16 | ||
|
|
5739782877 | ||
|
|
6112793b19 | ||
|
|
f8314fe007 | ||
|
|
dac3c7179f | ||
|
|
6511b3f355 | ||
|
|
6474297d1f | ||
|
|
27ebee1501 | ||
|
|
23b52025f9 | ||
|
|
87dc692a20 | ||
|
|
473db48943 | ||
|
|
aa3657e071 | ||
|
|
2a819f23c1 | ||
|
|
c6ff79aa0e |
@@ -639,6 +639,10 @@ 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
|
||||
@@ -803,7 +807,8 @@ omit =
|
||||
homeassistant/components/nuki/sensor.py
|
||||
homeassistant/components/nx584/alarm_control_panel.py
|
||||
homeassistant/components/oasa_telematics/sensor.py
|
||||
homeassistant/components/obihai/*
|
||||
homeassistant/components/obihai/connectivity.py
|
||||
homeassistant/components/obihai/sensor.py
|
||||
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.3
|
||||
HA_SHORT_VERSION: 2023.4
|
||||
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.1.0
|
||||
uses: actions/checkout@v3.3.0
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.3.0
|
||||
uses: actions/setup-python@v4.5.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
||||
@@ -186,6 +186,7 @@ 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,7 +825,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nzbget/ @chriscla
|
||||
/tests/components/nzbget/ @chriscla
|
||||
/homeassistant/components/obihai/ @dshokouhi
|
||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
||||
/tests/components/obihai/ @dshokouhi @ejpenney
|
||||
/homeassistant/components/octoprint/ @rfleming71
|
||||
/tests/components/octoprint/ @rfleming71
|
||||
/homeassistant/components/ohmconnect/ @robbiet480
|
||||
@@ -1138,8 +1139,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/starline/ @anonym-tsk
|
||||
/homeassistant/components/starlink/ @boswelja
|
||||
/tests/components/starlink/ @boswelja
|
||||
/homeassistant/components/statistics/ @fabaff @ThomDietrich
|
||||
/tests/components/statistics/ @fabaff @ThomDietrich
|
||||
/homeassistant/components/statistics/ @ThomDietrich
|
||||
/tests/components/statistics/ @ThomDietrich
|
||||
/homeassistant/components/steam_online/ @tkdrob
|
||||
/tests/components/steam_online/ @tkdrob
|
||||
/homeassistant/components/steamist/ @bdraco
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "heltun",
|
||||
"name": "HELTUN",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from AIOAladdinConnect import AladdinConnectClient
|
||||
@@ -20,8 +19,6 @@ 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,
|
||||
@@ -134,12 +131,6 @@ 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,63 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from AIOAladdinConnect import AladdinConnectClient
|
||||
import voluptuous as vol
|
||||
|
||||
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.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import 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,6 +5,7 @@ import asyncio
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
@@ -15,6 +16,7 @@ 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
|
||||
@@ -162,9 +164,10 @@ async def async_send_changereport_message(
|
||||
if response.status == HTTPStatus.ACCEPTED:
|
||||
return
|
||||
|
||||
response_json = json.loads(response_text)
|
||||
response_json = json_loads_object(response_text)
|
||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
||||
|
||||
if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
||||
if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
|
||||
if invalidate_access_token:
|
||||
# Invalidate the access token and try again
|
||||
config.async_invalidate_access_token()
|
||||
@@ -180,8 +183,8 @@ async def async_send_changereport_message(
|
||||
_LOGGER.error(
|
||||
"Error when sending ChangeReport for %s to Alexa: %s: %s",
|
||||
alexa_entity.entity_id,
|
||||
response_json["payload"]["code"],
|
||||
response_json["payload"]["description"],
|
||||
response_payload["code"],
|
||||
response_payload["description"],
|
||||
)
|
||||
|
||||
|
||||
@@ -299,11 +302,12 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity):
|
||||
if response.status == HTTPStatus.ACCEPTED:
|
||||
return
|
||||
|
||||
response_json = json.loads(response_text)
|
||||
response_json = json_loads_object(response_text)
|
||||
response_payload = cast(JsonObjectType, response_json["payload"])
|
||||
|
||||
_LOGGER.error(
|
||||
"Error when sending DoorbellPress event for %s to Alexa: %s: %s",
|
||||
alexa_entity.entity_id,
|
||||
response_json["payload"]["code"],
|
||||
response_json["payload"]["description"],
|
||||
response_payload["code"],
|
||||
response_payload["description"],
|
||||
)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apprise",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"requirements": ["apprise==1.2.1"]
|
||||
"requirements": ["apprise==1.3.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aurora",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["auroranoaa"],
|
||||
"requirements": ["auroranoaa==0.0.3"]
|
||||
"requirements": ["auroranoaa==0.0.2"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==2.5.2"]
|
||||
"requirements": ["bthome-ble==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -119,6 +119,16 @@ 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,6 +3,7 @@
|
||||
DOMAIN = "conversation"
|
||||
|
||||
DEFAULT_EXPOSED_DOMAINS = {
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
@@ -16,3 +17,5 @@ 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_DOMAINS, DOMAIN
|
||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, 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 = matched[0]
|
||||
state1 = intent_response.matched_states[0]
|
||||
elif intent_response.unmatched_states:
|
||||
state1 = unmatched[0]
|
||||
state1 = intent_response.unmatched_states[0]
|
||||
|
||||
# Render response template
|
||||
speech = response_template.async_render(
|
||||
@@ -479,6 +479,12 @@ 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:
|
||||
@@ -518,6 +524,9 @@ 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.6", "home-assistant-intents==2023.2.28"]
|
||||
"requirements": ["hassil==1.0.5", "home-assistant-intents==2023.2.22"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -17,24 +18,13 @@ if TYPE_CHECKING:
|
||||
from homeassistant.helpers import condition
|
||||
|
||||
|
||||
class DeviceAutomationConditionProtocol(Protocol):
|
||||
class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol):
|
||||
"""Define the format of device_condition modules.
|
||||
|
||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
|
||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config
|
||||
from ConditionProtocol.
|
||||
"""
|
||||
|
||||
CONDITION_SCHEMA: vol.Schema
|
||||
|
||||
async def async_validate_condition_config(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
|
||||
def async_condition_from_config(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> condition.ConditionCheckerType:
|
||||
"""Evaluate state based on configuration."""
|
||||
|
||||
async def async_get_condition_capabilities(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> dict[str, vol.Schema]:
|
||||
@@ -62,4 +52,4 @@ async def async_condition_from_config(
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||
)
|
||||
return platform.async_condition_from_config(hass, config)
|
||||
return trace_condition_function(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.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,8 +132,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
association_data = await lock.associate(user_input["activation_code"])
|
||||
except BleakError as err:
|
||||
_LOGGER.warning("BleakError", exc_info=err)
|
||||
except BleakError:
|
||||
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.3"]
|
||||
"requirements": ["py-dormakaba-dkey==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from random import randint
|
||||
|
||||
from enturclient import EnturPublicTransportData
|
||||
import voluptuous as vol
|
||||
@@ -23,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
API_CLIENT_NAME = "homeassistant-{}"
|
||||
API_CLIENT_NAME = "homeassistant-homeassistant"
|
||||
|
||||
CONF_STOP_IDS = "stop_ids"
|
||||
CONF_EXPAND_PLATFORMS = "expand_platforms"
|
||||
@@ -106,7 +105,7 @@ async def async_setup_platform(
|
||||
quays = [s for s in stop_ids if "Quay" in s]
|
||||
|
||||
data = EnturPublicTransportData(
|
||||
API_CLIENT_NAME.format(str(randint(100000, 999999))),
|
||||
API_CLIENT_NAME,
|
||||
stops=stops,
|
||||
quays=quays,
|
||||
line_whitelist=line_whitelist,
|
||||
|
||||
@@ -341,6 +341,11 @@ 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),
|
||||
|
||||
60
homeassistant/components/frontend/dev.py
Normal file
60
homeassistant/components/frontend/dev.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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(),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow for HLK-SW16."""
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
from hlk_sw16 import create_hlk_sw16_connection
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -35,7 +36,8 @@ async def connect_client(hass, user_input):
|
||||
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
|
||||
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
|
||||
)
|
||||
return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT)
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
return await client_aw
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, user_input):
|
||||
|
||||
@@ -14,6 +14,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WEATHER,
|
||||
|
||||
39
homeassistant/components/homematicip_cloud/helpers.py
Normal file
39
homeassistant/components/homematicip_cloud/helpers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""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
|
||||
95
homeassistant/components/homematicip_cloud/lock.py
Normal file
95
homeassistant/components/homematicip_cloud/lock.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""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
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
@@ -40,24 +39,18 @@ 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 block commonly known exploit attempts."""
|
||||
if FILTERS.search(_recursive_unquote(request.path)):
|
||||
"""Process request and tblock commonly known exploit attempts."""
|
||||
if FILTERS.search(request.path):
|
||||
_LOGGER.warning(
|
||||
"Filtered a potential harmful request to: %s", request.raw_path
|
||||
)
|
||||
raise HTTPBadRequest
|
||||
|
||||
if FILTERS.search(_recursive_unquote(request.query_string)):
|
||||
if FILTERS.search(request.query_string):
|
||||
_LOGGER.warning(
|
||||
"Filtered a request with a potential harmful query string: %s",
|
||||
request.raw_path,
|
||||
|
||||
@@ -35,7 +35,6 @@ 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.2"],
|
||||
"requirements": ["aiohue==4.6.1"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -118,14 +118,13 @@ 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 if group_type == "Room" else None,
|
||||
suggested_area=self.group.metadata.name,
|
||||
via_device=(DOMAIN, self.bridge.api.config.bridge_device.id),
|
||||
)
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ DEFAULT_BUTTON_EVENT_TYPES = (
|
||||
ButtonEvent.INITIAL_PRESS,
|
||||
ButtonEvent.REPEAT,
|
||||
ButtonEvent.SHORT_RELEASE,
|
||||
ButtonEvent.LONG_PRESS,
|
||||
ButtonEvent.LONG_RELEASE,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,13 +55,7 @@ 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 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:
|
||||
if self.device is not None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.device.id)},
|
||||
)
|
||||
@@ -143,14 +137,17 @@ 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:
|
||||
# handle removal of room and zone 'virtual' devices/services
|
||||
# remove any services created for zones/rooms
|
||||
# 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)
|
||||
# cleanup entities that are not strictly device-bound and have the bridge as parent
|
||||
if self.device is None:
|
||||
if resource.type in (
|
||||
ResourceTypes.GROUPED_LIGHT,
|
||||
ResourceTypes.SCENE,
|
||||
ResourceTypes.SMART_SCENE,
|
||||
):
|
||||
ent_reg = async_get_entity_registry(self.hass)
|
||||
ent_reg.async_remove(self.entity_id)
|
||||
return
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.3.3",
|
||||
"pyinsteon==1.3.2",
|
||||
"insteon-frontend-home-assistant==0.3.2"
|
||||
],
|
||||
"usb": [
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
"""The islamic_prayer_times component."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from prayer_times_calculator import PrayerTimesCalculator, exceptions
|
||||
from requests.exceptions import ConnectionError as ConnError
|
||||
from __future__ import annotations
|
||||
|
||||
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 CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IslamicPrayerDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
@@ -25,154 +16,32 @@ 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."""
|
||||
client = IslamicPrayerClient(hass, config_entry)
|
||||
hass.data[DOMAIN] = client
|
||||
await client.async_setup()
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload Islamic Prayer entry from config_entry."""
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""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
|
||||
|
||||
@@ -22,7 +25,9 @@ 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=None):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
@@ -40,7 +45,9 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
"""Constants for the Islamic Prayer component."""
|
||||
from typing import Final
|
||||
|
||||
from prayer_times_calculator import PrayerTimesCalculator
|
||||
|
||||
DOMAIN = "islamic_prayer_times"
|
||||
NAME = "Islamic Prayer Times"
|
||||
PRAYER_TIMES_ICON = "mdi:calendar-clock"
|
||||
DOMAIN: Final = "islamic_prayer_times"
|
||||
NAME: Final = "Islamic Prayer Times"
|
||||
|
||||
SENSOR_TYPES = {
|
||||
"Fajr": "prayer",
|
||||
"Sunrise": "time",
|
||||
"Dhuhr": "prayer",
|
||||
"Asr": "prayer",
|
||||
"Maghrib": "prayer",
|
||||
"Isha": "prayer",
|
||||
"Midnight": "time",
|
||||
}
|
||||
|
||||
CONF_CALC_METHOD = "calculation_method"
|
||||
CONF_CALC_METHOD: Final = "calculation_method"
|
||||
|
||||
CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS)
|
||||
DEFAULT_CALC_METHOD = "isna"
|
||||
|
||||
DATA_UPDATED = "Islamic_prayer_data_updated"
|
||||
DEFAULT_CALC_METHOD: Final = "isna"
|
||||
|
||||
121
homeassistant/components/islamic_prayer_times/coordinator.py
Normal file
121
homeassistant/components/islamic_prayer_times/coordinator.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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,12 +1,51 @@
|
||||
"""Platform to retrieve Islamic prayer times information for Home Assistant."""
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -16,46 +55,38 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Islamic prayer times sensor platform."""
|
||||
|
||||
client = hass.data[DOMAIN]
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
for sensor_type in SENSOR_TYPES:
|
||||
entities.append(IslamicPrayerTimeSensor(sensor_type, client))
|
||||
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(
|
||||
IslamicPrayerTimeSensor(coordinator, description)
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
class IslamicPrayerTimeSensor(SensorEntity):
|
||||
class IslamicPrayerTimeSensor(
|
||||
CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Representation of an Islamic prayer time sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_icon = PRAYER_TIMES_ICON
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, sensor_type, client):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IslamicPrayerDataUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Islamic prayer time sensor."""
|
||||
self.sensor_type = sensor_type
|
||||
self.client = client
|
||||
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,
|
||||
)
|
||||
|
||||
@property
|
||||
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):
|
||||
def native_value(self) -> datetime:
|
||||
"""Return the state of the sensor."""
|
||||
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)
|
||||
)
|
||||
return self.coordinator.data[self.entity_description.key]
|
||||
|
||||
@@ -8,16 +8,43 @@ 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
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import 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:
|
||||
@@ -32,6 +59,7 @@ async def async_setup_entry(
|
||||
entities.append(ISYLockProgramEntity(name, status, actions))
|
||||
|
||||
async_add_entities(entities)
|
||||
async_setup_lock_services(hass)
|
||||
|
||||
|
||||
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||
@@ -47,12 +75,26 @@ 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():
|
||||
_LOGGER.error("Unable to lock device")
|
||||
raise HomeAssistantError(f"Unable to lock device {self._node.address}")
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Send the unlock command to the ISY device."""
|
||||
if not await self._node.secure_unlock():
|
||||
_LOGGER.error("Unable to lock device")
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
||||
@@ -66,9 +108,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
if not await self._actions.run_then():
|
||||
_LOGGER.error("Unable to lock device")
|
||||
raise HomeAssistantError(f"Unable to lock device {self._node.address}")
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
if not await self._actions.run_else():
|
||||
_LOGGER.error("Unable to unlock device")
|
||||
raise HomeAssistantError(f"Unable to unlock device {self._node.address}")
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyisy"],
|
||||
"requirements": ["pyisy==3.1.13"],
|
||||
"requirements": ["pyisy==3.1.14"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Universal Devices Inc.",
|
||||
|
||||
@@ -52,8 +52,14 @@ 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"
|
||||
@@ -129,6 +135,13 @@ 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,6 +118,52 @@ 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: >-
|
||||
|
||||
@@ -17,10 +17,9 @@ from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, 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
|
||||
@@ -167,15 +166,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
We do not want the discovery task to block startup.
|
||||
"""
|
||||
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)
|
||||
hass.async_create_background_task(
|
||||
discovery_manager.async_discovery(), "lifx-discovery"
|
||||
)
|
||||
|
||||
# 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 HomeAssistant
|
||||
from homeassistant.core import Event, 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) -> None:
|
||||
async def handle_stop(event: 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):
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult:
|
||||
"""Import litejet config from configuration.yaml."""
|
||||
return self.async_create_entry(title=import_data[CONF_PORT], data=import_data)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -42,7 +44,7 @@ async def async_attach_trigger(
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Listen for events based on configuration."""
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
number = config.get(CONF_NUMBER)
|
||||
number = cast(int, config[CONF_NUMBER])
|
||||
held_more_than = config.get(CONF_HELD_MORE_THAN)
|
||||
held_less_than = config.get(CONF_HELD_LESS_THAN)
|
||||
pressed_time = None
|
||||
@@ -50,7 +52,7 @@ async def async_attach_trigger(
|
||||
job = HassJob(action)
|
||||
|
||||
@callback
|
||||
def call_action():
|
||||
def call_action() -> None:
|
||||
"""Call action with right context."""
|
||||
hass.async_run_hass_job(
|
||||
job,
|
||||
@@ -72,11 +74,11 @@ async def async_attach_trigger(
|
||||
# neither: trigger on pressed
|
||||
|
||||
@callback
|
||||
def pressed_more_than_satisfied(now):
|
||||
def pressed_more_than_satisfied(now: datetime) -> None:
|
||||
"""Handle the LiteJet's switch's button pressed >= held_more_than."""
|
||||
call_action()
|
||||
|
||||
def pressed():
|
||||
def pressed() -> None:
|
||||
"""Handle the press of the LiteJet switch's button."""
|
||||
nonlocal cancel_pressed_more_than, pressed_time
|
||||
nonlocal held_less_than, held_more_than
|
||||
@@ -88,10 +90,12 @@ async def async_attach_trigger(
|
||||
hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than
|
||||
)
|
||||
|
||||
def released():
|
||||
def released() -> None:
|
||||
"""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
|
||||
@@ -110,7 +114,7 @@ async def async_attach_trigger(
|
||||
system.on_switch_released(number, released)
|
||||
|
||||
@callback
|
||||
def async_remove():
|
||||
def async_remove() -> None:
|
||||
"""Remove all subscriptions used for this trigger."""
|
||||
system.unsubscribe(pressed)
|
||||
system.unsubscribe(released)
|
||||
|
||||
@@ -8,14 +8,15 @@ 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, SWITCH_PLATFORM
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LivisiDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: Final = [SWITCH_PLATFORM]
|
||||
PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
212
homeassistant/components/livisi/climate.py
Normal file
212
homeassistant/components/livisi/climate.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""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,12 +7,15 @@ 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"
|
||||
|
||||
SWITCH_PLATFORM: Final = "switch"
|
||||
|
||||
PSS_DEVICE_TYPE: Final = "PSS"
|
||||
VRCC_DEVICE_TYPE: Final = "VRCC"
|
||||
|
||||
MAX_TEMPERATURE: Final = 30.0
|
||||
MIN_TEMPERATURE: Final = 6.0
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -69,14 +70,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
||||
livisi_connection_data=livisi_connection_data
|
||||
)
|
||||
controller_data = await self.aiolivisi.async_get_controller()
|
||||
if controller_data["controllerType"] == "Avatar":
|
||||
if (controller_type := 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."""
|
||||
@@ -84,7 +85,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] = await self.aiolivisi.async_get_device_state(
|
||||
response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state(
|
||||
capability[1:]
|
||||
)
|
||||
if response is None:
|
||||
@@ -92,6 +93,35 @@ 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()
|
||||
@@ -108,6 +138,12 @@ 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.15"]
|
||||
"requirements": ["aiolivisi==0.0.16"]
|
||||
}
|
||||
|
||||
@@ -1 +1,18 @@
|
||||
"""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)
|
||||
|
||||
73
homeassistant/components/obihai/config_flow.py
Normal file
73
homeassistant/components/obihai/config_flow.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""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],
|
||||
},
|
||||
)
|
||||
67
homeassistant/components/obihai/connectivity.py
Normal file
67
homeassistant/components/obihai/connectivity.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""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
|
||||
15
homeassistant/components/obihai/const.py
Normal file
15
homeassistant/components/obihai/const.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""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,7 +1,8 @@
|
||||
{
|
||||
"domain": "obihai",
|
||||
"name": "Obihai",
|
||||
"codeowners": ["@dshokouhi"],
|
||||
"codeowners": ["@dshokouhi", "@ejpenney"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyobihai"],
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyobihai import PyObihai
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -12,20 +10,19 @@ 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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .connectivity import ObihaiConnection
|
||||
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
OBIHAI = "Obihai"
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
@@ -35,46 +32,58 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
# DEPRECATED
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_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",
|
||||
)
|
||||
|
||||
username = config[CONF_USERNAME]
|
||||
password = config[CONF_PASSWORD]
|
||||
host = config[CONF_HOST]
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the Obihai sensor entries."""
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
host = entry.data[CONF_HOST]
|
||||
requester = ObihaiConnection(host, username, password)
|
||||
|
||||
await hass.async_add_executor_job(requester.update)
|
||||
sensors = []
|
||||
for key in requester.services:
|
||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
||||
|
||||
pyobihai = PyObihai(host, username, password)
|
||||
if requester.line_services is not None:
|
||||
for key in requester.line_services:
|
||||
sensors.append(
|
||||
ObihaiServiceSensors(requester.pyobihai, requester.serial, key)
|
||||
)
|
||||
|
||||
login = pyobihai.check_account()
|
||||
if not login:
|
||||
_LOGGER.error("Invalid credentials")
|
||||
return
|
||||
for key in requester.call_direction:
|
||||
sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key))
|
||||
|
||||
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)
|
||||
async_add_entities(sensors, update_before_add=True)
|
||||
|
||||
|
||||
class ObihaiServiceSensors(SensorEntity):
|
||||
@@ -148,6 +157,10 @@ 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:
|
||||
|
||||
25
homeassistant/components/obihai/strings.json
Normal file
25
homeassistant/components/obihai/strings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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,6 +3,7 @@ import asyncio
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
|
||||
import async_timeout
|
||||
import pyotgw
|
||||
import pyotgw.vars as gw_vars
|
||||
from serial import SerialException
|
||||
@@ -112,10 +113,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
config_entry.add_update_listener(options_updated)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gateway.connect_and_subscribe(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
await gateway.connect_and_subscribe()
|
||||
except (asyncio.TimeoutError, ConnectionError, SerialException) as ex:
|
||||
await gateway.cleanup()
|
||||
raise ConfigEntryNotReady(
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import async_timeout
|
||||
import pyotgw
|
||||
from pyotgw import vars as gw_vars
|
||||
from serial import SerialException
|
||||
@@ -68,10 +69,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
test_connection(),
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
async with async_timeout.timeout(CONNECTION_TIMEOUT):
|
||||
await test_connection()
|
||||
except asyncio.TimeoutError:
|
||||
return self._show_form({"base": "timeout_connect"})
|
||||
except (ConnectionError, SerialException):
|
||||
|
||||
@@ -9,14 +9,11 @@ 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
|
||||
|
||||
@@ -26,18 +23,6 @@ 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]]
|
||||
@@ -85,65 +70,21 @@ 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_tlvs = await otbrdata.get_active_dataset_tlvs()
|
||||
dataset = await otbrdata.get_active_dataset_tlvs()
|
||||
except (
|
||||
HomeAssistantError,
|
||||
aiohttp.ClientError,
|
||||
asyncio.TimeoutError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
if dataset_tlvs:
|
||||
_warn_on_default_network_settings(hass, entry, dataset_tlvs)
|
||||
await async_add_dataset(hass, entry.title, dataset_tlvs.hex())
|
||||
if dataset:
|
||||
await async_add_dataset(hass, entry.title, dataset.hex())
|
||||
|
||||
hass.data[DOMAIN] = otbrdata
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -16,7 +15,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 DEFAULT_CHANNEL, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,26 +29,11 @@ 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:
|
||||
# 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))
|
||||
if dataset := await async_get_preferred_dataset(self.hass):
|
||||
await api.set_active_dataset_tlvs(bytes.fromhex(dataset))
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"not importing TLV with channel %s", thread_dataset_channel
|
||||
)
|
||||
await api.create_active_dataset(
|
||||
python_otbr_api.OperationalDataSet(
|
||||
channel=zha_channel, network_name="home-assistant"
|
||||
)
|
||||
python_otbr_api.OperationalDataSet(network_name="home-assistant")
|
||||
)
|
||||
await api.set_enabled(True)
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"""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.5"]
|
||||
"requirements": ["python-otbr-api==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -12,13 +12,7 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"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."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OTBRData
|
||||
@@ -70,10 +70,6 @@ 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:
|
||||
@@ -84,9 +80,7 @@ async def websocket_create_network(
|
||||
|
||||
try:
|
||||
await data.create_active_dataset(
|
||||
python_otbr_api.OperationalDataSet(
|
||||
channel=zha_channel, network_name="home-assistant"
|
||||
)
|
||||
python_otbr_api.OperationalDataSet(network_name="home-assistant")
|
||||
)
|
||||
except HomeAssistantError as exc:
|
||||
connection.send_error(msg["id"], "create_active_dataset_failed", str(exc))
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -21,4 +22,5 @@ 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,6 +15,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
PRESET_COMFORT1 = "comfort-1"
|
||||
@@ -47,6 +48,7 @@ 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,6 +16,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -70,6 +71,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
)
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -43,6 +44,7 @@ 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,6 +13,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -49,6 +50,7 @@ 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,6 +17,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
@@ -78,6 +79,7 @@ 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
|
||||
|
||||
@@ -15,19 +15,17 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
PRESET_FREEZE = "freeze"
|
||||
PRESET_NIGHT = "night"
|
||||
|
||||
STATE_DEROGATION_ACTIVE = "active"
|
||||
STATE_DEROGATION_INACTIVE = "inactive"
|
||||
|
||||
|
||||
OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = {
|
||||
STATE_DEROGATION_ACTIVE: HVACMode.HEAT,
|
||||
STATE_DEROGATION_INACTIVE: HVACMode.AUTO,
|
||||
OverkizCommandParam.ACTIVE: HVACMode.HEAT,
|
||||
OverkizCommandParam.INACTIVE: HVACMode.AUTO,
|
||||
}
|
||||
HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()}
|
||||
|
||||
@@ -60,6 +58,8 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
|
||||
)
|
||||
_attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ]
|
||||
_attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ]
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
# Both min and max temp values have been retrieved from the Somfy Application.
|
||||
_attr_min_temp = 15.0
|
||||
_attr_max_temp = 26.0
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Support for ValveHeatingTemperatureInterface."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import OverkizDataUpdateCoordinator
|
||||
from ..entity import OverkizEntity
|
||||
|
||||
PRESET_MANUAL = "manual"
|
||||
PRESET_FROST_PROTECTION = "frost_protection"
|
||||
|
||||
OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = {
|
||||
OverkizCommandParam.OPEN: HVACAction.HEATING,
|
||||
OverkizCommandParam.CLOSED: HVACAction.IDLE,
|
||||
}
|
||||
|
||||
OVERKIZ_TO_PRESET_MODE: dict[str, str] = {
|
||||
OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE,
|
||||
OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE,
|
||||
OverkizCommandParam.AWAY: PRESET_AWAY,
|
||||
OverkizCommandParam.COMFORT: PRESET_COMFORT,
|
||||
OverkizCommandParam.ECO: PRESET_ECO,
|
||||
OverkizCommandParam.FROSTPROTECTION: PRESET_FROST_PROTECTION,
|
||||
OverkizCommandParam.MANUAL: PRESET_MANUAL,
|
||||
}
|
||||
PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()}
|
||||
|
||||
TEMPERATURE_SENSOR_DEVICE_INDEX = 2
|
||||
|
||||
|
||||
class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
"""Representation of Valve Heating Temperature Interface device."""
|
||||
|
||||
_attr_hvac_mode = HVACMode.HEAT
|
||||
_attr_hvac_modes = [HVACMode.HEAT]
|
||||
_attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ]
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(
|
||||
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||
) -> None:
|
||||
"""Init method."""
|
||||
super().__init__(device_url, coordinator)
|
||||
self.temperature_device = self.executor.linked_device(
|
||||
TEMPERATURE_SENSOR_DEVICE_INDEX
|
||||
)
|
||||
|
||||
self._attr_min_temp = cast(
|
||||
float, self.executor.select_state(OverkizState.CORE_MIN_SETPOINT)
|
||||
)
|
||||
self._attr_max_temp = cast(
|
||||
float, self.executor.select_state(OverkizState.CORE_MAX_SETPOINT)
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> str:
|
||||
"""Return the current running hvac operation."""
|
||||
return OVERKIZ_TO_HVAC_ACTION[
|
||||
cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE))
|
||||
]
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return cast(
|
||||
float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE)
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]:
|
||||
return temperature.value_as_float
|
||||
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new temperature."""
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_DEROGATION,
|
||||
float(temperature),
|
||||
OverkizCommandParam.FURTHER_NOTICE,
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
return
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
return OVERKIZ_TO_PRESET_MODE[
|
||||
cast(
|
||||
str, self.executor.select_state(OverkizState.IO_DEROGATION_HEATING_MODE)
|
||||
)
|
||||
]
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
|
||||
# If we want to switch to manual mode via a preset, we need to pass in a temperature
|
||||
# Manual mode will be on automatically if an user sets a temperature
|
||||
if preset_mode == PRESET_MANUAL:
|
||||
if current_temperature := self.current_temperature:
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_DEROGATION,
|
||||
current_temperature,
|
||||
OverkizCommandParam.FURTHER_NOTICE,
|
||||
)
|
||||
else:
|
||||
await self.executor.async_execute_command(
|
||||
OverkizCommand.SET_DEROGATION,
|
||||
PRESET_MODE_TO_OVERKIZ[preset_mode],
|
||||
OverkizCommandParam.FURTHER_NOTICE,
|
||||
)
|
||||
@@ -83,6 +83,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
|
||||
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported)
|
||||
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported)
|
||||
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported)
|
||||
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
|
||||
}
|
||||
|
||||
# Map Overkiz camelCase to Home Assistant snake_case for translation
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.7.3"],
|
||||
"requirements": ["pyoverkiz==1.7.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
||||
@@ -28,6 +28,34 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"climate": {
|
||||
"overkiz": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"comfort-1": "Comfort 1",
|
||||
"comfort-2": "Comfort 2",
|
||||
"drying": "Drying",
|
||||
"external": "External",
|
||||
"freeze": "Freeze",
|
||||
"frost_protection": "Frost protection",
|
||||
"manual": "Manual",
|
||||
"night": "Night",
|
||||
"prog": "Prog"
|
||||
}
|
||||
},
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"away": "Away",
|
||||
"bypass_boost": "Bypass boost",
|
||||
"home_boost": "Home boost",
|
||||
"kitchen_boost": "Kitchen boost"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"open_closed_pedestrian": {
|
||||
"state": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from icmplib import NameLookupError, async_ping
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -230,9 +231,8 @@ class PingDataSubProcess(PingData):
|
||||
close_fds=False, # required for posix_spawn
|
||||
)
|
||||
try:
|
||||
out_data, out_error = await asyncio.wait_for(
|
||||
pinger.communicate(), self._count + PING_TIMEOUT
|
||||
)
|
||||
async with async_timeout.timeout(self._count + PING_TIMEOUT):
|
||||
out_data, out_error = await pinger.communicate()
|
||||
|
||||
if out_data:
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import CONF_COUNTRY, DOMAIN
|
||||
|
||||
PLATFORMS = [Platform.ALARM_CONTROL_PANEL]
|
||||
PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_DISARMED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DOMAIN
|
||||
@@ -59,6 +60,14 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity):
|
||||
self._attr_name = f"contract {self.contract}"
|
||||
self._attr_unique_id = self.contract
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name="Prosegur Alarm",
|
||||
manufacturer="Prosegur",
|
||||
model="smart",
|
||||
identifiers={(DOMAIN, self.contract)},
|
||||
configuration_url="https://smart.prosegur.com",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update alarm status."""
|
||||
|
||||
|
||||
97
homeassistant/components/prosegur/camera.py
Normal file
97
homeassistant/components/prosegur/camera.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Support for Prosegur cameras."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyprosegur.auth import Auth
|
||||
from pyprosegur.exceptions import ProsegurException
|
||||
from pyprosegur.installation import Camera as InstallationCamera, Installation
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import SERVICE_REQUEST_IMAGE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the Prosegur camera platform."""
|
||||
|
||||
platform = async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_REQUEST_IMAGE,
|
||||
{},
|
||||
"async_request_image",
|
||||
)
|
||||
|
||||
_installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id])
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id])
|
||||
for camera in _installation.cameras
|
||||
],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
|
||||
class ProsegurCamera(Camera):
|
||||
"""Representation of a Smart Prosegur Camera."""
|
||||
|
||||
def __init__(
|
||||
self, installation: Installation, camera: InstallationCamera, auth: Auth
|
||||
) -> None:
|
||||
"""Initialize Prosegur Camera component."""
|
||||
Camera.__init__(self)
|
||||
|
||||
self._installation = installation
|
||||
self._camera = camera
|
||||
self._auth = auth
|
||||
self._attr_name = camera.description
|
||||
self._attr_unique_id = f"{self._installation.contract} {camera.id}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=self._camera.description,
|
||||
manufacturer="Prosegur",
|
||||
model="smart camera",
|
||||
identifiers={(DOMAIN, self._installation.contract)},
|
||||
configuration_url="https://smart.prosegur.com",
|
||||
)
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return bytes of camera image."""
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Get image for %s", self._camera.description)
|
||||
return await self._installation.get_image(self._auth, self._camera.id)
|
||||
|
||||
except ProsegurException as err:
|
||||
_LOGGER.error("Image %s doesn't exist: %s", self._camera.description, err)
|
||||
|
||||
return None
|
||||
|
||||
async def async_request_image(self):
|
||||
"""Request new image from the camera."""
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Request image for %s", self._camera.description)
|
||||
await self._installation.request_image(self._auth, self._camera.id)
|
||||
|
||||
except ProsegurException as err:
|
||||
_LOGGER.error(
|
||||
"Could not request image from camera %s: %s",
|
||||
self._camera.description,
|
||||
err,
|
||||
)
|
||||
@@ -3,3 +3,5 @@
|
||||
DOMAIN = "prosegur"
|
||||
|
||||
CONF_COUNTRY = "country"
|
||||
|
||||
SERVICE_REQUEST_IMAGE = "request_image"
|
||||
|
||||
29
homeassistant/components/prosegur/diagnostics.py
Normal file
29
homeassistant/components/prosegur/diagnostics.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Diagnostics support for Prosegur."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyprosegur.installation import Installation
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id])
|
||||
|
||||
activity = await installation.activity(hass.data[DOMAIN][entry.entry_id])
|
||||
|
||||
return {
|
||||
"installation": async_redact_data(installation.data, TO_REDACT),
|
||||
"activity": activity,
|
||||
}
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/prosegur",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyprosegur"],
|
||||
"requirements": ["pyprosegur==0.0.5"]
|
||||
"requirements": ["pyprosegur==0.0.8"]
|
||||
}
|
||||
|
||||
7
homeassistant/components/prosegur/services.yaml
Normal file
7
homeassistant/components/prosegur/services.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
request_image:
|
||||
name: Request Camera image
|
||||
description: Request a new image from a Prosegur Camera
|
||||
target:
|
||||
entity:
|
||||
domain: camera
|
||||
integration: prosegur
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
@@ -45,12 +46,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
name="Down Speed",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_TYPE_UPLOAD_SPEED,
|
||||
name="Up Speed",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Coroutine, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
|
||||
from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
|
||||
from async_upnp_client.client_factory import UpnpFactory
|
||||
@@ -250,7 +251,8 @@ class SamsungTVDevice(MediaPlayerEntity):
|
||||
# enter it unless we have to (Python 3.11 will have zero cost try)
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY)
|
||||
async with async_timeout.timeout(APP_LIST_DELAY):
|
||||
await self._app_list_event.wait()
|
||||
except asyncio.TimeoutError as err:
|
||||
# No need to try again
|
||||
self._app_list_event.set()
|
||||
|
||||
@@ -4,6 +4,7 @@ from http import HTTPStatus
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import async_timeout
|
||||
from pysqueezebox import Server, async_discover
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -130,7 +131,8 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# no host specified, see if we can discover an unconfigured LMS server
|
||||
try:
|
||||
await asyncio.wait_for(self._discover(), timeout=TIMEOUT)
|
||||
async with async_timeout.timeout(TIMEOUT):
|
||||
await self._discover()
|
||||
return await self.async_step_edit()
|
||||
except asyncio.TimeoutError:
|
||||
errors["base"] = "no_server_found"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "statistics",
|
||||
"name": "Statistics",
|
||||
"after_dependencies": ["recorder"],
|
||||
"codeowners": ["@fabaff", "@ThomDietrich"],
|
||||
"codeowners": ["@ThomDietrich"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/statistics",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal"
|
||||
|
||||
@@ -21,6 +21,7 @@ set_climate_timer:
|
||||
description: Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay
|
||||
required: false
|
||||
example: "01:30:00"
|
||||
default: "01:00:00"
|
||||
selector:
|
||||
text:
|
||||
requested_overlay:
|
||||
@@ -28,6 +29,7 @@ set_climate_timer:
|
||||
description: Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting
|
||||
required: false
|
||||
example: "MANUAL"
|
||||
default: "TADO_DEFAULT"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
|
||||
@@ -233,9 +233,6 @@ class CoverTemplate(TemplateEntity, CoverEntity):
|
||||
if not self._position_template:
|
||||
self._position = None
|
||||
|
||||
self._is_opening = False
|
||||
self._is_closing = False
|
||||
|
||||
@callback
|
||||
def _update_position(self, result):
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Config flow for the Thread integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import onboarding, zeroconf
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
@@ -34,12 +32,4 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Set up because the user has border routers."""
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm the setup."""
|
||||
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
|
||||
return self.async_create_entry(title="Thread", data={})
|
||||
return self.async_show_form(step_id="confirm")
|
||||
return self.async_create_entry(title="Thread", data={})
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==1.0.5", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==1.0.4", "pyroute2==0.7.5"],
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,26 +198,26 @@ class UniFiController:
|
||||
@callback
|
||||
def async_load_entities(description: UnifiEntityDescription) -> None:
|
||||
"""Load and subscribe to UniFi endpoints."""
|
||||
entities: list[UnifiEntity] = []
|
||||
api_handler = description.api_handler_fn(self.api)
|
||||
|
||||
@callback
|
||||
def async_add_unifi_entity(obj_ids: list[str]) -> None:
|
||||
"""Add UniFi entity."""
|
||||
async_add_entities(
|
||||
[
|
||||
unifi_platform_entity(obj_id, self, description)
|
||||
for obj_id in obj_ids
|
||||
if description.allowed_fn(self, obj_id)
|
||||
if description.supported_fn(self, obj_id)
|
||||
]
|
||||
)
|
||||
|
||||
async_add_unifi_entity(list(api_handler))
|
||||
|
||||
@callback
|
||||
def async_create_entity(event: ItemEvent, obj_id: str) -> None:
|
||||
"""Create UniFi entity."""
|
||||
if not description.allowed_fn(
|
||||
self, obj_id
|
||||
) or not description.supported_fn(self, obj_id):
|
||||
return
|
||||
|
||||
entity = unifi_platform_entity(obj_id, self, description)
|
||||
if event == ItemEvent.ADDED:
|
||||
async_add_entities([entity])
|
||||
return
|
||||
entities.append(entity)
|
||||
|
||||
for obj_id in api_handler:
|
||||
async_create_entity(ItemEvent.CHANGED, obj_id)
|
||||
async_add_entities(entities)
|
||||
"""Create new UniFi entity on event."""
|
||||
async_add_unifi_entity([obj_id])
|
||||
|
||||
api_handler.subscribe(async_create_entity, ItemEvent.ADDED)
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.components.media_player.browse_media import BrowseMedia
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_ENTITY_PICTURE,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
@@ -78,6 +80,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import (
|
||||
TrackTemplate,
|
||||
@@ -93,6 +96,7 @@ ATTR_ACTIVE_CHILD = "active_child"
|
||||
CONF_ATTRS = "attributes"
|
||||
CONF_CHILDREN = "children"
|
||||
CONF_COMMANDS = "commands"
|
||||
CONF_BROWSE_MEDIA_ENTITY = "browse_media_entity"
|
||||
|
||||
STATES_ORDER = [
|
||||
STATE_UNKNOWN,
|
||||
@@ -119,6 +123,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
vol.Optional(CONF_ATTRS, default={}): vol.Or(
|
||||
cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA
|
||||
),
|
||||
vol.Optional(CONF_BROWSE_MEDIA_ENTITY): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
|
||||
@@ -136,17 +141,7 @@ async def async_setup_platform(
|
||||
"""Set up the universal media players."""
|
||||
await async_setup_reload_service(hass, "universal", ["media_player"])
|
||||
|
||||
player = UniversalMediaPlayer(
|
||||
hass,
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_CHILDREN),
|
||||
config.get(CONF_COMMANDS),
|
||||
config.get(CONF_ATTRS),
|
||||
config.get(CONF_UNIQUE_ID),
|
||||
config.get(CONF_DEVICE_CLASS),
|
||||
config.get(CONF_STATE_TEMPLATE),
|
||||
)
|
||||
|
||||
player = UniversalMediaPlayer(hass, config)
|
||||
async_add_entities([player])
|
||||
|
||||
|
||||
@@ -158,30 +153,25 @@ class UniversalMediaPlayer(MediaPlayerEntity):
|
||||
def __init__(
|
||||
self,
|
||||
hass,
|
||||
name,
|
||||
children,
|
||||
commands,
|
||||
attributes,
|
||||
unique_id=None,
|
||||
device_class=None,
|
||||
state_template=None,
|
||||
config,
|
||||
):
|
||||
"""Initialize the Universal media device."""
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self._children = children
|
||||
self._cmds = commands
|
||||
self._name = config.get(CONF_NAME)
|
||||
self._children = config.get(CONF_CHILDREN)
|
||||
self._cmds = config.get(CONF_COMMANDS)
|
||||
self._attrs = {}
|
||||
for key, val in attributes.items():
|
||||
for key, val in config.get(CONF_ATTRS).items():
|
||||
attr = list(map(str.strip, val.split("|", 1)))
|
||||
if len(attr) == 1:
|
||||
attr.append(None)
|
||||
self._attrs[key] = attr
|
||||
self._child_state = None
|
||||
self._state_template_result = None
|
||||
self._state_template = state_template
|
||||
self._device_class = device_class
|
||||
self._attr_unique_id = unique_id
|
||||
self._state_template = config.get(CONF_STATE_TEMPLATE)
|
||||
self._device_class = config.get(CONF_DEVICE_CLASS)
|
||||
self._attr_unique_id = config.get(CONF_UNIQUE_ID)
|
||||
self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to children and template state changes."""
|
||||
@@ -302,6 +292,11 @@ class UniversalMediaPlayer(MediaPlayerEntity):
|
||||
"""Return the name of universal player."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return True if unable to access real state of the entity."""
|
||||
return self._child_attr(ATTR_ASSUMED_STATE)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the current state of media player.
|
||||
@@ -497,6 +492,9 @@ class UniversalMediaPlayer(MediaPlayerEntity):
|
||||
if SERVICE_PLAY_MEDIA in self._cmds:
|
||||
flags |= MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
|
||||
if self._browse_media_entity:
|
||||
flags |= MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
|
||||
if SERVICE_CLEAR_PLAYLIST in self._cmds:
|
||||
flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
|
||||
@@ -628,6 +626,20 @@ class UniversalMediaPlayer(MediaPlayerEntity):
|
||||
# Delegate to turn_on or turn_off by default
|
||||
await super().async_toggle()
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
media_content_type: str | None = None,
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""Return a BrowseMedia instance."""
|
||||
entity_id = self._browse_media_entity
|
||||
if not entity_id and self._child_state:
|
||||
entity_id = self._child_state.entity_id
|
||||
component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN]
|
||||
if entity_id and (entity := component.get_entity(entity_id)):
|
||||
return await entity.async_browse_media(media_content_type, media_content_id)
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update state in HA."""
|
||||
self._child_state = None
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import async_timeout
|
||||
from async_upnp_client.exceptions import UpnpConnectionError
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
@@ -70,7 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(device_discovered_event.wait(), timeout=10)
|
||||
async with async_timeout.timeout(10):
|
||||
await device_discovered_event.wait()
|
||||
except asyncio.TimeoutError as err:
|
||||
raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err
|
||||
finally:
|
||||
|
||||
@@ -5,24 +5,28 @@ from typing import cast
|
||||
|
||||
from homeassistant.const import CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
TriggerProtocol,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .triggers import TriggersPlatformModule, turn_on
|
||||
from .triggers import turn_on
|
||||
|
||||
TRIGGERS = {
|
||||
"turn_on": turn_on,
|
||||
}
|
||||
|
||||
|
||||
def _get_trigger_platform(config: ConfigType) -> TriggersPlatformModule:
|
||||
def _get_trigger_platform(config: ConfigType) -> TriggerProtocol:
|
||||
"""Return trigger platform."""
|
||||
platform_split = config[CONF_PLATFORM].split(".", maxsplit=1)
|
||||
if len(platform_split) < 2 or platform_split[1] not in TRIGGERS:
|
||||
raise ValueError(
|
||||
f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}"
|
||||
)
|
||||
return cast(TriggersPlatformModule, TRIGGERS[platform_split[1]])
|
||||
return cast(TriggerProtocol, TRIGGERS[platform_split[1]])
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
@@ -41,10 +45,4 @@ async def async_attach_trigger(
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach trigger of specified platform."""
|
||||
platform = _get_trigger_platform(config)
|
||||
assert hasattr(platform, "async_attach_trigger")
|
||||
return cast(
|
||||
CALLBACK_TYPE,
|
||||
await getattr(platform, "async_attach_trigger")(
|
||||
hass, config, action, trigger_info
|
||||
),
|
||||
)
|
||||
return await platform.async_attach_trigger(hass, config, action, trigger_info)
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
"""webOS Smart TV triggers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
class TriggersPlatformModule(Protocol):
|
||||
"""Protocol type for the triggers platform."""
|
||||
|
||||
TRIGGER_SCHEMA: vol.Schema
|
||||
|
||||
@@ -7,8 +7,8 @@ from .backports.enum import StrEnum
|
||||
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 3
|
||||
PATCH_VERSION: Final = "0b7"
|
||||
MINOR_VERSION: Final = 4
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user