forked from home-assistant/core
Compare commits
69 Commits
2022.3.0b5
...
2022.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
737c502e94 | ||
|
|
a1abcbc7eb | ||
|
|
b09ab2dafb | ||
|
|
4e6fc3615b | ||
|
|
580c998552 | ||
|
|
97ba17d1ec | ||
|
|
8d7cdceb75 | ||
|
|
dfa1c3abb3 | ||
|
|
c807c57a9b | ||
|
|
f4ec7e0902 | ||
|
|
814c96834e | ||
|
|
87492e6b3e | ||
|
|
4aaafb0a99 | ||
|
|
2aecdd3d6d | ||
|
|
76336df91a | ||
|
|
88e0380aa2 | ||
|
|
10a2c97cab | ||
|
|
92c3c08a10 | ||
|
|
4f8b69d985 | ||
|
|
f5aaf44e50 | ||
|
|
f3c85b3459 | ||
|
|
d7348718e0 | ||
|
|
2a6d5ea7bd | ||
|
|
5ae83e3c40 | ||
|
|
5657a9e6bd | ||
|
|
b290e62170 | ||
|
|
679ddbd1be | ||
|
|
b54652a849 | ||
|
|
24013ad94c | ||
|
|
9849b86a84 | ||
|
|
8bbf55c85d | ||
|
|
0541c708da | ||
|
|
ba40d62081 | ||
|
|
73765a1f29 | ||
|
|
b5b945ab4d | ||
|
|
d361643500 | ||
|
|
eff7a12557 | ||
|
|
63f8e9ee08 | ||
|
|
ee0bdaa2de | ||
|
|
48d9e9a83c | ||
|
|
8de94f3b5c | ||
|
|
d7c480f2d8 | ||
|
|
0349d7d09d | ||
|
|
be19a2e2ab | ||
|
|
b9f44eec0a | ||
|
|
9db56a8119 | ||
|
|
ddf7efd937 | ||
|
|
da4f4f641d | ||
|
|
288270ac08 | ||
|
|
092b973067 | ||
|
|
9aba0ba990 | ||
|
|
4668720f02 | ||
|
|
274e4d5558 | ||
|
|
94fd7ec028 | ||
|
|
c81ccaebd3 | ||
|
|
4c0ba7cd77 | ||
|
|
1ebb4cf395 | ||
|
|
17bc8c64f8 | ||
|
|
fa01715bbb | ||
|
|
99322e2658 | ||
|
|
9a306e2a89 | ||
|
|
47812c6b91 | ||
|
|
40d72b3188 | ||
|
|
b31e570ec7 | ||
|
|
768a031128 | ||
|
|
f1620cbb2e | ||
|
|
aeac31c926 | ||
|
|
26203e9924 | ||
|
|
d766b17323 |
117
.github/workflows/builder.yml
vendored
117
.github/workflows/builder.yml
vendored
@@ -122,13 +122,13 @@ jobs:
|
||||
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -187,13 +187,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -243,22 +243,30 @@ jobs:
|
||||
channel: beta
|
||||
|
||||
publish_container:
|
||||
name: Publish meta container
|
||||
name: Publish meta container for ${{ matrix.registry }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry:
|
||||
- "ghcr.io/home-assistant"
|
||||
- "homeassistant"
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2.4.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
if: matrix.registry == 'homeassistant'
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
if: matrix.registry == 'ghcr.io/home-assistant'
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -273,38 +281,37 @@ jobs:
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
function create_manifest() {
|
||||
local docker_reg=${1}
|
||||
local tag_l=${2}
|
||||
local tag_r=${3}
|
||||
local tag_l=${1}
|
||||
local tag_r=${2}
|
||||
|
||||
docker manifest create "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/amd64-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/i386-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/armhf-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/armv7-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/aarch64-homeassistant:${tag_r}"
|
||||
docker manifest create "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}"
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/amd64-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
|
||||
--os linux --arch amd64
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/i386-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
|
||||
--os linux --arch 386
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/armhf-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm --variant=v6
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/armv7-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm --variant=v7
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/aarch64-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm64 --variant=v8
|
||||
|
||||
docker manifest push --purge "${docker_reg}/home-assistant:${tag_l}"
|
||||
docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}"
|
||||
}
|
||||
|
||||
function validate_image() {
|
||||
@@ -315,36 +322,34 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
for docker_reg in "homeassistant" "ghcr.io/home-assistant"; do
|
||||
docker pull "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
validate_image "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create version tag
|
||||
create_manifest "${docker_reg}" "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
|
||||
# Create version tag
|
||||
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
create_manifest "${docker_reg}" "dev" "${{ needs.init.outputs.version }}"
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
|
||||
else
|
||||
create_manifest "${docker_reg}" "stable" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "latest" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
create_manifest"dev" "${{ needs.init.outputs.version }}"
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
else
|
||||
create_manifest "stable" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "latest" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create series version tag (e.g. 2021.6)
|
||||
v="${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "${v%.*}" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
done
|
||||
# Create series version tag (e.g. 2021.6)
|
||||
v="${{ needs.init.outputs.version }}"
|
||||
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
|
||||
@@ -204,13 +204,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
# Init ip webcam
|
||||
cam = PyDroidIPCam(
|
||||
hass.loop,
|
||||
websession,
|
||||
host,
|
||||
cam_config[CONF_PORT],
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=cam_config[CONF_TIMEOUT],
|
||||
ssl=False,
|
||||
)
|
||||
|
||||
if switches is None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "android_ip_webcam",
|
||||
"name": "Android IP Webcam",
|
||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||
"requirements": ["pydroid-ipcam==0.8"],
|
||||
"requirements": ["pydroid-ipcam==1.3.1"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -92,14 +92,15 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data["data"].get(self._description.key) is not None
|
||||
super().available and self._description.key in self.coordinator.data["data"]
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return sensor state."""
|
||||
return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc]
|
||||
if (value := self.coordinator.data["data"][self._description.key]) is None: # type: ignore[misc]
|
||||
return None
|
||||
return round(value, 2)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
|
||||
@@ -542,7 +542,7 @@ class DmsDeviceSource:
|
||||
children = await self._device.async_browse_direct_children(
|
||||
object_id,
|
||||
metadata_filter=DLNA_BROWSE_FILTER,
|
||||
sort_criteria=DLNA_SORT_CRITERIA,
|
||||
sort_criteria=self._sort_criteria,
|
||||
)
|
||||
|
||||
return self._didl_to_media_source(base_object, children)
|
||||
@@ -575,7 +575,8 @@ class DmsDeviceSource:
|
||||
children=children,
|
||||
)
|
||||
|
||||
media_source.calculate_children_class()
|
||||
if media_source.children:
|
||||
media_source.calculate_children_class()
|
||||
|
||||
return media_source
|
||||
|
||||
@@ -645,7 +646,8 @@ class DmsDeviceSource:
|
||||
thumbnail=self._didl_thumbnail_url(item),
|
||||
)
|
||||
|
||||
media_source.calculate_children_class()
|
||||
if media_source.children:
|
||||
media_source.calculate_children_class()
|
||||
|
||||
return media_source
|
||||
|
||||
@@ -673,6 +675,27 @@ class DmsDeviceSource:
|
||||
"""Make an identifier for BrowseMediaSource."""
|
||||
return f"{self.source_id}/{action}{object_id}"
|
||||
|
||||
@property # type: ignore
|
||||
@functools.cache
|
||||
def _sort_criteria(self) -> list[str]:
|
||||
"""Return criteria to be used for sorting results.
|
||||
|
||||
The device must be connected before reading this property.
|
||||
"""
|
||||
assert self._device
|
||||
|
||||
if self._device.sort_capabilities == ["*"]:
|
||||
return DLNA_SORT_CRITERIA
|
||||
|
||||
# Filter criteria based on what the device supports. Strings in
|
||||
# DLNA_SORT_CRITERIA are prefixed with a sign, while those in
|
||||
# the device's sort_capabilities are not.
|
||||
return [
|
||||
criterion
|
||||
for criterion in DLNA_SORT_CRITERIA
|
||||
if criterion[1:] in self._device.sort_capabilities
|
||||
]
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
"""Actions that can be specified in a DMS media-source identifier."""
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Support for Elgato Lights."""
|
||||
from typing import NamedTuple
|
||||
|
||||
from elgato import Elgato, Info, State
|
||||
from elgato import Elgato, ElgatoConnectionError, Info, State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
@@ -31,12 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def _async_update_data() -> State:
|
||||
"""Fetch Elgato data."""
|
||||
try:
|
||||
return await elgato.state()
|
||||
except ElgatoConnectionError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_method=elgato.state,
|
||||
update_method=_async_update_data,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -279,9 +279,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
keypad.add_callback(_element_changed)
|
||||
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, SYNC_TIMEOUT, bool(conf[CONF_USERNAME])
|
||||
):
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
|
||||
return False
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
|
||||
@@ -334,7 +332,6 @@ async def async_wait_for_elk_to_sync(
|
||||
elk: elkm1.Elk,
|
||||
login_timeout: int,
|
||||
sync_timeout: int,
|
||||
password_auth: bool,
|
||||
) -> bool:
|
||||
"""Wait until the elk has finished sync. Can fail login or timeout."""
|
||||
|
||||
@@ -354,18 +351,23 @@ async def async_wait_for_elk_to_sync(
|
||||
login_event.set()
|
||||
sync_event.set()
|
||||
|
||||
def first_response(*args, **kwargs):
|
||||
_LOGGER.debug("ElkM1 received first response (VN)")
|
||||
login_event.set()
|
||||
|
||||
def sync_complete():
|
||||
sync_event.set()
|
||||
|
||||
success = True
|
||||
elk.add_handler("login", login_status)
|
||||
# VN is the first command sent for panel, when we get
|
||||
# it back we now we are logged in either with or without a password
|
||||
elk.add_handler("VN", first_response)
|
||||
elk.add_handler("sync_complete", sync_complete)
|
||||
events = []
|
||||
if password_auth:
|
||||
events.append(("login", login_event, login_timeout))
|
||||
events.append(("sync_complete", sync_event, sync_timeout))
|
||||
|
||||
for name, event, timeout in events:
|
||||
for name, event, timeout in (
|
||||
("login", login_event, login_timeout),
|
||||
("sync_complete", sync_event, sync_timeout),
|
||||
):
|
||||
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
||||
try:
|
||||
async with async_timeout.timeout(timeout):
|
||||
|
||||
@@ -81,10 +81,11 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
|
||||
)
|
||||
elk.connect()
|
||||
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT, bool(userid)
|
||||
):
|
||||
raise InvalidAuth
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
|
||||
raise InvalidAuth
|
||||
finally:
|
||||
elk.disconnect()
|
||||
|
||||
short_mac = _short_mac(mac) if mac else None
|
||||
if prefix and prefix != short_mac:
|
||||
@@ -227,7 +228,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
info = await validate_input(user_input, self.unique_id)
|
||||
except asyncio.TimeoutError:
|
||||
return {CONF_HOST: "cannot_connect"}, None
|
||||
return {"base": "cannot_connect"}, None
|
||||
except InvalidAuth:
|
||||
return {CONF_PASSWORD: "invalid_auth"}, None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
@@ -287,9 +288,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if device := await async_discover_device(
|
||||
self.hass, user_input[CONF_ADDRESS]
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_ADDRESS] = f"{device.ip_address}:{device.port}"
|
||||
# Ignore the port from discovery since its always going to be
|
||||
# 2601 if secure is turned on even though they may want insecure
|
||||
user_input[CONF_ADDRESS] = device.ip_address
|
||||
errors, result = await self._async_create_or_error(user_input, False)
|
||||
if not errors:
|
||||
return result
|
||||
@@ -324,7 +329,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if is_ip_address(host) and (
|
||||
device := await async_discover_device(self.hass, host)
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return (await self._async_create_or_error(user_input, True))[1]
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from flipr_api import FliprAPIRestClient
|
||||
from flipr_api.exceptions import FliprError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
@@ -11,6 +12,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME
|
||||
@@ -68,9 +70,14 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Fetch data from API endpoint."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
self.client.get_pool_measure_latest, self.flipr_id
|
||||
)
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self.client.get_pool_measure_latest, self.flipr_id
|
||||
)
|
||||
except (FliprError) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class FliprEntity(CoordinatorEntity):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/flipr",
|
||||
"requirements": [
|
||||
"flipr-api==1.4.1"],
|
||||
"flipr-api==1.4.2"],
|
||||
"codeowners": [
|
||||
"@cnico"
|
||||
],
|
||||
|
||||
@@ -64,7 +64,7 @@ async def async_setup_entry(
|
||||
coordinator, base_unique_id, f"{name} Operating Mode", "operating_mode"
|
||||
)
|
||||
)
|
||||
if device.wirings:
|
||||
if device.wirings and device.wiring is not None:
|
||||
entities.append(
|
||||
FluxWiringsSelect(coordinator, base_unique_id, f"{name} Wiring", "wiring")
|
||||
)
|
||||
|
||||
@@ -33,6 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = avm_wrapper
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""AVM FRITZ!Box connectivity sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
@@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class FritzBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Fritz sensor entity."""
|
||||
|
||||
exclude_mesh_role: MeshRoles = MeshRoles.SLAVE
|
||||
is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
@@ -45,7 +46,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
name="Firmware Update",
|
||||
device_class=BinarySensorDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exclude_mesh_role=MeshRoles.NONE,
|
||||
is_suitable=lambda info: True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,10 +58,12 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxBinarySensor(avm_wrapper, entry.title, description)
|
||||
for description in SENSOR_TYPES
|
||||
if (description.exclude_mesh_role != avm_wrapper.mesh_role)
|
||||
if description.is_suitable(connection_info)
|
||||
]
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
@@ -392,6 +392,8 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
)
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
for mac, info in hosts.items():
|
||||
if info.ip_address:
|
||||
info.wan_access = self._get_wan_access(info.ip_address)
|
||||
if self.manage_device_info(info, mac, consider_home):
|
||||
new_device = True
|
||||
self.send_signal_device_update(new_device)
|
||||
@@ -630,6 +632,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
)
|
||||
return {}
|
||||
|
||||
async def async_get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return await self.hass.async_add_executor_job(self.get_upnp_configuration)
|
||||
|
||||
async def async_get_wan_link_properties(self) -> dict[str, Any]:
|
||||
"""Call WANCommonInterfaceConfig service."""
|
||||
|
||||
@@ -637,6 +644,22 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.get_wan_link_properties)
|
||||
)
|
||||
|
||||
async def async_get_connection_info(self) -> ConnectionInfo:
|
||||
"""Return ConnectionInfo data."""
|
||||
|
||||
link_properties = await self.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=self.mesh_role,
|
||||
wan_enabled=self.device_is_router,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
self.host,
|
||||
connection_info,
|
||||
)
|
||||
return connection_info
|
||||
|
||||
async def async_get_port_mapping(self, con_type: str, index: int) -> dict[str, Any]:
|
||||
"""Call GetGenericPortMappingEntry action."""
|
||||
|
||||
@@ -698,6 +721,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.set_allow_wan_access, ip_address, turn_on)
|
||||
)
|
||||
|
||||
def get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return self._service_call_action("X_AVM-DE_UPnP", "1", "GetInfo")
|
||||
|
||||
def get_ontel_num_deflections(self) -> dict[str, Any]:
|
||||
"""Call GetNumberOfDeflections action from X_AVM-DE_OnTel service."""
|
||||
|
||||
@@ -960,3 +988,12 @@ class FritzBoxBaseEntity:
|
||||
name=self._device_name,
|
||||
sw_version=self._avm_wrapper.current_firmware,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
ERROR_AUTH_INVALID,
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_UNKNOWN,
|
||||
ERROR_UPNP_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,6 +80,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return ERROR_UNKNOWN
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in self.avm_wrapper.connection.services
|
||||
and not (await self.avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
return ERROR_UPNP_NOT_CONFIGURED
|
||||
|
||||
return None
|
||||
|
||||
async def async_check_configured_entry(self) -> ConfigEntry | None:
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_USERNAME = ""
|
||||
|
||||
ERROR_AUTH_INVALID = "invalid_auth"
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
FRITZ_SERVICES = "fritz_services"
|
||||
@@ -56,6 +57,7 @@ SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
|
||||
|
||||
SWITCH_TYPE_DEFLECTION = "CallDeflection"
|
||||
SWITCH_TYPE_PORTFORWARD = "PortForward"
|
||||
SWITCH_TYPE_PROFILE = "Profile"
|
||||
SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
|
||||
|
||||
UPTIME_DEVIATION = 5
|
||||
|
||||
@@ -22,6 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"device_info": {
|
||||
"model": avm_wrapper.model,
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
|
||||
@@ -28,8 +28,8 @@ from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,15 +134,6 @@ def _retrieve_link_attenuation_received_state(
|
||||
return status.attenuation[1] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class FritzRequireKeysMixin:
|
||||
"""Fritz sensor data class."""
|
||||
@@ -283,18 +274,7 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
link_properties = await avm_wrapper.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=avm_wrapper.mesh_role,
|
||||
wan_enabled=avm_wrapper.device_is_router,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
avm_wrapper.host,
|
||||
connection_info,
|
||||
)
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxSensor(avm_wrapper, entry.title, description)
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"upnp_not_configured": "Missing UPnP settings on device.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
|
||||
@@ -30,6 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
SWITCH_TYPE_DEFLECTION,
|
||||
SWITCH_TYPE_PORTFORWARD,
|
||||
SWITCH_TYPE_PROFILE,
|
||||
SWITCH_TYPE_WIFINETWORK,
|
||||
WIFI_STANDARD,
|
||||
MeshRoles,
|
||||
@@ -185,6 +186,7 @@ def profile_entities_list(
|
||||
data_fritz: FritzData,
|
||||
) -> list[FritzBoxProfileSwitch]:
|
||||
"""Add new tracker entities from the AVM device."""
|
||||
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PROFILE)
|
||||
|
||||
new_profiles: list[FritzBoxProfileSwitch] = []
|
||||
|
||||
@@ -198,11 +200,15 @@ def profile_entities_list(
|
||||
if device_filter_out_from_trackers(
|
||||
mac, device, data_fritz.profile_switches.values()
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Skipping profile switch creation for device %s", device.hostname
|
||||
)
|
||||
continue
|
||||
|
||||
new_profiles.append(FritzBoxProfileSwitch(avm_wrapper, device))
|
||||
data_fritz.profile_switches[avm_wrapper.unique_id].add(mac)
|
||||
|
||||
_LOGGER.debug("Creating %s profile switches", len(new_profiles))
|
||||
return new_profiles
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"upnp_not_configured": "Missing UPnP settings on device."
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
@@ -51,4 +52,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20220226.0"
|
||||
"home-assistant-frontend==20220301.1"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
@@ -13,7 +13,8 @@
|
||||
"diagnostics",
|
||||
"http",
|
||||
"lovelace",
|
||||
"onboarding", "search",
|
||||
"onboarding",
|
||||
"search",
|
||||
"system_log",
|
||||
"websocket_api"
|
||||
],
|
||||
|
||||
@@ -59,11 +59,12 @@ SERVICE_SET = "set"
|
||||
SERVICE_REMOVE = "remove"
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.COVER,
|
||||
Platform.NOTIFY,
|
||||
Platform.FAN,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.COVER,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
]
|
||||
|
||||
REG_KEY = f"{DOMAIN}_registry"
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
@@ -80,7 +81,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
|
||||
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
|
||||
self._attr_unique_id = unique_id
|
||||
self._device_class = device_class
|
||||
self._state: str | None = None
|
||||
self.mode = any
|
||||
if mode:
|
||||
self.mode = all
|
||||
@@ -106,13 +106,23 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the binary sensor group state."""
|
||||
all_states = [self.hass.states.get(x) for x in self._entity_ids]
|
||||
|
||||
# filtered_states are members currently in the state machine
|
||||
filtered_states: list[str] = [x.state for x in all_states if x is not None]
|
||||
|
||||
# Set group as unavailable if all members are unavailable
|
||||
self._attr_available = any(
|
||||
state != STATE_UNAVAILABLE for state in filtered_states
|
||||
)
|
||||
if STATE_UNAVAILABLE in filtered_states:
|
||||
|
||||
valid_state = self.mode(
|
||||
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states
|
||||
)
|
||||
if not valid_state:
|
||||
# Set as unknown if any / all member is not unknown or unavailable
|
||||
self._attr_is_on = None
|
||||
else:
|
||||
# Set as ON if any / all member is ON
|
||||
states = list(map(lambda x: x == STATE_ON, filtered_states))
|
||||
state = self.mode(states)
|
||||
self._attr_is_on = state
|
||||
|
||||
@@ -221,12 +221,9 @@ class GrowattData:
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt.now().date()
|
||||
last_updated_time = dt.parse_time(str(sorted_keys[-1]))
|
||||
combined_timestamp = datetime.datetime.combine(
|
||||
date_now, last_updated_time
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now, last_updated_time, dt.DEFAULT_TIME_ZONE
|
||||
)
|
||||
# Convert datetime to UTC
|
||||
combined_timestamp_utc = dt.as_utc(combined_timestamp)
|
||||
mix_detail["lastdataupdate"] = combined_timestamp_utc.isoformat()
|
||||
|
||||
# Dashboard data is largely inaccurate for mix system but it is the only call with the ability to return the combined
|
||||
# imported from grid value that is the combination of charging AND load consumption
|
||||
|
||||
@@ -274,7 +274,7 @@ class HomeAccessory(Accessory):
|
||||
if self.config.get(ATTR_SW_VERSION) is not None:
|
||||
sw_version = format_version(self.config[ATTR_SW_VERSION])
|
||||
if sw_version is None:
|
||||
sw_version = __version__
|
||||
sw_version = format_version(__version__)
|
||||
hw_version = None
|
||||
if self.config.get(ATTR_HW_VERSION) is not None:
|
||||
hw_version = format_version(self.config[ATTR_HW_VERSION])
|
||||
@@ -289,7 +289,9 @@ class HomeAccessory(Accessory):
|
||||
serv_info = self.get_service(SERV_ACCESSORY_INFO)
|
||||
char = self.driver.loader.get_char(CHAR_HARDWARE_REVISION)
|
||||
serv_info.add_characteristic(char)
|
||||
serv_info.configure_char(CHAR_HARDWARE_REVISION, value=hw_version)
|
||||
serv_info.configure_char(
|
||||
CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
|
||||
)
|
||||
self.iid_manager.assign(char)
|
||||
char.broker = self
|
||||
|
||||
@@ -532,7 +534,7 @@ class HomeBridge(Bridge):
|
||||
"""Initialize a Bridge object."""
|
||||
super().__init__(driver, name)
|
||||
self.set_info_service(
|
||||
firmware_revision=__version__,
|
||||
firmware_revision=format_version(__version__),
|
||||
manufacturer=MANUFACTURER,
|
||||
model=BRIDGE_MODEL,
|
||||
serial_number=BRIDGE_SERIAL_NUMBER,
|
||||
|
||||
@@ -285,20 +285,19 @@ class Thermostat(HomeAccessory):
|
||||
CHAR_CURRENT_HUMIDITY, value=50
|
||||
)
|
||||
|
||||
fan_modes = self.fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES, [])
|
||||
}
|
||||
fan_modes = {}
|
||||
self.ordered_fan_speeds = []
|
||||
if (
|
||||
features & SUPPORT_FAN_MODE
|
||||
and fan_modes
|
||||
and PRE_DEFINED_FAN_MODES.intersection(fan_modes)
|
||||
):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if features & SUPPORT_FAN_MODE:
|
||||
fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES) or []
|
||||
}
|
||||
if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds):
|
||||
self.fan_chars.append(CHAR_TARGET_FAN_STATE)
|
||||
|
||||
@@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
|
||||
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
|
||||
MAX_VERSION_PART = 2**32 - 1
|
||||
|
||||
|
||||
MAX_PORT = 65535
|
||||
@@ -363,7 +364,15 @@ def convert_to_float(state):
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_name_for_homekit(name: str | None) -> str | None:
|
||||
def coerce_int(state: str) -> int:
|
||||
"""Return int."""
|
||||
try:
|
||||
return int(state)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_name_for_homekit(name: str | None) -> str:
|
||||
"""Ensure the name of the device will not crash homekit."""
|
||||
#
|
||||
# This is not a security measure.
|
||||
@@ -371,7 +380,7 @@ def cleanup_name_for_homekit(name: str | None) -> str | None:
|
||||
# UNICODE_EMOJI is also not allowed but that
|
||||
# likely isn't a problem
|
||||
if name is None:
|
||||
return None
|
||||
return "None" # None crashes apple watches
|
||||
return name.translate(HOMEKIT_CHAR_TRANSLATIONS)[:MAX_NAME_LENGTH]
|
||||
|
||||
|
||||
@@ -420,13 +429,23 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _format_version_part(version_part: str) -> str:
|
||||
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
|
||||
|
||||
|
||||
def format_version(version):
|
||||
"""Extract the version string in a format homekit can consume."""
|
||||
split_ver = str(version).replace("-", ".")
|
||||
split_ver = str(version).replace("-", ".").replace(" ", ".")
|
||||
num_only = NUMBERS_ONLY_RE.sub("", split_ver)
|
||||
if match := VERSION_RE.search(num_only):
|
||||
return match.group(0)
|
||||
return None
|
||||
if (match := VERSION_RE.search(num_only)) is None:
|
||||
return None
|
||||
value = ".".join(map(_format_version_part, match.group(0).split(".")))
|
||||
return None if _is_zero_but_true(value) else value
|
||||
|
||||
|
||||
def _is_zero_but_true(value):
|
||||
"""Zero but true values can crash apple watches."""
|
||||
return convert_to_float(value) == 0
|
||||
|
||||
|
||||
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==0.7.14"],
|
||||
"requirements": ["aiohomekit==0.7.16"],
|
||||
"zeroconf": ["_hap._tcp.local."],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@Jc2k", "@bdraco"],
|
||||
|
||||
@@ -48,42 +48,6 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = {
|
||||
icon="mdi:volume-high",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL,
|
||||
name="Home Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT,
|
||||
name="Home Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL,
|
||||
name="Sleep Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT,
|
||||
name="Sleep Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL,
|
||||
name="Away Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT,
|
||||
name="Away Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LIFX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lifx",
|
||||
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
|
||||
"requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"],
|
||||
"homekit": {
|
||||
"models": ["LIFX"]
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import yarl
|
||||
@@ -74,11 +75,15 @@ class BrowseMedia:
|
||||
|
||||
def as_dict(self, *, parent: bool = True) -> dict:
|
||||
"""Convert Media class to browse media dictionary."""
|
||||
response = {
|
||||
if self.children_media_class is None and self.children:
|
||||
self.calculate_children_class()
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"title": self.title,
|
||||
"media_class": self.media_class,
|
||||
"media_content_type": self.media_content_type,
|
||||
"media_content_id": self.media_content_id,
|
||||
"children_media_class": self.children_media_class,
|
||||
"can_play": self.can_play,
|
||||
"can_expand": self.can_expand,
|
||||
"thumbnail": self.thumbnail,
|
||||
@@ -87,11 +92,7 @@ class BrowseMedia:
|
||||
if not parent:
|
||||
return response
|
||||
|
||||
if self.children_media_class is None:
|
||||
self.calculate_children_class()
|
||||
|
||||
response["not_shown"] = self.not_shown
|
||||
response["children_media_class"] = self.children_media_class
|
||||
|
||||
if self.children:
|
||||
response["children"] = [
|
||||
@@ -104,11 +105,8 @@ class BrowseMedia:
|
||||
|
||||
def calculate_children_class(self) -> None:
|
||||
"""Count the children media classes and calculate the correct class."""
|
||||
if self.children is None or len(self.children) == 0:
|
||||
return
|
||||
|
||||
self.children_media_class = MEDIA_CLASS_DIRECTORY
|
||||
|
||||
assert self.children is not None
|
||||
proposed_class = self.children[0].media_class
|
||||
if all(child.media_class == proposed_class for child in self.children):
|
||||
self.children_media_class = proposed_class
|
||||
|
||||
@@ -28,6 +28,7 @@ ATTR_CONFIG_ENTRY_ID = "entry_id"
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_MANUFACTURER = "manufacturer"
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption"
|
||||
ATTR_OS_NAME = "os_name"
|
||||
ATTR_OS_VERSION = "os_version"
|
||||
ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel"
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
|
||||
from aiohttp.web import Response, json_response
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.encoding import Base64Encoder, HexEncoder, RawEncoder
|
||||
from nacl.secret import SecretBox
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON
|
||||
@@ -23,6 +23,7 @@ from .const import (
|
||||
ATTR_DEVICE_NAME,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NO_LEGACY_ENCRYPTION,
|
||||
ATTR_OS_VERSION,
|
||||
ATTR_SUPPORTS_ENCRYPTION,
|
||||
CONF_SECRET,
|
||||
@@ -34,7 +35,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_decrypt() -> tuple[int, Callable]:
|
||||
def setup_decrypt(key_encoder) -> tuple[int, Callable]:
|
||||
"""Return decryption function and length of key.
|
||||
|
||||
Async friendly.
|
||||
@@ -42,12 +43,14 @@ def setup_decrypt() -> tuple[int, Callable]:
|
||||
|
||||
def decrypt(ciphertext, key):
|
||||
"""Decrypt ciphertext using key."""
|
||||
return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
|
||||
return SecretBox(key, encoder=key_encoder).decrypt(
|
||||
ciphertext, encoder=Base64Encoder
|
||||
)
|
||||
|
||||
return (SecretBox.KEY_SIZE, decrypt)
|
||||
|
||||
|
||||
def setup_encrypt() -> tuple[int, Callable]:
|
||||
def setup_encrypt(key_encoder) -> tuple[int, Callable]:
|
||||
"""Return encryption function and length of key.
|
||||
|
||||
Async friendly.
|
||||
@@ -55,15 +58,22 @@ def setup_encrypt() -> tuple[int, Callable]:
|
||||
|
||||
def encrypt(ciphertext, key):
|
||||
"""Encrypt ciphertext using key."""
|
||||
return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder)
|
||||
return SecretBox(key, encoder=key_encoder).encrypt(
|
||||
ciphertext, encoder=Base64Encoder
|
||||
)
|
||||
|
||||
return (SecretBox.KEY_SIZE, encrypt)
|
||||
|
||||
|
||||
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
def _decrypt_payload_helper(
|
||||
key: str | None,
|
||||
ciphertext: str,
|
||||
get_key_bytes: Callable[[str, int], str | bytes],
|
||||
key_encoder,
|
||||
) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
try:
|
||||
keylen, decrypt = setup_decrypt()
|
||||
keylen, decrypt = setup_decrypt(key_encoder)
|
||||
except OSError:
|
||||
_LOGGER.warning("Ignoring encrypted payload because libsodium not installed")
|
||||
return None
|
||||
@@ -72,18 +82,33 @@ def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
_LOGGER.warning("Ignoring encrypted payload because no decryption key known")
|
||||
return None
|
||||
|
||||
key_bytes = key.encode("utf-8")
|
||||
key_bytes = key_bytes[:keylen]
|
||||
key_bytes = key_bytes.ljust(keylen, b"\0")
|
||||
key_bytes = get_key_bytes(key, keylen)
|
||||
|
||||
try:
|
||||
msg_bytes = decrypt(ciphertext, key_bytes)
|
||||
message = json.loads(msg_bytes.decode("utf-8"))
|
||||
_LOGGER.debug("Successfully decrypted mobile_app payload")
|
||||
return message
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
|
||||
return None
|
||||
msg_bytes = decrypt(ciphertext, key_bytes)
|
||||
message = json.loads(msg_bytes.decode("utf-8"))
|
||||
_LOGGER.debug("Successfully decrypted mobile_app payload")
|
||||
return message
|
||||
|
||||
|
||||
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
|
||||
def get_key_bytes(key: str, keylen: int) -> str:
|
||||
return key
|
||||
|
||||
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder)
|
||||
|
||||
|
||||
def _decrypt_payload_legacy(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
|
||||
def get_key_bytes(key: str, keylen: int) -> bytes:
|
||||
key_bytes = key.encode("utf-8")
|
||||
key_bytes = key_bytes[:keylen]
|
||||
key_bytes = key_bytes.ljust(keylen, b"\0")
|
||||
return key_bytes
|
||||
|
||||
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder)
|
||||
|
||||
|
||||
def registration_context(registration: dict) -> Context:
|
||||
@@ -158,11 +183,16 @@ def webhook_response(
|
||||
data = json.dumps(data, cls=JSONEncoder)
|
||||
|
||||
if registration[ATTR_SUPPORTS_ENCRYPTION]:
|
||||
keylen, encrypt = setup_encrypt()
|
||||
keylen, encrypt = setup_encrypt(
|
||||
HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder
|
||||
)
|
||||
|
||||
key = registration[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b"\0")
|
||||
if ATTR_NO_LEGACY_ENCRYPTION in registration:
|
||||
key = registration[CONF_SECRET]
|
||||
else:
|
||||
key = registration[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b"\0")
|
||||
|
||||
enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8")
|
||||
data = json.dumps({"encrypted": True, "encrypted_data": enc_data})
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import secrets
|
||||
|
||||
from aiohttp.web import HTTPBadRequest, Request, Response, json_response
|
||||
from nacl.exceptions import CryptoError
|
||||
from nacl.secret import SecretBox
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -58,6 +59,7 @@ from .const import (
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NO_LEGACY_ENCRYPTION,
|
||||
ATTR_OS_VERSION,
|
||||
ATTR_SENSOR_ATTRIBUTES,
|
||||
ATTR_SENSOR_DEVICE_CLASS,
|
||||
@@ -97,6 +99,7 @@ from .const import (
|
||||
)
|
||||
from .helpers import (
|
||||
_decrypt_payload,
|
||||
_decrypt_payload_legacy,
|
||||
empty_okay_response,
|
||||
error_response,
|
||||
registration_context,
|
||||
@@ -191,7 +194,27 @@ async def handle_webhook(
|
||||
|
||||
if req_data[ATTR_WEBHOOK_ENCRYPTED]:
|
||||
enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA]
|
||||
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
|
||||
try:
|
||||
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
|
||||
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
|
||||
data = {**config_entry.data, ATTR_NO_LEGACY_ENCRYPTION: True}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
except CryptoError:
|
||||
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
|
||||
try:
|
||||
webhook_payload = _decrypt_payload_legacy(
|
||||
config_entry.data[CONF_SECRET], enc_data
|
||||
)
|
||||
except CryptoError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because unable to decrypt"
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring invalid encrypted payload")
|
||||
else:
|
||||
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring invalid encrypted payload")
|
||||
|
||||
if webhook_type not in WEBHOOK_COMMANDS:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -209,7 +209,7 @@ def duplicate_entity_validator(config: dict) -> dict:
|
||||
addr += "_" + str(entry[CONF_COMMAND_ON])
|
||||
if CONF_COMMAND_OFF in entry:
|
||||
addr += "_" + str(entry[CONF_COMMAND_OFF])
|
||||
addr += "_" + str(entry[CONF_SLAVE])
|
||||
addr += "_" + str(entry.get(CONF_SLAVE, 0))
|
||||
if addr in addresses:
|
||||
err = f"Modbus {component}/{name} address {addr} is duplicate, second entry not loaded!"
|
||||
_LOGGER.warning(err)
|
||||
|
||||
@@ -75,11 +75,16 @@ from .const import (
|
||||
ATTR_TOPIC,
|
||||
CONF_BIRTH_MESSAGE,
|
||||
CONF_BROKER,
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TLS_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_WILL_MESSAGE,
|
||||
DATA_MQTT_CONFIG,
|
||||
@@ -94,6 +99,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
MQTT_CONNECTED,
|
||||
MQTT_DISCONNECTED,
|
||||
PROTOCOL_31,
|
||||
PROTOCOL_311,
|
||||
)
|
||||
from .discovery import LAST_DISCOVERY
|
||||
@@ -118,13 +124,6 @@ SERVICE_DUMP = "dump"
|
||||
|
||||
CONF_DISCOVERY_PREFIX = "discovery_prefix"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
|
||||
DEFAULT_PORT = 1883
|
||||
DEFAULT_KEEPALIVE = 60
|
||||
@@ -757,6 +756,58 @@ class Subscription:
|
||||
encoding: str | None = attr.ib(default="utf-8")
|
||||
|
||||
|
||||
class MqttClientSetup:
|
||||
"""Helper class to setup the paho mqtt client from config."""
|
||||
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the MQTT client setup helper."""
|
||||
|
||||
if config[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto = self.mqtt.MQTTv31
|
||||
else:
|
||||
proto = self.mqtt.MQTTv311
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = self.mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._client = self.mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._client.enable_logger()
|
||||
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._client.username_pw_set(username, password)
|
||||
|
||||
if (certificate := config.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = config.get(CONF_CLIENT_KEY)
|
||||
client_cert = config.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = config.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._client.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._client.tls_insecure_set(tls_insecure)
|
||||
|
||||
@property
|
||||
def client(self) -> mqtt.Client:
|
||||
"""Return the paho MQTT client."""
|
||||
return self._client
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
@@ -821,46 +872,7 @@ class MQTT:
|
||||
|
||||
def init_client(self):
|
||||
"""Initialize paho client."""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if self.conf[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto: int = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
if (client_id := self.conf.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._mqttc = mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._mqttc.enable_logger()
|
||||
|
||||
username = self.conf.get(CONF_USERNAME)
|
||||
password = self.conf.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._mqttc.username_pw_set(username, password)
|
||||
|
||||
if (certificate := self.conf.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = self.conf.get(CONF_CLIENT_KEY)
|
||||
client_cert = self.conf.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = self.conf.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._mqttc.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._mqttc.tls_insecure_set(tls_insecure)
|
||||
|
||||
self._mqttc = MqttClientSetup(self.conf).client
|
||||
self._mqttc.on_connect = self._mqtt_on_connect
|
||||
self._mqttc.on_disconnect = self._mqtt_on_disconnect
|
||||
self._mqttc.on_message = self._mqtt_on_message
|
||||
|
||||
@@ -271,7 +271,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_HOLD_LIST, default=list): cv.ensure_list,
|
||||
vol.Optional(CONF_HOLD_LIST): cv.ensure_list,
|
||||
vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(
|
||||
@@ -298,7 +298,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
),
|
||||
vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
|
||||
vol.Optional(CONF_SEND_IF_OFF): cv.boolean,
|
||||
vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic,
|
||||
# CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together
|
||||
@@ -431,6 +431,12 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
self._feature_preset_mode = False
|
||||
self._optimistic_preset_mode = None
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
self._send_if_off = True
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
self._hold_list = []
|
||||
|
||||
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
|
||||
|
||||
@staticmethod
|
||||
@@ -499,6 +505,15 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
|
||||
self._command_templates = command_templates
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if CONF_SEND_IF_OFF in config:
|
||||
self._send_if_off = config[CONF_SEND_IF_OFF]
|
||||
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
if CONF_HOLD_LIST in config:
|
||||
self._hold_list = config[CONF_HOLD_LIST]
|
||||
|
||||
def _prepare_subscribe_topics(self): # noqa: C901
|
||||
"""(Re)Subscribe to topics."""
|
||||
topics = {}
|
||||
@@ -806,7 +821,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
):
|
||||
presets.append(PRESET_AWAY)
|
||||
|
||||
presets.extend(self._config[CONF_HOLD_LIST])
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
presets.extend(self._hold_list)
|
||||
|
||||
if presets:
|
||||
presets.insert(0, PRESET_NONE)
|
||||
@@ -847,10 +864,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
setattr(self, attr, temp)
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if (
|
||||
self._config[CONF_SEND_IF_OFF]
|
||||
or self._current_operation != HVAC_MODE_OFF
|
||||
):
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[cmnd_template](temp)
|
||||
await self._publish(cmnd_topic, payload)
|
||||
|
||||
@@ -890,7 +904,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new swing mode."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](
|
||||
swing_mode
|
||||
)
|
||||
@@ -903,7 +917,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target temperature."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode)
|
||||
await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import MqttClientSetup
|
||||
from .const import (
|
||||
ATTR_PAYLOAD,
|
||||
ATTR_QOS,
|
||||
@@ -62,6 +63,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -102,6 +104,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data = self._hassio_discovery
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
data[CONF_HOST],
|
||||
data[CONF_PORT],
|
||||
data.get(CONF_USERNAME),
|
||||
@@ -152,6 +155,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -313,25 +317,24 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def try_connection(broker, port, username, password, protocol="3.1"):
|
||||
def try_connection(hass, broker, port, username, password, protocol="3.1"):
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
if protocol == "3.1":
|
||||
proto = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
client = mqtt.Client(protocol=proto)
|
||||
if username and password:
|
||||
client.username_pw_set(username, password)
|
||||
# Get the config from configuration.yaml
|
||||
yaml_config = hass.data.get(DATA_MQTT_CONFIG, {})
|
||||
entry_config = {
|
||||
CONF_BROKER: broker,
|
||||
CONF_PORT: port,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_PROTOCOL: protocol,
|
||||
}
|
||||
client = MqttClientSetup({**yaml_config, **entry_config}).client
|
||||
|
||||
result = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(client_, userdata, flags, result_code):
|
||||
"""Handle connection result."""
|
||||
result.put(result_code == mqtt.CONNACK_ACCEPTED)
|
||||
result.put(result_code == MqttClientSetup.mqtt.CONNACK_ACCEPTED)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ CONF_STATE_VALUE_TEMPLATE = "state_value_template"
|
||||
CONF_TOPIC = "topic"
|
||||
CONF_WILL_MESSAGE = "will_message"
|
||||
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
DATA_MQTT_CONFIG = "mqtt_config"
|
||||
DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed"
|
||||
|
||||
@@ -56,4 +62,5 @@ MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
PROTOCOL_311 = "3.1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "obihai",
|
||||
"name": "Obihai",
|
||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||
"requirements": ["pyobihai==1.3.1"],
|
||||
"requirements": ["pyobihai==1.3.2"],
|
||||
"codeowners": ["@dshokouhi"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyobihai"]
|
||||
|
||||
@@ -110,6 +110,15 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity):
|
||||
_attr_name = "Powerwall Charging"
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Powerwall is available."""
|
||||
# Return False if no battery is installed
|
||||
return (
|
||||
super().available
|
||||
and self.data.meters.get_meter(MeterType.BATTERY) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Device Uniqueid."""
|
||||
|
||||
@@ -114,7 +114,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
|
||||
class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
|
||||
"""Representation of an Powerwall Direction Energy sensor."""
|
||||
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
|
||||
@@ -160,7 +160,7 @@ class PowerWallExportSensor(PowerWallEnergyDirectionSensor):
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Get the current value in kWh."""
|
||||
return abs(self.meter.get_energy_exported())
|
||||
return self.meter.get_energy_exported()
|
||||
|
||||
|
||||
class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
||||
@@ -177,4 +177,4 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Get the current value in kWh."""
|
||||
return abs(self.meter.get_energy_imported())
|
||||
return self.meter.get_energy_imported()
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_MUSIC,
|
||||
)
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.components.media_source.error import Unresolvable
|
||||
from homeassistant.components.media_source.models import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
@@ -35,9 +36,8 @@ async def async_get_media_source(hass: HomeAssistant) -> RadioMediaSource:
|
||||
"""Set up Radio Browser media source."""
|
||||
# Radio browser support only a single config entry
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
radios = hass.data[DOMAIN]
|
||||
|
||||
return RadioMediaSource(hass, radios, entry)
|
||||
return RadioMediaSource(hass, entry)
|
||||
|
||||
|
||||
class RadioMediaSource(MediaSource):
|
||||
@@ -45,26 +45,33 @@ class RadioMediaSource(MediaSource):
|
||||
|
||||
name = "Radio Browser"
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, radios: RadioBrowser, entry: ConfigEntry
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize CameraMediaSource."""
|
||||
super().__init__(DOMAIN)
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.radios = radios
|
||||
|
||||
@property
|
||||
def radios(self) -> RadioBrowser | None:
|
||||
"""Return the radio browser."""
|
||||
return self.hass.data.get(DOMAIN)
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve selected Radio station to a streaming URL."""
|
||||
station = await self.radios.station(uuid=item.identifier)
|
||||
radios = self.radios
|
||||
|
||||
if radios is None:
|
||||
raise Unresolvable("Radio Browser not initialized")
|
||||
|
||||
station = await radios.station(uuid=item.identifier)
|
||||
if not station:
|
||||
raise BrowseError("Radio station is no longer available")
|
||||
raise Unresolvable("Radio station is no longer available")
|
||||
|
||||
if not (mime_type := self._async_get_station_mime_type(station)):
|
||||
raise BrowseError("Could not determine stream type of radio station")
|
||||
raise Unresolvable("Could not determine stream type of radio station")
|
||||
|
||||
# Register "click" with Radio Browser
|
||||
await self.radios.station_click(uuid=station.uuid)
|
||||
await radios.station_click(uuid=station.uuid)
|
||||
|
||||
return PlayMedia(station.url, mime_type)
|
||||
|
||||
@@ -73,6 +80,11 @@ class RadioMediaSource(MediaSource):
|
||||
item: MediaSourceItem,
|
||||
) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
radios = self.radios
|
||||
|
||||
if radios is None:
|
||||
raise BrowseError("Radio Browser not initialized")
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
@@ -83,10 +95,10 @@ class RadioMediaSource(MediaSource):
|
||||
can_expand=True,
|
||||
children_media_class=MEDIA_CLASS_DIRECTORY,
|
||||
children=[
|
||||
*await self._async_build_popular(item),
|
||||
*await self._async_build_by_tag(item),
|
||||
*await self._async_build_by_language(item),
|
||||
*await self._async_build_by_country(item),
|
||||
*await self._async_build_popular(radios, item),
|
||||
*await self._async_build_by_tag(radios, item),
|
||||
*await self._async_build_by_language(radios, item),
|
||||
*await self._async_build_by_country(radios, item),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -100,7 +112,9 @@ class RadioMediaSource(MediaSource):
|
||||
return mime_type
|
||||
|
||||
@callback
|
||||
def _async_build_stations(self, stations: list[Station]) -> list[BrowseMediaSource]:
|
||||
def _async_build_stations(
|
||||
self, radios: RadioBrowser, stations: list[Station]
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Build list of media sources from radio stations."""
|
||||
items: list[BrowseMediaSource] = []
|
||||
|
||||
@@ -126,23 +140,23 @@ class RadioMediaSource(MediaSource):
|
||||
return items
|
||||
|
||||
async def _async_build_by_country(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by country."""
|
||||
category, _, country_code = (item.identifier or "").partition("/")
|
||||
if country_code:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.COUNTRY_CODE_EXACT,
|
||||
filter_term=country_code,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
# We show country in the root additionally, when there is no item
|
||||
if not item.identifier or category == "country":
|
||||
countries = await self.radios.countries(order=Order.NAME)
|
||||
countries = await radios.countries(order=Order.NAME)
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
@@ -160,22 +174,22 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_by_language(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by language."""
|
||||
category, _, language = (item.identifier or "").partition("/")
|
||||
if category == "language" and language:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.LANGUAGE_EXACT,
|
||||
filter_term=language,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if category == "language":
|
||||
languages = await self.radios.languages(order=Order.NAME, hide_broken=True)
|
||||
languages = await radios.languages(order=Order.NAME, hide_broken=True)
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
@@ -206,17 +220,17 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_popular(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing popular radio stations."""
|
||||
if item.identifier == "popular":
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
hide_broken=True,
|
||||
limit=250,
|
||||
order=Order.CLICK_COUNT,
|
||||
reverse=True,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if not item.identifier:
|
||||
return [
|
||||
@@ -234,22 +248,22 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_by_tag(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by tags."""
|
||||
category, _, tag = (item.identifier or "").partition("/")
|
||||
if category == "tag" and tag:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.TAG_EXACT,
|
||||
filter_term=tag,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if category == "tag":
|
||||
tags = await self.radios.tags(
|
||||
tags = await radios.tags(
|
||||
hide_broken=True,
|
||||
limit=100,
|
||||
order=Order.STATION_COUNT,
|
||||
|
||||
@@ -47,7 +47,7 @@ send_command:
|
||||
required: true
|
||||
example: "Play"
|
||||
selector:
|
||||
text:
|
||||
object:
|
||||
num_repeats:
|
||||
name: Repeats
|
||||
description: The number of times you want to repeat the command(s).
|
||||
|
||||
@@ -104,7 +104,7 @@ class RenaultVehicleProxy:
|
||||
coordinator = self.coordinators[key]
|
||||
if coordinator.not_supported:
|
||||
# Remove endpoint as it is not supported for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is not supported for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
@@ -112,7 +112,7 @@ class RenaultVehicleProxy:
|
||||
del self.coordinators[key]
|
||||
elif coordinator.access_denied:
|
||||
# Remove endpoint as it is denied for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is denied for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
|
||||
@@ -6,7 +6,7 @@ import binascii
|
||||
from collections.abc import Callable
|
||||
import copy
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
from typing import NamedTuple, cast
|
||||
|
||||
import RFXtrx as rfxtrxmod
|
||||
import async_timeout
|
||||
@@ -229,11 +229,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
|
||||
devices[device_id] = config
|
||||
|
||||
@callback
|
||||
def _remove_device(event: Event):
|
||||
if event.data["action"] != "remove":
|
||||
return
|
||||
device_entry = device_registry.deleted_devices[event.data["device_id"]]
|
||||
device_id = next(iter(device_entry.identifiers))[1:]
|
||||
def _remove_device(device_id: DeviceTuple):
|
||||
data = {
|
||||
**entry.data,
|
||||
CONF_DEVICES: {
|
||||
@@ -245,8 +241,19 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data)
|
||||
devices.pop(device_id)
|
||||
|
||||
@callback
|
||||
def _updated_device(event: Event):
|
||||
if event.data["action"] != "remove":
|
||||
return
|
||||
device_entry = device_registry.deleted_devices[event.data["device_id"]]
|
||||
if entry.entry_id not in device_entry.config_entries:
|
||||
return
|
||||
device_id = get_device_tuple_from_identifiers(device_entry.identifiers)
|
||||
if device_id:
|
||||
_remove_device(device_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _remove_device)
|
||||
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _updated_device)
|
||||
)
|
||||
|
||||
def _shutdown_rfxtrx(event):
|
||||
@@ -413,6 +420,18 @@ def get_device_id(
|
||||
return DeviceTuple(f"{device.packettype:x}", f"{device.subtype:x}", id_string)
|
||||
|
||||
|
||||
def get_device_tuple_from_identifiers(
|
||||
identifiers: set[tuple[str, str]]
|
||||
) -> DeviceTuple | None:
|
||||
"""Calculate the device tuple from a device entry."""
|
||||
identifier = next((x for x in identifiers if x[0] == DOMAIN), None)
|
||||
if not identifier:
|
||||
return None
|
||||
# work around legacy identifier, being a multi tuple value
|
||||
identifier2 = cast(tuple[str, str, str, str], identifier)
|
||||
return DeviceTuple(identifier2[1], identifier2[2], identifier2[3])
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -34,7 +34,13 @@ from homeassistant.helpers.entity_registry import (
|
||||
async_get_registry as async_get_entity_registry,
|
||||
)
|
||||
|
||||
from . import DOMAIN, DeviceTuple, get_device_id, get_rfx_object
|
||||
from . import (
|
||||
DOMAIN,
|
||||
DeviceTuple,
|
||||
get_device_id,
|
||||
get_device_tuple_from_identifiers,
|
||||
get_rfx_object,
|
||||
)
|
||||
from .binary_sensor import supported as binary_supported
|
||||
from .const import (
|
||||
CONF_AUTOMATIC_ADD,
|
||||
@@ -59,7 +65,7 @@ CONF_MANUAL_PATH = "Enter Manually"
|
||||
class DeviceData(TypedDict):
|
||||
"""Dict data representing a device entry."""
|
||||
|
||||
event_code: str
|
||||
event_code: str | None
|
||||
device_id: DeviceTuple
|
||||
|
||||
|
||||
@@ -388,15 +394,15 @@ class OptionsFlow(config_entries.OptionsFlow):
|
||||
|
||||
def _get_device_data(self, entry_id) -> DeviceData:
|
||||
"""Get event code based on device identifier."""
|
||||
event_code: str
|
||||
event_code: str | None = None
|
||||
entry = self._device_registry.async_get(entry_id)
|
||||
assert entry
|
||||
device_id = cast(DeviceTuple, next(iter(entry.identifiers))[1:])
|
||||
device_id = get_device_tuple_from_identifiers(entry.identifiers)
|
||||
assert device_id
|
||||
for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items():
|
||||
if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id:
|
||||
event_code = cast(str, packet_id)
|
||||
break
|
||||
assert event_code
|
||||
return DeviceData(event_code=event_code, device_id=device_id)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "rfxtrx",
|
||||
"name": "RFXCOM RFXtrx",
|
||||
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
|
||||
"requirements": ["pyRFXtrx==0.27.1"],
|
||||
"requirements": ["pyRFXtrx==0.28.0"],
|
||||
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"""Support for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -16,7 +8,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
@@ -27,10 +18,6 @@ PLATFORMS = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound="RokuEntity")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -53,22 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
def roku_exception_handler(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"""Helpers for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from .entity import RokuEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound=RokuEntity)
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def format_channel_name(channel_number: str, channel_name: str | None = None) -> str:
|
||||
"""Format a Roku Channel name."""
|
||||
@@ -8,3 +23,28 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) ->
|
||||
return f"{channel_name} ({channel_number})"
|
||||
|
||||
return channel_number
|
||||
|
||||
|
||||
def roku_exception_handler(ignore_timeout: bool = False) -> Callable[..., Callable]:
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]], # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionTimeoutError as error:
|
||||
if not ignore_timeout and self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "roku",
|
||||
"name": "Roku",
|
||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||
"requirements": ["rokuecp==0.14.1"],
|
||||
"requirements": ["rokuecp==0.15.0"],
|
||||
"homekit": {
|
||||
"models": ["3810X", "4660X", "7820X", "C105X", "C135X"]
|
||||
},
|
||||
|
||||
@@ -51,7 +51,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .browse_media import async_browse_media
|
||||
from .const import (
|
||||
ATTR_ARTIST_NAME,
|
||||
@@ -65,7 +64,7 @@ from .const import (
|
||||
)
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -289,7 +288,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
app.name for app in self.coordinator.data.apps if app.name is not None
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def search(self, keyword: str) -> None:
|
||||
"""Emulate opening the search screen and entering the search keyword."""
|
||||
await self.coordinator.roku.search(keyword)
|
||||
@@ -321,68 +320,68 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
media_content_type,
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the Roku."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the Roku."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PAUSED):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PLAYING):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play_pause(self) -> None:
|
||||
"""Send play/pause command."""
|
||||
if self.state != STATE_STANDBY:
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self.coordinator.roku.remote("reverse")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self.coordinator.roku.remote("forward")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
await self.coordinator.roku.remote("volume_mute")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up media player."""
|
||||
await self.coordinator.roku.remote("volume_up")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
await self.coordinator.roku.remote("volume_down")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_play_media(
|
||||
self, media_type: str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
@@ -487,7 +486,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if source == "Home":
|
||||
|
||||
@@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import roku_exception_handler
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -44,19 +44,19 @@ class RokuRemote(RokuEntity, RemoteEntity):
|
||||
"""Return true if device is on."""
|
||||
return not self.coordinator.data.state.standby
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to one device."""
|
||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||
|
||||
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -163,7 +162,7 @@ class RokuSelectEntity(RokuEntity, SelectEntity):
|
||||
"""Return a set of selectable options."""
|
||||
return self.entity_description.options_fn(self.coordinator.data)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
await self.entity_description.set_fn(
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import ATTR_TRANSITION
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -117,7 +117,11 @@ class Scene(RestoreEntity):
|
||||
"""Call when the scene is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state is not None:
|
||||
if (
|
||||
state is not None
|
||||
and state.state is not None
|
||||
and state.state != STATE_UNAVAILABLE
|
||||
):
|
||||
self.__last_activated = state.state
|
||||
|
||||
def activate(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
|
||||
|
||||
MAX_POSSIBLE_STEP = 1000
|
||||
|
||||
|
||||
class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""A Sensibo Data Update Coordinator."""
|
||||
@@ -74,7 +76,11 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
.get("values", [0, 1])
|
||||
)
|
||||
if temperatures_list:
|
||||
temperature_step = temperatures_list[1] - temperatures_list[0]
|
||||
diff = MAX_POSSIBLE_STEP
|
||||
for i in range(len(temperatures_list) - 1):
|
||||
if temperatures_list[i + 1] - temperatures_list[i] < diff:
|
||||
diff = temperatures_list[i + 1] - temperatures_list[i]
|
||||
temperature_step = diff
|
||||
|
||||
active_features = list(ac_states)
|
||||
full_features = set()
|
||||
|
||||
@@ -317,4 +317,14 @@ class BlockSleepingClimate(
|
||||
|
||||
if self.device_block and self.block:
|
||||
_LOGGER.debug("Entity %s attached to blocks", self.name)
|
||||
|
||||
assert self.block.channel
|
||||
|
||||
self._preset_modes = [
|
||||
PRESET_NONE,
|
||||
*self.wrapper.device.settings["thermostats"][int(self.block.channel)][
|
||||
"schedule_profile_names"
|
||||
],
|
||||
]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==1.0.10"],
|
||||
"requirements": ["aioshelly==1.0.11"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -215,6 +215,15 @@ SENSORS: Final = {
|
||||
icon="mdi:gauge",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
("sensor", "temp"): BlockSensorDescription(
|
||||
key="sensor|temp",
|
||||
name="Temperature",
|
||||
unit_fn=temperature_unit,
|
||||
value=lambda value: round(value, 1),
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("sensor", "extTemp"): BlockSensorDescription(
|
||||
key="sensor|extTemp",
|
||||
name="Temperature",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sony Songpal",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/songpal",
|
||||
"requirements": ["python-songpal==0.14"],
|
||||
"requirements": ["python-songpal==0.14.1"],
|
||||
"codeowners": ["@rytilahti", "@shenxn"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sonos",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"requirements": ["soco==0.26.3"],
|
||||
"requirements": ["soco==0.26.4"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["plex", "spotify", "zeroconf", "media_source"],
|
||||
"zeroconf": ["_sonos._tcp.local."],
|
||||
|
||||
@@ -172,7 +172,7 @@ class SQLSensor(SensorEntity):
|
||||
else:
|
||||
self._attr_native_value = data
|
||||
|
||||
if not data:
|
||||
if data is None:
|
||||
_LOGGER.warning("%s returned no results", self._query)
|
||||
|
||||
sess.close()
|
||||
|
||||
@@ -277,8 +277,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
"""Turn off the fan."""
|
||||
await self._off_script.async_run(context=self._context)
|
||||
self._state = STATE_OFF
|
||||
self._percentage = 0
|
||||
self._preset_mode = None
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the percentage speed of the fan."""
|
||||
|
||||
@@ -14,7 +14,6 @@ from miio import (
|
||||
AirHumidifierMiot,
|
||||
AirHumidifierMjjsq,
|
||||
AirPurifier,
|
||||
AirPurifierMB4,
|
||||
AirPurifierMiot,
|
||||
CleaningDetails,
|
||||
CleaningSummary,
|
||||
@@ -23,10 +22,8 @@ from miio import (
|
||||
DNDStatus,
|
||||
Fan,
|
||||
Fan1C,
|
||||
FanMiot,
|
||||
FanP5,
|
||||
FanP9,
|
||||
FanP10,
|
||||
FanP11,
|
||||
FanZA5,
|
||||
RoborockVacuum,
|
||||
Timer,
|
||||
@@ -52,7 +49,6 @@ from .const import (
|
||||
KEY_DEVICE,
|
||||
MODEL_AIRFRESH_A1,
|
||||
MODEL_AIRFRESH_T2017,
|
||||
MODEL_AIRPURIFIER_3C,
|
||||
MODEL_FAN_1C,
|
||||
MODEL_FAN_P5,
|
||||
MODEL_FAN_P9,
|
||||
@@ -111,10 +107,10 @@ AIR_MONITOR_PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR]
|
||||
|
||||
MODEL_TO_CLASS_MAP = {
|
||||
MODEL_FAN_1C: Fan1C,
|
||||
MODEL_FAN_P10: FanP10,
|
||||
MODEL_FAN_P11: FanP11,
|
||||
MODEL_FAN_P9: FanMiot,
|
||||
MODEL_FAN_P10: FanMiot,
|
||||
MODEL_FAN_P11: FanMiot,
|
||||
MODEL_FAN_P5: FanP5,
|
||||
MODEL_FAN_P9: FanP9,
|
||||
MODEL_FAN_ZA5: FanZA5,
|
||||
}
|
||||
|
||||
@@ -314,8 +310,6 @@ async def async_create_miio_device_and_coordinator(
|
||||
device = AirHumidifier(host, token, model=model)
|
||||
migrate = True
|
||||
# Airpurifiers and Airfresh
|
||||
elif model == MODEL_AIRPURIFIER_3C:
|
||||
device = AirPurifierMB4(host, token)
|
||||
elif model in MODELS_PURIFIER_MIOT:
|
||||
device = AirPurifierMiot(host, token)
|
||||
elif model.startswith("zhimi.airpurifier."):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Xiaomi Miio",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.10"],
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.11"],
|
||||
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
|
||||
"zeroconf": ["_miio._udp.local."],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -7,7 +7,7 @@ from .backports.enum import StrEnum
|
||||
|
||||
MAJOR_VERSION: Final = 2022
|
||||
MINOR_VERSION: Final = 3
|
||||
PATCH_VERSION: Final = "0b5"
|
||||
PATCH_VERSION: Final = "3"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
from collections.abc import Awaitable, Iterable, Mapping, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum, auto
|
||||
import functools as ft
|
||||
import logging
|
||||
import math
|
||||
@@ -207,6 +208,19 @@ class EntityCategory(StrEnum):
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class EntityPlatformState(Enum):
|
||||
"""The platform state of an entity."""
|
||||
|
||||
# Not Added: Not yet added to a platform, polling updates are written to the state machine
|
||||
NOT_ADDED = auto()
|
||||
|
||||
# Added: Added to a platform, polling updates are written to the state machine
|
||||
ADDED = auto()
|
||||
|
||||
# Removed: Removed from a platform, polling updates are not written to the state machine
|
||||
REMOVED = auto()
|
||||
|
||||
|
||||
def convert_to_entity_category(
|
||||
value: EntityCategory | str | None, raise_report: bool = True
|
||||
) -> EntityCategory | None:
|
||||
@@ -294,7 +308,7 @@ class Entity(ABC):
|
||||
_context_set: datetime | None = None
|
||||
|
||||
# If entity is added to an entity platform
|
||||
_added = False
|
||||
_platform_state = EntityPlatformState.NOT_ADDED
|
||||
|
||||
# Entity Properties
|
||||
_attr_assumed_state: bool = False
|
||||
@@ -553,6 +567,10 @@ class Entity(ABC):
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine."""
|
||||
if self._platform_state == EntityPlatformState.REMOVED:
|
||||
# Polling returned after the entity has already been removed
|
||||
return
|
||||
|
||||
if self.registry_entry and self.registry_entry.disabled_by:
|
||||
if not self._disabled_reported:
|
||||
self._disabled_reported = True
|
||||
@@ -758,7 +776,7 @@ class Entity(ABC):
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
if self._added:
|
||||
if self._platform_state == EntityPlatformState.ADDED:
|
||||
raise HomeAssistantError(
|
||||
f"Entity {self.entity_id} cannot be added a second time to an entity platform"
|
||||
)
|
||||
@@ -766,7 +784,7 @@ class Entity(ABC):
|
||||
self.hass = hass
|
||||
self.platform = platform
|
||||
self.parallel_updates = parallel_updates
|
||||
self._added = True
|
||||
self._platform_state = EntityPlatformState.ADDED
|
||||
|
||||
@callback
|
||||
def add_to_platform_abort(self) -> None:
|
||||
@@ -774,7 +792,7 @@ class Entity(ABC):
|
||||
self.hass = None # type: ignore[assignment]
|
||||
self.platform = None
|
||||
self.parallel_updates = None
|
||||
self._added = False
|
||||
self._platform_state = EntityPlatformState.NOT_ADDED
|
||||
|
||||
async def add_to_platform_finish(self) -> None:
|
||||
"""Finish adding an entity to a platform."""
|
||||
@@ -792,12 +810,12 @@ class Entity(ABC):
|
||||
If the entity doesn't have a non disabled entry in the entity registry,
|
||||
or if force_remove=True, its state will be removed.
|
||||
"""
|
||||
if self.platform and not self._added:
|
||||
if self.platform and self._platform_state != EntityPlatformState.ADDED:
|
||||
raise HomeAssistantError(
|
||||
f"Entity {self.entity_id} async_remove called twice"
|
||||
)
|
||||
|
||||
self._added = False
|
||||
self._platform_state = EntityPlatformState.REMOVED
|
||||
|
||||
if self._on_remove is not None:
|
||||
while self._on_remove:
|
||||
|
||||
@@ -25,7 +25,9 @@ class NoURLAvailableError(HomeAssistantError):
|
||||
def is_internal_request(hass: HomeAssistant) -> bool:
|
||||
"""Test if the current request is internal."""
|
||||
try:
|
||||
_get_internal_url(hass, require_current_request=True)
|
||||
get_url(
|
||||
hass, allow_external=False, allow_cloud=False, require_current_request=True
|
||||
)
|
||||
return True
|
||||
except NoURLAvailableError:
|
||||
return False
|
||||
|
||||
@@ -14,7 +14,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.2.0
|
||||
cryptography==35.0.0
|
||||
hass-nabucasa==0.54.0
|
||||
home-assistant-frontend==20220226.0
|
||||
home-assistant-frontend==20220301.1
|
||||
httpx==0.21.3
|
||||
ifaddr==0.1.7
|
||||
jinja2==3.0.3
|
||||
|
||||
@@ -149,10 +149,17 @@ async def _async_setup_component(
|
||||
|
||||
This method is a coroutine.
|
||||
"""
|
||||
integration: loader.Integration | None = None
|
||||
|
||||
def log_error(msg: str, link: str | None = None) -> None:
|
||||
def log_error(msg: str) -> None:
|
||||
"""Log helper."""
|
||||
_LOGGER.error("Setup failed for %s: %s", domain, msg)
|
||||
if integration is None:
|
||||
custom = ""
|
||||
link = None
|
||||
else:
|
||||
custom = "" if integration.is_built_in else "custom integration "
|
||||
link = integration.documentation
|
||||
_LOGGER.error("Setup failed for %s%s: %s", custom, domain, msg)
|
||||
async_notify_setup_error(hass, domain, link)
|
||||
|
||||
try:
|
||||
@@ -174,7 +181,7 @@ async def _async_setup_component(
|
||||
try:
|
||||
await async_process_deps_reqs(hass, config, integration)
|
||||
except HomeAssistantError as err:
|
||||
log_error(str(err), integration.documentation)
|
||||
log_error(str(err))
|
||||
return False
|
||||
|
||||
# Some integrations fail on import because they call functions incorrectly.
|
||||
@@ -182,7 +189,7 @@ async def _async_setup_component(
|
||||
try:
|
||||
component = integration.get_component()
|
||||
except ImportError as err:
|
||||
log_error(f"Unable to import component: {err}", integration.documentation)
|
||||
log_error(f"Unable to import component: {err}")
|
||||
return False
|
||||
|
||||
processed_config = await conf_util.async_process_component_config(
|
||||
@@ -190,7 +197,7 @@ async def _async_setup_component(
|
||||
)
|
||||
|
||||
if processed_config is None:
|
||||
log_error("Invalid config.", integration.documentation)
|
||||
log_error("Invalid config.")
|
||||
return False
|
||||
|
||||
start = timer()
|
||||
@@ -287,6 +294,7 @@ async def async_prepare_setup_platform(
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
"""Log helper."""
|
||||
|
||||
_LOGGER.error("Unable to prepare setup for platform %s: %s", platform_path, msg)
|
||||
async_notify_setup_error(hass, platform_path)
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ aioguardian==2021.11.0
|
||||
aioharmony==0.2.9
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==0.7.14
|
||||
aiohomekit==0.7.16
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -206,7 +206,7 @@ aiokafka==0.6.0
|
||||
aiokef==0.2.16
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx==0.7.0
|
||||
aiolifx==0.7.1
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx_effects==0.2.2
|
||||
@@ -257,7 +257,7 @@ aioridwell==2021.12.2
|
||||
aiosenseme==0.6.1
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==1.0.10
|
||||
aioshelly==1.0.11
|
||||
|
||||
# homeassistant.components.steamist
|
||||
aiosteamist==0.3.1
|
||||
@@ -679,7 +679,7 @@ fixerio==1.0.0a0
|
||||
fjaraskupan==1.0.2
|
||||
|
||||
# homeassistant.components.flipr
|
||||
flipr-api==1.4.1
|
||||
flipr-api==1.4.2
|
||||
|
||||
# homeassistant.components.flux_led
|
||||
flux_led==0.28.27
|
||||
@@ -843,7 +843,7 @@ hole==0.7.0
|
||||
holidays==0.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20220226.0
|
||||
home-assistant-frontend==20220301.1
|
||||
|
||||
# homeassistant.components.zwave
|
||||
# homeassistant-pyozw==0.1.10
|
||||
@@ -1360,7 +1360,7 @@ pyMetEireann==2021.8.0
|
||||
pyMetno==0.9.0
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.27.1
|
||||
pyRFXtrx==0.28.0
|
||||
|
||||
# homeassistant.components.switchmate
|
||||
# pySwitchmate==0.4.6
|
||||
@@ -1486,7 +1486,7 @@ pydispatcher==2.0.5
|
||||
pydoods==1.0.2
|
||||
|
||||
# homeassistant.components.android_ip_webcam
|
||||
pydroid-ipcam==0.8
|
||||
pydroid-ipcam==1.3.1
|
||||
|
||||
# homeassistant.components.ebox
|
||||
pyebox==1.1.4
|
||||
@@ -1727,7 +1727,7 @@ pynx584==0.5
|
||||
pynzbgetapi==0.2.0
|
||||
|
||||
# homeassistant.components.obihai
|
||||
pyobihai==1.3.1
|
||||
pyobihai==1.3.2
|
||||
|
||||
# homeassistant.components.octoprint
|
||||
pyoctoprintapi==0.1.7
|
||||
@@ -1952,7 +1952,7 @@ python-kasa==0.4.1
|
||||
# python-lirc==1.2.3
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.10
|
||||
python-miio==0.5.11
|
||||
|
||||
# homeassistant.components.mpd
|
||||
python-mpd2==3.0.4
|
||||
@@ -1982,7 +1982,7 @@ python-smarttub==0.0.29
|
||||
python-sochain-api==0.0.2
|
||||
|
||||
# homeassistant.components.songpal
|
||||
python-songpal==0.14
|
||||
python-songpal==0.14.1
|
||||
|
||||
# homeassistant.components.tado
|
||||
python-tado==0.12.0
|
||||
@@ -2121,7 +2121,7 @@ rjpl==0.3.6
|
||||
rocketchat-API==0.6.1
|
||||
|
||||
# homeassistant.components.roku
|
||||
rokuecp==0.14.1
|
||||
rokuecp==0.15.0
|
||||
|
||||
# homeassistant.components.roomba
|
||||
roombapy==1.6.5
|
||||
@@ -2235,7 +2235,7 @@ smhi-pkg==1.0.15
|
||||
snapcast==2.1.3
|
||||
|
||||
# homeassistant.components.sonos
|
||||
soco==0.26.3
|
||||
soco==0.26.4
|
||||
|
||||
# homeassistant.components.solaredge_local
|
||||
solaredge-local==0.2.0
|
||||
|
||||
@@ -134,7 +134,7 @@ aioguardian==2021.11.0
|
||||
aioharmony==0.2.9
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==0.7.14
|
||||
aiohomekit==0.7.16
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -192,7 +192,7 @@ aioridwell==2021.12.2
|
||||
aiosenseme==0.6.1
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==1.0.10
|
||||
aioshelly==1.0.11
|
||||
|
||||
# homeassistant.components.steamist
|
||||
aiosteamist==0.3.1
|
||||
@@ -431,7 +431,7 @@ fivem-api==0.1.2
|
||||
fjaraskupan==1.0.2
|
||||
|
||||
# homeassistant.components.flipr
|
||||
flipr-api==1.4.1
|
||||
flipr-api==1.4.2
|
||||
|
||||
# homeassistant.components.flux_led
|
||||
flux_led==0.28.27
|
||||
@@ -553,7 +553,7 @@ hole==0.7.0
|
||||
holidays==0.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20220226.0
|
||||
home-assistant-frontend==20220301.1
|
||||
|
||||
# homeassistant.components.zwave
|
||||
# homeassistant-pyozw==0.1.10
|
||||
@@ -858,7 +858,7 @@ pyMetEireann==2021.8.0
|
||||
pyMetno==0.9.0
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.27.1
|
||||
pyRFXtrx==0.28.0
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.22.1
|
||||
@@ -1219,7 +1219,7 @@ python-juicenet==1.0.2
|
||||
python-kasa==0.4.1
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.10
|
||||
python-miio==0.5.11
|
||||
|
||||
# homeassistant.components.nest
|
||||
python-nest==4.2.0
|
||||
@@ -1234,7 +1234,7 @@ python-picnic-api==1.1.0
|
||||
python-smarttub==0.0.29
|
||||
|
||||
# homeassistant.components.songpal
|
||||
python-songpal==0.14
|
||||
python-songpal==0.14.1
|
||||
|
||||
# homeassistant.components.tado
|
||||
python-tado==0.12.0
|
||||
@@ -1313,7 +1313,7 @@ rflink==0.0.62
|
||||
ring_doorbell==0.7.2
|
||||
|
||||
# homeassistant.components.roku
|
||||
rokuecp==0.14.1
|
||||
rokuecp==0.15.0
|
||||
|
||||
# homeassistant.components.roomba
|
||||
roombapy==1.6.5
|
||||
@@ -1371,7 +1371,7 @@ smarthab==0.21
|
||||
smhi-pkg==1.0.15
|
||||
|
||||
# homeassistant.components.sonos
|
||||
soco==0.26.3
|
||||
soco==0.26.4
|
||||
|
||||
# homeassistant.components.solaredge
|
||||
solaredge==0.0.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = homeassistant
|
||||
version = 2022.3.0b5
|
||||
version = 2022.3.3
|
||||
author = The Home Assistant Authors
|
||||
author_email = hello@home-assistant.io
|
||||
license = Apache-2.0
|
||||
|
||||
@@ -857,6 +857,7 @@ async def test_entity_browse_media(hass: HomeAssistant, hass_ws_client):
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_1 in response["result"]["children"]
|
||||
|
||||
@@ -868,6 +869,7 @@ async def test_entity_browse_media(hass: HomeAssistant, hass_ws_client):
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_2 in response["result"]["children"]
|
||||
|
||||
@@ -911,6 +913,7 @@ async def test_entity_browse_media_audio_only(
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_1 not in response["result"]["children"]
|
||||
|
||||
@@ -922,6 +925,7 @@ async def test_entity_browse_media_audio_only(
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_2 in response["result"]["children"]
|
||||
|
||||
@@ -1858,6 +1862,7 @@ async def test_cast_platform_browse_media(hass: HomeAssistant, hass_ws_client):
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png",
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child in response["result"]["children"]
|
||||
|
||||
|
||||
@@ -961,6 +961,7 @@ async def test_browse_media(
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_video in response["result"]["children"]
|
||||
|
||||
@@ -972,6 +973,7 @@ async def test_browse_media(
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": None,
|
||||
"children_media_class": None,
|
||||
}
|
||||
assert expected_child_audio in response["result"]["children"]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
|
||||
from didl_lite import didl_lite
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.dlna_dms.const import DOMAIN
|
||||
from homeassistant.components.dlna_dms.const import DLNA_SORT_CRITERIA, DOMAIN
|
||||
from homeassistant.components.dlna_dms.dms import (
|
||||
ActionError,
|
||||
DeviceConnectionError,
|
||||
@@ -686,6 +686,81 @@ async def test_browse_media_object(
|
||||
assert not child.children
|
||||
|
||||
|
||||
async def test_browse_object_sort_anything(
|
||||
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
|
||||
) -> None:
|
||||
"""Test sort criteria for children where device allows anything."""
|
||||
dms_device_mock.sort_capabilities = ["*"]
|
||||
|
||||
object_id = "0"
|
||||
dms_device_mock.async_browse_metadata.return_value = didl_lite.Container(
|
||||
id="0", restricted="false", title="root"
|
||||
)
|
||||
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
|
||||
[], 0, 0, 0
|
||||
)
|
||||
await device_source_mock.async_browse_object("0")
|
||||
|
||||
# Sort criteria should be dlna_dms's default
|
||||
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
|
||||
object_id, metadata_filter=ANY, sort_criteria=DLNA_SORT_CRITERIA
|
||||
)
|
||||
|
||||
|
||||
async def test_browse_object_sort_superset(
|
||||
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
|
||||
) -> None:
|
||||
"""Test sorting where device allows superset of integration's criteria."""
|
||||
dms_device_mock.sort_capabilities = [
|
||||
"dc:title",
|
||||
"upnp:originalTrackNumber",
|
||||
"upnp:class",
|
||||
"upnp:artist",
|
||||
"dc:creator",
|
||||
"upnp:genre",
|
||||
]
|
||||
|
||||
object_id = "0"
|
||||
dms_device_mock.async_browse_metadata.return_value = didl_lite.Container(
|
||||
id="0", restricted="false", title="root"
|
||||
)
|
||||
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
|
||||
[], 0, 0, 0
|
||||
)
|
||||
await device_source_mock.async_browse_object("0")
|
||||
|
||||
# Sort criteria should be dlna_dms's default
|
||||
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
|
||||
object_id, metadata_filter=ANY, sort_criteria=DLNA_SORT_CRITERIA
|
||||
)
|
||||
|
||||
|
||||
async def test_browse_object_sort_subset(
|
||||
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
|
||||
) -> None:
|
||||
"""Test sorting where device allows subset of integration's criteria."""
|
||||
dms_device_mock.sort_capabilities = [
|
||||
"dc:title",
|
||||
"upnp:class",
|
||||
]
|
||||
|
||||
object_id = "0"
|
||||
dms_device_mock.async_browse_metadata.return_value = didl_lite.Container(
|
||||
id="0", restricted="false", title="root"
|
||||
)
|
||||
dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult(
|
||||
[], 0, 0, 0
|
||||
)
|
||||
await device_source_mock.async_browse_object("0")
|
||||
|
||||
# Sort criteria should be reduced to only those allowed,
|
||||
# and in the order specified by DLNA_SORT_CRITERIA
|
||||
expected_criteria = ["+upnp:class", "+dc:title"]
|
||||
dms_device_mock.async_browse_direct_children.assert_awaited_once_with(
|
||||
object_id, metadata_filter=ANY, sort_criteria=expected_criteria
|
||||
)
|
||||
|
||||
|
||||
async def test_browse_media_path(
|
||||
device_source_mock: DmsDeviceSource, dms_device_mock: Mock
|
||||
) -> None:
|
||||
|
||||
@@ -73,6 +73,155 @@ async def test_form_user_with_secure_elk_no_discovery(hass):
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_user_with_insecure_elk_skip_discovery(hass):
|
||||
"""Test we can setup a insecure elk with skipping discovery."""
|
||||
|
||||
with _patch_discovery(), _patch_elk():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with _patch_discovery(no_device=True):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "manual_connection"
|
||||
|
||||
mocked_elk = mock_elk(invalid_auth=False, sync_complete=True)
|
||||
|
||||
with _patch_discovery(), _patch_elk(elk=mocked_elk), patch(
|
||||
"homeassistant.components.elkm1.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.elkm1.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"protocol": "non-secure",
|
||||
"address": "1.2.3.4",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "ElkM1"
|
||||
assert result2["data"] == {
|
||||
"auto_configure": True,
|
||||
"host": "elk://1.2.3.4",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
"username": "test-username",
|
||||
}
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_user_with_insecure_elk_no_discovery(hass):
|
||||
"""Test we can setup a insecure elk."""
|
||||
|
||||
with _patch_discovery(), _patch_elk():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with _patch_discovery(no_device=True):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "manual_connection"
|
||||
|
||||
mocked_elk = mock_elk(invalid_auth=False, sync_complete=True)
|
||||
|
||||
with _patch_discovery(no_device=True), _patch_elk(elk=mocked_elk), patch(
|
||||
"homeassistant.components.elkm1.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.elkm1.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"protocol": "non-secure",
|
||||
"address": "1.2.3.4",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "ElkM1"
|
||||
assert result2["data"] == {
|
||||
"auto_configure": True,
|
||||
"host": "elk://1.2.3.4",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
"username": "test-username",
|
||||
}
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_user_with_insecure_elk_times_out(hass):
|
||||
"""Test we can setup a insecure elk that times out."""
|
||||
|
||||
with _patch_discovery(), _patch_elk():
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with _patch_discovery(no_device=True):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "manual_connection"
|
||||
|
||||
mocked_elk = mock_elk(invalid_auth=False, sync_complete=False)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.elkm1.config_flow.VALIDATE_TIMEOUT",
|
||||
0,
|
||||
), patch(
|
||||
"homeassistant.components.elkm1.config_flow.LOGIN_TIMEOUT", 0
|
||||
), _patch_discovery(), _patch_elk(
|
||||
elk=mocked_elk
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"protocol": "non-secure",
|
||||
"address": "1.2.3.4",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_user_with_secure_elk_no_discovery_ip_already_configured(hass):
|
||||
"""Test we abort when we try to configure the same ip."""
|
||||
config_entry = MockConfigEntry(
|
||||
@@ -262,7 +411,7 @@ async def test_form_user_with_secure_elk_with_discovery_pick_manual_direct_disco
|
||||
assert result3["title"] == "ElkM1 ddeeff"
|
||||
assert result3["data"] == {
|
||||
"auto_configure": True,
|
||||
"host": "elks://127.0.0.1:2601",
|
||||
"host": "elks://127.0.0.1",
|
||||
"password": "test-password",
|
||||
"prefix": "",
|
||||
"username": "test-username",
|
||||
@@ -434,7 +583,7 @@ async def test_form_cannot_connect(hass):
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {CONF_HOST: "cannot_connect"}
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_unknown_exception(hass):
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as entity_reg
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -36,7 +37,7 @@ async def test_sensors(hass: HomeAssistant) -> None:
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||
registry = entity_reg.async_get(hass)
|
||||
|
||||
with patch(
|
||||
"flipr_api.FliprAPIRestClient.get_pool_measure_latest",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from flipr_api.exceptions import FliprError
|
||||
|
||||
from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ICON,
|
||||
@@ -11,6 +13,7 @@ from homeassistant.const import (
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as entity_reg
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -42,7 +45,7 @@ async def test_sensors(hass: HomeAssistant) -> None:
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||
registry = entity_reg.async_get(hass)
|
||||
|
||||
with patch(
|
||||
"flipr_api.FliprAPIRestClient.get_pool_measure_latest",
|
||||
@@ -84,3 +87,31 @@ async def test_sensors(hass: HomeAssistant) -> None:
|
||||
assert state.attributes.get(ATTR_ICON) == "mdi:pool"
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV"
|
||||
assert state.state == "0.23654886"
|
||||
|
||||
|
||||
async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None:
|
||||
"""Test the Flipr sensors error."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="test_entry_unique_id",
|
||||
data={
|
||||
CONF_EMAIL: "toto@toto.com",
|
||||
CONF_PASSWORD: "myPassword",
|
||||
CONF_FLIPR_ID: "myfliprid",
|
||||
},
|
||||
)
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
registry = entity_reg.async_get(hass)
|
||||
|
||||
with patch(
|
||||
"flipr_api.FliprAPIRestClient.get_pool_measure_latest",
|
||||
side_effect=FliprError("Error during flipr data retrieval..."),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check entity is not generated because of the FliprError raised.
|
||||
entity = registry.async_get("sensor.flipr_myfliprid_red_ox")
|
||||
assert entity is None
|
||||
|
||||
@@ -299,3 +299,23 @@ async def test_select_white_channel_type(hass: HomeAssistant) -> None:
|
||||
== WhiteChannelType.NATURAL.name.lower()
|
||||
)
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_select_device_no_wiring(hass: HomeAssistant) -> None:
|
||||
"""Test select is not created if the device does not support wiring."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
|
||||
unique_id=MAC_ADDRESS,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_bulb()
|
||||
bulb.wiring = None
|
||||
bulb.wirings = ["RGB", "GRB"]
|
||||
bulb.raw_state = bulb.raw_state._replace(model_num=0x25)
|
||||
with _patch_discovery(), _patch_wifibulb(device=bulb):
|
||||
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
wiring_entity_id = "select.bulb_rgbcw_ddeeff_wiring"
|
||||
assert hass.states.get(wiring_entity_id) is None
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import MOCK_USER_DATA
|
||||
from .const import MOCK_MESH_MASTER_MAC, MOCK_USER_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
@@ -69,6 +69,7 @@ async def test_entry_diagnostics(
|
||||
"latest_firmware": None,
|
||||
"mesh_role": "master",
|
||||
"model": "FRITZ!Box 7530 AX",
|
||||
"unique_id": MOCK_MESH_MASTER_MAC.replace("6F:12", "XX:XX"),
|
||||
"update_available": False,
|
||||
"wan_link_properties": {
|
||||
"NewLayer1DownstreamMaxBitRate": 318557000,
|
||||
|
||||
@@ -95,6 +95,16 @@ async def test_state_reporting_all(hass):
|
||||
hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
hass.states.async_set("binary_sensor.test1", STATE_ON)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN
|
||||
|
||||
hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_state_reporting_any(hass):
|
||||
"""Test the state reporting."""
|
||||
@@ -116,11 +126,10 @@ async def test_state_reporting_any(hass):
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# binary sensors have state off if unavailable
|
||||
hass.states.async_set("binary_sensor.test1", STATE_ON)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON
|
||||
|
||||
hass.states.async_set("binary_sensor.test1", STATE_ON)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_OFF)
|
||||
@@ -137,7 +146,6 @@ async def test_state_reporting_any(hass):
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON
|
||||
|
||||
# binary sensors have state off if unavailable
|
||||
hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
@@ -149,3 +157,13 @@ async def test_state_reporting_any(hass):
|
||||
entry = entity_registry.async_get("binary_sensor.binary_sensor_group")
|
||||
assert entry
|
||||
assert entry.unique_id == "unique_identifier"
|
||||
|
||||
hass.states.async_set("binary_sensor.test1", STATE_ON)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON
|
||||
|
||||
hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN)
|
||||
hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN
|
||||
|
||||
@@ -42,7 +42,6 @@ from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
__version__,
|
||||
__version__ as hass_version,
|
||||
)
|
||||
from homeassistant.helpers.event import TRACK_STATE_CHANGE_CALLBACKS
|
||||
@@ -166,7 +165,9 @@ async def test_home_accessory(hass, hk_driver):
|
||||
serv.get_characteristic(CHAR_SERIAL_NUMBER).value
|
||||
== "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum"
|
||||
)
|
||||
assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version
|
||||
assert hass_version.startswith(
|
||||
serv.get_characteristic(CHAR_FIRMWARE_REVISION).value
|
||||
)
|
||||
|
||||
hass.states.async_set(entity_id, "on")
|
||||
await hass.async_block_till_done()
|
||||
@@ -216,7 +217,9 @@ async def test_accessory_with_missing_basic_service_info(hass, hk_driver):
|
||||
assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Home Assistant Sensor"
|
||||
assert serv.get_characteristic(CHAR_MODEL).value == "Sensor"
|
||||
assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == entity_id
|
||||
assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version
|
||||
assert hass_version.startswith(
|
||||
serv.get_characteristic(CHAR_FIRMWARE_REVISION).value
|
||||
)
|
||||
assert isinstance(acc.to_HAP(), dict)
|
||||
|
||||
|
||||
@@ -244,7 +247,9 @@ async def test_accessory_with_hardware_revision(hass, hk_driver):
|
||||
assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Home Assistant Sensor"
|
||||
assert serv.get_characteristic(CHAR_MODEL).value == "Sensor"
|
||||
assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == entity_id
|
||||
assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version
|
||||
assert hass_version.startswith(
|
||||
serv.get_characteristic(CHAR_FIRMWARE_REVISION).value
|
||||
)
|
||||
assert serv.get_characteristic(CHAR_HARDWARE_REVISION).value == "1.2.3"
|
||||
assert isinstance(acc.to_HAP(), dict)
|
||||
|
||||
@@ -687,7 +692,9 @@ def test_home_bridge(hk_driver):
|
||||
serv = bridge.services[0] # SERV_ACCESSORY_INFO
|
||||
assert serv.display_name == SERV_ACCESSORY_INFO
|
||||
assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME
|
||||
assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == __version__
|
||||
assert hass_version.startswith(
|
||||
serv.get_characteristic(CHAR_FIRMWARE_REVISION).value
|
||||
)
|
||||
assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER
|
||||
assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL
|
||||
assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == BRIDGE_SERIAL_NUMBER
|
||||
|
||||
@@ -399,4 +399,4 @@ async def test_empty_name(hass, hk_driver):
|
||||
assert acc.category == 10 # Sensor
|
||||
|
||||
assert acc.char_humidity.value == 20
|
||||
assert acc.display_name is None
|
||||
assert acc.display_name == "None"
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.components.climate.const import (
|
||||
ATTR_HVAC_MODES,
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_TEMP,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
ATTR_SWING_MODE,
|
||||
ATTR_SWING_MODES,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
@@ -74,6 +76,7 @@ from homeassistant.components.homekit.type_thermostats import (
|
||||
from homeassistant.components.water_heater import DOMAIN as DOMAIN_WATER_HEATER
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_TEMPERATURE_UNIT,
|
||||
@@ -2349,3 +2352,127 @@ async def test_thermostat_with_fan_modes_with_off(hass, hk_driver, events):
|
||||
assert len(call_set_fan_mode) == 2
|
||||
assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id
|
||||
assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_OFF
|
||||
|
||||
|
||||
async def test_thermostat_with_fan_modes_set_to_none(hass, hk_driver, events):
|
||||
"""Test a thermostate with fan modes set to None."""
|
||||
entity_id = "climate.test"
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
HVAC_MODE_OFF,
|
||||
{
|
||||
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE
|
||||
| SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
| SUPPORT_FAN_MODE
|
||||
| SUPPORT_SWING_MODE,
|
||||
ATTR_FAN_MODES: None,
|
||||
ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL],
|
||||
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
|
||||
ATTR_FAN_MODE: FAN_AUTO,
|
||||
ATTR_SWING_MODE: SWING_BOTH,
|
||||
ATTR_HVAC_MODES: [
|
||||
HVAC_MODE_HEAT,
|
||||
HVAC_MODE_HEAT_COOL,
|
||||
HVAC_MODE_FAN_ONLY,
|
||||
HVAC_MODE_COOL,
|
||||
HVAC_MODE_OFF,
|
||||
HVAC_MODE_AUTO,
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
|
||||
hk_driver.add_accessory(acc)
|
||||
|
||||
await acc.run()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert acc.char_cooling_thresh_temp.value == 23.0
|
||||
assert acc.char_heating_thresh_temp.value == 19.0
|
||||
assert acc.ordered_fan_speeds == []
|
||||
assert CHAR_ROTATION_SPEED not in acc.fan_chars
|
||||
assert CHAR_TARGET_FAN_STATE not in acc.fan_chars
|
||||
assert CHAR_SWING_MODE in acc.fan_chars
|
||||
assert CHAR_CURRENT_FAN_STATE in acc.fan_chars
|
||||
|
||||
|
||||
async def test_thermostat_with_fan_modes_set_to_none_not_supported(
|
||||
hass, hk_driver, events
|
||||
):
|
||||
"""Test a thermostate with fan modes set to None and supported feature missing."""
|
||||
entity_id = "climate.test"
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
HVAC_MODE_OFF,
|
||||
{
|
||||
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE
|
||||
| SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
| SUPPORT_SWING_MODE,
|
||||
ATTR_FAN_MODES: None,
|
||||
ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL],
|
||||
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
|
||||
ATTR_FAN_MODE: FAN_AUTO,
|
||||
ATTR_SWING_MODE: SWING_BOTH,
|
||||
ATTR_HVAC_MODES: [
|
||||
HVAC_MODE_HEAT,
|
||||
HVAC_MODE_HEAT_COOL,
|
||||
HVAC_MODE_FAN_ONLY,
|
||||
HVAC_MODE_COOL,
|
||||
HVAC_MODE_OFF,
|
||||
HVAC_MODE_AUTO,
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
|
||||
hk_driver.add_accessory(acc)
|
||||
|
||||
await acc.run()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert acc.char_cooling_thresh_temp.value == 23.0
|
||||
assert acc.char_heating_thresh_temp.value == 19.0
|
||||
assert acc.ordered_fan_speeds == []
|
||||
assert CHAR_ROTATION_SPEED not in acc.fan_chars
|
||||
assert CHAR_TARGET_FAN_STATE not in acc.fan_chars
|
||||
assert CHAR_SWING_MODE in acc.fan_chars
|
||||
assert CHAR_CURRENT_FAN_STATE in acc.fan_chars
|
||||
|
||||
|
||||
async def test_thermostat_with_supported_features_target_temp_but_fan_mode_set(
|
||||
hass, hk_driver, events
|
||||
):
|
||||
"""Test a thermostate with fan mode and supported feature missing."""
|
||||
entity_id = "climate.test"
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
HVAC_MODE_OFF,
|
||||
{
|
||||
ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE,
|
||||
ATTR_MIN_TEMP: 44.6,
|
||||
ATTR_MAX_TEMP: 95,
|
||||
ATTR_PRESET_MODES: ["home", "away"],
|
||||
ATTR_TEMPERATURE: 67,
|
||||
ATTR_TARGET_TEMP_HIGH: None,
|
||||
ATTR_TARGET_TEMP_LOW: None,
|
||||
ATTR_FAN_MODE: FAN_AUTO,
|
||||
ATTR_FAN_MODES: None,
|
||||
ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE,
|
||||
ATTR_FAN_MODE: FAN_AUTO,
|
||||
ATTR_PRESET_MODE: "home",
|
||||
ATTR_FRIENDLY_NAME: "Rec Room",
|
||||
ATTR_HVAC_MODES: [
|
||||
HVAC_MODE_OFF,
|
||||
HVAC_MODE_HEAT,
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
|
||||
hk_driver.add_accessory(acc)
|
||||
|
||||
await acc.run()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert acc.ordered_fan_speeds == []
|
||||
assert not acc.fan_chars
|
||||
|
||||
@@ -30,6 +30,7 @@ from homeassistant.components.homekit.util import (
|
||||
async_port_is_available,
|
||||
async_show_setup_message,
|
||||
cleanup_name_for_homekit,
|
||||
coerce_int,
|
||||
convert_to_float,
|
||||
density_to_air_quality,
|
||||
format_version,
|
||||
@@ -349,13 +350,23 @@ async def test_format_version():
|
||||
assert format_version("undefined-undefined-1.6.8") == "1.6.8"
|
||||
assert format_version("56.0-76060") == "56.0.76060"
|
||||
assert format_version(3.6) == "3.6"
|
||||
assert format_version("AK001-ZJ100") == "001.100"
|
||||
assert format_version("AK001-ZJ100") == "1.100"
|
||||
assert format_version("HF-LPB100-") == "100"
|
||||
assert format_version("AK001-ZJ2149") == "001.2149"
|
||||
assert format_version("AK001-ZJ2149") == "1.2149"
|
||||
assert format_version("13216407885") == "4294967295" # max value
|
||||
assert format_version("000132 16407885") == "132.16407885"
|
||||
assert format_version("0.1") == "0.1"
|
||||
assert format_version("0") is None
|
||||
assert format_version("unknown") is None
|
||||
|
||||
|
||||
async def test_coerce_int():
|
||||
"""Test coerce_int method."""
|
||||
assert coerce_int("1") == 1
|
||||
assert coerce_int("") == 0
|
||||
assert coerce_int(0) == 0
|
||||
|
||||
|
||||
async def test_accessory_friendly_name():
|
||||
"""Test we provide a helpful friendly name."""
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@ from homeassistant.components.climate.const import (
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_TARGET_TEMPERATURE_RANGE,
|
||||
)
|
||||
from homeassistant.components.number import NumberMode
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
|
||||
from tests.components.homekit_controller.common import (
|
||||
HUB_TEST_ACCESSORY_ID,
|
||||
@@ -123,84 +121,6 @@ async def test_ecobee3_setup(hass):
|
||||
},
|
||||
state="heat",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_home_cool_target",
|
||||
friendly_name="HomeW Home Cool Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:35",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 33.3,
|
||||
"min": 18.3,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="24.4",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_home_heat_target",
|
||||
friendly_name="HomeW Home Heat Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:34",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 26.1,
|
||||
"min": 7.2,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="22.2",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_sleep_cool_target",
|
||||
friendly_name="HomeW Sleep Cool Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:37",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 33.3,
|
||||
"min": 18.3,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="27.8",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_sleep_heat_target",
|
||||
friendly_name="HomeW Sleep Heat Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:36",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 26.1,
|
||||
"min": 7.2,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="17.8",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_away_cool_target",
|
||||
friendly_name="HomeW Away Cool Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:39",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 33.3,
|
||||
"min": 18.3,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="26.7",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="number.homew_away_heat_target",
|
||||
friendly_name="HomeW Away Heat Target",
|
||||
unique_id="homekit-123456789012-aid:1-sid:16-cid:38",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
capabilities={
|
||||
"max": 26.1,
|
||||
"min": 7.2,
|
||||
"mode": NumberMode.AUTO,
|
||||
"step": 0.1,
|
||||
},
|
||||
state="18.9",
|
||||
),
|
||||
EntityTestInfo(
|
||||
entity_id="sensor.homew_current_temperature",
|
||||
friendly_name="HomeW Current Temperature",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for the mobile_app HTTP API."""
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
@@ -75,6 +76,49 @@ async def test_registration_encryption(hass, hass_client):
|
||||
assert resp.status == HTTPStatus.CREATED
|
||||
register_json = await resp.json()
|
||||
|
||||
key = unhexlify(register_json[CONF_SECRET])
|
||||
|
||||
payload = json.dumps(RENDER_TEMPLATE["data"]).encode("utf-8")
|
||||
|
||||
data = SecretBox(key).encrypt(payload, encoder=Base64Encoder).decode("utf-8")
|
||||
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
|
||||
resp = await api_client.post(
|
||||
f"/api/webhook/{register_json[CONF_WEBHOOK_ID]}", json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
webhook_json = await resp.json()
|
||||
assert "encrypted_data" in webhook_json
|
||||
|
||||
decrypted_data = SecretBox(key).decrypt(
|
||||
webhook_json["encrypted_data"], encoder=Base64Encoder
|
||||
)
|
||||
decrypted_data = decrypted_data.decode("utf-8")
|
||||
|
||||
assert json.loads(decrypted_data) == {"one": "Hello world"}
|
||||
|
||||
|
||||
async def test_registration_encryption_legacy(hass, hass_client):
|
||||
"""Test that registrations happen."""
|
||||
try:
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.secret import SecretBox
|
||||
except (ImportError, OSError):
|
||||
pytest.skip("libnacl/libsodium is not installed")
|
||||
return
|
||||
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
api_client = await hass_client()
|
||||
|
||||
resp = await api_client.post("/api/mobile_app/registrations", json=REGISTER)
|
||||
|
||||
assert resp.status == HTTPStatus.CREATED
|
||||
register_json = await resp.json()
|
||||
|
||||
keylen = SecretBox.KEY_SIZE
|
||||
key = register_json[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Webhook tests for mobile_app."""
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -22,7 +23,29 @@ from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE
|
||||
from tests.common import async_mock_service
|
||||
|
||||
|
||||
def encrypt_payload(secret_key, payload):
|
||||
def encrypt_payload(secret_key, payload, encode_json=True):
|
||||
"""Return a encrypted payload given a key and dictionary of data."""
|
||||
try:
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.secret import SecretBox
|
||||
except (ImportError, OSError):
|
||||
pytest.skip("libnacl/libsodium is not installed")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
prepped_key = unhexlify(secret_key)
|
||||
|
||||
if encode_json:
|
||||
payload = json.dumps(payload)
|
||||
payload = payload.encode("utf-8")
|
||||
|
||||
return (
|
||||
SecretBox(prepped_key).encrypt(payload, encoder=Base64Encoder).decode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def encrypt_payload_legacy(secret_key, payload, encode_json=True):
|
||||
"""Return a encrypted payload given a key and dictionary of data."""
|
||||
try:
|
||||
from nacl.encoding import Base64Encoder
|
||||
@@ -38,7 +61,9 @@ def encrypt_payload(secret_key, payload):
|
||||
prepped_key = prepped_key[:keylen]
|
||||
prepped_key = prepped_key.ljust(keylen, b"\0")
|
||||
|
||||
payload = json.dumps(payload).encode("utf-8")
|
||||
if encode_json:
|
||||
payload = json.dumps(payload)
|
||||
payload = payload.encode("utf-8")
|
||||
|
||||
return (
|
||||
SecretBox(prepped_key).encrypt(payload, encoder=Base64Encoder).decode("utf-8")
|
||||
@@ -56,6 +81,27 @@ def decrypt_payload(secret_key, encrypted_data):
|
||||
|
||||
import json
|
||||
|
||||
prepped_key = unhexlify(secret_key)
|
||||
|
||||
decrypted_data = SecretBox(prepped_key).decrypt(
|
||||
encrypted_data, encoder=Base64Encoder
|
||||
)
|
||||
decrypted_data = decrypted_data.decode("utf-8")
|
||||
|
||||
return json.loads(decrypted_data)
|
||||
|
||||
|
||||
def decrypt_payload_legacy(secret_key, encrypted_data):
|
||||
"""Return a decrypted payload given a key and a string of encrypted data."""
|
||||
try:
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.secret import SecretBox
|
||||
except (ImportError, OSError):
|
||||
pytest.skip("libnacl/libsodium is not installed")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
keylen = SecretBox.KEY_SIZE
|
||||
prepped_key = secret_key.encode("utf-8")
|
||||
prepped_key = prepped_key[:keylen]
|
||||
@@ -273,6 +319,181 @@ async def test_webhook_handle_decryption(webhook_client, create_registrations):
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
|
||||
|
||||
async def test_webhook_handle_decryption_legacy(webhook_client, create_registrations):
|
||||
"""Test that we can encrypt/decrypt properly."""
|
||||
key = create_registrations[0]["secret"]
|
||||
data = encrypt_payload_legacy(key, RENDER_TEMPLATE["data"])
|
||||
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
webhook_json = await resp.json()
|
||||
assert "encrypted_data" in webhook_json
|
||||
|
||||
decrypted_data = decrypt_payload_legacy(key, webhook_json["encrypted_data"])
|
||||
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
|
||||
|
||||
async def test_webhook_handle_decryption_fail(
|
||||
webhook_client, create_registrations, caplog
|
||||
):
|
||||
"""Test that we can encrypt/decrypt properly."""
|
||||
key = create_registrations[0]["secret"]
|
||||
|
||||
# Send valid data
|
||||
data = encrypt_payload(key, RENDER_TEMPLATE["data"])
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
decrypted_data = decrypt_payload(key, webhook_json["encrypted_data"])
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
caplog.clear()
|
||||
|
||||
# Send invalid JSON data
|
||||
data = encrypt_payload(key, "{not_valid", encode_json=False)
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
assert decrypt_payload(key, webhook_json["encrypted_data"]) == {}
|
||||
assert "Ignoring invalid encrypted payload" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
# Break the key, and send JSON data
|
||||
data = encrypt_payload(key[::-1], RENDER_TEMPLATE["data"])
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
assert decrypt_payload(key, webhook_json["encrypted_data"]) == {}
|
||||
assert "Ignoring encrypted payload because unable to decrypt" in caplog.text
|
||||
|
||||
|
||||
async def test_webhook_handle_decryption_legacy_fail(
|
||||
webhook_client, create_registrations, caplog
|
||||
):
|
||||
"""Test that we can encrypt/decrypt properly."""
|
||||
key = create_registrations[0]["secret"]
|
||||
|
||||
# Send valid data using legacy method
|
||||
data = encrypt_payload_legacy(key, RENDER_TEMPLATE["data"])
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
decrypted_data = decrypt_payload_legacy(key, webhook_json["encrypted_data"])
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
caplog.clear()
|
||||
|
||||
# Send invalid JSON data
|
||||
data = encrypt_payload_legacy(key, "{not_valid", encode_json=False)
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
assert decrypt_payload_legacy(key, webhook_json["encrypted_data"]) == {}
|
||||
assert "Ignoring invalid encrypted payload" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
# Break the key, and send JSON data
|
||||
data = encrypt_payload_legacy(key[::-1], RENDER_TEMPLATE["data"])
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
webhook_json = await resp.json()
|
||||
assert decrypt_payload_legacy(key, webhook_json["encrypted_data"]) == {}
|
||||
assert "Ignoring encrypted payload because unable to decrypt" in caplog.text
|
||||
|
||||
|
||||
async def test_webhook_handle_decryption_legacy_upgrade(
|
||||
webhook_client, create_registrations
|
||||
):
|
||||
"""Test that we can encrypt/decrypt properly."""
|
||||
key = create_registrations[0]["secret"]
|
||||
|
||||
# Send using legacy method
|
||||
data = encrypt_payload_legacy(key, RENDER_TEMPLATE["data"])
|
||||
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
webhook_json = await resp.json()
|
||||
assert "encrypted_data" in webhook_json
|
||||
|
||||
decrypted_data = decrypt_payload_legacy(key, webhook_json["encrypted_data"])
|
||||
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
|
||||
# Send using new method
|
||||
data = encrypt_payload(key, RENDER_TEMPLATE["data"])
|
||||
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
webhook_json = await resp.json()
|
||||
assert "encrypted_data" in webhook_json
|
||||
|
||||
decrypted_data = decrypt_payload(key, webhook_json["encrypted_data"])
|
||||
|
||||
assert decrypted_data == {"one": "Hello world"}
|
||||
|
||||
# Send using legacy method - no longer possible
|
||||
data = encrypt_payload_legacy(key, RENDER_TEMPLATE["data"])
|
||||
|
||||
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
|
||||
|
||||
resp = await webhook_client.post(
|
||||
"/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
webhook_json = await resp.json()
|
||||
assert "encrypted_data" in webhook_json
|
||||
|
||||
# The response should be empty, encrypted with the new method
|
||||
with pytest.raises(Exception):
|
||||
decrypt_payload_legacy(key, webhook_json["encrypted_data"])
|
||||
decrypted_data = decrypt_payload(key, webhook_json["encrypted_data"])
|
||||
|
||||
assert decrypted_data == {}
|
||||
|
||||
|
||||
async def test_webhook_requires_encryption(webhook_client, create_registrations):
|
||||
"""Test that encrypted registrations only accept encrypted data."""
|
||||
resp = await webhook_client.post(
|
||||
|
||||
@@ -111,6 +111,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": None,
|
||||
"children_media_class": "directory",
|
||||
}
|
||||
],
|
||||
"not_shown": 0,
|
||||
@@ -143,6 +144,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": None,
|
||||
"children_media_class": "directory",
|
||||
}
|
||||
],
|
||||
"not_shown": 0,
|
||||
@@ -174,6 +176,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": None,
|
||||
"children_media_class": "video",
|
||||
},
|
||||
{
|
||||
"title": "Images",
|
||||
@@ -186,6 +189,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": None,
|
||||
"children_media_class": "image",
|
||||
},
|
||||
],
|
||||
"not_shown": 0,
|
||||
@@ -220,6 +224,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": True,
|
||||
"thumbnail": None,
|
||||
"children_media_class": "directory",
|
||||
}
|
||||
],
|
||||
"not_shown": 0,
|
||||
@@ -255,6 +260,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": "http://movie",
|
||||
"children_media_class": None,
|
||||
},
|
||||
{
|
||||
"title": "00-36-49.mp4",
|
||||
@@ -268,6 +274,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": "http://movie",
|
||||
"children_media_class": None,
|
||||
},
|
||||
{
|
||||
"title": "00-02-27.mp4",
|
||||
@@ -281,6 +288,7 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None:
|
||||
"can_play": True,
|
||||
"can_expand": False,
|
||||
"thumbnail": "http://movie",
|
||||
"children_media_class": None,
|
||||
},
|
||||
],
|
||||
"not_shown": 0,
|
||||
@@ -331,6 +339,7 @@ async def test_async_browse_media_images_success(hass: HomeAssistant) -> None:
|
||||
"can_play": False,
|
||||
"can_expand": False,
|
||||
"thumbnail": "http://image",
|
||||
"children_media_class": None,
|
||||
}
|
||||
],
|
||||
"not_shown": 0,
|
||||
|
||||
@@ -333,6 +333,43 @@ async def test_set_fan_mode(hass, mqtt_mock):
|
||||
assert state.attributes.get("fan_mode") == "high"
|
||||
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
@pytest.mark.parametrize(
|
||||
"send_if_off,assert_async_publish",
|
||||
[
|
||||
({}, [call("fan-mode-topic", "low", 0, False)]),
|
||||
({"send_if_off": True}, [call("fan-mode-topic", "low", 0, False)]),
|
||||
({"send_if_off": False}, []),
|
||||
],
|
||||
)
|
||||
async def test_set_fan_mode_send_if_off(
|
||||
hass, mqtt_mock, send_if_off, assert_async_publish
|
||||
):
|
||||
"""Test setting of fan mode if the hvac is off."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
config[CLIMATE_DOMAIN].update(send_if_off)
|
||||
assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(ENTITY_CLIMATE) is not None
|
||||
|
||||
# Turn on HVAC
|
||||
await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
# Updates for fan_mode should be sent when the device is turned on
|
||||
await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_called_once_with("fan-mode-topic", "high", 0, False)
|
||||
|
||||
# Turn off HVAC
|
||||
await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE)
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state.state == "off"
|
||||
|
||||
# Updates for fan_mode should be sent if SEND_IF_OFF is not set or is True
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
await common.async_set_fan_mode(hass, "low", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_has_calls(assert_async_publish)
|
||||
|
||||
|
||||
async def test_set_swing_mode_bad_attr(hass, mqtt_mock, caplog):
|
||||
"""Test setting swing mode without required attribute."""
|
||||
assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
|
||||
@@ -385,6 +422,43 @@ async def test_set_swing(hass, mqtt_mock):
|
||||
assert state.attributes.get("swing_mode") == "on"
|
||||
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
@pytest.mark.parametrize(
|
||||
"send_if_off,assert_async_publish",
|
||||
[
|
||||
({}, [call("swing-mode-topic", "on", 0, False)]),
|
||||
({"send_if_off": True}, [call("swing-mode-topic", "on", 0, False)]),
|
||||
({"send_if_off": False}, []),
|
||||
],
|
||||
)
|
||||
async def test_set_swing_mode_send_if_off(
|
||||
hass, mqtt_mock, send_if_off, assert_async_publish
|
||||
):
|
||||
"""Test setting of swing mode if the hvac is off."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
config[CLIMATE_DOMAIN].update(send_if_off)
|
||||
assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(ENTITY_CLIMATE) is not None
|
||||
|
||||
# Turn on HVAC
|
||||
await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
# Updates for swing_mode should be sent when the device is turned on
|
||||
await common.async_set_swing_mode(hass, "off", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "off", 0, False)
|
||||
|
||||
# Turn off HVAC
|
||||
await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE)
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state.state == "off"
|
||||
|
||||
# Updates for swing_mode should be sent if SEND_IF_OFF is not set or is True
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_has_calls(assert_async_publish)
|
||||
|
||||
|
||||
async def test_set_target_temperature(hass, mqtt_mock):
|
||||
"""Test setting the target temperature."""
|
||||
assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
|
||||
@@ -421,6 +495,45 @@ async def test_set_target_temperature(hass, mqtt_mock):
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
@pytest.mark.parametrize(
|
||||
"send_if_off,assert_async_publish",
|
||||
[
|
||||
({}, [call("temperature-topic", "21.0", 0, False)]),
|
||||
({"send_if_off": True}, [call("temperature-topic", "21.0", 0, False)]),
|
||||
({"send_if_off": False}, []),
|
||||
],
|
||||
)
|
||||
async def test_set_target_temperature_send_if_off(
|
||||
hass, mqtt_mock, send_if_off, assert_async_publish
|
||||
):
|
||||
"""Test setting of target temperature if the hvac is off."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
config[CLIMATE_DOMAIN].update(send_if_off)
|
||||
assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(ENTITY_CLIMATE) is not None
|
||||
|
||||
# Turn on HVAC
|
||||
await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
# Updates for target temperature should be sent when the device is turned on
|
||||
await common.async_set_temperature(hass, 16.0, ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_called_once_with(
|
||||
"temperature-topic", "16.0", 0, False
|
||||
)
|
||||
|
||||
# Turn off HVAC
|
||||
await common.async_set_hvac_mode(hass, "off", ENTITY_CLIMATE)
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state.state == "off"
|
||||
|
||||
# Updates for target temperature sent should be if SEND_IF_OFF is not set or is True
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
await common.async_set_temperature(hass, 21.0, ENTITY_CLIMATE)
|
||||
mqtt_mock.async_publish.assert_has_calls(assert_async_publish)
|
||||
|
||||
|
||||
async def test_set_target_temperature_pessimistic(hass, mqtt_mock):
|
||||
"""Test setting the target temperature."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
@@ -3,8 +3,9 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant import config as hass_config, config_entries, data_entry_flow
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.hassio import HassioServiceInfo
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -151,7 +152,7 @@ async def test_manual_config_set(
|
||||
"discovery": True,
|
||||
}
|
||||
# Check we tried the connection, with precedence for config entry settings
|
||||
mock_try_connection.assert_called_once_with("127.0.0.1", 1883, None, None)
|
||||
mock_try_connection.assert_called_once_with(hass, "127.0.0.1", 1883, None, None)
|
||||
# Check config entry got setup
|
||||
assert len(mock_finish_setup.mock_calls) == 1
|
||||
|
||||
@@ -642,3 +643,95 @@ async def test_options_bad_will_message_fails(hass, mock_try_connection):
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
mqtt.CONF_PORT: 1234,
|
||||
}
|
||||
|
||||
|
||||
async def test_try_connection_with_advanced_parameters(
|
||||
hass, mock_try_connection_success, tmp_path
|
||||
):
|
||||
"""Test config flow with advanced parameters from config."""
|
||||
# Mock certificate files
|
||||
certfile = tmp_path / "cert.pem"
|
||||
certfile.write_text("## mock certificate file ##")
|
||||
keyfile = tmp_path / "key.pem"
|
||||
keyfile.write_text("## mock key file ##")
|
||||
config = {
|
||||
"certificate": "auto",
|
||||
"tls_insecure": True,
|
||||
"client_cert": certfile,
|
||||
"client_key": keyfile,
|
||||
}
|
||||
new_yaml_config_file = tmp_path / "configuration.yaml"
|
||||
new_yaml_config = yaml.dump({mqtt.DOMAIN: config})
|
||||
new_yaml_config_file.write_text(new_yaml_config)
|
||||
assert new_yaml_config_file.read_text() == new_yaml_config
|
||||
|
||||
with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file):
|
||||
await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config})
|
||||
await hass.async_block_till_done()
|
||||
config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
config_entry.data = {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
mqtt.CONF_PORT: 1234,
|
||||
mqtt.CONF_USERNAME: "user",
|
||||
mqtt.CONF_PASSWORD: "pass",
|
||||
mqtt.CONF_DISCOVERY: True,
|
||||
mqtt.CONF_BIRTH_MESSAGE: {
|
||||
mqtt.ATTR_TOPIC: "ha_state/online",
|
||||
mqtt.ATTR_PAYLOAD: "online",
|
||||
mqtt.ATTR_QOS: 1,
|
||||
mqtt.ATTR_RETAIN: True,
|
||||
},
|
||||
mqtt.CONF_WILL_MESSAGE: {
|
||||
mqtt.ATTR_TOPIC: "ha_state/offline",
|
||||
mqtt.ATTR_PAYLOAD: "offline",
|
||||
mqtt.ATTR_QOS: 2,
|
||||
mqtt.ATTR_RETAIN: False,
|
||||
},
|
||||
}
|
||||
|
||||
# Test default/suggested values from config
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "broker"
|
||||
defaults = {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
mqtt.CONF_PORT: 1234,
|
||||
}
|
||||
suggested = {
|
||||
mqtt.CONF_USERNAME: "user",
|
||||
mqtt.CONF_PASSWORD: "pass",
|
||||
}
|
||||
for k, v in defaults.items():
|
||||
assert get_default(result["data_schema"].schema, k) == v
|
||||
for k, v in suggested.items():
|
||||
assert get_suggested(result["data_schema"].schema, k) == v
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
mqtt.CONF_PORT: 2345,
|
||||
mqtt.CONF_USERNAME: "us3r",
|
||||
mqtt.CONF_PASSWORD: "p4ss",
|
||||
},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "options"
|
||||
|
||||
# check if the username and password was set from config flow and not from configuration.yaml
|
||||
assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == (
|
||||
"us3r",
|
||||
"p4ss",
|
||||
)
|
||||
|
||||
# check if tls_insecure_set is called
|
||||
assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,)
|
||||
|
||||
# check if the certificate settings were set from configuration.yaml
|
||||
assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[
|
||||
"certfile"
|
||||
] == str(certfile)
|
||||
assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[
|
||||
"keyfile"
|
||||
] == str(keyfile)
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from rokuecp import RokuError
|
||||
from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerDeviceClass
|
||||
from homeassistant.components.media_player.const import (
|
||||
@@ -164,10 +164,15 @@ async def test_tv_setup(
|
||||
assert device_entry.suggested_area == "Living room"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[RokuConnectionTimeoutError, RokuConnectionError, RokuError],
|
||||
)
|
||||
async def test_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_roku: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
error: RokuError,
|
||||
) -> None:
|
||||
"""Test entity availability."""
|
||||
now = dt_util.utcnow()
|
||||
@@ -179,7 +184,7 @@ async def test_availability(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=future):
|
||||
mock_roku.update.side_effect = RokuError
|
||||
mock_roku.update.side_effect = error
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
@@ -759,7 +764,10 @@ async def test_media_browse(
|
||||
assert msg["result"]["children"][0]["title"] == "Roku Channel Store"
|
||||
assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP
|
||||
assert msg["result"]["children"][0]["media_content_id"] == "11"
|
||||
assert "/browse_media/app/11" in msg["result"]["children"][0]["thumbnail"]
|
||||
assert (
|
||||
msg["result"]["children"][0]["thumbnail"]
|
||||
== "http://192.168.1.160:8060/query/icon/11"
|
||||
)
|
||||
assert msg["result"]["children"][0]["can_play"]
|
||||
|
||||
# test invalid media type
|
||||
@@ -1016,14 +1024,18 @@ async def test_tv_media_browse(
|
||||
assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP
|
||||
assert msg["result"]["children"][0]["media_content_id"] == "tvinput.hdmi2"
|
||||
assert (
|
||||
"/browse_media/app/tvinput.hdmi2" in msg["result"]["children"][0]["thumbnail"]
|
||||
msg["result"]["children"][0]["thumbnail"]
|
||||
== "http://192.168.1.160:8060/query/icon/tvinput.hdmi2"
|
||||
)
|
||||
assert msg["result"]["children"][0]["can_play"]
|
||||
|
||||
assert msg["result"]["children"][3]["title"] == "Roku Channel Store"
|
||||
assert msg["result"]["children"][3]["media_content_type"] == MEDIA_TYPE_APP
|
||||
assert msg["result"]["children"][3]["media_content_id"] == "11"
|
||||
assert "/browse_media/app/11" in msg["result"]["children"][3]["thumbnail"]
|
||||
assert (
|
||||
msg["result"]["children"][3]["thumbnail"]
|
||||
== "http://192.168.1.160:8060/query/icon/11"
|
||||
)
|
||||
assert msg["result"]["children"][3]["can_play"]
|
||||
|
||||
# test channels
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ENTITY_MATCH_ALL,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import State
|
||||
@@ -177,6 +178,34 @@ async def test_restore_state(hass, entities, enable_custom_integrations):
|
||||
assert hass.states.get("scene.test").state == "2021-01-01T23:59:59+00:00"
|
||||
|
||||
|
||||
async def test_restore_state_does_not_restore_unavailable(
|
||||
hass, entities, enable_custom_integrations
|
||||
):
|
||||
"""Test we restore state integration but ignore unavailable."""
|
||||
mock_restore_cache(hass, (State("scene.test", STATE_UNAVAILABLE),))
|
||||
|
||||
light_1, light_2 = await setup_lights(hass, entities)
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
scene.DOMAIN,
|
||||
{
|
||||
"scene": [
|
||||
{
|
||||
"name": "test",
|
||||
"entities": {
|
||||
light_1.entity_id: "on",
|
||||
light_2.entity_id: "on",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("scene.test").state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def activate(hass, entity_id=ENTITY_MATCH_ALL):
|
||||
"""Activate a scene."""
|
||||
data = {}
|
||||
|
||||
@@ -115,7 +115,9 @@ async def test_query_limit(hass: HomeAssistant) -> None:
|
||||
assert state.attributes["value"] == 5
|
||||
|
||||
|
||||
async def test_query_no_value(hass: HomeAssistant) -> None:
|
||||
async def test_query_no_value(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test the SQL sensor with a query that returns no value."""
|
||||
config = {
|
||||
"sensor": {
|
||||
@@ -137,6 +139,9 @@ async def test_query_no_value(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("sensor.count_tables")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
text = "SELECT 5 as value where 1=2 returned no results"
|
||||
assert text in caplog.text
|
||||
|
||||
|
||||
async def test_invalid_query(hass: HomeAssistant) -> None:
|
||||
"""Test the SQL sensor for invalid queries."""
|
||||
|
||||
@@ -545,6 +545,22 @@ async def test_async_remove_runs_callbacks(hass):
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
async def test_async_remove_ignores_in_flight_polling(hass):
|
||||
"""Test in flight polling is ignored after removing."""
|
||||
result = []
|
||||
|
||||
ent = entity.Entity()
|
||||
ent.hass = hass
|
||||
ent.entity_id = "test.test"
|
||||
ent.async_on_remove(lambda: result.append(1))
|
||||
ent.async_write_ha_state()
|
||||
assert hass.states.get("test.test").state == STATE_UNKNOWN
|
||||
await ent.async_remove()
|
||||
assert len(result) == 1
|
||||
assert hass.states.get("test.test") is None
|
||||
ent.async_write_ha_state()
|
||||
|
||||
|
||||
async def test_set_context(hass):
|
||||
"""Test setting context."""
|
||||
context = Context()
|
||||
|
||||
@@ -390,6 +390,30 @@ async def test_async_remove_with_platform(hass):
|
||||
assert len(hass.states.async_entity_ids()) == 0
|
||||
|
||||
|
||||
async def test_async_remove_with_platform_update_finishes(hass):
|
||||
"""Remove an entity when an update finishes after its been removed."""
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass)
|
||||
entity1 = MockEntity(name="test_1")
|
||||
|
||||
async def _delayed_update(*args, **kwargs):
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
entity1.async_update = _delayed_update
|
||||
|
||||
# Add, remove, add, remove and make sure no updates
|
||||
# cause the entity to reappear after removal
|
||||
for i in range(2):
|
||||
await component.async_add_entities([entity1])
|
||||
assert len(hass.states.async_entity_ids()) == 1
|
||||
entity1.async_write_ha_state()
|
||||
assert hass.states.get(entity1.entity_id) is not None
|
||||
task = asyncio.create_task(entity1.async_update_ha_state(True))
|
||||
await entity1.async_remove()
|
||||
assert len(hass.states.async_entity_ids()) == 0
|
||||
await task
|
||||
assert len(hass.states.async_entity_ids()) == 0
|
||||
|
||||
|
||||
async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog):
|
||||
"""Test for not adding duplicate entities."""
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
@@ -20,6 +20,17 @@ from homeassistant.helpers.network import (
|
||||
from tests.common import mock_component
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_current_request")
|
||||
def mock_current_request_mock():
|
||||
"""Mock the current request."""
|
||||
mock_current_request = Mock(name="mock_request")
|
||||
with patch(
|
||||
"homeassistant.helpers.network.http.current_request",
|
||||
Mock(get=mock_current_request),
|
||||
):
|
||||
yield mock_current_request
|
||||
|
||||
|
||||
async def test_get_url_internal(hass: HomeAssistant):
|
||||
"""Test getting an instance URL when the user has set an internal URL."""
|
||||
assert hass.config.internal_url is None
|
||||
@@ -611,7 +622,7 @@ async def test_get_current_request_url_with_known_host(
|
||||
get_url(hass, require_current_request=True)
|
||||
|
||||
|
||||
async def test_is_internal_request(hass: HomeAssistant):
|
||||
async def test_is_internal_request(hass: HomeAssistant, mock_current_request):
|
||||
"""Test if accessing an instance on its internal URL."""
|
||||
# Test with internal URL: http://example.local:8123
|
||||
await async_process_ha_core_config(
|
||||
@@ -620,18 +631,16 @@ async def test_is_internal_request(hass: HomeAssistant):
|
||||
)
|
||||
|
||||
assert hass.config.internal_url == "http://example.local:8123"
|
||||
|
||||
# No request active
|
||||
mock_current_request.return_value = None
|
||||
assert not is_internal_request(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.network._get_request_host", return_value="example.local"
|
||||
):
|
||||
assert is_internal_request(hass)
|
||||
mock_current_request.return_value = Mock(url="http://example.local:8123")
|
||||
assert is_internal_request(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.network._get_request_host",
|
||||
return_value="no_match.example.local",
|
||||
):
|
||||
assert not is_internal_request(hass)
|
||||
mock_current_request.return_value = Mock(url="http://no_match.example.local:8123")
|
||||
assert not is_internal_request(hass)
|
||||
|
||||
# Test with internal URL: http://192.168.0.1:8123
|
||||
await async_process_ha_core_config(
|
||||
@@ -642,10 +651,26 @@ async def test_is_internal_request(hass: HomeAssistant):
|
||||
assert hass.config.internal_url == "http://192.168.0.1:8123"
|
||||
assert not is_internal_request(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
|
||||
mock_current_request.return_value = Mock(url="http://192.168.0.1:8123")
|
||||
assert is_internal_request(hass)
|
||||
|
||||
# Test for matching against local IP
|
||||
hass.config.api = Mock(use_ssl=False, local_ip="192.168.123.123", port=8123)
|
||||
for allowed in ("127.0.0.1", "192.168.123.123"):
|
||||
mock_current_request.return_value = Mock(url=f"http://{allowed}:8123")
|
||||
assert is_internal_request(hass), mock_current_request.return_value.url
|
||||
|
||||
# Test for matching against HassOS hostname
|
||||
with patch.object(
|
||||
hass.components.hassio, "is_hassio", return_value=True
|
||||
), patch.object(
|
||||
hass.components.hassio,
|
||||
"get_host_info",
|
||||
return_value={"hostname": "hellohost"},
|
||||
):
|
||||
assert is_internal_request(hass)
|
||||
for allowed in ("hellohost", "hellohost.local"):
|
||||
mock_current_request.return_value = Mock(url=f"http://{allowed}:8123")
|
||||
assert is_internal_request(hass), mock_current_request.return_value.url
|
||||
|
||||
|
||||
async def test_is_hass_url(hass):
|
||||
|
||||
@@ -11,6 +11,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.config_validation import (
|
||||
PLATFORM_SCHEMA,
|
||||
@@ -621,6 +622,22 @@ async def test_integration_disabled(hass, caplog):
|
||||
assert disabled_reason in caplog.text
|
||||
|
||||
|
||||
async def test_integration_logs_is_custom(hass, caplog):
|
||||
"""Test we highlight it's a custom component when errors happen."""
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule("test_component1"),
|
||||
built_in=False,
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.setup.async_process_deps_reqs",
|
||||
side_effect=HomeAssistantError("Boom"),
|
||||
):
|
||||
result = await setup.async_setup_component(hass, "test_component1", {})
|
||||
assert not result
|
||||
assert "Setup failed for custom integration test_component1: Boom" in caplog.text
|
||||
|
||||
|
||||
async def test_async_get_loaded_integrations(hass):
|
||||
"""Test we can enumerate loaded integations."""
|
||||
hass.config.components.add("notbase")
|
||||
|
||||
Reference in New Issue
Block a user